·6 min read
JSON in Go: Structs, Encoding, and Best Practices
Go's encoding/json package provides powerful JSON encoding and decoding through struct tags, interfaces, and streaming APIs. Go's approach to JSON is unique — it uses compile-time type safety, struct field tags for mapping, and explicit error handling. This guide covers everything from basic struct mapping to advanced custom serialization, performance optimization, and best practices. Use our JSON to Go generator to create structs from JSON samples and JSON Validator to verify output.
Basic Struct Mapping
import "encoding/json"
type Address struct {
Street string
City string
Zip string
}
type User struct {
ID int
Name string
Email string
Age int
Address Address
Tags []string
}
// JSON to struct
jsonString := "{"id":1,"name":"Alice","email":"alice@example.com","age":30}"
var user User
err := json.Unmarshal([]byte(jsonString), &user)
if err != nil {
log.Fatal(err)
}
// Struct to JSON
bytes, err := json.Marshal(user)
// Compact: {"ID":1,"Name":"Alice","Email":"alice@example.com","Age":30,"Address":{"Street":"","City":"","Zip":""},"Tags":null}
// Pretty print
bytes, err = json.MarshalIndent(user, "", " ")
Struct Tags (JSON Field Mapping)
type User struct {
ID int // json:"id"
Name string // json:"name"
Email string // json:"email"
Password string // json:"-" - skipped entirely
Age int // json:"age,omitempty"
CreatedAt time.Time // json:"created_at"
Metadata map[string]interface{} // json:"metadata,omitempty"
}
// Tag options:
// json:fieldname - Use custom field name in JSON
// json:- - Always exclude from JSON
// json:fieldname,omitempty - Exclude if zero value
// json:,string - Force string encoding for numbers
// json:,omitempty - Exclude if zero value (no rename)
// Example with string option
type Product struct {
ID int // json:"id,string" - encode as "42" not 42
Price float64 // json:"price,string"
}
product := Product{ID: 42, Price: 19.99}
bytes, _ := json.Marshal(product)
// {"id":"42","price":"19.99"}
Custom JSON Marshal/Unmarshal
// Custom time format
type CustomTime struct {
time.Time
}
func (ct CustomTime) MarshalJSON() ([]byte, error) {
formatted := ct.Format("2006-01-02")
return json.Marshal(formatted)
}
func (ct *CustomTime) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return err
}
ct.Time = t
return nil
}
// Using custom marshal/unmarshal
type Event struct {
ID int // json:"id"
Date CustomTime // json:"date"
}
event := Event{ID: 1, Date: CustomTime{time.Now()}}
bytes, _ := json.Marshal(event)
// {"id":1,"date":"2025-01-15"}
JSON RawMessage for Dynamic Fields
// json.RawMessage preserves raw JSON for deferred decoding
type Response struct {
Status string // json:"status"
Data json.RawMessage // json:"data" - raw, unparsed JSON
}
func processResponse(body []byte) error {
var resp Response
if err := json.Unmarshal(body, &resp); err != nil {
return err
}
// Decide how to parse Data based on Status
switch resp.Status {
case "user":
var user User
json.Unmarshal(resp.Data, &user)
// Process user
case "product":
var product Product
json.Unmarshal(resp.Data, &product)
// Process product
}
return nil
}
Streaming JSON with Decoder/Encoder
// Streaming decoder for NDJSON or large JSON arrays
func streamUsers(reader io.Reader) error {
dec := json.NewDecoder(reader)
// Expect opening bracket for array
t, err := dec.Token()
if err != nil {
return err
}
if delim, ok := t.(json.Delim); !ok || delim != '[' {
return fmt.Errorf("expected array start")
}
// Decode each array element
for dec.More() {
var user User
if err := dec.Decode(&user); err != nil {
return err
}
processUser(user)
}
// Expect closing bracket
_, err = dec.Token()
return err
}
// Streaming encoder for NDJSON output
func writeUsers(writer io.Writer, users []User) error {
enc := json.NewEncoder(writer)
for _, user := range users {
if err := enc.Encode(user); err != nil {
return err
}
}
return nil
}
JSON with Interfaces and Type Switches
// Unmarshal into interface{} for dynamic data
func processDynamicJSON(data []byte) {
var result interface{}
json.Unmarshal(data, &result)
// Type switch to handle different structures
switch v := result.(type) {
case map[string]interface{}:
for key, val := range v {
fmt.Printf("Key: %s, Value: %v (type: %T)
", key, val, val)
}
case []interface{}:
for i, item := range v {
fmt.Printf("Index: %d, Value: %v
", i, item)
}
}
}
// Use json.Number to preserve number precision
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber() // Numbers become json.Number (string) instead of float64
var result map[string]interface{}
dec.Decode(&result)
// Access numbers without precision loss
age := result["age"].(json.Number)
ageInt, _ := age.Int64() // Parse as int64
ageFloat, _ := age.Float64() // Parse as float64
Performance Optimization
| Technique | Improvement | Code |
|---|---|---|
| Pre-allocate slices | 50-80% faster for large arrays | users := make([]User, 0, expectedSize) |
| Use bytes.Buffer | Faster than strings.Builder for JSON | buf := new(bytes.Buffer) |
| Avoid reflect with fast-path | Use concrete types, not interface{} | Map to specific struct types |
| Use json.Encoder for streams | No intermediate byte slice | json.NewEncoder(w).Encode(v) |
| Disable HTML escaping | Slightly faster, smaller output | enc.SetEscapeHTML(false) |
Common Pitfalls
- Unexported fields are silently ignored during marshaling
- JSON
nullunmarshals to Go's zero value, not nil pointer float64is the default for JSON numbers in interface{} - usejson.NumberorUseNumber()- Time marshaling uses RFC3339 by default
- Channel, complex, and function types cannot be marshaled
- Cyclic struct references cause infinite recursion
Next Steps
Generate Go structs from your JSON with JSON to Go. Validate JSON output with JSON Validator. Format Go JSON output with JSON Formatter.