Go JSON Marshalling and Unmarshalling: Complete Guide

Go JSON Marshalling: The Serialization Mirror
Go's encoding/json package converts between Go structs and JSON using reflection and struct tags. Marshalling (json.Marshal or json.NewEncoder) converts a Go struct into a JSON byte slice or stream. Unmarshalling (json.Unmarshal or json.NewDecoder) parses JSON into a Go struct. Struct tags like `json:"field_name,omitempty"` control the mapping between Go field names and JSON keys.
In this module, we will explore how to send and receive JSON data like a professional Go developer.
1. The Serialization Mirror: Reflection Costs
Go's JSON implementation is "Batteries Included" because it uses the reflect package to inspect your types at runtime.
The Inspection Physics
- The Type Mirror: When you call
json.Marshal, Go pauses to inspect the struct's fields, types, and tags. This is a dynamic operation that occurs on every call. - The CPU Cost: Reflection-based marshalling is significantly slower than static code generation (like Protobuf or
easyjson). Each field lookup requires the CPU to traverse the Go type-header mirror. - The Strategy: For 99% of applications, this cost is negligible. However, in high-frequency hardware mirrors (100k+ RPS), developers often switch to Code Generation to bypass reflection and directly map Go types to binary buffers.
2. Working with JSON in Go
- Unmarshalling: Converting JSON text into a Go Struct (Receiving data).
3. The Field-Tag Mirror: Metadata Physics
Struct tags are the Go compiler's way of allowing Attached Metadata to survive into the binary execution mirror.
The Mapping Physics
- The String Mirror: Tags like
`json:"id"`are stored as raw strings in the binary's symbol table. - The Serializer's Path: The JSON engine reads these strings at runtime to decide if a Go field named
UserIDshould be written as"user_id"in the network packet. - The Zero-Value Mirror: The
omitemptytag guides the physics of "Pruning." If a field's value matches the silicon zero-state (e.g.,0for int), the serializer skips it entirely, reducing the network payload size.
4. Struct Tags: The Mapping Secret
Go uses "Struct Tags" to define exactly how a field should be represented in JSON. This allows your Go code to use PascalCase while your JSON uses snake_case.
type User struct {
ID int `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
// Omitting a field from JSON entirely
Password string `json:"-"`
}Marshalling Go to JSON
To send JSON in an HTTP response, you transform your data structure using json.Marshal.
func userHandler(w http.ResponseWriter, r *http.Request) {
u := User{ID: 1, FirstName: "Alice", LastName: "Smith"}
// Set the Content-Type header so the browser knows this is JSON
w.Header().Set("Content-Type", "application/json")
// Encode and write directly to the response
json.NewEncoder(w).Encode(u)
}Unmarshalling JSON to Go
When receiving data from an API request, you need to "decode" the incoming JSON body into a Go struct.
func createUser(w http.ResponseWriter, r *http.Request) {
var u User
// Decode the request body into our user struct
err := json.NewDecoder(r.Body).Decode(&u)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fmt.Printf("Received user: %s %s\n", u.FirstName, u.LastName)
}JSON Power Tools
Performance: Encoders vs. Marshals
Wait! There are two ways to handle JSON in Go. Which one should you use?
| Task / Feature | json.Marshal | json.NewEncoder |
|---|---|---|
| No comparison data available | ||
Validating Incoming JSON
The encoding/json package does basic type validation during decoding, but it won't enforce business rules (like a required field being non-empty). For production APIs, validate the struct after decoding:
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}
func (r *CreateUserRequest) Validate() error {
if r.Name == "" {
return errors.New("name is required")
}
if r.Email == "" {
return errors.New("email is required")
}
if r.Age < 0 || r.Age > 150 {
return errors.New("age must be between 0 and 150")
}
return nil
}
func createUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if err := req.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
// Process valid request...
}For larger applications, the go-playground/validator package provides struct tag-based validation (like `validate:"required,email"`) that eliminates the boilerplate.
Custom Marshalling with MarshalJSON
Sometimes you need to control exactly how a struct is serialised to JSON. Implement the json.Marshaler interface by defining a MarshalJSON() ([]byte, error) method:
type Money struct {
Amount int64 // Store as cents internally
Currency string
}
// Custom marshaller: output as decimal string, not integer
func (m Money) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}{
Amount: fmt.Sprintf("%.2f", float64(m.Amount)/100),
Currency: m.Currency,
})
}
// Output: {"amount": "42.99", "currency": "USD"}Similarly, implement UnmarshalJSON([]byte) error for custom parsing logic.
Handling Nullable Fields
Go's zero values (0, "", false) are not the same as JSON null. If you need to distinguish between a field being absent versus present-but-zero, use pointer types:
type Profile struct {
Bio *string `json:"bio"` // nil = absent, "" = empty string
Age *int `json:"age"` // nil = not provided, 0 = zero
Premium bool `json:"premium"` // Can't distinguish false from absent
}When Bio is nil, it marshals to null in JSON. When it's &"", it marshals to "". This pattern is essential for PATCH endpoints where you need to know which fields the client actually sent.
JSON and the Go Standard Library: A Format Overview
Understanding JSON in the context of other formats Go supports:
| Format | Package | Use Case |
|---|---|---|
| JSON | encoding/json | REST APIs, config files |
| XML | encoding/xml | Legacy SOAP services |
| CSV | encoding/csv | Tabular data export |
| Gob | encoding/gob | Go-to-Go binary communication |
| Protocol Buffers | google.golang.org/protobuf | High-performance microservices |
For REST APIs serving web and mobile clients, JSON is the clear choice. Protocol Buffers are used in performance-critical microservice-to-microservice communication.
Phase 17: Serialization Architecture Mastery Checklist
- Verify Field Exportability: Audit all JSON structs. Ensure that fields destined for the wire are Uppercase (exported) to remain visible to the reflection mirror.
- Audit Tag Consistency: Use a linter (like
golangci-lint) to ensure that your struct tags are valid and follow the "snake_case" standard mirror for public APIs. - Implement Streaming Decoders: For incoming HTTP requests, use
json.NewDecoder(r.Body)to process payloads directly from the network buffer, minimizing RAM allocation spikes. - Test Omitempty Semantics: Confirm that "Required Zero-Values" (like a zero balance) are not accidentally pruned by
omitempty. Use pointers for true optionality. - Use RawMessage for Dynamic Payloads: When handling polymorphic JSON, capture the dynamic segment into
json.RawMessageto delay the reflection cost until the subtype is identified.
Read next: Go Web Servers with net/http: The Connectivity Mirror →
Next Steps
JSON is how we talk to clients, but how do we store that data permanently? In our next tutorial, we will explore Database Connectivity, learning how to connect our Go web servers to SQL databases using the standard library and ORMs like GORM.
Common JSON Mistakes in Go
1. Unexported fields are silently ignored
json.Marshal skips fields that start with a lowercase letter. Always export fields you want in the JSON output and use struct tags to control the key name: Name string \json:"name"``.
2. Using interface{} for JSON values you know the shape of
Unmarshalling into map[string]interface{} works but forces type assertions everywhere downstream. Define a concrete struct — the compiler will catch shape mismatches at build time, not runtime.
3. Forgetting omitempty causes zero values to appear
Count int \json:"count"`always serialises, even as"count": 0. Add omitempty (json:"count,omitempty") to omit zero values from the output. Note: omitemptyomits the zero value of the type —false, 0, "", nil`.
4. Using - to skip a field vs omitempty
json:"-" always excludes the field. omitempty excludes it only when empty. Use - for fields that should never appear in JSON output (internal state, sensitive data).
5. Time zone information lost in time.Time marshalling
time.Time marshals to RFC3339 format, which includes timezone. But if you parse a time string without a timezone offset, it defaults to UTC. Always be explicit about timezones when serialising time values. See the encoding/json package documentation.
Frequently Asked Questions
How do I marshal a Go struct to a JSON string?
Use json.Marshal(v) which returns []byte and an error, or json.MarshalIndent(v, "", " ") for pretty-printed output. Convert to string with string(b). Always check the returned error.
How do I handle JSON fields that may or may not be present?
Use a pointer type for the field: Name *string \json:"name"`. If the field is absent from the JSON, the pointer will be nil`. If present, it will point to the parsed value.
Can I customise JSON serialisation for a type?
Yes — implement the json.Marshaler interface by adding a MarshalJSON() ([]byte, error) method to your type, and json.Unmarshaler by adding UnmarshalJSON([]byte) error. This gives you full control over the JSON representation.
