• English
  • Go encoding/json in Production: Data Boundaries, Streaming, and Failure Semantics

    Go's encoding/json package provides JSON syntax encoding and decoding. It does not define application schemas, validation rules, resource budgets, authorization, or business semantics.

    That distinction matters at system boundaries.

    An HTTP request containing JSON is not yet a valid application object. It is untrusted input that must pass through several independent boundaries:

    Untrusted Bytes
    
    
    Bounded Input
    
    
    JSON Syntax
    
    
    Typed Decode
    
    
    Schema Validation
    
    
    Domain Validation
    
    
    Application State

    encoding/json owns the syntax boundary. The surrounding application owns everything beyond it.


    1. Choose the Decoding Model Deliberately

    Go provides two primary JSON decoding paths.

    json.Unmarshal operates on a complete byte slice:

    var req CreateUserRequest
    
    if err := json.Unmarshal(data, &req); err != nil {
        return fmt.Errorf("decode request: %w", err)
    }

    This is appropriate when the complete payload is already available and its size is controlled.

    json.Decoder operates on an io.Reader:

    dec := json.NewDecoder(r)
    
    var req CreateUserRequest
    if err := dec.Decode(&req); err != nil {
        return fmt.Errorf("decode request: %w", err)
    }

    The architectural difference is:

    json.Unmarshal
        []byte
    
    
    complete payload already materialized
    
    
    json.Decoder
        io.Reader
    
    
    incremental consumption

    Decoder provides streaming consumption, but it does not automatically provide a resource budget.


    2. Establish the Input Boundary Before Decoding

    A JSON decoder can consume arbitrarily large input unless the surrounding I/O layer imposes a limit.

    For an HTTP request, http.MaxBytesReader is often the simplest boundary:

    func DecodeRequest(
        w http.ResponseWriter,
        r *http.Request,
        dst any,
        maxBytes int64,
    ) error {
        if maxBytes <= 0 {
            return errors.New("invalid maximum request size")
        }
    
        body := http.MaxBytesReader(w, r.Body, maxBytes)
        defer body.Close()
    
        dec := json.NewDecoder(body)
    
        if err := dec.Decode(dst); err != nil {
            return fmt.Errorf("decode JSON: %w", err)
        }
    
        var extra struct{}
        if err := dec.Decode(&extra); err != io.EOF {
            if err == nil {
                return errors.New("multiple JSON values are not allowed")
            }
            return fmt.Errorf("trailing JSON data: %w", err)
        }
    
        return nil
    }

    The important architectural property is that the limit exists outside the JSON parser:

    HTTP Request Body
    
    
    Size Boundary
    
    
    json.Decoder

    The decoder should never be treated as the resource governor.

    Generic Readers

    For a generic io.Reader, a sentinel byte can detect payloads exceeding a configured limit:

    func LimitedReader(r io.Reader, maxBytes int64) io.Reader {
        if maxBytes == math.MaxInt64 {
            return r
        }
    
        return io.LimitReader(r, maxBytes+1)
    }

    However, merely using LimitReader is not sufficient to determine that the entire input stayed within the limit. A JSON decoder may stop reading after it has obtained one complete value.

    A production implementation that must enforce an exact whole-input limit should use a counting reader or another boundary that records whether the underlying input attempted to cross the limit.

    The math.MaxInt64 branch exists to prevent integer overflow when constructing maxBytes + 1. In real systems, a maximum JSON size should normally be orders of magnitude smaller than MaxInt64.


    3. Typed Decoding Is a Boundary Decision

    Decoding arbitrary JSON into map[string]any is convenient:

    var payload map[string]any
    
    if err := json.NewDecoder(r).Decode(&payload); err != nil {
        return err
    }

    But the resulting representation has weak semantics.

    JSON values become generic Go values:

    JSON                  Go
    
    object       →        map[string]any
    array        →        []any
    string       →        string
    boolean      →        bool
    number       →        float64
    null         →        nil

    The conversion loses domain information.

    At application boundaries, prefer typed structures:

    type CreateUserRequest struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }
    
    var req CreateUserRequest
    
    if err := dec.Decode(&req); err != nil {
        return fmt.Errorf("decode request: %w", err)
    }

    This creates an explicit boundary:

    JSON
    
    
    CreateUserRequest
    
    
    validated domain object

    The struct is not merely a convenience. It defines the representation expected by the next application layer.


    4. Unknown Fields Are a Schema Policy

    By default, encoding/json ignores unknown object fields when decoding into a struct.

    Given:

    type CreateUserRequest struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }

    this payload decodes successfully:

    {
        "name": "alice",
        "email": "alice@example.com",
        "admin": true
    }

    The admin field is ignored.

    For strict APIs and configuration formats:

    dec := json.NewDecoder(r)
    dec.DisallowUnknownFields()

    Unexpected fields now become decoding errors.

    This is useful when schema drift should fail immediately.

    It is not a security mechanism.

    DisallowUnknownFields
    
    
    schema strictness
    
    NOT
    
    DisallowUnknownFields
    
    
    security boundary

    Authentication, authorization, resource limits, and domain validation remain separate concerns.


    5. Decode Syntax, Then Validate Semantics

    Successful JSON decoding does not imply a valid application request.

    Consider:

    type TransferRequest struct {
        From   string `json:"from"`
        To     string `json:"to"`
        Amount int64  `json:"amount"`
    }

    This payload is syntactically valid:

    {
        "from": "",
        "to": "",
        "amount": -100
    }

    JSON parsing succeeds.

    Struct decoding succeeds.

    The request can still be invalid.

    A production pipeline therefore separates:

    JSON syntax
    
    
    typed representation
    
    
    field validation
    
    
    domain validation

    Do not make successful decoding equivalent to successful validation.


    6. JSON Numbers Require an Explicit Policy

    When decoding JSON into interface{}, numbers normally become float64:

    var value any
    
    if err := json.Unmarshal(data, &value); err != nil {
        return err
    }

    That representation has precision limitations.

    For example, integers beyond the exact integer range of IEEE-754 float64 cannot necessarily be represented without loss.

    When generic JSON processing must preserve the number representation:

    dec := json.NewDecoder(r)
    dec.UseNumber()

    JSON numbers can then be represented as json.Number.

    For known application fields, typed integers are usually preferable:

    type Request struct {
        UserID int64 `json:"user_id"`
    }

    The rule is:

    Do not let the default generic number representation accidentally become your application data model.


    7. json.RawMessage Creates a Deferred-Decoding Boundary

    Some protocols contain an envelope whose payload type is determined by another field.

    json.RawMessage provides a controlled deferred-decoding boundary:

    type Envelope struct {
        Type    string          `json:"type"`
        Payload json.RawMessage `json:"payload"`
    }

    Decode the envelope first:

    var env Envelope
    
    if err := dec.Decode(&env); err != nil {
        return fmt.Errorf("decode envelope: %w", err)
    }

    Then dispatch:

    switch env.Type {
    case "user.created":
        var event UserCreated
    
        if err := json.Unmarshal(env.Payload, &event); err != nil {
            return fmt.Errorf("decode user.created: %w", err)
        }
    
    case "user.deleted":
        var event UserDeleted
    
        if err := json.Unmarshal(env.Payload, &event); err != nil {
            return fmt.Errorf("decode user.deleted: %w", err)
        }
    
    default:
        return fmt.Errorf("unsupported event type %q", env.Type)
    }

    The resulting architecture is:

    JSON Envelope
    
         ├── Type
    
         └── RawMessage
    
    
           deferred decoding
    
    
           concrete message

    This is useful for event buses, polymorphic APIs, versioned protocols, and partially opaque payloads.

    RawMessage should remain at the protocol boundary when possible. Propagating arbitrary JSON deeply into the application weakens type guarantees.


    8. Streaming JSON Requires Explicit Framing

    json.Decoder can consume multiple JSON values from one stream:

    {"id":1}
    {"id":2}
    {"id":3}

    That is useful for stream-oriented protocols.

    dec := json.NewDecoder(r)
    
    for {
        var item Item
    
        if err := dec.Decode(&item); err != nil {
            if errors.Is(err, io.EOF) {
                break
            }
    
            return fmt.Errorf("decode item: %w", err)
        }
    
        if err := process(item); err != nil {
            return err
        }
    }

    This is fundamentally different from a conventional HTTP endpoint where exactly one JSON value is expected.

    For a single-value protocol, explicitly reject trailing values:

    if err := dec.Decode(&req); err != nil {
        return fmt.Errorf("decode request: %w", err)
    }
    
    var extra struct{}
    if err := dec.Decode(&extra); err != io.EOF {
        if err == nil {
            return errors.New("multiple JSON values are not allowed")
        }
    
        return fmt.Errorf("trailing JSON data: %w", err)
    }

    The parser provides the ability to consume a sequence.

    The application defines whether a sequence is valid protocol framing.


    9. Encoder Is a Stateful Output Component

    json.Encoder writes JSON directly to an io.Writer:

    enc := json.NewEncoder(w)
    
    if err := enc.Encode(response); err != nil {
        return fmt.Errorf("encode response: %w", err)
    }

    This avoids requiring the complete encoded representation to be materialized as a separate byte slice.

    For streaming output:

    enc := json.NewEncoder(w)
    
    for item := range items {
        if err := enc.Encode(item); err != nil {
            return fmt.Errorf("encode item: %w", err)
        }
    }

    Each call to Encode writes one JSON value followed by a newline.

    That means:

    Encode(x)
    Encode(y)
    
    
    
    [
        x,
        y
    ]

    The first form is a sequence of JSON values.

    The second is one JSON array.

    The application must define the framing contract.


    10. Serialization Hooks Are Executable Boundaries

    Types can participate directly in JSON serialization.

    type UserID uint64
    
    func (id UserID) MarshalJSON() ([]byte, error) {
        return json.Marshal(uint64(id))
    }

    Custom unmarshaling works similarly:

    func (id *UserID) UnmarshalJSON(data []byte) error {
        var value uint64
    
        if err := json.Unmarshal(data, &value); err != nil {
            return err
        }
    
        *id = UserID(value)
        return nil
    }

    These methods execute application code inside the JSON pipeline.

    They can:

    • allocate memory;
    • perform validation;
    • return application errors;
    • invoke other JSON operations;
    • perform expensive computation;
    • alter the wire representation.

    Serialization hooks should therefore remain small, deterministic, and bounded.

    Avoid Recursive UnmarshalJSON

    A common mistake is:

    func (id *UserID) UnmarshalJSON(data []byte) error {
        return json.Unmarshal(data, id)
    }

    This recursively invokes UserID.UnmarshalJSON again.

    The result is unbounded recursion and eventually stack exhaustion.

    Instead, decode into the underlying representation:

    func (id *UserID) UnmarshalJSON(data []byte) error {
        var value uint64
    
        if err := json.Unmarshal(data, &value); err != nil {
            return err
        }
    
        *id = UserID(value)
        return nil
    }

    For structured custom types, a separate helper type can similarly bypass the original method set when necessary.


    11. omitempty Is Not Validation

    Consider:

    type User struct {
        Name  string `json:"name,omitempty"`
        Email string `json:"email,omitempty"`
    }

    An empty string is omitted during marshaling.

    That does not mean the field is optional semantically.

    These are different layers:

    omitempty
    
    
    wire representation
    
    
    validation
    
    
    application semantics

    A required field may legitimately contain an empty value at the serialization layer.

    Conversely, an omitted field may be invalid according to application rules.

    Do not infer business semantics from serialization tags.


    12. Missing, null, and Zero Values Are Different States

    Consider:

    {}
    {
        "name": null
    }

    and:

    {
        "name": ""
    }

    These represent different wire states:

    missing
    null
    empty string

    A plain Go string cannot preserve all of these distinctions.

    When presence matters, use an explicit representation:

    type PatchRequest struct {
        Name *string `json:"name"`
    }

    Now the application can distinguish whether a value was supplied from the ordinary zero value.

    This is particularly important for:

    • PATCH APIs;
    • partial configuration updates;
    • merge operations;
    • backward-compatible protocol evolution.

    Serialization design is therefore part of API semantics.


    13. Avoid Unnecessary Intermediate JSON Trees

    This pattern:

    var payload map[string]any
    
    if err := json.NewDecoder(r).Decode(&payload); err != nil {
        return err
    }

    materializes an entire object graph.

    For a large document, that can produce:

    input bytes
    
    
    JSON parser
    
    
    map[string]any
    []any
    strings
    float64
    
    
    large heap graph

    If the application needs only a defined representation, typed decoding is generally more appropriate.

    If the application needs to process a large sequence incrementally, use json.Decoder and process each value before reading the next.

    The goal is not "zero allocation."

    JSON parsing naturally allocates for many inputs.

    The production goal is:

    Keep memory proportional to the representation the application actually needs rather than unnecessarily materializing the entire input.


    14. Context Does Not Automatically Cancel JSON Decoding

    Creating a context does not automatically make JSON parsing cancellable:

    ctx, cancel := context.WithTimeout(ctx, time.Second)
    defer cancel()
    
    dec := json.NewDecoder(r)

    encoding/json does not independently observe ctx.

    Cancellation works only when an underlying component observes it.

    For network requests, the HTTP transport and request lifecycle can establish cancellation and deadlines.

    For application-controlled readers, a context-aware wrapper can establish an explicit cancellation boundary:

    type ContextReader struct {
        ctx context.Context
        r   io.Reader
    }
    
    func (r ContextReader) Read(p []byte) (int, error) {
        if err := r.ctx.Err(); err != nil {
            return 0, err
        }
    
        return r.r.Read(p)
    }

    This wrapper checks cancellation between calls to the underlying reader.

    It can therefore terminate a long stream at an I/O chunk boundary.

    It cannot:

    • interrupt a Read that is already blocked inside the underlying reader;
    • preempt CPU work performed by the JSON decoder;
    • interrupt arbitrary computation between I/O operations.

    The actual boundary is:

    Context
    
    
    ContextReader
    
    
    next Read boundary
    
    
    JSON decoder

    Context cancellation is therefore a cooperation mechanism, not an asynchronous kill switch.


    15. Decoder Lifetime and Reader Lifetime Are Independent

    json.Decoder does not own the underlying io.Reader.

    For an HTTP request body:

    defer r.Body.Close()
    
    dec := json.NewDecoder(r.Body)

    The handler or request lifecycle owns the body.

    Likewise, an Encoder does not automatically own its destination writer.

    Decoder ──uses──► Reader
    
    Encoder ──uses──► Writer

    Passing an interface into a component does not automatically transfer ownership.

    This is consistent with Go's broader I/O capability model.

    Ownership must be established explicitly by the surrounding API.


    16. JSON Security Is a Pipeline

    A robust JSON boundary can be modeled as:

                        Untrusted Input
    
    
                        Size Boundary
    
    
                         JSON Decoder
    
    
                      Typed Representation
    
                  ┌────────────┴────────────┐
                  ▼                         ▼
            Schema Rules              Domain Rules
                  │                         │
                  └────────────┬────────────┘
    
                        Authorization
    
    
                        State Mutation

    Each layer has a different responsibility.

    LayerResponsibility
    I/O boundaryMaximum input size, timeout, cancellation
    JSON parserJSON syntax
    Typed decoderRepresentation mapping
    Schema policyRequired/unknown fields
    Domain validationBusiness invariants
    AuthorizationPermission to perform the operation
    ApplicationState mutation

    No single encoding/json option replaces these layers.


    Production Rules

    1. Bound the input before decoding. json.Decoder provides streaming semantics, not an automatic resource budget.

    2. Prefer typed decoding at application boundaries. map[string]any is a generic representation, not a strong domain model.

    3. Separate syntax from semantics. Successful JSON decoding does not imply a valid application request.

    4. Treat unknown-field handling as a schema policy. DisallowUnknownFields improves contract enforcement but is not a security boundary.

    5. Define numeric semantics explicitly. Generic JSON numbers become float64 unless UseNumber is enabled.

    6. Use json.RawMessage for deliberate deferred decoding. Keep opaque JSON at protocol boundaries instead of propagating it throughout the application.

    7. Define stream framing explicitly. json.Decoder can consume multiple JSON values; the protocol must decide whether that is valid.

    8. Treat serialization hooks as executable code. Marshaler and Unmarshaler participate directly in the serialization pipeline.

    9. Avoid recursive custom unmarshaling. Decode into an underlying primitive or helper type rather than invoking json.Unmarshal on the receiver itself.

    10. Do not infer business semantics from JSON tags. omitempty, missing fields, null, and zero values belong to different semantic layers.

    11. Do not assume context cancellation is automatic. Cancellation requires an underlying operation that actually observes the context.

    12. Keep parser and resource ownership separate. Decoder and Encoder consume readers and writers; ownership remains with the component that established the resource lifetime.

    13. Avoid unnecessary intermediate object graphs. Streaming and typed representations can keep memory proportional to the required application state.

    14. Keep untrusted JSON inside explicit boundaries. Resource, syntax, schema, domain, authorization, and mutation are separate stages.

    The central design rule is:

    encoding/json translates JSON syntax into Go values. The application is responsible for deciding whether those values are admissible, meaningful, affordable, and authorized.