The JSON contract in Go
You are building an API endpoint. The client sends {"name": "Alice"}. You want to grab "Alice", process it, and send back {"status": "ok"}. In JavaScript, req.body gives you an object and you're done. In Go, the compiler refuses to guess. It demands a struct. You write the handler, run the server, and hit the endpoint. The response is a 400 Bad Request. The JSON is valid. The struct looks correct. The error message is vague.
The issue is usually the bridge between JSON keys and Go fields. JSON keys are lowercase strings. Go fields must be exported, which means they start with a capital letter. The decoder doesn't know that Name matches name unless you tell it. That's where struct tags come in. They are the contract that maps external data to internal types.
Structs as molds, tags as labels
JSON is text. Go is typed. To turn text into a Go value, you need a shape to pour it into. That shape is a struct. The encoding/json package reads the text and fills the struct fields. Gin wraps this with helpers like ShouldBindJSON.
A struct tag is a metadata string attached to a field. It tells the JSON decoder which key in the JSON corresponds to which field in the struct. Without a tag, the decoder looks for an exact match between the field name and the JSON key. Since Name does not match name, the field stays empty. If the field is required, the binding fails.
Convention dictates that struct tags use backticks and the format json:"key". The community follows this pattern everywhere. It keeps code readable and predictable.
Here is the minimal handler. It defines a request struct, binds the JSON, and returns a response.
package main
import (
"github.com/gin-gonic/gin"
)
// Request holds the incoming JSON payload.
// The json tag maps the Go field Name to the JSON key "name".
type Request struct {
Name string `json:"name"`
}
// Response holds the outgoing JSON payload.
// Tags ensure the output keys match the API contract.
type Response struct {
Message string `json:"message"`
}
func main() {
r := gin.Default()
// POST /hello expects a JSON body.
r.POST("/hello", func(c *gin.Context) {
var req Request
// ShouldBindJSON decodes the body into req.
// It returns an error if the JSON is malformed or fields are missing.
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Send a 200 OK with the Response struct.
// Gin marshals the struct to JSON automatically.
c.JSON(200, Response{Message: "Hello " + req.Name})
})
r.Run()
}
The handler uses ShouldBindJSON to decode the request. It checks the error. If binding fails, it returns a 400 status with the error message. If binding succeeds, it constructs a Response struct and sends it. c.JSON marshals the struct and sets the Content-Type header to application/json.
Structs are the contract. Tags are the translation layer.
Structs versus maps for responses
Many beginners reach for gin.H to build responses. gin.H is just a type alias for map[string]any. It is flexible. You can add keys dynamically. It is also unsafe. If you typo a key, the client receives a missing field. The compiler cannot catch the mistake. Structs catch typos at compile time. They also document the response shape.
Use structs for responses. They provide type safety and self-documentation. Use gin.H only when the response shape varies wildly based on runtime conditions, which is rare in well-designed APIs.
Here is a comparison. The struct version is preferred.
// GoodResponse uses a struct for type safety.
// The compiler checks field names and types.
type GoodResponse struct {
Status string `json:"status"`
Data string `json:"data"`
}
// BadResponse uses a map.
// Typos in keys are runtime errors.
func handler(c *gin.Context) {
// Struct response: safe and documented.
c.JSON(200, GoodResponse{
Status: "success",
Data: "value",
})
// Map response: flexible but error-prone.
// A typo like "dat" instead of "data" compiles fine.
c.JSON(200, gin.H{
"status": "success",
"dat": "value",
})
}
The map version compiles even with a typo. The client gets {"status":"success","dat":"value"}. The integration test might pass if it doesn't check the key name. The struct version fails to compile if you reference a non-existent field. Trust the compiler. Wrap the value in a struct.
Accept interfaces, return structs. This mantra applies to responses too. Return a concrete struct so the caller knows exactly what they get.
Optional fields and validation
Real APIs have optional fields. A user might send a name but skip the email. Go structs have zero values. A missing string field becomes an empty string. An empty string is indistinguishable from a client sending "". You need a way to distinguish "not sent" from "sent as empty".
Pointers solve this. A pointer field is nil if the key is missing. It points to a value if the key is present. The omitempty tag changes marshaling behavior. If a field is omitted during marshaling, the key does not appear in the JSON output. For pointers, omitempty treats nil as empty. For values, it treats the zero value as empty.
Here is a struct with optional fields.
// UserRequest handles optional fields using pointers.
// The json tag omits nil pointers from the output.
type UserRequest struct {
Name string `json:"name"`
Email *string `json:"email,omitempty"`
Age *int `json:"age,omitempty"`
}
func handler(c *gin.Context) {
var req UserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Check if Email was provided.
// req.Email is nil if the key is missing or null.
if req.Email != nil {
// Use the value safely.
_ = *req.Email
}
// Validation logic runs after binding.
// Business rules are separate from syntax checks.
if req.Name == "" {
c.JSON(400, gin.H{"error": "name is required"})
return
}
c.JSON(200, gin.H{"status": "ok"})
}
The pointer allows checking presence. The omitempty tag keeps the JSON clean. Validation logic runs after binding. Binding checks syntax and types. Validation checks business rules. Keep them separate.
The if err != nil pattern is verbose by design. It makes the unhappy path visible. The community accepts the boilerplate because it prevents silent failures. Write the check. Return the error.
Binding strategies and control flow
Gin provides two main binding methods: BindJSON and ShouldBindJSON. They do the same decoding work. They differ in error handling.
BindJSON aborts the handler if binding fails. It sends a 400 response and stops execution. You cannot intercept the error. You cannot log it. You cannot customize the response.
ShouldBindJSON returns the error. You decide what to do. You can log the error. You can wrap it. You can send a custom response.
Use ShouldBindJSON in almost all cases. It gives you control. Use BindJSON only in throwaway scripts where you don't care about error details.
If you use BindJSON and the client sends invalid JSON, the handler aborts. You get a generic 400 response. The error message comes from Gin's default error handler. It might not match your API's error format.
The compiler rejects programs with unused imports. If you import a package and don't use it, you get imported and not used. This rule keeps code clean. It forces you to remove dead dependencies.
Pitfalls and silent failures
JSON handling has subtle traps. Missing tags cause silent failures. The decoder looks for Name in the JSON. The JSON has name. The field stays empty. The handler proceeds with an empty string. The bug hides until production.
Always use tags. Never rely on case-insensitive matching. The decoder is case-sensitive.
Passing a struct value instead of a pointer to ShouldBindJSON causes a panic or error. The decoder needs a pointer to modify the struct. If you pass a value, it cannot set fields. The compiler complains with json: cannot unmarshal object into Go value of type main.Request if you pass a value to json.Unmarshal. ShouldBindJSON has similar requirements.
Always pass a pointer to binding functions. &req is the standard.
The underscore discards a value intentionally. result, _ := ... says you considered the second return value and chose to drop it. Use it sparingly with errors. Dropping an error without a comment is a code smell. If you drop an error, explain why.
Public names start with a capital letter. Private names start with a lowercase letter. There are no public or private keywords. Visibility is controlled by capitalization. Struct tags bridge the gap between public Go fields and lowercase JSON keys.
Decision matrix
Use ShouldBindJSON when you need to decode the request body and handle errors explicitly. Use BindJSON when you want automatic 400 responses and don't need custom error handling. Use json.Unmarshal directly when you are processing JSON outside of a Gin handler or need full control over the decoding process. Use c.JSON when you want to send a JSON response with the correct content type header. Use c.Data when you have pre-marshaled JSON bytes and want to skip the marshaling step for performance. Use json.RawMessage when you need to defer parsing of a JSON field or pass it through without modification.
Gin handles the plumbing. You handle the logic. Define clear structs. Use tags. Check errors. Return types.