Optional Config Values: Matching JSON to Go Structs
Configuration files often carry options that are optional: a program may define many settings, but a particular run might only specify a few, relying on defaults for the rest. Mapping such JSON into Go structs introduces a few subtleties worth knowing about.
Unknown Fields and Missing Values
Start with a struct representing program options:
type Options struct {
Id string `json:"id,omitempty"`
Verbose bool `json:"verbose,omitempty"`
Level int `json:"level,omitempty"`
Power int `json:"power,omitempty"`
}
If a JSON file lists every field, a simple json.Unmarshal handles it. Real-world configs are rarely that tidy, though. Two cases tend to come up:
- The JSON omits some fields, and you want the Go struct to fall back to default values.
- The JSON contains extra keys that your struct doesn't define—you might want to ignore them or error out.
Go's json package handles the first case by only setting fields that appear in the JSON; anything else keeps its zero value. So if level is absent, Options.Level becomes 0.
For the second case, the default behavior is permissive: unknown fields pass silently. If you'd rather fail loudly, configure the decoder with DisallowUnknownFields:
dec := json.NewDecoder(bytes.NewReader(jsonText))
dec.DisallowUnknownFields()
var opts Options
if err := dec.Decode(&opts); err != nil {
fmt.Println("Decode error:", err)
}
Additionally, note that the omitempty tag on each field means zero-valued fields won't be emitted when marshaling:
opts := Options{
Id: "baz",
Level: 0,
}
out, _ := json.MarshalIndent(opts, "", " ")
fmt.Println(string(out))
Output:
{
"id": "baz"
}
To always include all fields, just drop omitempty from the tags.
Overriding the Zero-Value Default
Zero values suffice when they're the intended default—but that's not always so. If Power should default to 10 when "power" is absent from the JSON, a naive unmarshal gives you 0. And you can't simply check for zero afterward: what if the config actually sets "power": 0?
The robust solution is to set defaults before unmarshaling. Build a wrapper:
func parseOptions(jsn []byte) Options {
opts := Options{
Verbose: false,
Level: 0,
Power: 10,
}
if err := json.Unmarshal(jsn, &opts); err != nil {
log.Fatal(err)
}
return opts
}
Now replace direct calls to json.Unmarshal with parseOptions. If you prefer, you can tuck this logic into a custom UnmarshalJSON for Options:
func (o *Options) UnmarshalJSON(text []byte) error {
type options Options
opts := options{
Power: 10,
}
if err := json.Unmarshal(text, &opts); err != nil {
return err
}
*o = Options(opts)
return nil
}
Note the options type alias inside the method—this prevents infinite recursion in UnmarshalJSON. Every json.Unmarshal call for Options now sets the default power correctly.
This approach is clean for simple structs, but it carries two drawbacks. First, it couples default values tightly with parsing logic—any caller that needs different defaults can't override them after the fact. Second, it breaks down with nested structures:
type Region struct {
Name string `json:"name,omitempty"`
Power int `json:"power,omitempty"`
}
type Options struct {
Id string `json:"id,omitempty"`
Verbose bool `json:"verbose,omitempty"`
Level int `json:"level,omitempty"`
Power int `json:"power,omitempty"`
Regions []Region `json:"regions,omitempty"`
}
To default each Region's Power, you'd need a separate UnmarshalJSON for Region, spreading your default logic across many methods for deeper nesting.
Pointers for Distinguishing Absent from Zero
A different strategy offloads defaults to the caller using pointer fields:
type Options struct {
Id *string `json:"id,omitempty"`
Verbose *bool `json:"verbose,omitempty"`
Level *int `json:"level,omitempty"`
Power *int `json:"power,omitempty"`
}
Now, unmarshal JSON that omits some keys:
{
"id": "foobar",
"verbose": false,
"level": 10
}
After unmarshaling, fields missing from the JSON remain nil pointers, while those specified—even with zero values—point to allocated values. A parsing wrapper can then decide what to apply:
func parseOptions(jsn []byte) Options {
var opts Options
if err := json.Unmarshal(jsonText, &opts); err != nil {
log.Fatal(err)
}
if opts.Power == nil {
var v int = 10
opts.Power = &v
}
return opts
}
Setting opts.Power requires taking the address of a literal, which Go doesn't allow directly. Tiny helpers smooth that over:
func Bool(v bool) *bool { return &v }
func Int(v int) *int { return &v }
func String(v string) *string { return &v }
// etc...
With those, opts.Power = Int(10) works as expected.
The main advantage: defaults aren't baked into the parsing step. You can hand an Options to downstream code and let it apply defaults as it hits nil fields.
This pattern is battle-tested—the official protobuf package uses it for proto2 optional vs. required fields. That said, it has rough edges. Pointer syntax occasionally leaks through (as with the literal-address issue), and pointers can entail heap allocations—though for option structs, that's seldom a performance concern.



