When building REST APIs, microservices, CLI utilities, and cloud backend systems in Go (Golang), exchanging structured data via JSON is a fundamental daily requirement. Go’s static type system and fast execution make it a premier choice for high-concurrency network services, but bridging untyped, dynamic JSON payloads with strongly typed Go structs requires an in-depth understanding of the standard library encoding/json package.
Unlike interpreted languages where JSON maps directly to runtime dynamic hash maps, Go maps JSON keys to exported struct fields using struct tags (such as `json:"user_id,omitempty"`) and runtime reflection.
This comprehensive guide explores everything you need to master Go JSON serialization: from basic struct tag annotations and pointer-based null handling to zero-value omitempty gotchas, time.Time formatting, json.RawMessage polymorphic parsing, and custom json.Unmarshaler implementations.
1. Core Concepts: Struct Export Rules & Struct Tags
In Go, field visibility across package boundaries is strictly enforced by identifier capitalization. The encoding/json package is an external package; therefore, only exported fields starting with a capital letter can be marshaled or unmarshaled.
package main
import (
"encoding/json"
"fmt"
"time"
)
type UserAccount struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
// Unexported field — completely ignored by encoding/json
internalToken string
}
Struct Tag Reference
| Struct Tag | Unmarshaling Behavior | Marshaling Behavior |
|---|---|---|
| `json:"field_name"` | Reads JSON property "field_name". |
Writes JSON property "field_name". |
| `json:"field_name,omitempty"` | Reads JSON property "field_name". |
Omits property if field holds zero value (0, "", false, nil). |
| `json:"-"` | Ignores property during JSON decoding. | Never serializes field to JSON. |
| `json:",string"` | Unmarshals quoted JSON string (e.g. "12345") into integer/float/bool. |
Encodes number or boolean as a quoted JSON string. |
2. Handling Nullability, Optional Fields & the omitempty Trap
One of the most frequent sources of subtle bugs in Go backend services is misunderstanding how encoding/json treats zero values vs null values.
The Problem with Primitive Zero Values
In Go, primitive variables cannot be nil; they default to their zero value:
stringdefaults to""int/float64defaults to0booldefaults tofalse
If you annotate a boolean field with omitempty:
type ToggleRequest struct {
FeatureEnabled bool `json:"feature_enabled,omitempty"`
}
When FeatureEnabled is explicitly set to false, json.Marshal() will evaluate false as the zero value and omit the key completely from the generated JSON payload!
The Solution: Use Pointer Types (*T) for Optional & Nullable Values
By using a pointer (e.g., *bool, *string, *int64), the zero value becomes nil:
type UserPatchPayload struct {
DisplayName *string `json:"display_name,omitempty"`
IsVerified *bool `json:"is_verified,omitempty"`
Score *int `json:"score,omitempty"`
}
- If
IsVerified == nil: The field was omitted ornullin the request;omitemptyskips it. - If
IsVerified == &false: The client explicitly submittedfalse;json.Marshal()includes"is_verified": false.
3. Parsing ISO-8601 Datetime Strings with time.Time
The encoding/json package has built-in support for RFC 3339 / ISO-8601 formatted datetime strings (e.g., "2026-09-06T14:30:00Z"). When unmarshaling into a time.Time struct field, Go parses the timestamp automatically without extra configuration.
package main
import (
"encoding/json"
"fmt"
"time"
)
type AuditLog struct {
EventID string `json:"event_id"`
Timestamp time.Time `json:"timestamp"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
func main() {
payload := []byte(`{
"event_id": "evt_99812",
"timestamp": "2026-09-06T12:00:00Z",
"expires_at": null
}`)
var log AuditLog
if err := json.Unmarshal(payload, &log); err != nil {
panic(err)
}
fmt.Printf("Event %s logged at %s (UTC Year: %d)\n",
log.EventID, log.Timestamp.Format(time.RFC822), log.Timestamp.Year())
}
4. Number Precision: float64 vs int64 vs json.Number
JSON specification RFC 8259 does not distinguish integers from floating-point numbers. When unmarshaling JSON numbers into generic any / interface{} containers, Go defaults to float64.
For 64-bit integers (e.g., Snowflake IDs, Unix microsecond timestamps, or crypto satoshis), JavaScript/JSON float64 conversion can lose precision past $2^{53} - 1$ (9,007,199,254,740,991).
To preserve arbitrary precision:
- Declare explicit
int64oruint64fields in your struct. - Or use
json.Decoder.UseNumber()with thejson.Numbertype:
package main
import (
"bytes"
"encoding/json"
"fmt"
)
type FinancialTransaction struct {
TxID string `json:"tx_id"`
Amount json.Number `json:"amount"`
}
func main() {
raw := []byte(`{"tx_id": "tx_901", "amount": 9007199254740993.45}`)
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber() // Prevents float64 precision truncation
var tx FinancialTransaction
_ = dec.Decode(&tx)
intVal, _ := tx.Amount.Int64()
floatVal, _ := tx.Amount.Float64()
fmt.Printf("Raw String: %s | Float64: %f | Int64: %d\n", tx.Amount.String(), floatVal, intVal)
}
5. Decomposing Nested JSON Objects into Child Structs
Modern web APIs frequently return multi-level nested payloads. In Go, rather than creating unreadable inline anonymous structs, decompose each logical object into its own reusable PascalCase struct:
package main
import (
"encoding/json"
"fmt"
)
type OrderPayload struct {
ID string `json:"id"`
Customer CustomerInfo `json:"customer"`
Billing Address `json:"billing"`
Shipping Address `json:"shipping"`
Items []LineItem `json:"items"`
Summary CostSummary `json:"summary"`
}
type CustomerInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type Address struct {
Street string `json:"street"`
City string `json:"city"`
PostalCode string `json:"postal_code"`
Country string `json:"country"`
Line2 *string `json:"line_2,omitempty"`
}
type LineItem struct {
SKU string `json:"sku"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
}
type CostSummary struct {
Subtotal float64 `json:"subtotal"`
Tax float64 `json:"tax"`
Shipping float64 `json:"shipping"`
Total float64 `json:"total"`
}
6. Polymorphic JSON & Discriminated Unions with json.RawMessage
Unlike TypeScript unions (type Event = UserCreated | OrderShipped) or Rust enums (enum Event { UserCreated(User), OrderShipped(Order) }), Go does not feature native algebraic data types.
To handle polymorphic JSON payloads (e.g., webhook events where the data shape depends on an event_type discriminator), use json.RawMessage to delay decoding:
package main
import (
"encoding/json"
"fmt"
)
type WebhookEnvelope struct {
EventType string `json:"event_type"`
Timestamp int64 `json:"timestamp"`
Payload json.RawMessage `json:"payload"`
}
type UserRegisteredEvent struct {
UserID string `json:"user_id"`
Email string `json:"email"`
}
type PaymentCapturedEvent struct {
InvoiceID string `json:"invoice_id"`
Amount float64 `json:"amount"`
}
func HandleWebhook(data []byte) error {
var env WebhookEnvelope
if err := json.Unmarshal(data, &env); err != nil {
return err
}
switch env.EventType {
case "user.registered":
var userEvt UserRegisteredEvent
if err := json.Unmarshal(env.Payload, &userEvt); err != nil {
return err
}
fmt.Printf("Registered user: %s (%s)\n", userEvt.UserID, userEvt.Email)
case "payment.captured":
var payEvt PaymentCapturedEvent
if err := json.Unmarshal(env.Payload, &payEvt); err != nil {
return err
}
fmt.Printf("Payment received: $%.2f for %s\n", payEvt.Amount, payEvt.InvoiceID)
default:
fmt.Printf("Unhandled event type: %s\n", env.EventType)
}
return nil
}
7. Custom Deserialization with json.Unmarshaler
When API responses contain non-standard formats (such as Unix timestamps formatted as strings, or custom date strings like "2026/09/06"), implement the json.Unmarshaler interface:
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type CustomDate struct {
time.Time
}
const customDateFormat = "2006/01/02"
func (cd *CustomDate) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), "\"")
if s == "null" || s == "" {
return nil
}
t, err := time.Parse(customDateFormat, s)
if err != nil {
return err
}
cd.Time = t
return nil
}
type Report struct {
Title string `json:"title"`
StartDate CustomDate `json:"start_date"`
}
func main() {
raw := []byte(`{"title": "Q3 Financials", "start_date": "2026/09/01"}`)
var rep Report
_ = json.Unmarshal(raw, &rep)
fmt.Printf("Report: %s, starts on %s\n", rep.Title, rep.StartDate.Format("January 02, 2006"))
}
8. Idiomatic Go Naming Conventions for Initialisms
Go community standards (enforced by golint, revive, and the Uber Go Style Guide) mandate that common initialisms and acronyms retain consistent casing:
- Write
UserIDinstead ofUserId - Write
APIURLinstead ofApiUrl - Write
HTTPClientinstead ofHttpClient - Write
IPAddressinstead ofIpAddress - Write
JSONDatainstead ofJsonData
Our JSON to Go Struct Converter automatically applies these idiomatic acronym capitalization rules when transforming JSON keys into exported Go field names.
9. Converting JSON Payloads to Go Structs Online
Generating Go structs manually for large API payloads with hundreds of nested fields is time-consuming and prone to typos in struct tag strings.
Use our free, private JSON to Go Struct Converter to instantly generate clean Go struct definitions. It automatically:
- Formats struct tags (
json:"...") and supports optionalomitemptyannotations. - Converts nullable values into safe Go pointer types (
*string,*int64,*bool). - Automatically imports
"time"for detected ISO-8601 timestamps. - Traverses nested objects and creates separate named child structs.
- Runs 100% client-side in your web browser with zero server data retention.