GoGo example

How to convert a struct to JSON in Go

6 min read▶ Runs in an isolated hosted runtimeUpdated Aug 2026

Quick answer

Use encoding/json: data, err := json.Marshal(v). It returns []byte, not a string, so print it with string(data). Only exported (capitalised) fields are encoded, and a json:"name" struct tag chooses the key.

encoding/json ships with Go, so there is nothing to install and no code to generate. It walks your struct with reflection at run time, and every rule that trips people up follows from that: reflection can only read exported fields, so a lowercase field simply never appears, and the only way to rename a key or drop an empty one is the json struct tag. Each example below runs on this page — hit Run, then edit the code and run it again.

1json.MarshalRecommended

The whole job is one call: func Marshal(v any) ([]byte, error). Hand it a struct (or a pointer to one — Marshal dereferences it) and you get the encoded document back as bytes. Bytes, not a string, because that is what you actually want to hand to w.Write, os.WriteFile or an HTTP body; convert with string(data) only when a human has to read it.

marshal.go

Output

Prints []uint8 — Go's own name for []byte — and then {"Name":"Ada Lovelace","Email":"[email protected]","Age":36,"Admin":true}. Two things to notice: the keys are the Go field names, capitals and all, which is almost never the JSON you want (section 2 fixes it), and the err is real — encoding a struct of plain fields can't fail, but a channel, a function or a NaN float will, so check it.

2Struct tags: rename, omit, hide

A struct tag is a backtick-quoted string after the field type, and encoding/json reads the json: key out of it. Three forms cover almost everything: json:"name" renames the key, json:"nickname,omitempty" drops the field when its value is the zero value, and json:"-" removes it from the output entirely — the right way to keep a password or an internal ID out of an API response.

tags.go

Output

The first line is {"name":"Ada","email":"[email protected]"}: no nickname and no age, because both were still zero, and no password ever. Fill them in and the second line becomes {"name":"Ada","email":"[email protected]","nickname":"The Countess","age":36}, while u.Password still prints hunter2 — the tag hides the field from JSON, not from Go. Watch omitempty on booleans and numbers: it treats false and 0 as empty, so a real "age": 0 disappears too. When you must distinguish "zero" from "absent", make the field a pointer (*int) — then only nil is omitted.

3Pretty-print with json.MarshalIndent

json.MarshalIndent(v, prefix, indent) is Marshal plus line breaks. The usual call is json.MarshalIndent(v, "", " ") — no prefix, two spaces per level. The prefix argument is prepended to every line except the first, which is what you want when the block is being pasted into something already indented.

indent.go

Output

The first block is the familiar two-space JSON; the second shows the tab prefix pushing every line but the opening brace to the right. The last line prints 94 bytes pretty vs 64 bytes compact — nearly 50% more bytes on this tiny struct, which is why you indent logs, config files and docs but send compact JSON over the wire. Indenting is not free either: it is a second pass over the bytes, so don't reach for it in a hot path.

4Nested structs, slices, maps and time.Time

Marshal recurses, so a struct of structs, slices and maps needs no extra work — a nested struct becomes a nested object, a slice becomes an array, and a map[string]T becomes an object with its keys sorted (that is a documented guarantee, so the output is stable and diffable — no sorting the keys yourself the way you would to range over the map deterministically). time.Time implements json.Marshaler and encodes itself as an RFC 3339 string. The gotchas are on the empty side: a nil slice or map encodes as null, not [] or {}.

nested.go

Output

The limits map comes out as burst, daily, rps — alphabetical, not the order written. created renders as "2026-08-06T09:30:00Z", the nil *time.Time as null, and referrer vanishes because omitempty plus a nil pointer is the one combination that behaves the way people expect. The last line marshals an almost-empty Account and shows the trap: "limits":null and "labels":null for the untouched maps, but "orders":[] for the slice that was explicitly set to an empty non-nil one. JavaScript clients that loop over the result will happily iterate [] and crash on null, so initialise the slices you intend to send. Look at created on that same line, too: the field is tagged json:"created,omitempty" and the zero time is written out anyway as "0001-01-01T00:00:00Z". omitempty has no notion of an empty struct, so it never drops a time.Time — reach for *time.Time when "no date" has to mean absent.

5Which should you use?

MethodOutputCostBest for
json.Marshal(v)Compact []byteOne buffer per callAlmost everything
json.MarshalIndent(v, "", " ")Indented []byteSecond pass + ~50% bytesConfig files, logs, debugging
json.NewEncoder(w).Encode(v)Streamed, one per lineNo full copy heldHTTP handlers, NDJSON, big files
json.Marshal(map[string]any{…})Compact []byteBoxing + key sortAd-hoc shapes with no struct
fmt.Sprintf("%+v", v)Go syntax — not JSONCheapDebug printing only

6Streaming with json.NewEncoder

json.NewEncoder(w) writes straight to an io.Writer instead of building a []byte first — that is the idiomatic body of an HTTP handler (json.NewEncoder(w).Encode(resp)) and the way to emit newline-delimited JSON without ever holding the whole stream in memory. Encode appends a \n after each document, which is exactly the NDJSON format. The snippet writes to a bytes.Buffer so the page can show you the bytes; swap it for os.Stdout, a file or an http.ResponseWriter and nothing else changes.

encoder.go

Output

The three events come out one per line, no enclosing array — that is what a log tail or a jq pipeline wants. The fourth line shows the other surprise: json.Marshal escapes <, > and & as \u003c, \u003e and \u0026 so the output is safe to drop inside a <script> tag. It is still valid JSON and decodes back to the same string, but if you need the literal characters, only the encoder can turn it off — enc.SetEscapeHTML(false), shown on the last block together with enc.SetIndent, the encoder's answer to MarshalIndent.

Frequently asked questions

Why is my Go struct field missing from the JSON?

Almost always because the field is unexported. encoding/json uses reflection, and reflection cannot read a field whose name starts with a lowercase letter, so name string is silently skipped while Name string is encoded. Rename it to Name and add a json:"name" tag if you want a lowercase key. The other two causes are a json:"-" tag, which removes the field deliberately, and omitempty on a field that currently holds its zero value.

Does json.Marshal return a string?

No — its signature is func Marshal(v any) ([]byte, error), so you get bytes. Printing the result directly shows a slice of numbers; wrap it with string(data) (or use %s in a Printf) to see the JSON text. Bytes are the useful form for w.Write, os.WriteFile and HTTP bodies, so only convert when a human is reading.

What exactly does omitempty treat as empty?

The false boolean, 0 for any numeric type, an empty string, a nil pointer or interface, and an array, slice or map with length zero. It does NOT consider a zero struct empty — so a zero time.Time still encodes as "0001-01-01T00:00:00Z". When you need to tell "zero" apart from "not set", make the field a pointer such as *int or *bool; then only nil is omitted.

How do I pretty-print a struct as JSON in Go?

Use json.MarshalIndent(v, "", " ") — same as Marshal but with newlines and two-space indentation — then string(...) the result. If you are already writing to a stream, enc := json.NewEncoder(w); enc.SetIndent("", " ") does the same thing without building the whole document in memory.

Why does json.Marshal turn < and & into \u003c and \u0026?

By default encoding/json escapes <, > and & so the output can be embedded in HTML — inside a <script> block, for instance — without letting a string close the tag. The escaped form is still valid JSON and decodes back to the original characters. To switch it off you need an encoder: enc := json.NewEncoder(w); enc.SetEscapeHTML(false). json.Marshal has no equivalent option.

When does json.Marshal actually return an error?

When the value contains something JSON has no representation for. A function or channel field fails with json: unsupported type: func() int, and a non-finite float fails with json: unsupported value: +Inf or json: unsupported value: NaN. A pointer cycle is caught too — encoding/json returns an "encountered a cycle" error instead of recursing forever. Plain structs of strings, numbers, bools, slices, maps and time values never fail, but check err anyway: the compiler will not remind you later when someone adds a field.