Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 019 / 100

JSON in Go: everything engineers need to know in practice

Encode and decode with struct tags.

Expona Admin6 min read

JSON in Go: everything engineers need to know in practice

JSON marshalling and unmarshalling is one of the first things you'll do in any API you build, yet most teams handle it inconsistently across their codebase. Whether you're shipping a REST endpoint, consuming third-party webhooks, or building a gRPC service with JSON transcoding, how you configure your struct tags determines whether your API feels robust or fragile in production.

In this article, we'll walk through the practical decisions you need to make when working with JSON in Go, starting with the fundamentals and moving into patterns we've seen work well across e-commerce, SaaS, and real-time systems. By the end, you'll know exactly how to structure your types, validate incoming data, and avoid the gotchas that catch teams mid-sprint.

JSON tags control field names

By default, Go's encoding/json package marshals struct fields using their exported names. If your API contract expects snake_case or kebab-case but your Go code uses CamelCase, you need to map them explicitly using struct tags.

The struct tag syntax is straightforward:

go
type Product struct {
	ID        int    `json:"id"`
	Name      string `json:"product_name"`
	CreatedAt string `json:"created_at"`
}

When you marshal this struct to JSON, the field names in the output match what you specified in the tags, not the Go field names. This is essential for API contracts. Your backend might use ProductPrice, but if your mobile team and frontend expect product_price, the tag bridges that gap.

In real systems, you'll often encounter naming mismatches across legacy endpoints. A payment service might send TransactionID in one endpoint and txn_id in another. Use tags to normalize internally:

go
type PaymentEvent struct {
	ID          string `json:"transaction_id"`
	CustomerID  string `json:"customer_id"`
	Amount      float64 `json:"amount_cents"`
	Status      string `json:"status"`
}

When unmarshalling, Go matches incoming JSON keys to the tags. If your JSON has "transaction_id", Go knows to populate the ID field. This decoupling is crucial for maintainability.

Omitempty skips zero values

Not every field should appear in every JSON response. If a user hasn't set a phone number, you probably don't want to send "phone": "" or "phone": null. The omitempty option tells Go to exclude the field entirely if it holds its zero value.

go
type User struct {
	ID        int    `json:"id"`
	Name      string `json:"name"`
	Phone     string `json:"phone,omitempty"`
	Email     string `json:"email,omitempty"`
	CreatedAt string `json:"created_at"`
}

If Phone is an empty string, the marshalled JSON won't include the phone key at all. This produces cleaner payloads and respects frontend expectations. For a user object with only ID, Name, and CreatedAt set:

go
{
  "id": 123,
  "name": "Alice",
  "created_at": "2025-01-15T10:00:00Z"
}

This is particularly valuable in e-commerce contexts. A product might have optional attributes: color, size, variant images. You don't want to send empty arrays or null objects for missing variants when a customer views a simple product with no options.

However, omitempty only works for zero values. For strings, that's empty string. For integers, it's zero. For slices and maps, it's nil or empty. If you need to distinguish between "not set" and "set to zero", you need a different approach—which brings us to pointers.

Use pointers for optional fields

When a zero value is valid but different from "not provided", pointers are your answer. A price of zero dollars is legitimate in a promotion, but it's different from "price not specified". A user age of zero is invalid, but you need to represent "age not provided".

go
type Product struct {
	ID       int     `json:"id"`
	Name     string  `json:"name"`
	Price    *int    `json:"price_cents,omitempty"`
	Discount *int    `json:"discount_percent,omitempty"`
}

With pointers and omitempty, Go behaves like this:

  • Price is nil → field is omitted from JSON
  • Price points to 0 → field is included in JSON as "price_cents": 0
  • Price points to 599 → field is included as "price_cents": 599

When unmarshalling, Go allocates a new int and populates it. If the incoming JSON doesn't include the field, the pointer remains nil:

go
var p Product
json.Unmarshal([]byte(`{"id":1,"name":"Widget"}`), &p)
// p.Price is nil, p.Discount is nil

json.Unmarshal([]byte(`{"id":1,"name":"Widget","price_cents":0}`), &p)
// p.Price points to 0, p.Discount is nil

This pattern appears throughout trading systems and APIs where zero is a valid value. A stop-loss of $0.00 means something different from "no stop-loss configured". Use pointers when that distinction matters.

DisallowUnknownFields for strict input validation

By default, Go's JSON decoder ignores fields in the incoming JSON that don't have a corresponding struct field. This is permissive but risky. If a client sends a typo or an old field name, you won't catch it, and your code silently ignores the intended value.

For APIs where you want strict contracts, use a decoder with DisallowUnknownFields:

go
var req struct {
	OrderID  int    `json:"order_id"`
	Status   string `json:"status"`
	Quantity int    `json:"quantity"`
}

decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
	// If JSON contains "order_ids" instead of "order_id", this fails
	http.Error(w, "Invalid request", http.StatusBadRequest)
	return
}

This is particularly important in payment processing, order management, and any system handling critical data. A ride-hailing service might receive a request with "surge_multiplier" when it expects "surge_multiplier_cents"`. Without strict validation, the order gets processed at the default rate instead of the multiplied rate—a silent data integrity issue.

Combine this with validation:

go
if req.Quantity < 1 {
	http.Error(w, "Quantity must be at least 1", http.StatusBadRequest)
	return
}
if req.Status != "pending" && req.Status != "confirmed" {
	http.Error(w, "Invalid status", http.StatusBadRequest)
	return
}

Strict unmarshalling catches malformed input early. Validation rules catch logically invalid input. Together, they protect your business logic.

Common mistakes

Forgetting tags on exported fields

If you have a field UserID with no tag, Go exports it as "UserID" in JSON. Your API clients expect "user_id", causing deserialization failures. Always tag exported fields explicitly, even if the name matches what you want—it documents the contract.

Using omitempty when you should use pointers

If zero is a valid value, omitempty will omit it, confusing clients. A product with StockLevel: 0 looks the same as a product where stock level wasn't provided. Use pointers for optional numeric fields.

Not handling JSON numbers carefully

JSON represents all numbers as decimals. If you unmarshal into an int64 but the JSON contains a very large number, you lose precision or get overflow. For monetary amounts, unmarshal to string first, then parse, or use a decimal library. Many trading and e-commerce platforms have learned this the hard way.

Ignoring case sensitivity

JSON keys are case-sensitive. "user_id" and "User_ID" are different. If a tag says json:"userId" but the frontend sends "user_id", unmarshalling fails silently (the field stays empty) unless you use DisallowUnknownFields.

Marshalling without checking errors

JSON encoding rarely fails, but when it does (circular references, channels, functions), the error is easy to miss if you don't check:

go
// Don't do this
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data) // Error silently disappears

// Do this
if err := json.NewEncoder(w).Encode(data); err != nil {
	log.Printf("JSON encode error: %v", err)
	http.Error(w, "Internal error", http.StatusInternalServerError)
}

Key takeaways

  • Always tag exported struct fields with JSON names; don't rely on Go's default naming
  • Use omitempty for optional string and slice fields where the zero value means "not provided"
  • Use pointers for optional numeric fields where zero is a valid, distinct value
  • Enable DisallowUnknownFields on decoders handling critical data to catch contract violations early
  • Validate unmarshalled data against business rules before processing
  • Test your JSON contracts with generated clients and integration tests to catch naming mismatches before production
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles