Go version

go1.25.0 (actually since omitzero was added in go.1.24.0)

Output of go env in your module/workspace:

AR='ar'
CC='gcc'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='g++'
GCCGO='gccgo'
GO111MODULE=''
GOAMD64='v1'
GOARCH='amd64'
GOAUTH='netrc'
GOBIN=''
GOCACHE='/home/maruel/.cache/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/home/maruel/.config/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -m64 -pthread -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=/tmp/go-build3213510385=/tmp/go-build -gno-record-gcc-switches'
GOHOSTARCH='amd64'
GOHOSTOS='linux'
GOINSECURE=''
GOMOD='/home/maruel/src-oth/golang/src/go.mod'
GOMODCACHE='/home/maruel/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='linux'
GOPATH='/home/maruel/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/home/maruel/src-oth/golang'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/home/maruel/.config/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/home/maruel/src-oth/golang/pkg/tool/linux_amd64'
GOVCS=''
GOVERSION='go1.25.0'
GOWORK=''
PKG_CONFIG='pkg-config'

What did you do?

encoding/json's omitzero is great. One surprising behavior is that zero length and zero capacity slices are still included. I wasn't expecting that.

https://github.com/golang/go/blob/go1.25.0/src/encoding/json/encode.go#L747 fv.IsZero() calls https://github.com/golang/go/blob/go1.25.0/src/reflect/value.go#L1676 which only checks for nil. It's actually a bummer that reflect.Value.IsZero() only checks for nil for slices and not for zero-length. I fear it would probably be a breaking change to change that but that would be my preferred fix.

I believe there's a workaround that doesn't require changing reflect, https://github.com/golang/go/blob/go1.25.0/src/encoding/json/encode.go#L1216 we could hack this and add an explicit check for slice and if case t.Kind() == reflect.Slice && !t.Implements(isZeroerType): then add our custom isZero that return v.Len() == 0.

That said, there's already a workaround today. It is to create a type and implement IsZero(), as shown below:

https://go.dev/play/p/_Dyirw7MVQo

package main

import (
    "encoding/json"
    "fmt"
)

type Contents []string

func (c *Contents) IsZero() bool {
    return len(*c) == 0
}

type Container1 struct {
    Content []string `json:"content,omitzero"`
}

type Container2 struct {
    Content Contents `json:"content,omitzero"`
}

func printJSON(d any) {
    b, _ := json.Marshal(d)
    fmt.Println(string(b))
}

func main() {
    println("Container1")
    printJSON(Container1{Content: make([]string, 0, 1)})
    printJSON(Container1{Content: make([]string, 0)})
    printJSON(Container1{Content: nil})

    println("Container2")
    printJSON(Container2{Content: make([]string, 0, 1)})
    printJSON(Container2{Content: make([]string, 0)})
    printJSON(Container2{Content: nil})
}

What did you see happen?

Container1
{"content":[]}
{"content":[]}
{}
Container2
{}
{}
{}

What did you expect to see?

Container1
{}
{}
{}
Container2
{}
{}
{}

Comment From: TapirLiu

zero-length slices are not zero slices. It looks the docs is clear. The reflect IsZero is right here.

Comment From: callthingsoff

Hi, what do you think about also adding omitempty option:

`json:"content,omitzero,omitempty"`

https://go.dev/play/p/3VX3JLvdwWB

Container1
{}
{}
{}
Container2
{}
{}
{}

Comment From: maruel

Hi, what do you think about also adding omitempty option:

`json:"content,omitzero,omitempty"`

Oh I didn't think of that! That's brilliant! I guess that's the best solution all around. I'll update my code accordingly.

Comment From: gabyhelp

Related Issues

Related Code Changes

(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)