JSON and structs in Go: tags, reflection, and the generator myth
You are staring at a JSON response from an API. It contains a user profile with a name, an email, and a nested address object. You need the email to send a notification. You could parse the JSON into a generic map and dig through strings, but that approach breaks the moment the API adds a new field or changes a type. You want type safety. You want the compiler to catch mistakes. You have heard about code generators in other languages that create data classes from JSON schemas. In Go, the pattern is different. You define the shape yourself, and the standard library handles the conversion.
Go does not generate code at runtime to convert JSON. The encoding/json package uses reflection to map JSON keys to struct fields based on tags you write. You can use external tools to generate the struct source code before compilation, but the runtime mechanism is always the same: explicit structs, struct tags, and the standard library.
How struct tags bridge JSON and Go
A struct in Go is a collection of fields. JSON is a collection of key-value pairs. Struct tags provide the mapping between the two. A tag is a string literal attached to a field that the compiler ignores but packages like encoding/json read.
The tag syntax is backtick-quoted and key-value based. For JSON, the key is always json. The value is the JSON key name, optionally followed by options like omitempty.
package main
import (
"encoding/json"
"fmt"
)
// User represents a user profile from the API.
// Tags map Go fields to JSON keys.
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age,omitempty"`
}
func main() {
// Raw JSON payload.
data := []byte(`{"name":"Alice","email":"alice@example.com","age":30}`)
var u User
// Unmarshal parses JSON into the struct.
// The address operator passes a pointer so the function can modify u.
if err := json.Unmarshal(data, &u); err != nil {
panic(err)
}
fmt.Println(u.Name) // prints: Alice
fmt.Println(u.Age) // prints: 30
}
The json:"name" tag tells the unmarshaler to look for the key name in the JSON. If the tag is missing, the package uses the field name lowercased. Name becomes name. Email becomes email. This default behavior works for simple cases, but explicit tags are safer. They protect your code if you rename a Go field later; the JSON contract stays stable.
Tags are the contract. If the tag is wrong, the data goes nowhere.
What happens at runtime
When you call json.Unmarshal, the function inspects the type of the destination. It walks through the struct fields and checks for the json tag. If a tag exists, it extracts the key name. The function then searches the JSON object for that key. If the key is found, it converts the JSON value to the Go field type.
This process uses reflection. Reflection allows Go code to inspect types and values dynamically. It is slower than direct field access, but it is the only way to write a generic JSON parser. The compiler does not check JSON structure against your struct. You get no error at compile time if the JSON has a field your struct lacks, or if the types mismatch. The error appears only when the code runs.
If you try to unmarshal a JSON object into a string field, the compiler accepts the code. At runtime, json.Unmarshal returns json: cannot unmarshal object into Go struct field User.Profile of type string. The error message tells you exactly which field failed and what the mismatch was.
The compiler trusts you. The runtime will punish you if you lie about the types.
Handling optional fields and null values
JSON supports null. Go structs do not have a null value for basic types. An int is zero, a string is empty, a slice is nil. This difference causes confusion when handling optional fields.
If a JSON field is missing, json.Unmarshal leaves the Go field at its zero value. If the JSON field is null, the behavior depends on the Go type. For a value type like int, unmarshaling null returns an error. For a pointer type like *int, unmarshaling null sets the pointer to nil.
Use pointers when you need to distinguish between a missing value, a null value, and a zero value. A pointer can be nil, which represents absence. A non-nil pointer points to a value, which can be zero.
package main
import (
"encoding/json"
"fmt"
)
// Config holds settings where some fields may be absent.
// Pointers allow detecting null versus zero.
type Config struct {
Timeout *int `json:"timeout"`
Retries *int `json:"retries"`
}
func main() {
// JSON with null and missing fields.
data := []byte(`{"timeout": null}`)
var c Config
if err := json.Unmarshal(data, &c); err != nil {
panic(err)
}
// Timeout is nil because JSON had null.
if c.Timeout == nil {
fmt.Println("Timeout is null")
}
// Retries is nil because JSON did not have the key.
if c.Retries == nil {
fmt.Println("Retries is missing")
}
}
The omitempty option changes marshaling behavior. When you marshal a struct to JSON, omitempty skips the field if its value is the zero value for the type. For strings, empty string omits. For numbers, zero omits. For slices, nil omits but an empty slice [] does not omit. This distinction matters when you want to send an empty array versus no array at all.
Pointers add flexibility. They let you model optional data without guessing.
Streaming and performance
Calling json.Unmarshal reads the entire JSON payload into memory. For small responses, this is fine. For large streams or long-running connections, reading everything at once wastes memory and blocks the caller. The json.Decoder type reads from an io.Reader and unmarshals incrementally.
json.NewDecoder wraps a reader. Calling Decode reads enough bytes to parse one JSON value and unmarshals it into the destination. This approach works well with HTTP response bodies, which are streams.
package main
import (
"encoding/json"
"fmt"
"net/http"
)
// Response represents the API envelope.
// Nested struct avoids defining a separate type for simple cases.
type Response struct {
Status string `json:"status"`
Data struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
// FetchData demonstrates streaming unmarshal from an HTTP response.
// It uses a decoder to avoid buffering the entire body.
func FetchData() {
resp, err := http.Get("https://api.example.com/data")
if err != nil {
panic(err)
}
// Always close the body to release the connection.
defer resp.Body.Close()
var result Response
// Decode reads the stream and unmarshals directly.
// This is more efficient than reading all bytes first.
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
panic(err)
}
fmt.Println(result.Data.Name)
}
The decoder also supports DisallowUnknownFields. If you set this option, the decoder returns an error when the JSON contains keys that do not match any struct field. This is useful for catching API changes early. Without it, unknown fields are silently ignored.
package main
import (
"encoding/json"
"fmt"
)
type Item struct {
ID int `json:"id"`
}
func main() {
// JSON has an extra field the struct does not expect.
data := []byte(`{"id": 1, "extra": "value"}`)
dec := json.NewDecoder(json.NewReader(data))
// Reject unknown fields to catch schema drift.
dec.DisallowUnknownFields()
var item Item
if err := dec.Decode(&item); err != nil {
// Prints: json: unknown field "extra"
fmt.Println(err)
}
}
The decoder gives you control over strictness and memory. Use it when the payload size varies or when you need to validate the schema.
Deferred parsing with json.RawMessage
Sometimes you receive JSON with a nested object you do not need to parse immediately. Maybe the nested object has multiple shapes, or you need to pass the raw JSON to another service. Parsing it into a struct and then marshaling it back wastes CPU and loses formatting.
json.RawMessage is a type alias for []byte. It tells the unmarshaler to skip parsing the value and store the raw JSON bytes. You can parse it later by calling json.Unmarshal on the RawMessage.
package main
import (
"encoding/json"
"fmt"
)
// Event holds a notification with a dynamic payload.
// RawMessage preserves the payload without parsing.
type Event struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
func main() {
data := []byte(`{"type":"order","payload":{"id":123,"total":99.99}}`)
var evt Event
if err := json.Unmarshal(data, &evt); err != nil {
panic(err)
}
fmt.Println(evt.Type) // prints: order
// Parse the payload only when needed.
var payload struct {
ID int `json:"id"`
Total float64 `json:"total"`
}
if err := json.Unmarshal(evt.Payload, &payload); err != nil {
panic(err)
}
fmt.Println(payload.Total) // prints: 99.99
}
RawMessage is useful for middleware that forwards JSON, or for handling polymorphic data where the structure depends on a type field. It avoids double marshaling and preserves the original representation.
RawMessage defers work. Parse only what you need, when you need it.
The role of code generators
The title of this topic mentions code generators. Go does not have a built-in code generator for JSON conversion. The encoding/json package is the standard tool. However, the Go ecosystem includes tools that generate struct source code from JSON samples or schemas.
Tools like json-to-go take a JSON blob and output a Go struct definition with tags. You paste the JSON, the tool writes the struct, and you paste the struct into your code. This saves time when dealing with large, nested APIs. The generated code is just Go source. It compiles like any other code. The runtime still uses encoding/json.
Some projects use go generate to automate this workflow. You can write a comment in your code that triggers a tool to regenerate structs when the JSON schema changes. This keeps your structs in sync with the API. The generator runs before compilation. It produces .go files. The compiler sees those files and builds them. There is no runtime generation.
Generators write code. encoding/json runs it. Know the difference.
Pitfalls and conventions
Several patterns trip up developers working with JSON in Go.
Private fields are ignored. encoding/json only touches exported fields. A field named name is private. A field named Name is exported. If your struct has lowercase fields, the JSON package skips them. The marshaled JSON will be empty, and unmarshaling will leave the fields at zero values. This is not a bug. It is how Go visibility works.
package main
import (
"encoding/json"
"fmt"
)
type Secret struct {
key string // Private field. Ignored by json.
Key string // Exported field. Used by json.
}
func main() {
s := Secret{key: "hidden", Key: "visible"}
out, _ := json.Marshal(s)
fmt.Println(string(out)) // prints: {"Key":"visible"}
}
Error handling is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Always check the error from Unmarshal or Decode. Returning silently on error hides bugs and makes debugging harder.
// HandleJSON demonstrates proper error handling.
// Errors are returned immediately to the caller.
func HandleJSON(data []byte) (User, error) {
var u User
if err := json.Unmarshal(data, &u); err != nil {
return User{}, fmt.Errorf("unmarshal user: %w", err)
}
return u, nil
}
The receiver name convention applies to methods. If you add a method to your struct, use a short receiver name like (u User) or (u *User). Do not use (this User) or (self User). This is a community norm that keeps code readable.
Gofmt formats your code. Do not argue about indentation or spacing. Let the tool decide. Most editors run gofmt on save. Consistent formatting reduces noise in code reviews.
The worst JSON bug is the one that silently drops data. Check errors. Use tags. Export your fields.
When to use what
Use encoding/json with struct tags when you know the shape of the data and want type safety. This is the default approach for most applications.
Use json.RawMessage when you need to defer parsing of a nested object or preserve the original JSON for a downstream service. This avoids double marshaling and handles polymorphic payloads.
Use a code generation tool like json-to-go when you have a large JSON blob and want to scaffold the struct definition quickly. Remember the tool only writes the source code. The runtime still uses encoding/json.
Use map[string]any when the JSON structure is dynamic and you cannot define a fixed schema ahead of time. This sacrifices type safety for flexibility.
Use json.Decoder with DisallowUnknownFields when you need strict validation of the JSON schema against your struct. This catches API changes early.
Generators write code. encoding/json runs it. Know the difference.