1. Why do we even need “strictness” when parsing JSON?
When you’re just starting to write programs, you really want the computer to be kind and forgive mistakes. But with data it’s the opposite: the “kinder” the parser, the higher the chance it will silently swallow a problem—and you’ll get a bug that shows up three weeks later, on a Friday evening, and for some reason only on a colleague’s machine.
By default, encoding/json in Go is fairly tolerant: it tries to fill what it can and doesn’t throw a tantrum over extra fields. That’s convenient for format compatibility, but dangerous when you expect a strictly fixed contract. In this lecture we’ll learn how to enable “nitpicky mode”: “don’t recognize a field—then it’s an error.”
How encoding/json matches struct fields and JSON fields
Before turning on strict mode, it’s important to understand what exactly counts as “known” and “unknown”. encoding/json works with a very down-to-earth scheme: it sees a JSON object {...}, looks at the keys, and tries to find a suitable field in the struct. If the field is exported (starts with an uppercase letter) and matches by name/tag, it writes the value.
For example, here’s a small DTO (a struct we read JSON into). For now, we’ll build a simple “task” model for our learning app (a hypothetical todo/task tracker) so the examples tie together across lectures:
package main
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
Important point: json:"title" is a contract. If the JSON contains the key "title", it will go into Title. If "Title" or "taskTitle" comes in—that’s a different story (and often a source of surprises).
2. The “extra fields” problem: when an error exists but nobody notices
Now we get to the main pain point. Imagine: the user (or your own code, but from a week ago) sends JSON where there’s a typo in one place: "titel" instead of "title". A person expects the program to say “hey, that field name is wrong.” But by default the program often pretends everything is fine: it just ignores the unknown key, and Title stays an empty string.
Here’s a demonstration of the “silent” behavior (intentionally without strict mode):
package main
import (
"encoding/json"
"fmt"
)
func main() {
data := []byte(`{"id": 1, "titel": "Buy milk", "done": false}`)
var t TaskDTO
_ = json.Unmarshal(data, &t)
fmt.Printf("%+v\n", t) // {ID:1 Title: Done:false}
}
Everything is “successful”, and the task suddenly has no title. And then you might spend hours debugging “why is Title empty”, even though the root cause is a typo in the input data.
This behavior isn’t unique to JSON at all: the idea “fill only matching fields, ignore the rest” shows up in other codecs too. For example, in the descriptions of the binary gob format, it’s emphasized that fields are matched by name and fields that have “nowhere to go” are ignored.
4. Decoder.DisallowUnknownFields(): enable strict mode
To stop “forgiving” extra fields when reading into a struct, Go gives us a very straightforward setting: Decoder.DisallowUnknownFields(). You don’t enable it globally for the package, but on a specific decoder—right where you read the input.
The pattern is: create json.NewDecoder(r), enable strict mode, call Decode(&v).
package main
import (
"encoding/json"
"fmt"
"strings"
)
func main() {
r := strings.NewReader(`{"id": 1, "titel": "Buy milk", "done": false}`)
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
var t TaskDTO
err := dec.Decode(&t)
fmt.Println(err) // json: unknown field "titel"
}
Now this looks like adult life: we didn’t get an “empty Title”, we got a clear error. Yes, it’s unpleasant for the user who made a mistake, but it’s honest. And most importantly, it saves you time.
A couple of subtle points are worth calling out explicitly so there’s no “magic”.
First, strict mode applies specifically to decoding into a struct. If you decode into a map[string]any, then “unknown fields” do not exist—the map is ready to accept any keys.
Second, strict mode helps catch typos and unexpected data, but it does not replace required-field validation. If the "title" field is missing, strict mode won’t complain—it will just leave Title at its zero value (empty string), and then your validation should say “title is required”.
5. Why strict mode doesn’t always work
After enabling DisallowUnknownFields, it’s tempting to believe that now “everything is safe” and JSON can’t trick us. But it’s important to understand the boundaries.
If you do this:
package main
import (
"encoding/json"
"fmt"
"strings"
)
func main() {
r := strings.NewReader(`{"id": 1, "titel": "Buy milk", "done": false}`)
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
var m map[string]any
err := dec.Decode(&m)
fmt.Println(err) // <nil>
fmt.Println(m["titel"]) // Buy milk
}
There’s no error—because we didn’t ask the decoder to match keys to struct fields. For a map, any field is “known”.
This isn’t a bug; it’s just a different purpose: map[string]any is used when the format is truly dynamic or you’re writing a generic “proxy”/“middleware” layer. But if your input has a fixed contract, it’s better to decode into a struct—and then strict mode works as intended.
6. One more level of strictness: one JSON and no trailing data
Strictness isn’t only about “unknown fields”. There’s another classic problem: the input contains not one JSON document, but two in a row, or JSON plus a “tail” of garbage. For example:
{"id":1,"title":"Buy milk","done":false} well hello
or even this:
{"id":1,"title":"Buy milk","done":false}
{"id":2,"title":"Pay bills","done":true}
Decode reads one JSON value per call. And if you do one Decode, it doesn’t have to automatically check that the stream ended after that. So for a “strict contract” people often use this pattern: after the first Decode, do a second Decode and make sure it returns io.EOF.
Let’s make a small reusable helper decodeOneStrict. It enforces two kinds of strictness at once: it disallows unknown fields and it disallows “extra data” after the JSON value.
package main
import (
"encoding/json"
"fmt"
"io"
)
func decodeOneStrict(r io.Reader, v any) error {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
// Check that there is no second JSON value after this.
if err := dec.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("unexpected extra JSON value")
}
return fmt.Errorf("trailing data: %w", err)
}
return nil
}
Note: we decode “into nowhere” via &struct{}{}. We don’t care about the value; we care about the decoder’s behavior. If there’s a second JSON value, the second Decode will return nil. If there’s garbage that doesn’t start like JSON, there will be an error other than io.EOF. If everything is clean, we get io.EOF.
7. Integrating strict parsing into an application
To keep the topic from staying too abstract, let’s integrate this into the logic of our learning “task tracker” app. For now, without CLI flags and without HTTP: we’ll just read one task from an io.Reader. This will be useful later for reading from a file, from stdin, and for tests via strings.NewReader.
Let’s make a function ReadTaskStrict that reads one task strictly and returns the struct.
package main
import (
"fmt"
"io"
)
func ReadTaskStrict(r io.Reader) (TaskDTO, error) {
var t TaskDTO
if err := decodeOneStrict(r, &t); err != nil {
return TaskDTO{}, fmt.Errorf("read task: %w", err)
}
return t, nil
}
Here we added error context via fmt.Errorf("...": %w, err). This is a good habit: when the error bubbles up, you’ll understand at which step it occurred.
By the way, encoding/json has typed errors (for example, *json.SyntaxError contains an Offset), and the idea “an error is a value that can carry useful fields” is used heavily in Go. This becomes especially nice when you start doing diagnostics like “syntax error at byte N”.
Let’s test how this looks in a mini main:
package main
import (
"fmt"
"strings"
)
func main() {
input := `{"id": 1, "titel": "Buy milk", "done": false}`
t, err := ReadTaskStrict(strings.NewReader(input))
fmt.Println(t) // {0 false}
fmt.Println(err) // read task: json: unknown field "titel"
}
The error is visible, the "read task" context is visible too. For the user (or for future you), this is much better than a silent zero.
8. Useful nuances of strict decoding
Table: strictness levels when decoding
When you design input, it’s useful to decide up front: do you want to be “strict” or “future-friendly”? Sometimes a format must be extensible, and then extra fields are better ignored. And sometimes you’re processing commands/configs where a typo is a critical error. At that point, it helps to keep in mind what “strictness knobs” we actually have.
| Approach | What it catches | What it doesn’t catch | Where it fits |
|---|---|---|---|
| Regular Decode/Unmarshal | Invalid JSON syntax, type mismatches | Typos in field names, a second JSON value in the stream | Flexible/evolving format, compatibility |
| DisallowUnknownFields() | Unknown fields when decoding into a struct | Trailing data after JSON, a second JSON document | Fixed contract (config, API request) |
| DisallowUnknownFields + io.EOF check | Both unknown fields and trailing data/second JSON | Missing required fields (needs validation) | Strict input, imports, critical operations |
Where strict validation should live
To avoid mixing up application “layers”, it helps to draw a simple diagram. Strict parsing is part of the decode stage, not business logic. Business logic should receive already-normal structs and think about meaning, not syntax.
flowchart TD
A[io.Reader: file / stdin / string] --> B[json.Decoder + DisallowUnknownFields]
B --> C["Decode(&dto) + check EOF"]
C --> D[Post-validation of fields]
D --> E[Business logic: create/update task]
This diagram is nice because it shows: strict mode does not replace validation; it complements it. First we guarantee the “struct contract”, then we guarantee the “meaning contract”.
9. Common mistakes in strict JSON parsing
Error #1: you enabled DisallowUnknownFields but you decode into a map[string]any and expect it to work.
This is a common trap because on the surface it looks like “I turned on strict mode.” But unknown fields are a concept only relative to a struct. If the receiver is a map, then all fields are “known” and there’s nothing to check. If the contract is fixed—decode into a struct.
Error #2: you check unknown fields but forget to check for “extra data” after the JSON.
In the streaming model, Decode reads one value. If the input contains two JSON values back-to-back, the first Decode will succeed, and you’ll get a “half-truth”: you read the first object, and ignored that there was garbage after it. Fix it with the pattern “the second Decode must return io.EOF”.
Error #3: mixing up zero value and a missing field.
Even in strict mode, a missing "done" field is not an error: Done will simply be false. Similarly, missing "title" leaves an empty string. If a field must be required, that’s solved by post-validation, not by DisallowUnknownFields.
Error #4: returning a “bare” error without context, and then not understanding where it happened.
When your program has several places where JSON is read (config, import, network input), an error like json: unknown field "titel" without context forces you to guess: “where did this come from?” A habit of adding context via fmt.Errorf("decode task: %w", err) makes life much easier. And the idea “an error can store details (for example, an offset in JSON)” is part of the Go approach to errors.
GO TO FULL VERSION