Gin Binding JSON Returns Empty Struct
When Gin's JSON binding produces a struct with all fields at their zero values, despite the request clearly containing valid JSON, it's almost always due to a mismatch between the JSON keys and the struct's binding tags, a missing or incorrect Content-Type header, or the request body already being consumed before binding runs.
The Problem
You send a request with a JSON body, but after binding, every field in your struct is empty or zero:
var input struct {
Name string `json:"name"`
Email string `json:"email"`
}
c.ShouldBindJSON(&input)
fmt.Println(input) // {Name: Email:} - both empty, despite JSON in the request
No binding error is returned either, which makes it more confusing than an outright failure would be.
Why It Happens
Gin's JSON binding relies on Go's encoding/json package under the hood, which has specific requirements that silently produce empty results rather than errors when violated. Common causes:
- Struct fields aren't exported (don't start with a capital letter), so the JSON package can't populate them through reflection at all, regardless of binding tags
- A mismatch between the JSON key names sent in the request and the
json:tags on the struct fields — for example, sending"Name"when the struct expects"name", or vice versa, depending on tag configuration - The request's
Content-Typeheader isn't set toapplication/json, and depending on how the client sends the request, Gin's binding may not correctly interpret the body as JSON without it - The request body was already read earlier in the request lifecycle — by a logging middleware, for example — and since an HTTP request body can typically only be read once, binding afterward finds nothing left to read
The Fix
First, confirm every field you expect to bind is exported (capitalized), since this is a hard Go requirement, not a Gin-specific detail:
type Input struct {
Name string `json:"name"`
Email string `json:"email"`
}
Confirm your JSON tags match the actual keys being sent in the request body exactly, including case:
// Request body
{"name": "Alice", "email": "alice@example.com"}
// Struct - tags must match these key names
type Input struct {
Name string `json:"name"`
Email string `json:"email"`
}
Confirm the request includes the correct Content-Type header. When testing with curl, this is easy to forget:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"alice@example.com"}'
Without the header, some clients or Gin's own strictness settings may not correctly parse the body as JSON, resulting in an empty bound struct without necessarily raising an explicit error.
If a middleware earlier in the chain reads the request body (common with logging or request-inspection middleware), make sure it restores the body afterward so subsequent handlers can still read it — reading an io.Reader-based body consumes it, and without restoring it, anything reading afterward gets nothing:
func LoggingMiddleware(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
log.Println(string(body))
c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) // restore for later handlers
c.Next()
}
Always check the error returned by binding, rather than assuming success just because no panic occurred:
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
In many "silently empty" cases, the error is actually being returned but simply not checked, which hides useful information about exactly what went wrong.
Still Not Working?
If everything above checks out and the struct is still empty, log the raw request body directly inside the handler before binding, to confirm exactly what Gin is actually receiving at that point in the request lifecycle:
body, _ := io.ReadAll(c.Request.Body)
log.Println("raw body:", string(body))
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
c.ShouldBindJSON(&input)
If this logs an empty body, the problem is upstream of Gin entirely — either the client isn't actually sending a body, or something else in the request pipeline consumed it before this point.