Convert JSON to Go structs using encoding/json struct tags, pointer types for nullable fields, and json.Decoder for HTTP streaming. Define exported fields with `json:"name"` tags, use *T for optional values, and implement UnmarshalJSON for custom types like dates. For high-throughput services, replace encoding/json with jsoniter or sonic with zero code changes.
1. Why Use Go Structs for JSON?
Go is statically typed. When you reach for map[string]interface{} to handle JSON you trade compile-time safety for tedious type assertions and runtime panics. Proper struct definitions give you IDE autocompletion, exhaustive refactoring, and zero-overhead marshaling via reflection cache.
| Approach | Type Safety | IDE Support | Performance | Maintainability |
|---|---|---|---|---|
| Go Struct | Compile-time | Full autocomplete | Fast (cached reflection) | High — refactor-safe |
map[string]interface{} | None — panics at runtime | No completion | Moderate | Low — string keys |
json.RawMessage | Deferred only | Partial | Fastest (skip parse) | Medium — delayed decode |
2. Basic json.Unmarshal and json.Marshal
The encoding/json package is in the Go standard library — no dependencies needed. Struct fields must be exported (uppercase) and annotated with a `json:"..."` tag.
Defining the struct and unmarshaling
package main
import (
"encoding/json"
"fmt"
"log"
"time"
)
// Exported fields + json tags are required for encoding/json to see them.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Active bool `json:"active"`
Score float64 `json:"score"`
CreatedAt time.Time `json:"created_at"`
}
func main() {
data := []byte(`{
"id": 42,
"name": "Alice",
"email": "alice@example.com",
"active": true,
"score": 98.6,
"created_at": "2026-01-15T09:00:00Z"
}`)
var user User
if err := json.Unmarshal(data, &user); err != nil {
log.Fatal(err)
}
fmt.Printf("User: %s (%.1f)\n", user.Name, user.Score)
// User: Alice (98.6)
}Encoding (Marshal) back to JSON
// Compact JSON
out, err := json.Marshal(user)
// {"id":42,"name":"Alice","email":"alice@example.com","active":true,"score":98.6,"created_at":"2026-01-15T09:00:00Z"}
// Pretty-printed JSON
out, err = json.MarshalIndent(user, "", " ")
// {
// "id": 42,
// "name": "Alice",
// ...
// }
// Write JSON directly to an http.ResponseWriter (no intermediate buffer)
func writeJSON(w http.ResponseWriter, v any) error {
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(v)
}Error handling best practice
func parseUser(data []byte) (*User, error) {
var u User
if err := json.Unmarshal(data, &u); err != nil {
return nil, fmt.Errorf("parseUser: %w", err)
}
if u.Name == "" {
return nil, fmt.Errorf("parseUser: name is required")
}
return &u, nil
}3. JSON Struct Tags
Tags are raw string literals of the form `json:"key,options"`. They sit after the field type, surrounded by backticks.
type Product struct {
// Map Go CamelCase to JSON snake_case
ProductID int `json:"product_id"`
// omitempty: skip field in output when zero-value ("", 0, false, nil)
Description string `json:"description,omitempty"`
// "-": always exclude this field from JSON (passwords, internals)
InternalRef string `json:"-"`
// "string": encode number as a quoted JSON string
// Useful for JavaScript Number precision (int64 > 2^53)
BigID int64 `json:"big_id,string"`
// No tag: uses the exact Go field name ("Type")
Type string
}
// Combined options: snake_case key + omitempty
type Event struct {
StartedAt *time.Time `json:"started_at,omitempty"` // null or absent → omit
EndedAt *time.Time `json:"ended_at,omitempty"`
}| Tag Option | Effect on Marshal | Effect on Unmarshal |
|---|---|---|
`json:"key"` | Output key is key | Reads from JSON field key |
`,omitempty` | Skip if zero-value | No effect |
`json:"-"` | Always excluded | Always ignored |
`,string` | Number as quoted string | Expects quoted string |
4. Nested Structs, Arrays, and Maps
Nest structs for JSON objects, use slices for JSON arrays, and maps for dictionary-like objects with dynamic keys.
// JSON:
// {
// "id": 1,
// "customer": { "name": "Alice", "address": { "city": "NYC", "zip": "10001" } },
// "items": [{ "sku": "ABC", "qty": 2, "price": 9.99 }],
// "tags": ["urgent", "vip"],
// "meta": { "source": "web", "campaign": "summer" }
// }
type Order struct {
ID int `json:"id"`
Customer Customer `json:"customer"` // nested struct
Items []LineItem `json:"items"` // slice of structs
Tags []string `json:"tags"` // slice of primitives
Meta map[string]string `json:"meta,omitempty"` // map with string values
}
type Customer struct {
Name string `json:"name"`
Address Address `json:"address"`
}
type Address struct {
City string `json:"city"`
Street string `json:"street,omitempty"`
Zip string `json:"zip,omitempty"`
}
type LineItem struct {
SKU string `json:"sku"`
Quantity int `json:"qty"`
Price float64 `json:"price"`
}
// Decode the whole nested tree at once:
var order Order
json.Unmarshal(data, &order)
fmt.Println(order.Customer.Address.City) // NYC
fmt.Println(order.Items[0].SKU) // ABCPointer fields for optional nested objects
// If "shipping" may be absent from JSON:
type Order struct {
ID int `json:"id"`
Shipping *Address `json:"shipping,omitempty"` // nil if absent/null
}
// Check before access:
if order.Shipping != nil {
fmt.Println(order.Shipping.City)
}5. Optional Fields with Pointer Types
In Go there is no concept of "undefined". A string field missing from JSON gets the zero value "" — indistinguishable from an empty string that was explicitly set. Use *string, *int, etc. to distinguish absent from zero.
// JSON variants:
// { "id": 1, "name": "Alice", "bio": null } → bio is explicitly null
// { "id": 2, "name": "Bob" } → bio is absent
// { "id": 3, "name": "Carol", "bio": "Dev" } → bio has a value
type UserProfile struct {
ID int `json:"id"`
Name string `json:"name"`
Bio *string `json:"bio"` // nil for both null and absent
Age *int `json:"age,omitempty"` // omit entirely when nil
}
// Safe access pattern:
func displayBio(u UserProfile) string {
if u.Bio != nil {
return *u.Bio
}
return "(no bio)"
}
// Setting a pointer field:
bio := "Senior Gopher"
u := UserProfile{ID: 1, Name: "Alice", Bio: &bio}
// Helper to get a pointer to any value (Go 1.18+ generic):
func ptr[T any](v T) *T { return &v }
u2 := UserProfile{ID: 2, Name: "Bob", Bio: ptr("Backend Dev"), Age: ptr(30)}6. Custom UnmarshalJSON — Parsing Dates and Mixed Types
Implement the json.Unmarshaler interface (UnmarshalJSON([]byte) error) to control how a type is decoded. The reciprocal json.Marshaler interface controls encoding.
Custom date format (YYYY-MM-DD)
// time.Time uses RFC3339 by default ("2006-01-02T15:04:05Z07:00")
// To parse plain dates like "2006-01-02":
type Date struct{ time.Time }
func (d *Date) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return fmt.Errorf("Date.UnmarshalJSON: %w", err)
}
d.Time = t
return nil
}
func (d Date) MarshalJSON() ([]byte, error) {
return json.Marshal(d.Format("2006-01-02"))
}
type Subscription struct {
UserID int `json:"user_id"`
StartDate Date `json:"start_date"` // parses "2026-01-15"
EndDate Date `json:"end_date"`
}Mixed-type / discriminated union fields
// JSON: { "type": "user", "data": { "name": "Alice" } }
// or: { "type": "product", "data": { "sku": "ABC" } }
type Envelope struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"` // delay decode until type is known
}
func (e *Envelope) Decode() (any, error) {
switch e.Type {
case "user":
var u User
return &u, json.Unmarshal(e.Data, &u)
case "product":
var p Product
return &p, json.Unmarshal(e.Data, &p)
default:
return nil, fmt.Errorf("unknown type: %s", e.Type)
}
}7. json.Decoder for Streaming Large JSON
json.NewDecoder wraps any io.Reader and streams data without loading everything into memory. This is the idiomatic approach for HTTP handlers.
Decode a single object from HTTP body
func createUser(w http.ResponseWriter, r *http.Request) {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // reject unexpected keys
var u User
if err := dec.Decode(&u); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// u is now populated
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(u)
}Stream a large JSON array line by line
// Process millions of JSON objects from a file without loading all into memory:
func streamUsers(r io.Reader) error {
dec := json.NewDecoder(r)
// Read opening '['
if _, err := dec.Token(); err != nil {
return err
}
for dec.More() {
var u User
if err := dec.Decode(&u); err != nil {
return err
}
process(u) // handle each user without keeping all in memory
}
// Read closing ']'
_, err := dec.Token()
return err
}DisallowUnknownFields for strict validation
// By default, extra JSON keys are silently ignored.
// Use DisallowUnknownFields to error on unexpected keys:
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var payload CreateUserRequest
if err := dec.Decode(&payload); err != nil {
// err will mention "unknown field \"extraKey\"" if present
http.Error(w, "invalid request: "+err.Error(), http.StatusBadRequest)
return
}8. Struct Tags with go-playground/validator
Combine json tags with validate tags from the popular github.com/go-playground/validator/v10 library to add input validation without separate validation logic.
// go get github.com/go-playground/validator/v10
import "github.com/go-playground/validator/v10"
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=2,max=100"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=0,lte=130"`
Role string `json:"role" validate:"oneof=admin user viewer"`
Password string `json:"password" validate:"required,min=8"`
}
var validate = validator.New()
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad JSON: "+err.Error(), http.StatusBadRequest)
return
}
if err := validate.Struct(req); err != nil {
// Returns structured validation errors
errs := err.(validator.ValidationErrors)
http.Error(w, errs.Error(), http.StatusUnprocessableEntity)
return
}
// req is valid, proceed
}Common validator tags
Name string `validate:"required,min=2,max=100"` // non-empty, length 2-100
Email string `validate:"required,email"` // RFC 5322 email format
Age int `validate:"gte=0,lte=130"` // 0 ≤ age ≤ 130
URL string `validate:"url"` // valid URL
UUID string `validate:"uuid4"` // UUID v4 format
OneOf string `validate:"oneof=red green blue"` // enum check
Nested *Inner `validate:"required"` // non-nil pointer9. Go 1.21+ and the Upcoming encoding/json/v2
The Go team is developing encoding/json/v2 (tracked in golang.org/x/exp/jsonv2) with several long-requested improvements. It is not yet stable but worth knowing about.
// Key improvements in encoding/json/v2 (experimental as of 2026):
//
// 1. omitzero: omit fields when they are the zero value of their type
// (different from omitempty which uses interface-based zero check)
type Config struct {
Timeout int `json:",omitzero"` // omit when Timeout == 0
}
//
// 2. Better error messages with field path info:
// json: cannot unmarshal string into Go struct field User.age of type int
// (previously just "cannot unmarshal")
//
// 3. Strict mode: unknown fields are errors by default
// (flip of current behavior)
//
// 4. Deterministic map ordering (sorted keys)
//
// 5. Support for encoding.TextMarshaler on map keys
//
// Migration: the v2 API is largely compatible with v1.
// Change the import path and fix any breaking changes.10. Popular JSON Libraries Comparison
encoding/json is correct and battle-tested. Switch to a faster library only when JSON parsing is a proven bottleneck in your profiler.
| Library | Speed vs std | Key Feature | Best Use Case |
|---|---|---|---|
| encoding/json | Baseline (1x) | Zero dependencies, stdlib | Default for all Go projects |
| easyjson | ~3–5x faster | Code generation (no reflection) | High-throughput APIs, fixed schemas |
| jsoniter | ~2–3x faster | Drop-in replacement (import jsoniter "github.com/json-iterator/go") | Performance upgrade with no refactoring |
| sonic | ~6–10x faster | SIMD JIT (amd64 only) | Ultra-high throughput, Bytedance-scale |
// Drop-in replacement with jsoniter (same API as encoding/json):
import jsoniter "github.com/json-iterator/go"
var json = jsoniter.ConfigCompatibleWithStandardLibrary
// Now use json.Marshal / json.Unmarshal / json.NewDecoder as before.
// No other code changes needed.11. HTTP API with JSON — Full Pattern
The idiomatic Go pattern for JSON APIs uses json.NewDecoder for reading request bodies and json.NewEncoder for writing responses — both streaming without intermediate byte slices.
GET — fetch and decode JSON
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
type GitHubUser struct {
Login string `json:"login"`
ID int `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
func fetchGitHubUser(username string) (*GitHubUser, error) {
url := "https://api.github.com/users/" + username
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var user GitHubUser
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &user, nil
}POST — encode and send JSON
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
type CreateUserReq struct {
Name string `json:"name"`
Email string `json:"email"`
}
type CreateUserResp struct {
ID int `json:"id"`
Name string `json:"name"`
}
func createUser(baseURL string, req CreateUserReq) (*CreateUserResp, error) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(req); err != nil {
return nil, err
}
resp, err := http.Post(baseURL+"/users", "application/json", &buf)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result CreateUserResp
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &result, nil
}Full HTTP handler with validation
// GET /users/:id — respond with JSON
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // Go 1.22+
user, err := db.FindUser(id)
if err != nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
// POST /users — accept JSON body
func postUser(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/json" {
writeError(w, http.StatusUnsupportedMediaType, "Content-Type must be application/json")
return
}
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var req CreateUserReq
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, err := db.CreateUser(req)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create user")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
func writeError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}12. Common Pitfalls
1. Unexported fields are silently ignored
// ❌ Wrong — fields start with lowercase, invisible to encoding/json
type user struct {
id int
name string
}
data, _ := json.Marshal(user{id: 1, name: "Alice"})
// Output: {} ← empty!
// ✅ Correct — exported fields with json tags
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
data, _ = json.Marshal(User{ID: 1, Name: "Alice"})
// Output: {"id":1,"name":"Alice"}2. JSON numbers become float64 in interface{}
var m map[string]interface{}
json.Unmarshal([]byte(`{"count": 42}`), &m)
// ❌ Panics — it's float64, not int!
count := m["count"].(int)
// ✅ Correct type assertion
count := int(m["count"].(float64))
// ✅ Better: use json.Number to preserve original representation
dec := json.NewDecoder(strings.NewReader(`{"count": 42}`))
dec.UseNumber()
dec.Decode(&m)
n, _ := m["count"].(json.Number).Int64() // 42 exactly3. Date format gotchas
// ❌ time.Time only parses RFC3339 by default
// JSON: { "date": "2026-01-15" } ← no time component
var s struct{ Date time.Time `json:"date"` }
json.Unmarshal(data, &s) // ERROR: cannot parse "2026-01-15" as RFC3339
// ✅ Use a custom Date type (see Section 6)
// OR use a string and parse manually:
var s2 struct{ Date string `json:"date"` }
json.Unmarshal(data, &s2)
t, _ := time.Parse("2006-01-02", s2.Date)4. Nil slice vs empty array
// nil slice marshals as JSON null
var items []string // nil
json.Marshal(items) // null
// Initialized empty slice marshals as []
items = make([]string, 0)
json.Marshal(items) // []
// This matters when your frontend expects an array, not null:
type Response struct {
Items []string `json:"items"`
}
// Always initialize: Items: make([]string, 0)5. Circular references cause infinite loop
// ❌ Circular reference — json.Marshal will panic with stack overflow
type Node struct {
Value int
Children []*Node `json:"children"`
Parent *Node `json:"parent"` // points back → cycle!
}
// ✅ Exclude back-references with json:"-"
type Node struct {
Value int
Children []*Node `json:"children"`
Parent *Node `json:"-"` // excluded from JSON
}Frequently Asked Questions
How do I convert JSON to Go struct online?
Use DevToolBox's JSON to Go converter — paste your JSON and get idiomatic Go struct definitions with json tags, pointer types for nullable fields, and proper Go naming conventions instantly. It handles nested objects, arrays, and mixed types automatically.
Should I use json.Unmarshal or json.NewDecoder?
Use json.NewDecoder(r.Body).Decode(&v) for HTTP request/response bodies — it streams without buffering all bytes. Use json.Unmarshal(data, &v) when you already have a []byte in memory (e.g. from a database or file).
How do I handle nullable JSON fields in Go?
Use pointer types: *string, *int, *bool. A nil pointer marshals to JSON null. Always check for nil before dereferencing: if u.Bio != nil { use *u.Bio }.
What does omitempty do in a Go json tag?
omitempty omits the field from JSON output when it has its zero value — "" for strings, 0 for numbers, false for booleans, nil for pointers/slices/maps. Combine with a pointer: *string `json:"bio,omitempty"` to also omit null values.
Why are struct fields missing from JSON output?
Fields must start with an uppercase letter to be exported. Lowercase (unexported) fields are silently ignored by encoding/json. Also verify you have not accidentally used the `json:"-"` tag which always excludes a field.
How do I parse custom date formats in Go?
Create a custom type that embeds time.Time and implement UnmarshalJSONusing time.Parse("2006-01-02", s). Go's reference time is Mon Jan 2 15:04:05 MST 2006 — use those exact values in your format string.
How do I reject unknown JSON fields?
Use dec.DisallowUnknownFields() on a json.Decoder. By default, encoding/json silently ignores extra keys. Strict mode is useful for API handlers where unexpected fields may indicate a client bug or schema mismatch.
Which JSON library is fastest in Go?
For amd64 workloads: sonic (ByteDance) is ~6-10x faster using SIMD JIT. jsoniter is a safe 2-3x improvement with zero code changes — just replace the import. encoding/json remains the best default unless JSON is a proven bottleneck.
- Go struct fields must be exported (uppercase) to be visible to
encoding/json - Use
`json:"field_name"`tags to map Go names to JSON keys - Use
`,omitempty`to skip zero-value fields in JSON output - Use
`json:"-"`to permanently exclude sensitive fields - Pointer types (
*string,*int) model nullable / optional JSON fields - Use
json.NewDecoder(r.Body)for HTTP streams;json.Unmarshalfor[]byte - Call
dec.DisallowUnknownFields()for strict API validation - Implement
UnmarshalJSON/MarshalJSONfor custom date formats and enums - JSON numbers decode as
float64intointerface{}— use typed structs ordec.UseNumber() - Initialize slices with
make([]T, 0)when the API must return[]notnull - For high-throughput APIs, replace
encoding/jsonwith jsoniter or sonic