Working with JSON in Go
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"— usesfield_nameinstead of the Go field namejson:",omitempty"— omits the field if it has a zero valuejson:"-"— always excludes the field from JSONjson:",string"— encodes/decodes the field as a string (useful forint64in 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.Poolto reduce allocations - Pre-allocate slices when the size is known:
products := make([]Product, 0, expectedCount) - Use
json.Encoderinstead ofjson.Marshalfor streaming output - Avoid
map[string]any— it incurs allocation and reflection overhead - Consider
encoding/jsonalternatives likejson-iterator/goorsimdjsonfor 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.LimitReaderbefore decoding - Use
json.Decoder.DisallowUnknownFields()in strict APIs to catch typos - Avoid
json.Numberunless you need arbitrary precision — it degrades performance - Never use
json.Unmarshalwith 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.