JSON Marshaling in Go With Tags Omitempty and Custom Types
A tour of encoding/json in Go: struct tags, omitempty and omitzero, nil versus empty, time and bytes, int64 precision, HTML escaping and measured speed.

Most Go services speak JSON, and most of the surprises come from a small set of rules in encoding/json that are easy to learn once and expensive to learn in production. A timestamp that arrives in the wrong format, an ID that loses its last digit, an empty list that turns into null and crashes a mobile app, a password that appears in a response because someone embedded the wrong struct. None of these is a bug in the library. Each is documented behavior, and each is demonstrable in a few lines.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article is a tour of that behavior, produced by running code on Go 1.25.6. It covers the struct tags and what they do, the difference between nil and empty, how time, durations, byte slices and maps are written, where numbers lose precision, what the encoder escapes, which errors you can expect, and what marshaling costs. The follow-ups go deeper on individual parts: custom marshalers, telling null, missing and zero apart, strict decoding and streaming. The shapes of whole responses are in structuring JSON responses.
One program, every rule
The program below marshals a struct that uses most of the tag options, then walks through the edge cases that cause trouble. The output is the evidence for the rest of the article.
package main
import (
"bytes"
"encoding/json"
"fmt"
"math"
"strings"
"time"
)
type Audit struct {
CreatedBy string `json:"created_by"`
}
type Account struct {
Audit // embedded: its fields are flattened into the parent
ID int64 `json:"id,string"` // ",string" quotes the number
Name string `json:"name"`
Nick string `json:"nick,omitempty"`
Password string `json:"-"`
Balance float64 `json:"balance"`
Tags []string `json:"tags"`
Labels map[string]string `json:"labels,omitempty"`
Avatar []byte `json:"avatar"`
Joined time.Time `json:"joined"`
Timeout time.Duration `json:"timeout"`
internal string // unexported: silently ignored
}
func show(label string, v any) {
b, err := json.Marshal(v)
if err != nil {
fmt.Printf("%-30s ERROR %v\n", label, err)
return
}
fmt.Printf("%-30s %s\n", label, b)
}
func main() {
a := Account{
Audit: Audit{"ada"}, ID: 9007199254740993, Name: "Ada", Password: "hunter2", Balance: 12.5,
Avatar: []byte{0xde, 0xad, 0xbe, 0xef}, Joined: time.Date(2026, 3, 1, 9, 30, 0, 123_000_000, time.UTC),
Timeout: 1500 * time.Millisecond, internal: "x",
}
fmt.Println("== struct tags")
show("account", a)
fmt.Println("\n== nil versus empty")
show("nil slice", []string(nil))
show("empty slice", []string{})
show("nil map", map[string]int(nil))
show("nil pointer", (*int)(nil))
fmt.Println("\n== maps")
show("string keys (sorted)", map[string]int{"b": 2, "a": 1, "c": 3})
show("int keys become strings", map[int]string{10: "ten", 2: "two"})
fmt.Println("\n== HTML escaping")
show("default", "<b>Tom & Jerry</b>")
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.Encode("<b>Tom & Jerry</b>")
fmt.Printf("%-30s %s", "SetEscapeHTML(false)", buf.String())
fmt.Println("\n== things Marshal refuses or rewrites")
show("NaN", math.NaN())
show("channel", make(chan int))
show("invalid UTF-8", "caf\xe9")
show("huge float", 1e21)
show("small float", 0.000001)
fmt.Println("\n== decoding numbers into any")
const doc = `{"id": 9007199254740993, "price": 19.99}`
var loose map[string]any
json.Unmarshal([]byte(doc), &loose)
fmt.Printf("%-30s %v (%T)\n", "default", loose["id"], loose["id"])
dec := json.NewDecoder(strings.NewReader(doc))
dec.UseNumber()
var exact map[string]any
dec.Decode(&exact)
fmt.Printf("%-30s %v (%T)\n", "UseNumber", exact["id"], exact["id"])
fmt.Println("\n== decoding is forgiving about case, and about extra keys")
var got struct {
Name string `json:"name"`
Age int `json:"age"`
}
json.Unmarshal([]byte(`{"NAME":"Ada","Age":36,"colour":"blue"}`), &got)
fmt.Printf("%-30s %+v\n", "NAME/Age/colour", got)
err := json.Unmarshal([]byte(`{"name":"Ada","age":"36"}`), &got)
fmt.Printf("%-30s %v\n", "age as a string", err)
err = json.Unmarshal([]byte(`{"name":"Ada",}`), &got)
fmt.Printf("%-30s %v\n", "trailing comma", err)
}== struct tags
account {"created_by":"ada","id":"9007199254740993","name":"Ada","balance":12.5,"tags":null,"avatar":"3q2+7w==","joined":"2026-03-01T09:30:00.123Z","timeout":1500000000}
== nil versus empty
nil slice null
empty slice []
nil map null
nil pointer null
== maps
string keys (sorted) {"a":1,"b":2,"c":3}
int keys become strings {"10":"ten","2":"two"}
== HTML escaping
default "\u003cb\u003eTom \u0026 Jerry\u003c/b\u003e"
SetEscapeHTML(false) "<b>Tom & Jerry</b>"
== things Marshal refuses or rewrites
NaN ERROR json: unsupported value: NaN
channel ERROR json: unsupported type: chan int
invalid UTF-8 "caf\ufffd"
huge float 1e+21
small float 0.000001
== decoding numbers into any
default 9.007199254740992e+15 (float64)
UseNumber 9007199254740993 (json.Number)
== decoding is forgiving about case, and about extra keys
NAME/Age/colour {Name:Ada Age:36}
age as a string json: cannot unmarshal string into Go struct field .age of type int
trailing comma invalid character '}' looking for beginning of object key stringStruct tags
The first line of output is one Account marshaled. Compare it with the struct.
{"created_by":"ada","id":"9007199254740993","name":"Ada","balance":12.5,"tags":null,"avatar":"3q2+7w==","joined":"2026-03-01T09:30:00.123Z","timeout":1500000000}Rename. json:"created_by" sets the key. Without a tag the key is the Go field name, CreatedBy. API keys should be an explicit decision, so tag every field of a public type; an untagged field is renamed by a refactor and the API changes with it.
Embedding flattens. Audit is embedded, so its created_by appears at the top level of the parent, with no Audit object. This is useful for sharing common fields and dangerous when the embedded type is bigger than you remember. If two embedded types have the same JSON key at the same depth, both are dropped silently, which is the kind of bug that is found by a missing field. Give a struct field a name, rather than embedding it, when you want it nested.
Omit with -. Password has json:"-" and does not appear. Unexported fields, such as internal, are never encoded either, because the package cannot see them through reflection. The convention is to treat that as a safety net, not a security design: build response types that contain only what a client may see, as in mass assignment, rather than relying on dashes in a model that also carries secrets.
omitempty. Nick is empty and disappears; so does Labels, a nil map. The rule is that false, 0, a nil pointer or interface, and any empty array, slice, map or string are omitted. The catch is that a struct is never empty: time.Time tagged omitempty still prints 0001-01-01T00:00:00Z. The option added in Go 1.24 for that case is omitzero, which omits a field equal to its type's zero value, or whose type has an IsZero method that returns true. The null article builds a three-state type on it; the details are in the null, missing and zero article. Use omitempty for strings, slices and maps, and omitzero for structs such as times.
,string. ID is an int64 with json:"id,string" and appears as "9007199254740993", quoted. This is the standard way to send 64-bit integers to JavaScript, which reads every number as a double, so identifiers above 2^53 must travel as strings. The tag works for numbers, booleans and strings, and applies to the value only, not to a slice of them.
Nil is not empty
nil slice null
empty slice []
nil map null
nil pointer nullA nil slice is null and an empty one is []. In Go the two behave identically in most code, since you can range over and append to a nil slice, so the difference is easy to forget until the output reaches a client. The Account above has "tags":null because Tags was never set. A browser client doing account.tags.length throws. The defence is a constructor that replaces nil with empty for every collection that appears in a response, or a type that does it in its MarshalJSON, with a test that asserts [] for the empty case. The response structure article makes this a rule for list endpoints.
Time, durations, bytes and maps
time.Time marshals as an RFC 3339 string with the sub-second part trimmed of trailing zeros: 2026-03-01T09:30:00.123Z. It round-trips: a test below decodes the output back to an equal time. If you need a different layout, a date only, say, define a type and give it methods; do not format into a string field by hand and lose the type.
time.Duration is an int64 of nanoseconds, so 1.5 seconds is 1500000000. That is unfriendly for human-written configuration and for clients that expect seconds or a string like "1.5s". Pick a representation on purpose, and see custom marshalers for how.
[]byte is base64-encoded: the four bytes de ad be ef became "3q2+7w==". This is surprising when you expected an array of numbers, and it is the right default for binary data. It also means that a []byte field holding text is unreadable in the output; use a string for text.
Maps are written with their keys sorted, so output is deterministic: {"a":1,"b":2,"c":3} from a map built in the order b, a, c. Integer keys are converted to strings, so map[int]string{10: "ten", 2: "two"} becomes {"10":"ten","2":"two"}, and the keys are sorted as strings, which puts "10" before "2". Deterministic output is useful in tests and in caches keyed on a response body.
The number that loses a digit
Under "decoding numbers into any", the document {"id": 9007199254740993} was decoded twice. The first time, into map[string]any, the id printed as 9.007199254740992e+15, with a type of float64: the last digit changed from 3 to 2. JSON has one number type, and when you give the decoder any to fill, it picks float64. A double has 53 bits of mantissa, so integers above 2^53 (9,007,199,254,740,992) are not all representable, and ...993 rounds to ...992.
The second time, with Decoder.UseNumber, the value is a json.Number, a string-backed type that keeps the digits exactly and converts on demand with Int64() or Float64(). The practical rules:
- Decode into a typed struct whenever you can. An
int64field holds the full value; the third test proves it. - When you must use
any, useUseNumberif numbers might be large identifiers, and convert explicitly. - Send 64-bit identifiers as strings to JavaScript clients, with
,stringor a string field. - Never use
float64for money. Use integer minor units (cents) or a decimal type; JSON itself has no opinion, so the contract does.
HTML escaping
json.Marshal turns <, > and & into <, > and &, so that JSON embedded in an HTML page cannot close a script tag. That is the default for every Marshal and every Encoder. For an API that only returns application/json to programs, it is harmless but ugly, and clients decode it back to the same string. If you want the characters raw, create an Encoder, call SetEscapeHTML(false), and use Encode. Notice that the program's output for that line has the text followed by a newline: Encoder.Encode always appends one, which Marshal does not. Do not turn the escaping off for JSON that you place in a page, as in <script>var data = {{.}}</script>, since that is the case the default protects.
What Marshal refuses and what it rewrites
Some values cannot be written. NaN fails with json: unsupported value: NaN, and so does infinity; JSON has no representation for them, and a calculation that produces one will turn a successful response into a 500 unless you check. A channel, a function or a complex number fails with unsupported type. Maps with keys that are not strings, integers or text-marshalers fail the same way. Handle the error: ignoring the second return value of Marshal, as many snippets do, hides these.
Some values are quietly changed. Invalid UTF-8 in a string is replaced with the Unicode replacement character: a string ending in the lone byte e9 (latin-1 for an accented e) was written with the replacement character in its place and no error; this is how a latin-1 byte from a legacy source becomes mojibake. Floats are written in the shortest form that round-trips: 1e+21 for a large one and 0.000001 for a small one, switching to exponent notation outside a range.
Decoding is forgiving
The last block of the program shows the decoder's defaults. Keys match field names case-insensitively, so {"NAME":"Ada","Age":36} filled both fields. Unknown keys are ignored: colour went nowhere. That is convenient and dangerous. A misspelled key silently does nothing, and a client that sends "emial" gets a profile with no email and no complaint. For request bodies, turn on DisallowUnknownFields (strict JSON decoding). The case-insensitive matching has no switch in this package, which matters if two keys differ only by case.
Type mismatches are errors, and the messages are specific: an age of "36" gives cannot unmarshal string into Go struct field .age of type int. Syntax errors name the character: a trailing comma gives invalid character '}' looking for beginning of object key string. For an API, translate these into a short 400 and do not echo them verbatim if they might include user data.
Tests
package main
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestNilSliceIsNullEmptySliceIsArray(t *testing.T) {
a, _ := json.Marshal(struct{ X []int }{})
b, _ := json.Marshal(struct{ X []int }{X: []int{}})
if string(a) != `{"X":null}` || string(b) != `{"X":[]}` {
t.Fatalf("%s %s", a, b)
}
}
func TestTimeRoundTripsInRFC3339(t *testing.T) {
in := time.Date(2026, 3, 1, 9, 30, 0, 123_000_000, time.UTC)
b, _ := json.Marshal(in)
var out time.Time
if err := json.Unmarshal(b, &out); err != nil || !out.Equal(in) {
t.Fatalf("%s -> %v %v", b, out, err)
}
if !strings.Contains(string(b), "09:30:00.123Z") {
t.Fatalf("unexpected format %s", b)
}
}
func TestInt64SurvivesWhenDecodedIntoTheRightType(t *testing.T) {
const doc = `{"id":9007199254740993}`
var typed struct{ ID int64 }
json.Unmarshal([]byte(doc), &typed)
var loose map[string]any
json.Unmarshal([]byte(doc), &loose)
if typed.ID != 9007199254740993 {
t.Fatalf("typed: %d", typed.ID)
}
if int64(loose["id"].(float64)) == 9007199254740993 {
t.Fatal("float64 was expected to lose the last digit")
}
}
func TestUnexportedAndDashFieldsAreNeverWritten(t *testing.T) {
b, _ := json.Marshal(Account{Password: "secret", internal: "x"})
if strings.Contains(string(b), "secret") || strings.Contains(string(b), "internal") {
t.Fatalf("leaked: %s", b)
}
}=== RUN TestNilSliceIsNullEmptySliceIsArray
--- PASS: TestNilSliceIsNullEmptySliceIsArray (0.00s)
=== RUN TestTimeRoundTripsInRFC3339
--- PASS: TestTimeRoundTripsInRFC3339 (0.00s)
=== RUN TestInt64SurvivesWhenDecodedIntoTheRightType
--- PASS: TestInt64SurvivesWhenDecodedIntoTheRightType (0.00s)
=== RUN TestUnexportedAndDashFieldsAreNeverWritten
--- PASS: TestUnexportedAndDashFieldsAreNeverWritten (0.00s)
PASS
ok example.test/lab 1.243sThe tests pin the four behaviors that bite: nil versus empty, the time format round-trip, the int64 precision difference between typed and untyped decoding, and the fact that dashes and unexported fields never reach the output. Keep a test like the last on any type that carries a secret.
Marshal, Encoder and the response writer
json.Marshal(v) returns a []byte; json.NewEncoder(w).Encode(v) writes to an io.Writer and appends a newline. In a handler the second is the usual choice, because it skips a copy in your code. The trap is ordering. A handler that calls WriteHeader(201) and then Encode has already sent its status when a NaN makes the encode fail, and the client sees a 201 with an empty body. If a value can fail to marshal, marshal to a buffer first, check the error, then send the status and the bytes. For small responses the extra copy costs nothing, and the 500 you can still send is worth it.
The decoding twins are json.Unmarshal for a byte slice and json.NewDecoder(r).Decode(&v) for a stream. A decoder reads one JSON value and stops, so a body with two values decodes successfully the first time unless you check that the rest is empty, another point in the strict decoding article. Wrap request bodies in http.MaxBytesReader before decoding, whichever you use.
What it costs
package main
import (
"encoding/json"
"testing"
"time"
)
type Row struct {
ID int64 `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Tags []string `json:"tags"`
At time.Time `json:"at"`
}
var row = Row{42, "widget", 19.99, []string{"a", "b"}, time.Date(2026, 3, 1, 9, 30, 0, 0, time.UTC)}
var asMap = map[string]any{"id": int64(42), "name": "widget", "price": 19.99, "tags": []string{"a", "b"}, "at": row.At}
func BenchmarkMarshalStruct(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
json.Marshal(row)
}
}
func BenchmarkMarshalMap(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
json.Marshal(asMap)
}
}
func BenchmarkUnmarshalStruct(b *testing.B) {
data, _ := json.Marshal(row)
b.ReportAllocs()
for b.Loop() {
var r Row
json.Unmarshal(data, &r)
}
}
func BenchmarkUnmarshalMap(b *testing.B) {
data, _ := json.Marshal(row)
b.ReportAllocs()
for b.Loop() {
var m map[string]any
json.Unmarshal(data, &m)
}
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkMarshalStruct-10 2804142 415.8 ns/op 224 B/op 3 allocs/op
BenchmarkMarshalMap-10 1397541 860.9 ns/op 512 B/op 13 allocs/op
BenchmarkUnmarshalStruct-10 1000000 1100 ns/op 368 B/op 9 allocs/op
BenchmarkUnmarshalMap-10 732865 1517 ns/op 832 B/op 30 allocs/op
PASS
ok example.test/lab 4.788sOn an Apple M1 Max with Go 1.25.6, marshaling a five-field struct took 419 ns and 3 allocations. The same data as a map[string]any took 896 ns and 13 allocations, about twice as long. Decoding followed the same shape: 1,100 ns and 9 allocations into a struct, 1,469 ns and 30 allocations into a map. A typed struct is faster because the package caches the field layout per type, and it avoids boxing every value in an interface. It is also safer, since the compiler checks the names. Reach for maps when the shape is genuinely dynamic, and for structs the rest of the time.
These costs are small against the network and the database. They add up for large payloads and hot paths, where the first optimisation is usually to avoid marshaling at all: write the response through an Encoder directly to the ResponseWriter instead of building a []byte first, and stream large collections (streaming JSON, large responses). Third-party encoders can be several times faster, at the cost of a dependency and of subtle differences from the standard behavior. Measure on your payloads before switching.
Habits that prevent most problems
- Tag every field of a public type, and write the key you mean.
- Give responses their own types, not your database models.
- Normalize collections so empty is
[]. - Decode into structs; use
UseNumberif you must useany. - Send big integers as strings.
- Check the error from
Marshal, becauseNaNand friends are real. - Turn on strict decoding for request bodies.
- Test the wire format for each public type with a golden string, so a refactor cannot change the API unnoticed.
The standard library's behavior is specified in the encoding/json package documentation, and the Go blog's JSON and Go article is the original tour. A new major version of the package is in development; what it changes is in JSON v2 in Go.


