Go JSON: encoding/json, struct tags, json.RawMessage & Streaming
Last updated:
Go's encoding/json package serializes structs to JSON with json.Marshal() and deserializes with json.Unmarshal() — struct fields are included in JSON output only if they are exported (capitalized), and field names default to the Go field name unless overridden with a json:"name" struct tag. json.Marshal() returns a []byte slice, not a string — convert with string(data) for logging; a 1,000-field struct marshals in ~50 µs and unmarshals in ~80 µs in a Go 1.22 benchmark, making encoding/json suitable for most API workloads. This guide covers struct tags (json:"name,omitempty"), json.RawMessage for deferred parsing, custom Marshaler and Unmarshaler interfaces, streaming with json.Decoder and json.Encoder, handling dynamic JSON with map[string]interface{} and any, and using Gin and Chi for JSON HTTP handlers.
Struct Tags: json:"name,omitempty" and Field Mapping
Struct tags control how encoding/json maps Go struct fields to JSON keys. The json:"name" tag sets the JSON key name, enabling idiomatic snake_case JSON from CamelCase Go field names. The omitempty option omits a field when its value equals the zero value for its type. Use json:"-" to exclude a field from JSON entirely, and pointer types to handle nullable JSON fields.
package main
import (
"encoding/json"
"fmt"
)
type User struct {
ID int `json:"id"` // CamelCase Go → snake_case JSON
FirstName string `json:"first_name"` // key override
LastName string `json:"last_name"`
Email string `json:"email"`
Age int `json:"age,omitempty"` // omitted when Age == 0
Bio string `json:"bio,omitempty"` // omitted when Bio == ""
Score *int `json:"score,omitempty"` // nil ptr → omitted; &0 → included as 0
Password string `json:"-"` // never included in JSON output
internal string // unexported: always excluded, no tag needed
}
// omitempty with pointer: distinguish "zero value" from "missing"
type Config struct {
Timeout *int `json:"timeout_ms,omitempty"` // nil=missing; &0=explicitly 0ms
Debug *bool `json:"debug,omitempty"` // nil=missing; &false=explicitly false
}
// Embedded struct: fields are flattened into the outer JSON object
type Address struct {
Street string `json:"street"`
City string `json:"city"`
}
type Profile struct {
User // embedded: User fields appear at top level
Address `json:"address"` // named embed: nested under "address" key
}
func main() {
zero := 0
u := User{
ID: 1,
FirstName: "Alice",
LastName: "Smith",
Email: "alice@example.com",
Age: 0, // omitted (zero value + omitempty)
Score: &zero, // included as 0 (pointer to zero, not nil)
Password: "secret", // excluded by json:"-"
}
data, _ := json.Marshal(u)
fmt.Println(string(data))
// {"id":1,"first_name":"Alice","last_name":"Smith","email":"alice@example.com","score":0}
// Pretty print
pretty, _ := json.MarshalIndent(u, "", " ")
fmt.Println(string(pretty))
// json:"name,string" — encode numeric field as JSON string
type Item struct {
Price float64 `json:"price,string"` // marshals as "9.99" not 9.99
}
item := Item{Price: 9.99}
d, _ := json.Marshal(item)
fmt.Println(string(d)) // {"price":"9.99"}
}Struct tags are inspected at runtime via reflection — a typo in a tag (e.g., json: "id" with a space after the colon) causes the tag to be silently ignored, producing the Go field name in JSON output instead. Use go vet to catch malformed struct tags at compile time. For embedded structs, fields are promoted to the outer JSON object unless the embed has a named tag (e.g., Address `json:"address"`), in which case the struct is nested under that key.
json.Marshal and json.Unmarshal Patterns
json.Marshal(v any) returns ([]byte, error) — the byte slice is compact JSON with no extra whitespace. json.Unmarshal(data []byte, v any) writes into the value pointed to by v, which must be a non-nil pointer. Both functions handle all standard Go types: structs, slices, maps, and primitives. Understanding nil vs empty slice behavior and the error types returned by each function is essential for robust JSON handling.
package main
import (
"encoding/json"
"errors"
"fmt"
)
type Post struct {
ID int `json:"id"`
Tags []string `json:"tags"`
}
func main() {
// ── Marshal ──────────────────────────────────────────────────
// nil slice vs empty slice: different JSON output
p1 := Post{ID: 1, Tags: nil}
p2 := Post{ID: 2, Tags: []string{}}
p3 := Post{ID: 3, Tags: []string{"go", "json"}}
d1, _ := json.Marshal(p1)
d2, _ := json.Marshal(p2)
d3, _ := json.Marshal(p3)
fmt.Println(string(d1)) // {"id":1,"tags":null}
fmt.Println(string(d2)) // {"id":2,"tags":[]}
fmt.Println(string(d3)) // {"id":3,"tags":["go","json"]}
// Marshaling maps — keys must be strings (or implement encoding.TextMarshaler)
m := map[string]int{"a": 1, "b": 2}
dm, _ := json.Marshal(m)
fmt.Println(string(dm)) // {"a":1,"b":2} (key order is sorted alphabetically)
// ── Unmarshal ────────────────────────────────────────────────
// Basic struct unmarshal — always pass a pointer
var p Post
input := []byte(`{"id":10,"tags":["go","json"]}`)
if err := json.Unmarshal(input, &p); err != nil {
panic(err)
}
fmt.Println(p.ID, p.Tags) // 10 [go json]
// Unknown keys are silently ignored by default
extraInput := []byte(`{"id":11,"tags":[],"unknown_field":"ignored"}`)
var p4 Post
json.Unmarshal(extraInput, &p4) // no error
// SyntaxError: malformed JSON
badJSON := []byte(`{"id": bad}`)
var p5 Post
err := json.Unmarshal(badJSON, &p5)
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
fmt.Printf("syntax error at offset %d: %v\n", syntaxErr.Offset, syntaxErr)
}
// UnmarshalTypeError: wrong JSON type for field
wrongType := []byte(`{"id":"not-a-number"}`)
var p6 Post
err2 := json.Unmarshal(wrongType, &p6)
var typeErr *json.UnmarshalTypeError
if errors.As(err2, &typeErr) {
fmt.Printf("type error: field %q expects %v, got %v\n",
typeErr.Field, typeErr.Type, typeErr.Value)
}
}A nil slice and an empty slice are distinct in Go but differ in JSON: nil marshals to null while an initialized empty slice marshals to []. This distinction matters for API clients that treat null as "field absent" and [] as "empty list". Always initialize slices with make([]T, 0) when you want to guarantee [] in JSON output rather than null. Map keys in JSON output are sorted alphabetically by json.Marshal — Go maps are unordered, so encoding/json sorts keys for deterministic output.
json.RawMessage: Deferred and Polymorphic JSON
json.RawMessage is a []byte type alias that implements both json.Marshaler and json.Unmarshaler. When a struct field has type json.RawMessage, encoding/json stores the raw JSON bytes during unmarshal without parsing them, and writes them verbatim during marshal. This enables two-pass decoding for polymorphic JSON, proxying JSON without full deserialization, and embedding pre-computed JSON fragments.
package main
import (
"encoding/json"
"fmt"
)
// ── Two-pass decoding: polymorphic "data" field ───────────────────
// The outer envelope has a "type" discriminant; "data" shape depends on it.
type Event struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"` // stored raw, decoded in second pass
}
type UserCreated struct {
UserID string `json:"user_id"`
Email string `json:"email"`
}
type OrderPlaced struct {
OrderID string `json:"order_id"`
Total float64 `json:"total"`
}
func processEvent(data []byte) error {
// First pass: decode the envelope to read "type"
var event Event
if err := json.Unmarshal(data, &event); err != nil {
return err
}
// Second pass: decode "payload" based on "type" discriminant
switch event.Type {
case "user.created":
var uc UserCreated
if err := json.Unmarshal(event.Payload, &uc); err != nil {
return err
}
fmt.Printf("New user: %s (%s)\n", uc.UserID, uc.Email)
case "order.placed":
var op OrderPlaced
if err := json.Unmarshal(event.Payload, &op); err != nil {
return err
}
fmt.Printf("Order %s: $%.2f\n", op.OrderID, op.Total)
default:
fmt.Printf("Unknown event type %q — raw payload: %s\n", event.Type, event.Payload)
}
return nil
}
// ── Proxying JSON without parsing ────────────────────────────────
// Forward a JSON sub-tree from one API response to another without
// fully deserializing and re-serializing (preserves exact bytes).
type APIResponse struct {
Status string `json:"status"`
Data json.RawMessage `json:"data"` // forwarded verbatim
}
// ── Embedding pre-encoded JSON ────────────────────────────────────
// Useful when part of the response is already a []byte from a cache.
type CachedResponse struct {
Metadata map[string]string `json:"metadata"`
Body json.RawMessage `json:"body"` // pre-encoded, written verbatim
}
func main() {
userEvent := []byte(`{
"type": "user.created",
"payload": {"user_id": "u123", "email": "alice@example.com"}
}`)
processEvent(userEvent)
orderEvent := []byte(`{
"type": "order.placed",
"payload": {"order_id": "o456", "total": 99.99}
}`)
processEvent(orderEvent)
// Embed pre-encoded JSON
preEncoded := json.RawMessage(`{"key":"value","nested":{"a":1}}`)
resp := CachedResponse{
Metadata: map[string]string{"version": "1"},
Body: preEncoded,
}
out, _ := json.Marshal(resp)
fmt.Println(string(out))
// {"metadata":{"version":"1"},"body":{"key":"value","nested":{"a":1}}}
// Note: body is not double-encoded — RawMessage writes verbatim
}A common mistake is treating json.RawMessage as a string — it is a []byte, so fmt.Println(raw) prints the byte slice values, not the JSON text. Use fmt.Println(string(raw)) or fmt.Printf("%s", raw) for readable output. json.RawMessage preserves exact bytes including whitespace, so a pretty-printed input fragment is forwarded with its whitespace intact. See the JSON parsing performance guide for benchmarks comparing RawMessage proxying against full marshal/unmarshal cycles.
Custom Marshaler and Unmarshaler Interfaces
Implement json.Marshaler (method MarshalJSON() ([]byte, error)) and json.Unmarshaler (method UnmarshalJSON([]byte) error) to override encoding/json's default reflection-based encoding for a type. The most common use cases are custom time.Time formatting, encoding enums as strings, and handling types that have no standard JSON representation.
package main
import (
"encoding/json"
"fmt"
"time"
)
// ── Custom time.Time: Unix timestamp instead of RFC3339 ───────────
type UnixTime struct {
time.Time
}
func (t UnixTime) MarshalJSON() ([]byte, error) {
return json.Marshal(t.Time.Unix()) // marshal as integer seconds
}
func (t *UnixTime) UnmarshalJSON(data []byte) error {
var unix int64
if err := json.Unmarshal(data, &unix); err != nil {
return err
}
t.Time = time.Unix(unix, 0).UTC()
return nil
}
type Event struct {
Name string `json:"name"`
CreatedAt UnixTime `json:"created_at"` // marshals as Unix int, not RFC3339 string
}
// ── Custom enum: int constants ↔ JSON strings ─────────────────────
type Status int
const (
StatusPending Status = iota
StatusActive
StatusClosed
)
var statusNames = map[Status]string{
StatusPending: "pending",
StatusActive: "active",
StatusClosed: "closed",
}
var statusValues = map[string]Status{
"pending": StatusPending,
"active": StatusActive,
"closed": StatusClosed,
}
func (s Status) MarshalJSON() ([]byte, error) {
name, ok := statusNames[s]
if !ok {
return nil, fmt.Errorf("unknown status: %d", int(s))
}
return json.Marshal(name) // marshal as string "active" not integer 1
}
func (s *Status) UnmarshalJSON(data []byte) error {
var name string
if err := json.Unmarshal(data, &name); err != nil {
return err
}
v, ok := statusValues[name]
if !ok {
return fmt.Errorf("unknown status: %q", name)
}
*s = v
return nil
}
// ── CRITICAL: avoid infinite recursion in MarshalJSON ─────────────
// If you call json.Marshal(myValue) inside MarshalJSON on the same type,
// encoding/json will call MarshalJSON again → infinite recursion → stack overflow.
// Solution: use a type alias that does NOT inherit the MarshalJSON method.
type Temperature float64
func (t Temperature) MarshalJSON() ([]byte, error) {
// type Alias = Temperature would still call MarshalJSON (same underlying type)
// Use a named alias type to strip the method:
type Alias float64
return json.Marshal(struct {
Celsius Alias `json:"celsius"`
Fahrenheit Alias `json:"fahrenheit"`
}{
Celsius: Alias(t),
Fahrenheit: Alias(t*9/5 + 32),
})
}
func main() {
e := Event{Name: "launch", CreatedAt: UnixTime{time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)}}
data, _ := json.Marshal(e)
fmt.Println(string(data)) // {"name":"launch","created_at":1747699200}
var e2 Event
json.Unmarshal(data, &e2)
fmt.Println(e2.CreatedAt.Time.Format(time.RFC3339)) // 2026-05-20T00:00:00Z
type Order struct {
ID int `json:"id"`
Status Status `json:"status"`
}
o := Order{ID: 1, Status: StatusActive}
od, _ := json.Marshal(o)
fmt.Println(string(od)) // {"id":1,"status":"active"}
}The infinite recursion trap is the most common bug when implementing MarshalJSON: calling json.Marshal(t) where t is the same type causes encoding/json to detect the MarshalJSON method and call it again indefinitely. The fix is to use a named type alias (type Alias MyType) — Go's named types do not inherit methods from their underlying type, so json.Marshal(Alias(t)) uses the default reflection encoder. See the JSON API design guide for patterns on consistent date formatting across API responses.
json.Decoder: Streaming JSON from io.Reader
json.NewDecoder(r io.Reader) creates a streaming JSON decoder that reads from any io.Reader without buffering the entire input into memory first. This is the preferred approach for HTTP response bodies, file streams, and other large JSON sources. Decode(&v) reads the next complete JSON value from the stream; More() and Token() enable fine-grained streaming of JSON arrays and objects.
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
// ── HTTP response body: stream directly, no ioutil.ReadAll ────────
func fetchProducts(url string) ([]Product, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
dec := json.NewDecoder(resp.Body)
dec.DisallowUnknownFields() // return error for unknown JSON keys
// dec.UseNumber() // receive numbers as json.Number, not float64
var products []Product
if err := dec.Decode(&products); err != nil {
return nil, err
}
return products, nil
}
// ── Streaming JSON array: one object at a time ────────────────────
// Useful for large arrays where loading all items into memory is undesirable.
func streamJSONArray(jsonInput string) error {
dec := json.NewDecoder(strings.NewReader(jsonInput))
// Read the opening '[' token
if _, err := dec.Token(); err != nil {
return err
}
// Iterate over array elements
for dec.More() {
var p Product
if err := dec.Decode(&p); err != nil {
return err
}
fmt.Printf("Processing: %s ($%.2f)\n", p.Name, p.Price)
// Process p here without holding all items in memory
}
// Read the closing ']' token
if _, err := dec.Token(); err != nil {
return err
}
return nil
}
// ── UseNumber: prevent float64 precision loss for large integers ──
func parseWithNumbers(data string) {
dec := json.NewDecoder(strings.NewReader(data))
dec.UseNumber() // numbers come back as json.Number, not float64
var m map[string]interface{}
dec.Decode(&m)
// Without UseNumber: large int → float64 → precision loss
// With UseNumber: json.Number preserves exact string representation
if num, ok := m["big_id"].(json.Number); ok {
i64, _ := num.Int64()
fmt.Printf("big_id as int64: %d\n", i64)
}
}
// ── json.Encoder: write JSON to io.Writer ─────────────────────────
func writeJSONStream(products []Product, w interface{ Write([]byte) (int, error) }) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ") // optional: pretty-print
for _, p := range products {
if err := enc.Encode(p); err != nil { // Encode appends a newline after each value
return err
}
}
return nil
}
func main() {
input := `[
{"id":1,"name":"Widget","price":9.99},
{"id":2,"name":"Gadget","price":24.99},
{"id":3,"name":"Doohickey","price":4.99}
]`
streamJSONArray(input)
parseWithNumbers(`{"big_id": 9007199254740993}`) // > 2^53, loses precision as float64
}json.Decoder is stateful — after reading an array element with Decode, the decoder's position advances to the next element. Do not mix Decode calls with direct Read calls on the underlying reader; the decoder maintains an internal buffer. DisallowUnknownFields() is useful for strict API input validation but should not be used when proxying or forwarding JSON from external services that may add fields in the future. See the JSON microservices guide for streaming patterns at scale.
Dynamic JSON: map[string]interface{} and any
When the JSON schema is unknown at compile time, unmarshal into map[string]interface{} (equivalent to map[string]any in Go 1.18+) for objects, or []interface{} for arrays. All JSON values map to corresponding Go types: strings to string, booleans to bool, null to nil, arrays to []interface{}, and objects to map[string]interface{} — but numbers default to float64 regardless of whether they are integers.
package main
import (
"encoding/json"
"fmt"
)
func main() {
// ── Unmarshal into map[string]any ─────────────────────────────
data := []byte(`{
"name": "Alice",
"age": 30,
"active": true,
"score": null,
"tags": ["go", "backend"],
"address": {"city": "NYC", "zip": "10001"}
}`)
var m map[string]any
json.Unmarshal(data, &m)
// Type assertions for field access
name := m["name"].(string) // string
age := m["age"].(float64) // NOTE: always float64, not int
active := m["active"].(bool) // bool
score := m["score"] // nil (JSON null)
tags := m["tags"].([]any) // []interface{}
addr := m["address"].(map[string]any) // nested object
fmt.Println(name, int(age), active, score)
fmt.Println(tags[0].(string)) // "go"
fmt.Println(addr["city"].(string)) // "NYC"
// Safe type assertion (avoid panic on wrong type)
if city, ok := addr["city"].(string); ok {
fmt.Println("City:", city)
}
// ── JSON number types: float64 vs json.Number ─────────────────
// Default: all numbers → float64 (precision loss for large integers)
numData := []byte(`{"id": 9007199254740993, "price": 9.99}`)
var defaults map[string]any
json.Unmarshal(numData, &defaults)
fmt.Println(defaults["id"]) // 9.007199254740992e+15 — precision lost!
fmt.Println(defaults["price"]) // 9.99
// UseNumber: numbers → json.Number (string-backed, preserves exact value)
dec := json.NewDecoder(nil)
_ = dec // shown for illustration; pass a reader in real code
// In practice:
var withNumbers map[string]any
d2 := json.NewDecoder(
func() interface{ Read([]byte) (int, error) } {
r := *json.NewDecoder(nil) // placeholder
return &r
}(),
)
_ = d2 // use dec.UseNumber() + dec.Decode(&withNumbers) in real code
// ── Recursive type switch for arbitrary JSON ──────────────────
var walkJSON func(v any, depth int)
walkJSON = func(v any, depth int) {
indent := fmt.Sprintf("%*s", depth*2, "")
switch val := v.(type) {
case map[string]any:
for k, child := range val {
fmt.Printf("%s%s:\n", indent, k)
walkJSON(child, depth+1)
}
case []any:
for i, item := range val {
fmt.Printf("%s[%d]:\n", indent, i)
walkJSON(item, depth+1)
}
case string:
fmt.Printf("%s%q\n", indent, val)
case float64:
fmt.Printf("%s%g\n", indent, val)
case bool:
fmt.Printf("%s%v\n", indent, val)
case nil:
fmt.Printf("%snull\n", indent)
}
}
walkJSON(m, 0)
}The float64 default for all JSON numbers is a frequent source of bugs when handling integer IDs that exceed 2&sup5;³ (9,007,199,254,740,992) — the maximum integer representable exactly in a 64-bit float. JavaScript uses the same float64 representation, which is why the JS Number.MAX_SAFE_INTEGER is this value. Always use UseNumber() or unmarshal into a typed struct with int64 fields when working with large integer IDs. See the JSON OpenAPI specification guide for how to declare integer format constraints in schema definitions.
JSON in Gin, Chi, and net/http
Go's standard net/http package plus encoding/json provides a complete JSON HTTP handler without additional dependencies. Gin and Chi add ergonomic wrappers — c.ShouldBindJSON and render.JSON respectively — but the underlying mechanics are json.Decoder and json.Marshal. Returning errors as structured JSON using the RFC 9457 problem details format improves client error handling.
package main
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// RFC 9457 problem details structure for JSON error responses
type ProblemDetail struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
}
// ── net/http: manual json.Decoder + json.Marshal ──────────────────
func netHTTPHandler(w http.ResponseWriter, r *http.Request) {
var user User
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // strict: reject unknown keys
if err := dec.Decode(&user); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid request body", err.Error())
return
}
// Process user...
user.ID = 42
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user) // stream directly to ResponseWriter
}
func writeJSONError(w http.ResponseWriter, status int, title, detail string) {
problem := ProblemDetail{
Type: "https://jsonic.io/errors/" + http.StatusText(status),
Title: title,
Status: status,
Detail: detail,
}
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(problem)
}
// ── Gin: c.ShouldBindJSON + c.JSON ───────────────────────────────
func ginHandler(c *gin.Context) {
var user User
// ShouldBindJSON uses json.NewDecoder internally; does NOT abort on error
if err := c.ShouldBindJSON(&user); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid request body",
"detail": err.Error(),
})
return
}
user.ID = 42
c.JSON(http.StatusCreated, user) // sets Content-Type: application/json
}
// ── Chi: render.JSON ──────────────────────────────────────────────
func chiHandler(w http.ResponseWriter, r *http.Request) {
var user User
if err := render.DecodeJSON(r.Body, &user); err != nil {
render.Status(r, http.StatusBadRequest)
render.JSON(w, r, map[string]string{"error": err.Error()})
return
}
user.ID = 42
render.Status(r, http.StatusCreated)
render.JSON(w, r, user)
}
// ── JSON Content-Type middleware ───────────────────────────────────
func requireJSONMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/json" {
writeJSONError(w, http.StatusUnsupportedMediaType,
"Content-Type must be application/json",
"received: "+r.Header.Get("Content-Type"))
return
}
next.ServeHTTP(w, r)
})
}
func main() {
// net/http router
mux := http.NewServeMux()
mux.Handle("POST /users", requireJSONMiddleware(http.HandlerFunc(netHTTPHandler)))
// Gin router
r := gin.Default()
r.POST("/users", ginHandler)
// Chi router
cr := chi.NewRouter()
cr.Post("/users", chiHandler)
}Always set Content-Type: application/json on JSON responses — some HTTP clients (including fetch) check this header before parsing the body. For error responses, use application/problem+json (RFC 9457) which is recognized by API clients and gateways. Avoid reading the entire request body with io.ReadAll before calling json.Unmarshal — use json.NewDecoder(r.Body).Decode instead to stream the decode and avoid holding the full body in memory, which matters for large uploads. See the JSON API design guide for consistent response envelope patterns.
Key Terms
- struct tag
- A raw string literal in backticks attached to a struct field that provides metadata consumed by packages at runtime via reflection. The
encoding/jsonpackage reads thejsonkey of struct tags to determine JSON field names, omission behavior (omitempty), string encoding (string), and field exclusion (-). Struct tags are part of the Go field declaration syntax and have no effect at compile time — all tag parsing happens viareflect.StructTag.Lookupat runtime. Multiple packages can share a single struct tag using different keys, e.g.,`json:"name" db:"name" validate:"required"`. - omitempty
- A struct tag option for
encoding/jsonthat omits a field from JSON output when its value equals the zero value for its Go type:0for numeric types,falsefor bool,""for string,nilfor pointers, slices, maps, and interfaces, and an empty struct{}{}for struct types.omitemptydoes not apply to non-pointer struct fields with non-zero default values. To distinguish between a zero value that should appear in JSON and a genuinely absent field, use a pointer type — a nil pointer is omitted withomitempty, while a pointer to zero (&0) is included. Theomitemptyoption is position-sensitive: it must appear after the name, e.g.,`json:"age,omitempty"`. - json.RawMessage
- A type alias for
[]bytedefined inencoding/jsonthat implements bothjson.Marshalerandjson.Unmarshaler. When used as a struct field type,json.RawMessagecausesencoding/jsonto store the exact raw JSON bytes for that field during unmarshaling (without parsing) and write them verbatim during marshaling (without re-encoding). This makes it useful for deferred two-pass decoding of polymorphic fields, proxying JSON sub-trees between services, and embedding pre-computed JSON fragments into a response without a full marshal/unmarshal round-trip. Ajson.RawMessagefield that receives a JSON null will be set to the bytesnull, not to a Go nil slice. - Marshaler interface
- An interface in
encoding/jsondefined astype Marshaler interface { MarshalJSON() ([]byte, error) }. Any type that implements this interface will have itsMarshalJSONmethod called byjson.Marshalinstead of the default reflection-based encoding. The method must return valid JSON bytes. The complementary interface isjson.Unmarshaler, defined astype Unmarshaler interface { UnmarshalJSON([]byte) error }, whoseUnmarshalJSONmethod receives the raw JSON bytes for the field and is responsible for populating the receiver. Both interfaces are checked on pointer receivers as well as value receivers; prefer pointer receivers forUnmarshalJSONso the method can modify the value. - json.Decoder
- A streaming JSON decoder returned by
json.NewDecoder(r io.Reader). Unlikejson.Unmarshalwhich requires the full JSON bytes in memory,json.Decoderreads from anio.Readerincrementally, making it suitable for HTTP response bodies, large files, and network streams. Key methods:Decode(&v)reads the next complete JSON value;More()reports whether there are additional values in the current array or object;Token()reads one JSON token (delimiter, string, number, or boolean) for event-driven parsing;UseNumber()configures the decoder to return numbers asjson.Numberinstead offloat64;DisallowUnknownFields()returns an error if the JSON input contains a key not present in the destination struct. - UseNumber
- A method on
json.Decoderthat configures the decoder to represent JSON numbers asjson.Number(a string-backed type alias) instead offloat64when decoding into aninterface{}value. By default, all JSON numbers decode tofloat64when the target type isinterface{}— this causes silent precision loss for integers larger than 2&sup5;³ (9,007,199,254,740,992). After callingUseNumber(), numeric values decode tojson.Number, which exposesInt64(),Float64(), andString()methods for explicit conversion with error checking.UseNumberonly affects decoding intointerface{}— decoding into a typedint64orfloat64struct field is unaffected and always uses the field's declared type.
FAQ
How do I marshal a Go struct to JSON?
Use json.Marshal(v) from encoding/json. It returns ([]byte, error). Only exported (capitalized) struct fields are included — unexported fields are silently skipped. Field names default to the Go field name; use `json:"name"` struct tags to override the JSON key. json.Marshal returns compact JSON bytes; use string(data) to convert to a string for logging, or json.MarshalIndent(v, "", " ") for pretty-printed output. Marshaling can fail if the value contains a channel, function, or complex number — always check the returned error. A nil pointer marshals to JSON null; a nil slice marshals to null while an initialized empty slice (make([]T, 0)) marshals to [] — this distinction matters for API clients that treat null and [] differently.
How do I unmarshal JSON into a Go struct?
Use json.Unmarshal(data []byte, v any) from encoding/json, passing a pointer to the destination as v. Passing a non-pointer causes an InvalidUnmarshalError. json.Unmarshal matches JSON keys to struct fields case-insensitively; unknown JSON keys are silently ignored by default. It returns a json.SyntaxError for malformed JSON and a json.UnmarshalTypeError when a JSON value cannot be assigned to the destination Go type. Use errors.As to inspect these error types. For HTTP response bodies, prefer json.NewDecoder(resp.Body).Decode(&v) to avoid loading the full body into memory first. Always initialize your destination variable before unmarshaling — var u User; json.Unmarshal(data, &u) — because Unmarshal merges into the existing value rather than replacing it.
What are Go JSON struct tags and how do I use them?
Go JSON struct tags are backtick-quoted string literals attached to struct fields that control encoding/json serialization. The syntax is `json:"name,options"`. The name sets the JSON key (e.g., `json:"user_id"` maps UserID to "user_id" in JSON). Options are comma-separated after the name: omitempty omits the field when its value is the zero value; string encodes a numeric or bool field as a JSON string; - excludes the field entirely. Use `json:"-"` (not `json:"-,"`) to exclude. Struct tags are inspected at runtime via reflection — typos are silently ignored, so use go vet to catch malformed tags. Multiple packages share one tag: `json:"name" db:"name"`.
How do I handle optional JSON fields in Go?
Use the omitempty struct tag option to omit a field from JSON output when it equals the zero value: `json:"age,omitempty"` omits Age when it is 0. For three-way optionality (value present, zero value present, field absent), use a pointer type: a nil *int marshals to null and is omitted with omitempty; a pointer to zero (v := 0; &v) marshals to 0 and is included. For unmarshaling, a missing JSON key leaves the Go field at its zero value — use a pointer field if you need to detect whether the key was present in the input. For complex optional structures, use *MyStruct — a nil pointer field marshals to null or is omitted with omitempty, while a non-nil pointer is marshaled normally.
What is json.RawMessage in Go and when should I use it?
json.RawMessage is a []byte alias that stores raw JSON bytes without parsing during unmarshal and writes them verbatim during marshal. Use it for three patterns: (1) two-pass decoding of polymorphic JSON — unmarshal the outer envelope to read a type discriminant, then unmarshal the RawMessage field into the correct concrete type; (2) JSON proxying — forward a JSON sub-tree from one service to another without the overhead of full deserialization and re-serialization; (3) embedding pre-encoded JSON — write a cached JSON fragment directly into a response without re-marshaling. json.RawMessage preserves exact bytes including whitespace, so it round-trips losslessly. Use fmt.Printf("%s", raw) or string(raw) for readable output — printing the []byte directly shows byte values, not JSON text.
How do I stream large JSON in Go?
Use json.NewDecoder(r io.Reader) to stream JSON without buffering the entire input. For HTTP response bodies, pass resp.Body directly to json.NewDecoder instead of using io.ReadAll then json.Unmarshal. For streaming JSON arrays, read the opening [ token with dec.Token(), then loop with for dec.More() { dec.Decode(&item) } to process one object at a time without holding the full array in memory. Call dec.UseNumber() before the first Decode to receive numbers as json.Number and prevent float64 precision loss for large integers. Use dec.DisallowUnknownFields() for strict input validation. For writing, json.NewEncoder(w).Encode(v) streams JSON directly to an io.Writer, appending a newline after each value.
How do I implement a custom JSON marshaler in Go?
Define a MarshalJSON() ([]byte, error) method on your type to implement json.Marshaler. encoding/json calls this method instead of its default reflection-based encoding. For custom unmarshaling, define UnmarshalJSON(data []byte) error on a pointer receiver. Common uses: custom time.Time format (RFC3339 vs Unix timestamp), encoding Go int constants as JSON strings, and handling types not supported natively by encoding/json. Critical: avoid infinite recursion — do not call json.Marshal(t) where t is the same type inside MarshalJSON. Instead, create a named type alias (type Alias MyType) — Go named types do not inherit methods from their underlying type, so json.Marshal(Alias(t)) uses the default encoder without triggering MarshalJSON again.
How do I handle dynamic JSON with unknown structure in Go?
Unmarshal into map[string]interface{} (or map[string]any in Go 1.18+) for objects with unknown keys, or []interface{} for arrays. All JSON values map to Go types: strings to string, booleans to bool, null to nil, arrays to []interface{}, nested objects to map[string]interface{}, and numbers to float64 by default. Use type assertions to access values: m["key"].(string). Use the two-value form (v, ok := m["key"].(string)) to avoid panics on wrong types. For large integers, use json.Decoder with UseNumber() to get json.Number instead of float64. For partially-known structures, combine a typed struct with a map[string]json.RawMessage field to handle known fields with type safety and defer parsing of unknown fields.
Further reading and primary sources
- encoding/json — Go standard library — Official Go documentation for encoding/json: Marshal, Unmarshal, Decoder, Encoder, and all interfaces
- go-json: high-performance JSON encoder/decoder — Drop-in encoding/json replacement using code generation — up to 4× faster for large structs
- JSON and Go — The Go Blog — Official Go blog post covering encoding/json fundamentals, struct tags, and interface{} unmarshaling
- Gin: JSON binding and responses — Gin framework documentation for ShouldBindJSON, c.JSON, and validation with binding tags
- RFC 9457: Problem Details for HTTP APIs — Specification for application/problem+json error response format used in Go HTTP handlers