HowtoGo
Home / How to Go / Handle JSON of an unknown shape
How to Go

Handle JSON of an unknown shape

Decode JSON with no fixed struct behind it, using map[string]any when the document is free-form, and json.RawMessage to defer part of one.

Unmarshalling into map[string]any accepts any object. Every value comes back boxed in an any, so reading one means a type assertion.

Use the two-result form. The single-result form panics when the key is missing or holds another type.

var v map[string]any
if err := json.Unmarshal(data, &v); err != nil {
    // handle error
}

name, ok := v["name"].(string)
if !ok {
    // key missing, or not a string
}

Every JSON number lands as a float64, including one written without a decimal point. Asserting int on it fails.

Past 2^53 a float64 starts losing whole-number precision, which shows up with 64-bit database IDs and Twitter-style snowflake IDs.

// What every JSON value becomes in an any:
//   object  -> map[string]any
//   array   -> []any
//   string  -> string
//   number  -> float64
//   true    -> bool
//   null    -> nil

port, ok := v["port"].(float64) // not int
if ok {
    fmt.Println(int(port))
}

Deferring part of a document

json.RawMessage stores the original bytes for that key without parsing them. The outer document decodes normally, and the deferred part waits until a discriminator field says what it is.

This is the usual shape for webhook payloads and event feeds, where one endpoint carries several message types.

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

var e Event
json.Unmarshal(data, &e)

switch e.Type {
case "signup":
    var p SignupPayload
    if err := json.Unmarshal(e.Payload, &p); err != nil {
        // handle error
    }
case "purchase":
    var p PurchasePayload
    json.Unmarshal(e.Payload, &p)
}

UseNumber keeps numbers as the literal text that arrived, wrapped in a json.Number. Int64 then converts exactly, or reports that it cannot.

dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()

var v map[string]any
dec.Decode(&v)

id := v["id"].(json.Number)
exact, err := id.Int64() // no float64 rounding

Working program

A complete program that reads a two-event feed, dispatching each payload on the type field.

package main

import (
    "encoding/json"
    "fmt"
)

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

type Signup struct {
    Email string `json:"email"`
}

type Purchase struct {
    SKU   string  `json:"sku"`
    Cents int     `json:"cents"`
}

func describe(raw []byte) (string, error) {
    var e Event
    if err := json.Unmarshal(raw, &e); err != nil {
        return "", err
    }

    switch e.Type {
    case "signup":
        var p Signup
        if err := json.Unmarshal(e.Payload, &p); err != nil {
            return "", err
        }
        return "signup from " + p.Email, nil
    case "purchase":
        var p Purchase
        if err := json.Unmarshal(e.Payload, &p); err != nil {
            return "", err
        }
        return fmt.Sprintf("purchase %s for %d cents", p.SKU, p.Cents), nil
    }
    return "", fmt.Errorf("unknown event type %q", e.Type)
}

func main() {
    feed := [][]byte{
        []byte(`{"type":"signup","payload":{"email":"kt@example.com"}}`),
        []byte(`{"type":"purchase","payload":{"sku":"GO-1","cents":2500}}`),
    }

    for _, raw := range feed {
        s, err := describe(raw)
        if err != nil {
            fmt.Println("skipped:", err)
            continue
        }
        fmt.Println(s)
    }
}

Run it.

Terminal
$ go run main.go
signup from kt@example.com
purchase GO-1 for 2500 cents