Go makes it pleasantly simple to decode JSON into a struct. That convenience also hides a compatibility decision that matters at API boundaries: by default, fields that do not map to the destination struct are ignored.
For internal data this can be useful. For an HTTP request, it can turn a typo into a silent behavior change.
A client may send "expires_inn": 3600 while the server expects "expires_in". The JSON is valid, decoding can succeed, and the server may continue with a zero value or a default. The caller receives no direct signal that the field it thought it supplied was never used.
For APIs where request fields are meant to form a defined contract, strict decoding is often safer.
The default decoder accepts unknown object keys
Consider a request type:
type CreateTokenRequest struct {
Name string `json:"name"`
ExpiresIn int `json:"expires_in"`
}This payload contains a misspelled field:
{
"name": "deploy-bot",
"expires_inn": 3600
}A normal decode into CreateTokenRequest does not make the unknown expires_inn key an error. The resulting ExpiresIn field remains zero.
That behavior is not a parser bug. JSON syntax and application schema are separate concerns. The decoder can understand the JSON document even though the destination struct has no place for one of its keys.
The question for an API is whether silently ignoring that key is the contract you want.
Enable strict field matching with DisallowUnknownFields
json.Decoder has an explicit strictness switch for struct destinations:
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var req CreateTokenRequest
if err := dec.Decode(&req); err != nil {
return fmt.Errorf("decode request: %w", err)
}With DisallowUnknownFields enabled, an object key that does not match a non-ignored exported field in the destination struct causes decoding to return an error.
The misspelled payload now fails instead of quietly becoming ExpiresIn == 0.
This is especially valuable for configuration-like endpoints and command APIs, where callers usually expect every submitted property to have meaning.
Strict fields do not make the whole request validator strict
DisallowUnknownFields solves one problem. It does not replace validation.
A decoded request can still contain values that are syntactically valid JSON but invalid for your application:
if req.Name == "" {
return errors.New("name is required")
}
if req.ExpiresIn < 60 || req.ExpiresIn > 86400 {
return errors.New("expires_in must be between 60 and 86400")
}Keep the layers separate:
- Limit how much input you will accept.
- Decode the JSON representation.
- Reject fields outside the request schema.
- Validate required fields, ranges, and relationships.
- Execute the operation only after all checks pass.
This separation produces clearer code and clearer error handling.
Reject a second JSON value after the request
A subtle mistake is to call Decode once and assume the body contained exactly one JSON value.
For example, a stream can contain two valid JSON values one after another. The first Decode may succeed and leave the second value unread.
If your endpoint expects exactly one request object, check for the end of the JSON stream:
func decodeJSONBody(r io.Reader, dst any) error {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("decode JSON: %w", err)
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
if err == nil {
return errors.New("request body must contain exactly one JSON value")
}
return fmt.Errorf("decode trailing JSON: %w", err)
}
return nil
}The second decode is not trying to use another value. Its purpose is to prove that no second JSON value exists.
Whitespace after the first value is fine: the decoder skips it and reaches io.EOF.
Limit the body before decoding it
Strict schema handling does not protect memory or bandwidth by itself. An HTTP client can still send a body much larger than the endpoint needs.
At an HTTP boundary, impose a size limit independently of JSON validation. http.MaxBytesReader is useful when you have an http.ResponseWriter and request body:
func createToken(w http.ResponseWriter, r *http.Request) {
const maxBody = 64 << 10 // 64 KiB
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
var req CreateTokenRequest
if err := decodeJSONBody(r.Body, &req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Validate req, then perform the operation.
}The exact limit is an API design choice. Pick one based on the largest legitimate request, not on how much memory the server happens to have.
A body-size limit and DisallowUnknownFields defend different boundaries, so using one is not a reason to omit the other.
Be deliberate when adding strictness to an existing API
Turning on unknown-field rejection changes observable behavior.
Suppose an old client has been sending an obsolete field for years. The server ignored it, so requests succeeded. Enabling strict decoding immediately can turn those same requests into errors even though the client did not change.
That means strictness is easiest to adopt when an endpoint is new. For an established API, first understand what real clients send.
Useful migration strategies include logging unknown fields before rejecting them, introducing strictness in a new API version, or temporarily preserving a deprecated field in the request struct while ignoring its value intentionally.
For example:
type CreateTokenRequest struct {
Name string `json:"name"`
ExpiresIn int `json:"expires_in"`
// Deprecated: accepted only for compatibility with older clients.
LegacyTTL int `json:"ttl"`
}That is different from accepting arbitrary unknown fields. The compatibility exception is explicit, searchable, and removable later.
Do not decode into a map if you want struct-schema enforcement
DisallowUnknownFields applies when decoding an object into a struct. If you decode arbitrary input into map[string]any, arbitrary keys are the point of the destination type, so there is no struct field set against which to reject them.
Use a struct when the endpoint has a known request schema:
type UpdateProfileRequest struct {
DisplayName string `json:"display_name"`
Bio string `json:"bio"`
}Use maps when keys are intentionally dynamic, such as user-defined labels or metadata. If only part of a request is dynamic, model that part explicitly:
type UpdateProfileRequest struct {
DisplayName string `json:"display_name"`
Metadata map[string]string `json:"metadata"`
}Now top-level typos can still be rejected while metadata remains extensible by design.
Zero values still need semantic interpretation
Strict decoding catches unknown keys, but it does not tell you whether a known key was omitted.
For a field such as:
ExpiresIn int `json:"expires_in"`both an omitted field and an explicit JSON value of 0 produce the Go zero value 0.
If omission and zero mean different things, represent that distinction in the request model. A pointer is one simple option:
type CreateTokenRequest struct {
Name string `json:"name"`
ExpiresIn *int `json:"expires_in"`
}Then nil means the field was absent, while a non-nil pointer can contain zero or another value. Whether zero itself is valid remains a validation decision.
Strict field names and presence tracking solve different classes of mistakes.
Keep client-facing errors useful without leaking internals
The decoder’s error is useful for logs and can often guide a caller, but an API should still define its own error format.
Avoid returning an unstructured mix of raw internal errors from every layer. Instead, translate decoding failures into a stable client-facing response while preserving the original error for server-side diagnostics.
For a public API, you may choose to tell the caller that the JSON contains an unknown field. For an internal service, returning the decoder message may be acceptable if that is part of the service’s established error contract.
The important part is that the request fails close to the mistake. A clear 400 response is much easier to debug than a successful request whose misspelled option was ignored.
A reusable strict decoder
Putting the transport-level checks in one helper makes it harder for handlers to accidentally drift back to permissive decoding:
func decodeStrictJSON(r io.Reader, dst any) error {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
return fmt.Errorf("decode JSON: %w", err)
}
var extra any
switch err := dec.Decode(&extra); {
case err == io.EOF:
return nil
case err == nil:
return errors.New("request body must contain exactly one JSON value")
default:
return fmt.Errorf("decode trailing JSON: %w", err)
}
}The handler can then own HTTP-specific limits and status codes, while domain validation stays with the request or application layer.
This helper deliberately does not attempt to solve every JSON policy. Duplicate keys, numeric representation, custom unmarshaling, and API-specific null semantics may require additional decisions. Strict unknown-field handling should be treated as one well-defined contract improvement, not as a claim that every possible JSON ambiguity has disappeared.
The practical rule
When a Go endpoint accepts a JSON object with a known schema, decide explicitly whether unknown fields are allowed.
If they are not, use a struct destination and enable DisallowUnknownFields. Pair it with a request-size limit, verify that the body contains only one JSON value, and run semantic validation after decoding.
Most importantly, treat strictness as part of the API contract. For new endpoints, rejecting unknown fields can prevent typo-driven bugs from the beginning. For existing endpoints, introduce the behavior with the same care you would use for any other compatibility change.