Gin Binding Query Parameters Array or Slice Returning Empty Values
Quick answer
Gin binding an array or slice query parameter as empty, despite the request clearly including multiple values, almost always comes down to using the wrong...
Gin binding an array or slice query parameter as empty, despite the request clearly including multiple values, almost always comes down to using the wrong query string format for how Gin expects repeated parameters to be structured, or a struct tag that doesn't match what's actually being sent.
The Problem
A query parameter meant to represent multiple values binds to an empty slice, even though the request URL clearly includes several values:
type SearchParams struct {
Tags []string `form:"tags"`
}
// Request: /search?tags=go&tags=docker&tags=postgres
var params SearchParams
c.ShouldBindQuery(¶ms)
// params.Tags is empty, despite three "tags" values in the query string
Why It Happens
Gin's query binding, built on the go-playground/form library, supports array/slice binding correctly, but requires the query string to be formatted in a way it recognizes, and several common formatting mismatches prevent successful binding:
- Using a comma-separated single value (
?tags=go,docker,postgres) instead of repeated parameter instances (?tags=go&tags=docker&tags=postgres) — Gin's default binding expects the latter format, not a single comma-separated string that would need separate, manual splitting - A mismatch between the
formtag name in the struct and the actual parameter name used in the query string, which is the same general category of mismatch that also affects simple, non-array binding, just easier to overlook when focused specifically on the array aspect of the problem - Using bracket notation (
?tags[]=go&tags[]=docker), a common convention in some other web frameworks/languages, which Gin's default binding doesn't recognize the same way — Gin expects the plain repeated-parameter format without brackets
The Fix
Ensure the client sends repeated query parameters with the exact same name, not a bracket-suffixed or comma-separated variant:
// Correct format for Gin's default array binding
/search?tags=go&tags=docker&tags=postgres
If you're constructing this URL from frontend JavaScript, make sure you're appending each value as its own separate parameter instance with the same key, not joining them into a single value first:
const params = new URLSearchParams();
['go', 'docker', 'postgres'].forEach(tag => params.append('tags', tag));
fetch(`/search?${params.toString()}`);
// produces: tags=go&tags=docker&tags=postgres — the correct format
Confirm the struct tag matches the actual parameter name being sent, exactly:
type SearchParams struct {
Tags []string `form:"tags"` // must match the query parameter name "tags" exactly
}
If you specifically need to support a comma-separated single-value format instead (perhaps because you don't control the client sending the request, and it's already sending values this way), bind the parameter as a plain string first, then split it manually rather than relying on Gin's automatic array binding for this particular format:
type SearchParams struct {
TagsRaw string `form:"tags"`
}
var params SearchParams
c.ShouldBindQuery(¶ms)
tags := strings.Split(params.TagsRaw, ",")
This approach explicitly handles the comma-separated format yourself, since it's not what Gin's default array binding mechanism expects or handles automatically.
If you need to support both formats simultaneously (some clients sending repeated parameters, others sending comma-separated), implement custom binding logic that checks the raw query values directly and handles either case explicitly, rather than relying purely on struct tag-based automatic binding for both variants at once:
func parseTagsParam(c *gin.Context) []string {
values := c.QueryArray("tags") // gets all repeated "tags" values
if len(values) == 1 && strings.Contains(values[0], ",") {
return strings.Split(values[0], ",") // handle comma-separated fallback
}
return values
}
c.QueryArray() specifically retrieves every value associated with a repeated query parameter directly, which is also useful as a more explicit, manual alternative to struct-based binding when you need finer control over exactly how array-style query parameters are interpreted.
Still Not Working?
If you've confirmed the correct repeated-parameter format is being sent and the struct tag matches, but binding still returns empty, log the raw query string Gin actually received to rule out the value being altered somewhere upstream (a proxy, gateway, or CDN normalizing or collapsing repeated query parameters before the request reaches your application) — this is a less common but real possibility in more complex infrastructure setups, and confirming the actual raw request Gin receives (rather than what the client originally sent) isolates whether the issue is in your binding code or somewhere earlier in the request's journey to your server:
func searchHandler(c *gin.Context) {
log.Printf("raw query string: %s", c.Request.URL.RawQuery)
}