You have data in memory. The wire needs JSON.
You're writing a web handler. You queried the database and got a user record. The frontend expects a JSON payload. You have a Go struct sitting in memory. You need to turn that struct into a string of bytes that a browser can parse. This happens in almost every Go service. The standard library handles it without external dependencies.
Go's encoding/json package provides Marshal, which converts any Go value to a JSON byte slice. It uses reflection to inspect your types, respects struct tags for control, and produces compact output. You don't need to install anything. The tool is built into the language.
How serialization works
Serialization is the process of converting an in-memory data structure into a format that can be stored or transmitted. json.Marshal takes your struct and produces a []byte. It looks at the struct fields, checks if they are exported, and writes them out. Struct tags give you control over the output keys and behavior.
The function walks the value recursively. If it sees a string, it writes a quoted string. If it sees an int, it writes a number. If it sees a slice, it writes an array. If it sees a map, it writes an object. If it sees a struct, it writes an object with keys derived from the field names or tags.
Reflection is the engine. Struct tags are the steering wheel.
Minimal example
Here's the simplest conversion: define a struct, marshal it, handle the error.
package main
import (
"encoding/json"
"fmt"
)
type User struct {
// Exported fields start with a capital letter.
// The json tag controls the key name in the output.
Name string `json:"name"`
Age int `json:"age"`
}
func main() {
u := User{Name: "Alice", Age: 30}
// Marshal converts the struct to a byte slice.
// It returns an error if the type cannot be serialized.
data, err := json.Marshal(u)
if err != nil {
// Marshal rarely fails for simple structs.
// It fails on cycles or unsupported types like functions.
panic(err)
}
// Convert bytes to string for display.
fmt.Println(string(data))
}
The output is {"name":"Alice","age":30}. The keys match the tags. The values match the types. The byte slice contains valid JSON.
Walking through the runtime
When you call Marshal, the runtime uses reflection to inspect the struct type. It iterates over the fields in declaration order. For each field, it checks the visibility. If the field name starts with a lowercase letter, the field is unexported. Marshal skips unexported fields silently. This is a common source of confusion. You think the data is there, but it's not.
If the field is exported, Marshal checks for a json struct tag. The tag format is json:"key,options". The key overrides the field name. Options modify behavior. If there is no tag, Marshal uses the field name as the key, lowercased.
The function handles type conversion automatically. Booleans become true or false. Nil pointers become null. Empty slices become []. Empty maps become {}. The result is a compact byte slice with no whitespace.
Marshal reads the struct. Tags guide the output.
Realistic API response
Real APIs need more control. You often want to skip null fields, handle nested objects, or wrap data in a response envelope. Struct tags support omitempty to skip fields when they hold the zero value. Pointers allow distinguishing between missing data and present-but-null data.
Here's a response structure with nesting and omission logic:
package main
import (
"encoding/json"
"fmt"
)
type Address struct {
Street string `json:"street"`
City string `json:"city"`
}
type Profile struct {
// Pointer to Address allows nil.
// omitempty skips the field if Address is nil.
Address *Address `json:"address,omitempty"`
// omitempty skips the field if the string is empty.
Bio string `json:"bio,omitempty"`
}
type Response struct {
// Status code for the API response.
Status int `json:"status"`
// Data holds the actual payload.
Data Profile `json:"data"`
}
func main() {
// Create a profile with no address.
p := Profile{
Bio: "Go developer",
}
// Marshal the response wrapper.
resp := Response{Status: 200, Data: p}
data, err := json.Marshal(resp)
if err != nil {
panic(err)
}
fmt.Println(string(data))
}
The output is {"status":200,"data":{"bio":"Go developer"}}. The address field is missing because the pointer is nil and omitempty is set. The bio field is present because the string is not empty.
Use omitempty to keep payloads lean. Watch out for zero values you actually want to send.
Pitfalls and compiler errors
Unexported fields vanish. Marshal ignores them silently. You lose data without a warning. Capitalize the field name to export it. If you intentionally want to hide a field, use json:"-" to make the intent explicit.
Circular references cause runtime errors. If a struct contains a pointer back to itself, Marshal detects the cycle and returns an error. The error message is json: unsupported value: encodes to Go value type. You must break cycles manually or use a custom marshaler.
Unsupported types fail immediately. Functions, channels, and complex numbers cannot be serialized. The compiler rejects these with json: unsupported type: func() or similar messages. You cannot marshal a function. You cannot marshal a channel.
Float precision can be tricky. Go's float64 maps to JSON numbers. Very large or very small floats may lose precision or trigger scientific notation. If you need exact decimal representation, use a string or a custom type.
time.Time marshals to RFC3339 format by default. This is usually what you want. The output looks like "2024-01-15T10:30:00Z". If you need a different format, implement the json.Marshaler interface.
The community accepts verbose error handling. if err != nil { return err } is the standard pattern. It makes the unhappy path visible. Don't swallow errors from Marshal.
Unexported fields are invisible to JSON. Capitalize them or lose the data.
Custom marshaling
When the default behavior doesn't match your API contract, you can implement MarshalJSON. This method lets you control the exact bytes produced. It's useful for formatting numbers, handling sensitive data, or producing non-standard structures.
Here's a custom type that formats a monetary value as a string:
package main
import (
"encoding/json"
"fmt"
)
// Money represents an amount in cents.
type Money struct {
Cents int64
}
// MarshalJSON formats the money as a currency string.
// The receiver is a value to avoid accidental mutation.
func (m Money) MarshalJSON() ([]byte, error) {
// Return a quoted string for JSON.
// json.Marshal handles the quoting automatically.
return json.Marshal(fmt.Sprintf("$%.2f", float64(m.Cents)/100))
}
type Invoice struct {
Total Money `json:"total"`
}
func main() {
inv := Invoice{Total: Money{Cents: 1234}}
data, err := json.Marshal(inv)
if err != nil {
panic(err)
}
fmt.Println(string(data))
}
The output is {"total":"$12.34"}. The custom method returns a string representation. The caller gets the formatted value without knowing the internal storage.
Standard marshaling covers 90% of cases. Write custom logic only when the contract demands it.
Performance considerations
json.Marshal allocates a byte slice. It builds the entire JSON document in memory before returning. This is fine for small payloads. For large responses, the allocation can add pressure to the garbage collector.
If you're writing to an http.ResponseWriter or a file, use json.NewEncoder. The encoder writes directly to the io.Writer. It avoids the intermediate byte slice. It streams the output in chunks. This reduces memory usage and improves throughput.
The convention is to use Marshal for logging, storage, or when you need the bytes. Use Encoder for HTTP responses and file output.
gofmt is mandatory. Don't argue about indentation. Let the tool decide. Most editors run gofmt on save. Your JSON tags should follow the same discipline. Keep them on the same line as the field. Use json:"key,omitempty" consistently.
Marshal to bytes for memory. Encode to writers for streams.
Decision matrix
Use json.Marshal when you need a byte slice for logging, storage, or passing between functions. Use json.NewEncoder(w) when writing directly to an HTTP response or file to avoid allocating the intermediate byte slice. Use json.MarshalIndent when debugging and you need human-readable output with whitespace. Use a custom MarshalJSON method when the default serialization doesn't match your API requirements or you need to hide sensitive fields. Use plain sequential code when you don't need JSON: the simplest thing that works is usually the right thing.