JSON to Go Struct Guide: Type Mapping, Nested Structs & Best Practices

By Suvom Das March 27, 2026 18 min read

1. Introduction to JSON and Go Structs

Go (Golang) has become one of the most popular languages for building APIs, microservices, and cloud-native applications. At the heart of nearly every Go web service is the encoding/json package, which provides the ability to marshal (serialize) Go structs into JSON and unmarshal (deserialize) JSON data into Go structs. This bidirectional conversion is fundamental to how Go applications communicate with the outside world.

When you receive a JSON response from an API, you need a Go struct that mirrors the JSON structure. Each field in the struct must have the correct type, and each field needs a json struct tag that maps it to the corresponding JSON key. For simple JSON payloads with a few fields, writing these structs by hand is straightforward. But when you are dealing with deeply nested objects, arrays of mixed types, or API responses with dozens of fields, manual struct creation becomes tedious and error-prone.

This guide covers everything you need to know about converting JSON to Go structs: the type mapping rules, how struct tags work, handling nested objects and arrays, dealing with optional and nullable fields, custom unmarshaling, and best practices for production code. Whether you are a Go beginner or an experienced developer, understanding these concepts will make you more productive when working with JSON in Go.

2. JSON to Go Type Mapping

JSON has six data types: string, number, boolean, null, object, and array. Go has a much richer type system, so there are multiple valid Go types for some JSON types. Understanding the mapping rules is essential for writing correct and efficient structs.

String

JSON strings map directly to Go string. This is the simplest and most common mapping. JSON string values can contain Unicode characters, escape sequences, and any valid UTF-8 text, all of which Go handles natively since Go strings are UTF-8 encoded by default.

// JSON: {"name": "John Doe"}
type User struct {
    Name string `json:"name"`
}

Number

JSON does not distinguish between integers and floating-point numbers -- all numbers are represented the same way. In Go, however, you must choose the appropriate numeric type. The general rules are:

// JSON: {"age": 30, "balance": 1250.75, "id": 9007199254740993}
type Account struct {
    Age     int     `json:"age"`
    Balance float64 `json:"balance"`
    ID      int64   `json:"id"`
}

Boolean

JSON booleans (true and false) map to Go bool. This is a direct one-to-one mapping with no ambiguity.

// JSON: {"is_active": true}
type User struct {
    IsActive bool `json:"is_active"`
}

Null

JSON null is one of the trickier values to handle in Go. A non-pointer Go field cannot represent null -- it will always have a zero value. To distinguish between "the field was null" and "the field had a zero value," use pointer types or the special interface{} type.

// JSON: {"middle_name": null}
// Option 1: Pointer type (recommended)
type User struct {
    MiddleName *string `json:"middle_name"`
}

// Option 2: interface{} (less type-safe)
type User struct {
    MiddleName interface{} `json:"middle_name"`
}

Object

JSON objects map to Go structs. Each key in the JSON object becomes a field in the struct. For nested objects, you can either define a separate named struct or use an anonymous inline struct (more on this in the nested structs section).

Array

JSON arrays map to Go slices. The element type of the slice depends on the array contents: []string for arrays of strings, []int for arrays of integers, []MyStruct for arrays of objects, and []interface{} for mixed-type or empty arrays.

// JSON: {"tags": ["go", "json"], "scores": [95, 87, 92]}
type Data struct {
    Tags   []string `json:"tags"`
    Scores []int    `json:"scores"`
}

3. Understanding JSON Struct Tags

Go struct tags are string annotations attached to struct fields that provide metadata used by packages like encoding/json. The json tag is the most commonly used tag and controls how the field is marshaled to and unmarshaled from JSON.

Basic Tag Syntax

The basic json tag specifies the JSON key name that corresponds to the struct field. Without a json tag, the encoding/json package uses the field name as-is (case-sensitive). Since JSON conventionally uses snake_case or camelCase while Go uses PascalCase, tags are essential for maintaining idiomatic naming in both languages.

type User struct {
    FirstName string `json:"first_name"`
    LastName  string `json:"last_name"`
    Email     string `json:"email"`
}

The omitempty Option

The omitempty option tells the JSON encoder to skip the field if it has a zero value. Zero values in Go are: "" for strings, 0 for numbers, false for booleans, nil for pointers, interfaces, slices, and maps. This is useful for optional fields in API requests.

type CreateUserRequest struct {
    Name     string  `json:"name"`
    Email    string  `json:"email"`
    Phone    string  `json:"phone,omitempty"`    // Optional
    Website  string  `json:"website,omitempty"`  // Optional
}

With omitempty, if Phone is an empty string, the marshaled JSON will not include the "phone" key at all. Without omitempty, it would appear as "phone": "".

The Dash Tag

Using json:"-" tells the encoder to completely ignore the field during both marshaling and unmarshaling. This is useful for internal fields that should not be exposed in JSON.

type User struct {
    ID       int    `json:"id"`
    Name     string `json:"name"`
    Password string `json:"-"`  // Never include in JSON output
}

The string Option

The string option tells the encoder to marshal and unmarshal the value as a JSON string, even if the Go type is numeric or boolean. This is useful when an API sends numbers as quoted strings.

// JSON: {"count": "42"}
type Data struct {
    Count int `json:"count,string"`
}

4. Working with Nested Structs

Real-world JSON data is rarely flat. API responses typically contain nested objects, sometimes several levels deep. Go provides two approaches for handling nested JSON objects: separate named structs and inline anonymous structs.

Separate Named Structs

The most common approach is to define each nested object as its own named struct type. This promotes reusability, testability, and clarity.

type Address struct {
    Street string `json:"street"`
    City   string `json:"city"`
    State  string `json:"state"`
    Zip    string `json:"zip_code"`
}

type User struct {
    Name    string  `json:"name"`
    Email   string  `json:"email"`
    Address Address `json:"address"`
}

Separate structs are preferred when the nested object represents a distinct domain concept (like Address, Coordinates, or Metadata), when the same nested structure appears in multiple parent structs, or when you need to write methods on the nested type.

Inline Anonymous Structs

For one-off nested structures that are only used in a single place, Go allows inline anonymous struct definitions directly within the parent struct.

type User struct {
    Name    string `json:"name"`
    Address struct {
        Street string `json:"street"`
        City   string `json:"city"`
    } `json:"address"`
}

Inline structs reduce the number of type definitions in your package but sacrifice reusability and make it harder to write helper functions or methods for the nested data.

Embedding and Composition

Go's struct embedding (anonymous fields) can be combined with JSON to create compositions. When a struct is embedded without a field name, its fields are promoted to the parent level during JSON marshaling and unmarshaling.

type Timestamps struct {
    CreatedAt string `json:"created_at"`
    UpdatedAt string `json:"updated_at"`
}

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Timestamps     // Embedded: fields are promoted
}

// JSON: {"id": 1, "name": "John", "created_at": "2026-01-01", "updated_at": "2026-03-15"}

5. Arrays, Slices, and Complex Types

JSON arrays are ubiquitous in API responses. Paginated lists, tag collections, nested item arrays -- understanding how Go handles these is critical for building robust API clients and servers.

Slices of Primitives

Arrays of simple values (strings, numbers, booleans) map directly to typed Go slices.

type Project struct {
    Tags     []string  `json:"tags"`
    Versions []float64 `json:"versions"`
    Flags    []bool    `json:"flags"`
}

Slices of Structs

Arrays of objects are one of the most common patterns in APIs. Each object in the array is represented by a struct, and the array becomes a slice of that struct type.

type Comment struct {
    ID      int    `json:"id"`
    Author  string `json:"author"`
    Body    string `json:"body"`
}

type Post struct {
    Title    string    `json:"title"`
    Comments []Comment `json:"comments"`
}

Mixed-Type Arrays

While uncommon in well-designed APIs, some JSON responses contain arrays with mixed types (e.g., [1, "hello", true]). In Go, these must be represented as []interface{} since Go slices are homogeneous.

type Data struct {
    MixedValues []interface{} `json:"mixed_values"`
}

Nested Arrays

Arrays of arrays (matrices, grids) are represented as slices of slices in Go.

// JSON: {"matrix": [[1,2,3], [4,5,6]]}
type Data struct {
    Matrix [][]int `json:"matrix"`
}

6. Optional Fields and Pointer Types

One of the most common challenges when working with JSON in Go is handling optional or nullable fields. JSON has a clear distinction between a missing field, a field set to null, and a field with a zero value. Go's type system does not have this distinction by default, which is where pointer types become essential.

The Problem with Zero Values

Consider a PATCH endpoint where the client sends only the fields they want to update. If the client sends {"name": "John"}, how do you know whether age was intentionally omitted (don't update) or should be set to 0? With a non-pointer int field, both cases result in the zero value 0 after unmarshaling.

Using Pointer Types

Pointer types solve this by adding a third state: nil (field was missing or null), zero value (field was explicitly set to 0), and non-zero value. After unmarshaling, you can check if the pointer is nil to determine if the field was present.

type UpdateUserRequest struct {
    Name  *string `json:"name,omitempty"`
    Age   *int    `json:"age,omitempty"`
    Email *string `json:"email,omitempty"`
}

func handleUpdate(req UpdateUserRequest) {
    if req.Name != nil {
        // Name was provided, update it
        user.Name = *req.Name
    }
    if req.Age != nil {
        // Age was provided (could be 0), update it
        user.Age = *req.Age
    }
    // Email was not provided, leave unchanged
}

When to Use Pointers

Use pointer types when you need to distinguish between "field absent" and "field is zero value," which is common in PATCH/update endpoints, optional configuration fields, and API responses where null has semantic meaning. For required fields that always have a value, non-pointer types are simpler and more efficient.

7. Custom JSON Unmarshaling

Sometimes the default JSON unmarshaling behavior does not match the data format you are working with. Go allows you to implement the json.Unmarshaler interface to customize how a type is deserialized from JSON.

The json.Unmarshaler Interface

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}

Any type that implements this interface will have its UnmarshalJSON method called instead of the default behavior when the JSON decoder encounters a value for that type.

Example: Custom Date Parsing

APIs often return dates in formats other than RFC 3339 (which Go's time.Time expects). A custom unmarshaler lets you handle this.

type CustomDate struct {
    time.Time
}

func (d *CustomDate) UnmarshalJSON(b []byte) error {
    var s string
    if err := json.Unmarshal(b, &s); err != nil {
        return err
    }
    t, err := time.Parse("2006-01-02", s)
    if err != nil {
        return err
    }
    d.Time = t
    return nil
}

type Event struct {
    Name string     `json:"name"`
    Date CustomDate `json:"date"`
}

// JSON: {"name": "Launch", "date": "2026-03-27"}

Example: String-or-Number Fields

Some APIs inconsistently return a field as either a string or a number. A custom unmarshaler can handle both cases gracefully.

type FlexibleInt struct {
    Value int
}

func (f *FlexibleInt) UnmarshalJSON(b []byte) error {
    var n int
    if err := json.Unmarshal(b, &n); err == nil {
        f.Value = n
        return nil
    }
    var s string
    if err := json.Unmarshal(b, &s); err == nil {
        n, err := strconv.Atoi(s)
        if err != nil {
            return err
        }
        f.Value = n
        return nil
    }
    return fmt.Errorf("cannot unmarshal %s into int", string(b))
}

8. Common Patterns and Idioms

API Response Wrappers

Many APIs wrap their response data in a standard envelope containing metadata like pagination, status codes, or error messages. Go generics (introduced in Go 1.18) make this pattern cleaner.

type APIResponse[T any] struct {
    Data       T      `json:"data"`
    Status     string `json:"status"`
    Message    string `json:"message,omitempty"`
    TotalCount int    `json:"total_count,omitempty"`
}

// Usage:
var resp APIResponse[[]User]
json.Unmarshal(body, &resp)
for _, user := range resp.Data {
    fmt.Println(user.Name)
}

Map Fields for Dynamic Keys

When JSON keys are dynamic (not known at compile time), use map[string]interface{} or typed maps like map[string]string.

type Config struct {
    Name     string                 `json:"name"`
    Settings map[string]interface{} `json:"settings"`
    Labels   map[string]string      `json:"labels"`
}

json.RawMessage for Deferred Parsing

When you need to delay parsing of a JSON field until you know its type, use json.RawMessage. This is common in event systems where different event types have different payload structures.

type Event struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"`
}

func processEvent(data []byte) error {
    var event Event
    json.Unmarshal(data, &event)

    switch event.Type {
    case "user_created":
        var user User
        json.Unmarshal(event.Payload, &user)
        // Handle user creation
    case "order_placed":
        var order Order
        json.Unmarshal(event.Payload, &order)
        // Handle order
    }
    return nil
}

Handling Acronyms in Field Names

Go convention dictates that common acronyms should be fully capitalized: ID not Id, URL not Url, HTTP not Http. This applies to struct fields that will be exposed in public APIs.

type Resource struct {
    ID        int    `json:"id"`
    URL       string `json:"url"`
    HTMLURL   string `json:"html_url"`
    APIKey    string `json:"api_key"`
    HTTPCode  int    `json:"http_code"`
}

9. Best Practices

Always Add JSON Tags

Even if the JSON key matches the Go field name, add explicit json tags. This makes the mapping explicit, protects against breaking changes if you rename the Go field, and serves as documentation for other developers reading your code.

Use Separate Structs for Request and Response

Don't use the same struct for both API request bodies and response bodies. Requests often have fewer fields, different validation rules, and different omitempty requirements than responses. Define separate types like CreateUserRequest and UserResponse.

Validate After Unmarshaling

Go's encoding/json package does not validate required fields, string lengths, value ranges, or business rules. Always validate your data after unmarshaling, either with custom validation functions or a validation library like go-playground/validator.

Use json.Decoder for Streams

When reading JSON from an HTTP response body or file, use json.NewDecoder(reader).Decode(&v) instead of reading the entire body into memory first. This is more memory-efficient for large payloads.

func handleRequest(w http.ResponseWriter, r *http.Request) {
    var user User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    // Process user...
}

Handle Unknown Fields

By default, encoding/json silently ignores unknown JSON fields. If you want to enforce strict parsing (reject unknown fields), use json.Decoder with DisallowUnknownFields().

decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&user); err != nil {
    // Will error on unknown fields
}

10. Using the JSON to Go Struct Converter

Our free JSON to Go Struct Converter automates the conversion process described in this guide. Simply paste your JSON into the left panel and get instantly generated Go struct definitions in the right panel.

The tool supports all the features discussed in this article: proper type inference (distinguishing int from float64), nested struct generation (both separate and inline), omitempty tags, Go naming conventions (PascalCase with proper acronym handling), and arrays of objects. It handles edge cases like null values, empty arrays, and deeply nested structures.

All processing happens entirely in your browser -- no data is sent to any server. You can use the tool offline once the page has loaded. Try it now with your API response data to save time writing boilerplate struct definitions.

Try the JSON to Go Struct Converter

Paste your JSON and get Go struct definitions instantly. Free, no sign-up required.

Open JSON to Go Converter →