A JSON API is expected to always return a collection key as an array, using [] for the empty case. In practice:
Verified on Go 1.26.0. Full runnable repro, tests, and write-up are in /tmp/omitempty-demo/ (api.go, api_test.go, SOLUTION.md, go.mod). Solution below.
encoding/json omitempty Slice Contract DriftA JSON API is expected to always return a collection key as an array, using [] for the empty case. In practice:
json:"roles,omitempty" disappear entirely when the slice is non-nil but has length 0.omitempty fixed the missing keys, but now nil slices emit null instead of [].null field.encoding/json decides whether to skip an omitempty field with isEmptyValue:
// encoding/json/encode.go (abridged)
func isEmptyValue(v reflect.Value) bool {
switch v.Kind() {
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
return v.Len() == 0
...
}
}
For slices the test is len(v) == 0, not v == nil. Therefore a non-nil empty slice ([]string{}, make([]string, 0)) is "empty" and the tagged field is omitted. Conversely, once omitempty is removed, the default marshaler renders a nil slice as the JSON literal null.
So neither "keep the tag" nor "delete the tag" alone yields the desired []. The tag and the value's nil-ness are two independent axes and both must be pinned. The drift appears because the struct's zero value (nil slice) disagrees with the wire contract ([]); the zero value leaks onto the wire whenever a struct is constructed without a normalizing constructor.
Two changes, applied together:
omitempty from the collection field so the key is always emitted.// FixedUser is the corrected representation: no `omitempty` on the
// collection field, and NewFixedUser guarantees a non-nil empty slice.
type FixedUser struct {
ID int `json:"id"`
Name string `json:"name"`
Roles []string `json:"roles"` // always emitted; [] when empty
}
// NewFixedUser is the single construction path that upholds the invariant
// "Roles is never nil". Every exported builder must funnel through here.
func NewFixedUser(id int, name string, roles []string) *FixedUser {
if roles == nil {
roles = []string{}
}
return &FixedUser{ID: id, Name: name, Roles: roles}
}
Rules that make the invariant hold:
[]string{} / make([]string, 0) — never var s []string — when building the field.if roles == nil), not at call sites; a single choke point is auditable.map[...] fields: remove omitempty and initialize to a non-nil empty map.json.Unmarshal of a missing or explicit null value sets the slice to nil, which would then re-serialize as null. If the type is also a decode target, add a MarshalJSON safety net:
func (u FixedUser) MarshalJSON() ([]byte, error) {
type alias FixedUser // alias drops methods; avoids recursion
a := alias(u)
if a.Roles == nil {
a.Roles = []string{}
}
return json.Marshal(a)
}
The constructor remains the primary invariant; MarshalJSON only covers values that bypassed it.
mkdir -p /tmp/omitempty-demo && cd /tmp/omitempty-demo
cat > go.mod <<'EOF'
module omitempty-demo
go 1.26
EOF
# place api.go and api_test.go here, then:
gofmt -l .
go vet ./...
go test -v ./...
The tests encode both the bug and the corrected contract — the first two assert the broken behavior so it can't silently return:
func TestBuggyOmitemptyDropsEmptySlice(t *testing.T) {
b := BuggyUser{ID: 1, Name: "ada", Roles: []string{}} // non-nil, zero-length
got, _ := json.Marshal(b)
if strings.Contains(string(got), "roles") {
t.Fatalf("expected omitempty to drop roles, got %s", got)
}
}
func TestFixedEmptySliceSerializesAsArray(t *testing.T) {
u := NewFixedUser(1, "ada", nil) // nil input is normalised
got, _ := json.Marshal(u)
want := `{"id":1,"name":"ada","roles":[]}`
if string(got) != want {
t.Fatalf("empty path\n got: %s\nwant: %s", got, want)
}
}
func TestFixedPopulatedSlicePreserved(t *testing.T) {
u := NewFixedUser(2, "grace", []string{"admin", "ops"})
got, _ := json.Marshal(u)
want := `{"id":2,"name":"grace","roles":["admin","ops"]}`
if string(got) != want {
t.Fatalf("populated path\n got: %s\nwant: %s", got, want)
}
}
Observed result:
=== RUN TestBuggyOmitemptyDropsEmptySlice
--- PASS: TestBuggyOmitemptyDropsEmptySlice (0.00s)
=== RUN TestBuggyRemovingOmitemptyEmitsNull
--- PASS: TestBuggyRemovingOmitemptyEmitsNull (0.00s)
=== RUN TestFixedEmptySliceSerializesAsArray
--- PASS: TestFixedEmptySliceSerializesAsArray (0.00s)
=== RUN TestFixedPopulatedSlicePreserved
--- PASS: TestFixedPopulatedSlicePreserved (0.00s)
=== RUN TestFixedRoundTrip
--- PASS: TestFixedRoundTrip (0.00s)
PASS
ok omitempty-demo 0.003s
omitempty on fields that must appear as arrays/objects."roles":[] (test).[] decodes to a non-nil slice and re-encodes as [].go vet and gofmt -l are clean.# Evidence - Problem class: go-json-omitempty-nil-slice-api-contract-drift - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-11T23:13:00.112Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Go encoding/json API contract bug: fields tagged omitempty vanish for zero-length slices even when non-nil; removing omitempty alone can emit null for nil slices. Fix by removing omitempty and initializing collection fields to non-nil empty slices, with regression tests for empty arrays and populated paths.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-json-omitempty-nil-slice-api-contract-drift", "provider": "openrouter", "solved_at": "2026-09-11T23:13:00.112Z", "version": ""}