Encoding JSON and Struct Tags in Go

Turn Go structs into JSON with json.Marshal, control field names with struct tags, use omitempty and "-", and fix fields that do not show up.

The first JSON surprise in Go is usually an empty object: you marshal a struct and get {}. The second is field names in the wrong case. Both come down to two rules, exported fields and struct tags, and this page covers them along with omitempty.

json.Marshal and Exported Fields

package main

import (
	"encoding/json"
	"fmt"
)

type user struct {
	name  string
	Email string
}

func main() {
	data, err := json.Marshal(user{name: "Asha", Email: "asha@example.com"})
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(string(data))
}
{"Email":"asha@example.com"}

name is missing. encoding/json can only see exported fields, the ones that start with a capital letter. Lowercase fields are skipped without any error. If you get {} or missing fields, this is almost always the cause.

Struct Tags Set the JSON Names

Go fields are CamelCase, but most APIs use snake_case or camelCase. A struct tag tells the encoder what name to use:

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

type Order struct {
	ID        int       `json:"id"`
	Customer  string    `json:"customer_name"`
	Total     float64   `json:"total"`
	CreatedAt time.Time `json:"created_at"`
	Items     []string  `json:"items"`
}

func main() {
	o := Order{
		ID:        42,
		Customer:  "Ravi",
		Total:     19.5,
		CreatedAt: time.Date(2026, 3, 1, 10, 30, 0, 0, time.UTC),
		Items:     []string{"pen", "notebook"},
	}
	data, _ := json.MarshalIndent(o, "", "  ")
	fmt.Println(string(data))
}
{
  "id": 42,
  "customer_name": "Ravi",
  "total": 19.5,
  "created_at": "2026-03-01T10:30:00Z",
  "items": [
    "pen",
    "notebook"
  ]
}

The tag goes in backticks after the type. MarshalIndent gives readable output, which is nice for config files and debugging. time.Time is written in RFC 3339 format automatically.

Tag typos fail silently:

json: "id" with a space, or json:id without quotes, is not a valid tag, and the field falls back to its Go name. go vet catches this with a "struct field tag ... not compatible with reflect.StructTag.Get" warning, so run it.

omitempty and "-"

Two tag options cover most needs:

package main

import (
	"encoding/json"
	"fmt"
)

type Profile struct {
	Name     string   `json:"name"`
	Bio      string   `json:"bio,omitempty"`
	Tags     []string `json:"tags,omitempty"`
	Age      int      `json:"age,omitempty"`
	Password string   `json:"-"`
}

func main() {
	a, _ := json.Marshal(Profile{Name: "Meera", Password: "secret"})
	fmt.Println(string(a))

	b, _ := json.Marshal(Profile{Name: "Dev", Bio: "Go dev", Tags: []string{"go"}, Age: 30})
	fmt.Println(string(b))
}
{"name":"Meera"}
{"name":"Dev","bio":"Go dev","tags":["go"],"age":30}
  • omitempty leaves the field out when it holds the zero value: "", 0, false, nil, or an empty slice or map.
  • - means never include this field. Use it for passwords and internal data.

omitempty can hide real zeros:

With omitempty, an Age of 0 disappears from the output, so the reader cannot tell "zero" from "not set". When zero is a valid value, use a pointer like *int. A nil pointer is omitted, while a pointer to 0 is written as 0.

omitempty has no effect on structs such as time.Time, because a struct is never considered empty. Go 1.24 added the omitzero option for that case, which omits a field when it is the zero value of its type, including a zero time.Time.

Nil Slices vs Empty Slices

This catches people writing APIs for JavaScript frontends:

package main

import (
	"encoding/json"
	"fmt"
)

type Response struct {
	Results []string `json:"results"`
}

func main() {
	var none []string
	a, _ := json.Marshal(Response{Results: none})
	b, _ := json.Marshal(Response{Results: []string{}})
	fmt.Println(string(a))
	fmt.Println(string(b))
}
{"results":null}
{"results":[]}

A nil slice becomes null, and an empty slice becomes []. If your frontend does data.results.length, null crashes it. Initialize the slice with []string{} or make([]string, 0) before encoding when you want [].

Maps and Custom Output

For JSON whose shape you do not know ahead of time, a map[string]any works. Keys are sorted in the output:

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	data, _ := json.Marshal(map[string]any{
		"status": "ok",
		"count":  3,
		"tags":   []string{"a", "b"},
	})
	fmt.Println(string(data))
}
{"count":3,"status":"ok","tags":["a","b"]}

Prefer structs when you know the fields. They document the format and the compiler catches typos.

You control the JSON shape:

If you can explain why a lowercase field is missing, rename fields with tags, drop empty ones with omitempty, hide secrets with -, and choose between null and [], you can produce any JSON an API needs.

Summary

json.Marshal only encodes exported fields, so capitalize the ones you want. Struct tags like json:"created_at" set the names, omitempty drops zero values, omitzero handles zero structs, and - hides a field completely. Use pointers when zero is meaningful, and remember that a nil slice encodes as null while an empty one encodes as [].