Learn/Language Integrations

JSON in Go — encoding/json, Struct Tags & Custom Marshaling

Go's standard library handles JSON through the encoding/json package. This guide covers marshaling, unmarshaling, struct tags, handling dynamic JSON, custom type serialization, streaming large payloads, and high-performance alternatives for demanding workloads.

Marshal and Unmarshal Basics

Go JSON Round-Trip
Basic Marshal and Unmarshalgo
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6)
7
8type User struct {
9 ID int `json:"id"`
10 Name string `json:"name"`
11 Email string `json:"email"`
12 IsActive bool `json:"is_active"`
13 Roles []string `json:"roles"`
14}
15
16func main() {
17 // Marshal: Go struct -> JSON
18 user := User{
19 ID: 1, Name: "Alice", Email: "[email protected]",
20 IsActive: true, Roles: []string{"admin", "editor"},
21 }
22
23 data, err := json.Marshal(user)
24 if err != nil {
25 panic(err)
26 }
27 fmt.Println(string(data))
28 // {"id":1,"name":"Alice","email":"[email protected]","is_active":true,"roles":["admin","editor"]}
29
30 // MarshalIndent: pretty-printed JSON
31 pretty, _ := json.MarshalIndent(user, "", " ")
32 fmt.Println(string(pretty))
33
34 // Unmarshal: JSON -> Go struct
35 jsonStr := `{"id":2,"name":"Bob","email":"[email protected]","is_active":false,"roles":["viewer"]}`
36 var parsed User
37 if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
38 panic(err)
39 }
40 fmt.Printf("Parsed: %+v\n", parsed)
41}

Struct Tags Reference

TagEffectExample
json:"name"Maps Go field to JSON key "name"Name string `json:"name"`
json:"name,omitempty"Omit field if zero valueBio string `json:"bio,omitempty"`
json:"-"Always exclude from JSONPassword string `json:"-"`
json:",string"Encode number/bool as JSON stringID int `json:"id,string"`
json:"-,"Use literal key "-"Dash string `json:"-,"`
Struct tags in practicego
1type Product struct {
2 ID int `json:"id"`
3 Name string `json:"name"`
4 Price float64 `json:"price"`
5 Description string `json:"description,omitempty"` // omitted if ""
6 InStock bool `json:"in_stock"`
7 Tags []string `json:"tags,omitempty"` // omitted if nil
8 Internal string `json:"-"` // never in JSON
9 SKU int64 `json:"sku,string"` // "sku":"12345" (string in JSON)
10}
11
12// With zero values:
13p := Product{ID: 1, Name: "Widget", Price: 9.99, InStock: true}
14data, _ := json.Marshal(p)
15// {"id":1,"name":"Widget","price":9.99,"in_stock":true,"sku":"0"}
16// Note: Description and Tags are omitted (omitempty), Internal is excluded

omitempty Gotcha

The omitempty flag treats zero values (0, false, "") as empty. If a field legitimately has a value of 0 or false, use a pointer type (*int,*bool) so that nil means "absent" and the zero value is preserved.

Handling Dynamic JSON

interface{} for Unknown Structure

Parsing unknown JSONgo
1var result interface{}
2json.Unmarshal([]byte(`{"name":"Alice","age":30,"nested":{"key":"val"}}`), &result)
3
4// Type assertion to access fields
5m := result.(map[string]interface{})
6name := m["name"].(string) // "Alice"
7age := m["age"].(float64) // 30.0 (all JSON numbers become float64)
8nested := m["nested"].(map[string]interface{})
9key := nested["key"].(string) // "val"

Note

All JSON numbers become float64 when unmarshaling into interface{}. This can lose precision for large integers. Use json.Decoder with UseNumber() to preserve numeric precision as a json.Number string type.

json.RawMessage for Deferred Parsing

Polymorphic JSON with RawMessagego
1type Event struct {
2 Type string `json:"type"`
3 Payload json.RawMessage `json:"payload"` // parsed later based on Type
4}
5
6type UserCreated struct {
7 UserID int `json:"user_id"`
8 Email string `json:"email"`
9}
10
11type OrderPlaced struct {
12 OrderID int `json:"order_id"`
13 Total float64 `json:"total"`
14}
15
16func parseEvent(data []byte) {
17 var event Event
18 json.Unmarshal(data, &event)
19
20 switch event.Type {
21 case "user.created":
22 var payload UserCreated
23 json.Unmarshal(event.Payload, &payload)
24 fmt.Printf("New user: %s\n", payload.Email)
25 case "order.placed":
26 var payload OrderPlaced
27 json.Unmarshal(event.Payload, &payload)
28 fmt.Printf("Order total: $%.2f\n", payload.Total)
29 }
30}

Custom MarshalJSON and UnmarshalJSON

Custom time formatgo
1type CustomTime struct {
2 time.Time
3}
4
5const layout = "2006-01-02" // Go's reference time format
6
7func (ct CustomTime) MarshalJSON() ([]byte, error) {
8 return json.Marshal(ct.Format(layout))
9}
10
11func (ct *CustomTime) UnmarshalJSON(data []byte) error {
12 var s string
13 if err := json.Unmarshal(data, &s); err != nil {
14 return err
15 }
16 t, err := time.Parse(layout, s)
17 if err != nil {
18 return err
19 }
20 ct.Time = t
21 return nil
22}
23
24type Event struct {
25 Name string `json:"name"`
26 Date CustomTime `json:"date"`
27}
28
29// Marshal: {"name":"Launch","date":"2026-04-15"}
30// Unmarshal: parses "2026-04-15" back into time.Time
Custom enum serializationgo
1type Status int
2
3const (
4 StatusPending Status = iota // 0
5 StatusActive // 1
6 StatusInactive // 2
7)
8
9var statusNames = map[Status]string{
10 StatusPending: "pending", StatusActive: "active", StatusInactive: "inactive",
11}
12
13var statusValues = map[string]Status{
14 "pending": StatusPending, "active": StatusActive, "inactive": StatusInactive,
15}
16
17func (s Status) MarshalJSON() ([]byte, error) {
18 name, ok := statusNames[s]
19 if !ok {
20 return nil, fmt.Errorf("unknown status: %d", s)
21 }
22 return json.Marshal(name)
23}
24
25func (s *Status) UnmarshalJSON(data []byte) error {
26 var name string
27 if err := json.Unmarshal(data, &name); err != nil {
28 return err
29 }
30 val, ok := statusValues[name]
31 if !ok {
32 return fmt.Errorf("unknown status: %q", name)
33 }
34 *s = val
35 return nil
36}
37
38// {"status":"active"} instead of {"status":1}

Streaming with json.Decoder

For large JSON payloads or HTTP request bodies, use json.Decoder instead of json.Unmarshal. The decoder reads from an io.Reader and doesn't need the entire payload in memory:

HTTP handler with json.Decodergo
1func createUserHandler(w http.ResponseWriter, r *http.Request) {
2 var req CreateUserRequest
3 decoder := json.NewDecoder(r.Body)
4 decoder.DisallowUnknownFields() // strict: reject extra fields
5
6 if err := decoder.Decode(&req); err != nil {
7 http.Error(w, `{"error":"invalid JSON: `+err.Error()+`"}`, http.StatusBadRequest)
8 return
9 }
10
11 // Validate
12 if req.Email == "" {
13 http.Error(w, `{"error":"email is required"}`, http.StatusBadRequest)
14 return
15 }
16
17 // Process...
18 user := processUser(req)
19
20 // Respond with JSON
21 w.Header().Set("Content-Type", "application/json")
22 json.NewEncoder(w).Encode(user)
23}
24
25// json.NewDecoder reads from r.Body (io.Reader) — no ioutil.ReadAll needed
26// json.NewEncoder writes directly to w (io.Writer) — no Marshal + w.Write needed

Streaming JSON Lines (NDJSON)

Processing JSON Lines from a large filego
1func processLogs(r io.Reader) error {
2 decoder := json.NewDecoder(r)
3 for decoder.More() {
4 var entry LogEntry
5 if err := decoder.Decode(&entry); err != nil {
6 return fmt.Errorf("decode error: %w", err)
7 }
8 // Process each log entry without loading entire file
9 handleLogEntry(entry)
10 }
11 return nil
12}

Error Handling

Handling different JSON error typesgo
1var user User
2err := json.Unmarshal(data, &user)
3if err != nil {
4 var syntaxErr *json.SyntaxError
5 var typeErr *json.UnmarshalTypeError
6
7 switch {
8 case errors.As(err, &syntaxErr):
9 fmt.Printf("Syntax error at byte offset %d\n", syntaxErr.Offset)
10 case errors.As(err, &typeErr):
11 fmt.Printf("Type mismatch: field %q expects %s, got %s\n",
12 typeErr.Field, typeErr.Type, typeErr.Value)
13 default:
14 fmt.Printf("JSON error: %v\n", err)
15 }
16}

Performance Alternatives

LibrarySpeedup vs stdlibApproachCompatibility
encoding/json1x (baseline)Reflection-basedStandard library, always available
go-json~2-3xOptimized reflectionDrop-in replacement, import path change only
sonic~5-6xJIT compilation (amd64)Near drop-in, amd64 Linux/macOS only
easyjson~4-5xCode generationRequires running easyjson generator tool
jsoniter~2-3xOptimized reflectionConfigCompatibleWithStandardLibrary mode
Using go-json as a drop-in replacementgo
1// Change import from "encoding/json" to "github.com/goccy/go-json"
2import json "github.com/goccy/go-json"
3
4// All existing code works unchanged
5data, err := json.Marshal(user)
6err = json.Unmarshal(data, &user)
7
8// Also works with json.NewDecoder/NewEncoder
9decoder := json.NewDecoder(r.Body)
10encoder := json.NewEncoder(w)

Tip

Profile before optimizing. For most web services, encoding/json is fast enough. Switch to alternatives only when benchmarks show JSON serialization is a bottleneck (typically at thousands of requests per second with large payloads).

Real-World Example: GitHub Webhook

Parsing a GitHub push webhookgo
1type GitHubPushEvent struct {
2 Ref string `json:"ref"`
3 Before string `json:"before"`
4 After string `json:"after"`
5 Repository Repository `json:"repository"`
6 Commits []Commit `json:"commits"`
7 Pusher GitHubUser `json:"pusher"`
8}
9
10type Repository struct {
11 ID int `json:"id"`
12 Name string `json:"name"`
13 FullName string `json:"full_name"`
14 Private bool `json:"private"`
15 HTMLURL string `json:"html_url"`
16}
17
18type Commit struct {
19 ID string `json:"id"`
20 Message string `json:"message"`
21 Timestamp string `json:"timestamp"`
22 Author Author `json:"author"`
23 Added []string `json:"added"`
24 Removed []string `json:"removed"`
25 Modified []string `json:"modified"`
26}
27
28type Author struct {
29 Name string `json:"name"`
30 Email string `json:"email"`
31}
32
33type GitHubUser struct {
34 Name string `json:"name"`
35 Email string `json:"email"`
36}
37
38func handleWebhook(w http.ResponseWriter, r *http.Request) {
39 var event GitHubPushEvent
40 if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
41 http.Error(w, "invalid webhook payload", http.StatusBadRequest)
42 return
43 }
44 fmt.Printf("Push to %s: %d commits by %s\n",
45 event.Repository.FullName, len(event.Commits), event.Pusher.Name)
46 w.WriteHeader(http.StatusOK)
47}

Common Gotchas

  • Unexported fields (lowercase) are silently ignored — always export fields or use struct tags
  • All numbers become float64 when unmarshaling to interface{} — use UseNumber() for precision
  • omitempty hides legitimate zero values (0, false) — use pointers for nullable fields
  • Unknown fields are silently ignored by default — use DisallowUnknownFields() for strict parsing
  • time.Time marshals to RFC 3339 by default — implement custom MarshalJSON for other formats
  • A nil slice marshals to null, not [] — initialize with make([]T, 0) if you need empty arrays

Frequently Asked Questions

How do I parse JSON in Go?
Use json.Unmarshal([]byte(jsonString), &target) where target is a pointer to a struct or interface{}. Define a Go struct with fields matching the JSON keys and use struct tags (e.g., `json:"field_name"`) to map JSON keys to Go field names. Only exported (uppercase) fields are included in JSON operations.
Why are my Go struct fields missing from JSON output?
Only exported fields (starting with an uppercase letter) are included in JSON marshaling. If your field is named "name" (lowercase), change it to "Name" and use a struct tag `json:"name"` to keep the JSON key lowercase. Unexported fields are silently ignored by encoding/json.
What does omitempty do in Go JSON struct tags?
The omitempty option causes a field to be omitted from JSON output when it has its zero value: 0 for numbers, "" for strings, false for booleans, nil for pointers/slices/maps, and empty for structs. Use it as `json:"field,omitempty"`. Note: a non-nil empty slice and a nil slice both produce different JSON ([] vs omitted).
How do I handle unknown JSON fields in Go?
By default, json.Unmarshal silently ignores unknown fields. To catch them, use json.NewDecoder(reader).DisallowUnknownFields() which returns an error if the JSON contains keys not present in the target struct. This is useful for strict API validation.
How do I parse JSON with dynamic or unknown structure in Go?
Use interface{} (or any in Go 1.18+) as the target type. JSON objects become map[string]interface{}, arrays become []interface{}, strings become string, and numbers become float64. For partial parsing, use json.RawMessage to defer decoding of specific fields.
What is json.RawMessage used for?
json.RawMessage is a raw encoded JSON value that delays parsing. Use it when a field can contain different JSON structures (polymorphic types) — first unmarshal the outer structure with RawMessage for the dynamic field, then inspect a type discriminator to decide which concrete struct to unmarshal the raw JSON into.
Is Go encoding/json slow? What are faster alternatives?
encoding/json uses reflection and is slower than code-generation approaches. Faster alternatives: sonic (~6x faster, uses JIT on amd64), go-json (~2-3x faster, drop-in replacement), easyjson (code-gen, ~5x faster but requires running a generator). For most applications, encoding/json is fast enough — optimize only when profiling shows it is a bottleneck.