Skip to content
Home
Working with JSON in Go

Working with JSON in Go

Go Go 8 min read 1649 words Beginner ExcellentWiki Editorial Team

JSON is the universal data interchange format for web APIs, configuration files, and data serialization. Go’s standard library provides excellent JSON support through the encoding/json package, which maps JSON data to and from Go types with minimal boilerplate.

Basic JSON Encoding and Decoding

The two fundamental functions are json.Marshal (Go → JSON) and json.Unmarshal (JSON → Go).

Encoding (Marshaling)

package main

import (
    "encoding/json"
    "fmt"
    "log"
)

type User struct {
    Name  string
    Email string
    Age   int
---

func main() {
    u := User{
        Name:  "Alice",
        Email: "alice@example.com",
        Age:   30,
    }

    data, err := json.Marshal(u)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(data))
    // {"Name":"Alice","Email":"alice@example.com","Age":30}
---

By default, JSON field names match the Go field names exactly. This can produce uppercase field names that do not follow JSON conventions.

Decoding (Unmarshaling)

func main() {
    jsonData := `{"Name":"Bob","Email":"bob@example.com","Age":25}`
    var u User

    err := json.Unmarshal([]byte(jsonData), &u)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%+v\n", u)
    // {Name:Bob Email:bob@example.com Age:25}
---

Unmarshal always requires a pointer. If the JSON contains fields that do not exist in the target type, they are silently ignored — a deliberate design choice that allows forward compatibility.

Struct Tags for Custom Field Names

Use struct tags to control the JSON representation:

type User struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email,omitempty"`
    CreatedAt time.Time `json:"created_at"`
    Password  string    `json:"-"` // never included in JSON
---

Common tag options:

  • json:"field_name" — uses field_name instead of the Go field name
  • json:",omitempty" — omits the field if it has a zero value
  • json:"-" — always excludes the field from JSON
  • json:",string" — encodes/decodes the field as a string (useful for int64 in JavaScript-safe JSON)

These tags are evaluated at compile time and cached for performance.

Working with JSON Streams

For large JSON documents or network streams, use json.Encoder and json.Decoder instead of marshaling/unmarshaling entire byte slices:

type Product struct {
    ID    int     `json:"id"`
    Name  string  `json:"name"`
    Price float64 `json:"price"`
---

// Decoding from a file
func readProducts(filename string) ([]Product, error) {
    f, err := os.Open(filename)
    if err != nil {
        return nil, err
    }
    defer f.Close()

    var products []Product
    decoder := json.NewDecoder(f)
    if err := decoder.Decode(&products); err != nil {
        return nil, err
    }
    return products, nil
---

// Encoding to an HTTP response
func writeProducts(w http.ResponseWriter, products []Product) {
    w.Header().Set("Content-Type", "application/json")
    encoder := json.NewEncoder(w)
    encoder.SetIndent("", "  ") // pretty-print for debugging
    encoder.Encode(products)
---

Streaming decoders are memory-efficient for large payloads because they never hold the entire document in memory at once.

JSON with Time and Custom Types

Go’s time.Time implements json.Marshaler and json.Unmarshaler, using RFC 3339 format by default:

type Event struct {
    Name      string    `json:"name"`
    Timestamp time.Time `json:"timestamp"`
---

func main() {
    e := Event{
        Name:      "Deploy",
        Timestamp: time.Now(),
    }
    data, _ := json.Marshal(e)
    fmt.Println(string(data))
    // {"name":"Deploy","timestamp":"2025-06-27T15:04:05Z"}
---

For custom formats, implement the two interfaces yourself:

type CustomTime struct {
    time.Time
---

const customFormat = "2006-01-02 15:04:05"

func (ct CustomTime) MarshalJSON() ([]byte, error) {
    return json.Marshal(ct.Format(customFormat))
---

func (ct *CustomTime) UnmarshalJSON(data []byte) error {
    var s string
    if err := json.Unmarshal(data, &s); err != nil {
        return err
    }
    t, err := time.Parse(customFormat, s)
    if err != nil {
        return err
    }
    ct.Time = t
    return nil
---

JSON with Interfaces and Polymorphism

Since Go is statically typed, decoding polymorphic JSON requires extra work. A common pattern uses a “discriminator” field:

type Animal struct {
    Type string          `json:"type"`
    Data json.RawMessage `json:"data"` // raw bytes, decoded later
---

type Dog struct {
    Breed string `json:"breed"`
    BarkVolume int `json:"bark_volume"`
---

type Cat struct {
    Breed string `json:"breed"`
    SleepsOnFeet bool `json:"sleeps_on_feet"`
---

func processAnimal(raw []byte) (any, error) {
    var animal Animal
    if err := json.Unmarshal(raw, &animal); err != nil {
        return nil, err
    }

    switch animal.Type {
    case "dog":
        var dog Dog
        if err := json.Unmarshal(animal.Data, &dog); err != nil {
            return nil, err
        }
        return dog, nil
    case "cat":
        var cat Cat
        if err := json.Unmarshal(animal.Data, &cat); err != nil {
            return nil, err
        }
        return cat, nil
    default:
        return nil, fmt.Errorf("unknown animal type: %s", animal.Type)
    }
---

This two-pass approach is widely used in production APIs that handle heterogeneous data.

Raw JSON and Lazy Decoding

Use json.RawMessage to defer decoding or to preserve exact JSON content:

type Flexible struct {
    Name   string          `json:"name"`
    Metadata json.RawMessage `json:"metadata"`
---

func main() {
    input := `{"name":"test","metadata":{"key":"value","count":3}}`
    var f Flexible
    json.Unmarshal([]byte(input), &f)

    // Forward the raw metadata without re-encoding
    // Or decode selectively:
    var meta map[string]any
    json.Unmarshal(f.Metadata, &meta)
---

json.RawMessage is a []byte that implements json.Marshaler and json.Unmarshaler. It stores the raw JSON bytes and emits them verbatim during encoding, avoiding unnecessary decode/re-encode cycles.

Error Handling in JSON

JSON operations can fail in several ways. Robust code checks every error:

func safeUnmarshal(data []byte, v any) error {
    decoder := json.NewDecoder(bytes.NewReader(data))
    decoder.DisallowUnknownFields() // rejects unexpected fields
    if err := decoder.Decode(v); err != nil {
        return fmt.Errorf("decode failed: %w", err)
    }
    // Check for trailing data
    if decoder.More() {
        return fmt.Errorf("unexpected trailing data after JSON value")
    }
    return nil
---

Common JSON errors and their causes:

  • SyntaxError — malformed JSON (missing brackets, invalid escapes)
  • UnmarshalTypeError — type mismatch (string where number expected)
  • InvalidUnmarshalError — passed non-pointer or nil to Unmarshal
  • An unexported field causes a compile-time check failure in recent Go versions

Performance Considerations

For high-throughput JSON processing:

  • Reuse buffers with sync.Pool to reduce allocations
  • Pre-allocate slices when the size is known: products := make([]Product, 0, expectedCount)
  • Use json.Encoder instead of json.Marshal for streaming output
  • Avoid map[string]any — it incurs allocation and reflection overhead
  • Consider encoding/json alternatives like json-iterator/go or simdjson for extreme throughput needs
  • Set SetEscapeHTML(false) on the encoder to avoid escaping <, >, & when you trust the output

Benchmarking JSON Operations

func BenchmarkMarshalSmall(b *testing.B) {
    u := User{Name: "Alice", Email: "a@b.com", Age: 30}
    for i := 0; i < b.N; i++ {
        json.Marshal(u)
    }
---

Run with go test -bench=. -benchmem and look at allocation counts. A well-optimized JSON path should allocate zero bytes per operation after amortizing initialization.

Security Best Practices

  • Limit input size — wrap the reader with io.LimitReader before decoding
  • Use json.Decoder.DisallowUnknownFields() in strict APIs to catch typos
  • Avoid json.Number unless you need arbitrary precision — it degrades performance
  • Never use json.Unmarshal with user-supplied schemas — it creates arbitrary types
  • Validate decoded data — JSON may decode structurally but contain semantically invalid values
func safeDecode(r io.Reader, maxBytes int64, v any) error {
    limited := io.LimitReader(r, maxBytes)
    decoder := json.NewDecoder(limited)
    decoder.DisallowUnknownFields()
    return decoder.Decode(v)
---

Working with JSON Files

Reading and writing JSON files is a common requirement:

type Config struct {
    Host    string `json:"host"`
    Port    int    `json:"port"`
    Debug   bool   `json:"debug,omitempty"`
    Timeout int    `json:"timeout"`
---

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("reading config: %w", err)
    }

    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parsing config: %w", err)
    }
    return &cfg, nil
---

func saveConfig(path string, cfg *Config) error {
    data, err := json.MarshalIndent(cfg, "", "  ")
    if err != nil {
        return fmt.Errorf("encoding config: %w", err)
    }
    return os.WriteFile(path, data, 0644)
---

JSON Lines (NDJSON)

For streaming multiple JSON objects, JSON Lines format (one JSON object per line) is efficient:

func readJSONLines(path string) ([]map[string]any, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()

    var records []map[string]any
    scanner := bufio.NewScanner(f)
    for scanner.Scan() {
        line := scanner.Bytes()
        if len(line) == 0 {
            continue
        }
        var record map[string]any
        if err := json.Unmarshal(line, &record); err != nil {
            return nil, fmt.Errorf("parsing line: %w", err)
        }
        records = append(records, record)
    }
    return records, scanner.Err()
---

JSON Schema Validation

For strict API validation, check JSON structure before processing:

func validateUserJSON(data []byte) error {
    var required struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }
    if err := json.Unmarshal(data, &required); err != nil {
        return fmt.Errorf("invalid JSON: %w", err)
    }
    if required.Name == "" {
        return errors.New("name is required")
    }
    if !strings.Contains(required.Email, "@") {
        return errors.New("invalid email format")
    }
    return nil
---

Partial JSON Updates

Use json.RawMessage for partial updates to large documents:

type PatchOperation struct {
    Op    string          `json:"op"`
    Path  string          `json:"path"`
    Value json.RawMessage `json:"value"`
---

func applyPatch(original []byte, patches []PatchOperation) ([]byte, error) {
    var doc map[string]any
    if err := json.Unmarshal(original, &doc); err != nil {
        return nil, err
    }

    for _, patch := range patches {
        switch patch.Op {
        case "replace":
            var value any
            if err := json.Unmarshal(patch.Value, &value); err != nil {
                return nil, err
            }
            doc[patch.Path] = value
        case "remove":
            delete(doc, patch.Path)
        }
    }
    return json.Marshal(doc)
---

FAQ

Q: How do I pretty-print JSON output? A: Use json.MarshalIndent(data, "", " ") or set encoder.SetIndent("", " ").

Q: Why are my struct fields empty after unmarshaling? A: JSON field names are case-sensitive. Either match your struct tags to the JSON keys or use lowercase json:"fieldname" tags.

Q: How do I handle deeply nested JSON? A: Define nested structs that mirror the JSON structure. For sparse access, use map[string]any and type assertions, or define an intermediate json.RawMessage approach.

Q: What is the size limit for json.Marshal? A: There is no hard limit, but very large payloads (hundreds of MB) benefit from streaming with json.Encoder.

Q: How do I include unexported fields in JSON? A: You cannot — encoding/json only accesses exported fields. Either export the field or implement json.Marshaler on the type.

Q: Does Go support JSON comments? A: No. JSON does not allow comments per the specification. Use a preprocessing step to strip comments if you need them in configuration files.

Q: How do I decode JSON with dynamic keys? A: Use map[string]any for fully dynamic keys, or use json.Decoder with Token() to process tokens manually.

Q: What is the difference between json.Marshal and json.Encoder? A: json.Marshal produces a []byte in memory. json.Encoder writes to an io.Writer stream, which is more memory-efficient for large payloads.

For a comprehensive overview, read our article on Getting Started With Go.

For a comprehensive overview, read our article on Go Concurrency Guide.

Section: Go 1649 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top