← All blogs
  • Go
  • Struct Tag
  • Json
  • Struct

Understanding Structs and JSON in Go: A Practical Guide with Examples

A practical guide to Go structs, pointers, struct tags, JSON encoding and decoding, and common serialization mistakes.

Originally published on Medium ↗. Read the full article below.

Go version used in this article: Go 1.27.1

When building applications in Go, we often need to represent real-world entities such as users, employees, products, orders, and API responses.

This is where structs become one of the most important features of Go.

A struct allows us to combine related values of different types into a single data structure. Once we understand structs, several other Go concepts become much easier to understand, including pointers, maps, JSON serialization, reflection, and struct tags.

Let’s explore these concepts with practical examples.

Understanding Structs and JSON in Go: A Practical Guide with Examples

1. What Is a Struct in Go?

A struct is a collection of fields where each field can have a different type.

For example, let’s represent an employee:

package main

import "fmt"

type Employee struct {
    Name   string
    Number int
    Active bool
}

func main() {
    employee := Employee{
        Name:   "Alice",
        Number: 101,
        Active: true,
    }

    fmt.Println(employee)
}

Output:

{Alice 101 true}

Our Employee struct contains three different types:

Name   → string
Number → int
Active → bool

This is one of the main purposes of a struct: grouping related information into one meaningful type.

2. Accessing Struct Fields

We can access individual fields using dot notation.

package main

import "fmt"

type Employee struct {
    Name   string
    Number int
}

func main() {
    employee := Employee{
        Name:   "Alice",
        Number: 101,
    }

    fmt.Println(employee.Name)
    fmt.Println(employee.Number)
}

Output:

Alice
101

The syntax is straightforward:

employee.Name
employee.Number

The . operator is used to access a field of a struct.

3. The Zero Value of a Struct

Go automatically initializes variables with their zero values.

Consider:

package main

import "fmt"

type Employee struct {
    Name   string
    Number int
    Active bool
}

func main() {
    var employee Employee

    fmt.Printf("%+v\n", employee)
}

Output:

{Name: Number:0 Active:false}

The zero values are:

string → ""
int    → 0
bool   → false

We didn’t initialize anything manually, but the struct is still completely valid.

4. Initializing a Struct

We can initialize a struct using a struct literal.

package main

import "fmt"

type Employee struct {
    Name   string
    Number int
}

func main() {
    employee := Employee{
        Name:   "Alice",
        Number: 101,
    }

    fmt.Printf("%+v\n", employee)
}

Output:

{Name:Alice Number:101}

Using field names is generally clearer than relying on their order.

You can also initialize only the fields you need:

employee := Employee{
    Name: "Alice",
}

The remaining fields automatically receive their zero values.

5. Structs Can Contain Pointers to Other Structs

Structs can contain pointers to other structs, including pointers to the same type.

Consider an employee hierarchy:

package main

import "fmt"

type Employee struct {
    Name string
    Boss *Employee
}

func main() {
    boss := Employee{
        Name: "Alice",
    }

    employee := Employee{
        Name: "Bob",
        Boss: &boss,
    }

    fmt.Println(employee.Name)
    fmt.Println(employee.Boss.Name)
}

Output:

Bob
Alice

Here:

Boss *Employee

means that Boss contains a pointer to another Employee.

The relationship looks like this:

Bob
 |
 └── Boss → Alice

This pattern is useful for representing hierarchical structures such as:

  • employee-manager relationships
  • trees
  • linked structures
  • organizational hierarchies

6. Structs Are Values

A very important property of Go is that structs are value types.

When we assign one struct to another, the data is copied.

package main

import "fmt"

type Employee struct {
    Name   string
    Number int
}

func main() {
    employee1 := Employee{
        Name:   "Alice",
        Number: 101,
    }

    employee2 := employee1
    employee2.Number = 202

    fmt.Println(employee1.Number)
    fmt.Println(employee2.Number)
}

Output:

101
202

Why didn’t employee1.Number change?

Because:

employee2 := employee1

created a copy.

Conceptually:

employee1
   ↓
{Name: Alice, Number: 101}

copy
employee2
   ↓
{Name: Alice, Number: 101}

Changing employee2 doesn't change employee1.

7. Passing Structs to Functions

The same copying behavior applies when passing structs to functions.

package main

import "fmt"

type Employee struct {
    Name   string
    Number int
}

func increaseNumber(employee Employee) {
    employee.Number++
}

func main() {
    employee := Employee{
        Name:   "Alice",
        Number: 101,
    }

    increaseNumber(employee)

    fmt.Println(employee.Number)
}

Output:

101

The function received a copy.

Therefore, modifying the parameter didn’t modify the original struct.

8. Using Pointers to Modify the Original Struct

If we want a function to modify the original struct, we can pass a pointer.

package main

import "fmt"

type Employee struct {
    Name   string
    Number int
}

func increaseNumber(employee *Employee) {
    employee.Number++
}

func main() {
    employee := Employee{
        Name:   "Alice",
        Number: 101,
    }

    increaseNumber(&employee)

    fmt.Println(employee.Number)
}

Output:

102

The important difference is:

func increaseNumber(employee Employee)

receives a copy.

Whereas:

func increaseNumber(employee *Employee)

receives a pointer to the original value.

9. Go Makes Pointer Field Access Convenient

Go makes accessing fields through pointers simple.

package main

import "fmt"

type Employee struct {
    Name string
}

func main() {
    employee := Employee{
        Name: "Alice",
    }

    ptr := &employee

    fmt.Println(ptr.Name)
}

Output:

Alice

Even though ptr is a pointer, we can simply write:

ptr.Name

rather than explicitly dereferencing it first.

10. A Common Gotcha: Struct Values Inside Maps

This is an area where Go’s behavior can initially be surprising.

Suppose we have:

employees := map[string]Employee{}

and we want to increment Alice’s employee number:

employees["Alice"].Number++

This does not compile:

cannot assign to struct field employees["Alice"].Number in map

The important question is: why?

What does a map lookup give us?

Consider:

employee := employees["Alice"]

A lookup retrieves the Employee value associated with "Alice".

You can think of it as obtaining the value from the map:

employees
    │
    └── "Alice" → Employee{Number: 101}
                       │
                       ↓
                map lookup result

But that result is not an ordinary variable representing a stable, addressable storage location that you can use to modify one of its fields directly.

So this:

employees["Alice"].Number++

cannot be used to modify the struct stored in the map.

The Go specification specifically treats map index expressions specially in assignments, but a selector such as .Number cannot be used to turn a map lookup into an assignable struct field.

The correct approach with map[string]Employee

Retrieve the struct, modify the local copy, and put it back:

package main

import "fmt"

type Employee struct {
    Name   string
    Number int
}

func main() {
    employees := map[string]Employee{
        "Alice": {
            Name:   "Alice",
            Number: 101,
        },
    }

    employee := employees["Alice"]
    employee.Number++

    employees["Alice"] = employee
    fmt.Println(employees["Alice"].Number)
}

Output:

102

The process is:

Map
 │
 └── "Alice" → Employee{Number: 101}
                     │
                     │ lookup
                     ↓
               local copy
                     │
                     │ Number++
                     ↓
               Number: 102
                     │
                     │ assign back
                     ↓
Map
 │
 └── "Alice" → Employee{Number: 102}

This is the key point: when the map stores structs, retrieve → modify → store back.

11. A Better Option When You Need to Mutate Values

If you frequently need to modify the employees stored in the map, you can store pointers instead:

package main

import "fmt"

type Employee struct {
    Name   string
    Number int
}

func main() {
    employees := map[string]*Employee{}

    employees["Alice"] = &Employee{
        Name:   "Alice",
        Number: 101,
    }

    employees["Alice"].Number++

    fmt.Println(employees["Alice"].Number)
}

Output:

102

Now the map contains:

map[string]*Employee

rather than:

map[string]Employee

The difference is important.

Map of values

map
 │
 └── "Alice" → Employee value

Map of pointers

map
 │
 └── "Alice" → *Employee
                    │
                    ↓
               Employee object

In the pointer version:

employees["Alice"].Number++

doesn’t try to modify a field of the map entry itself.

Instead, it:

  1. Looks up the pointer stored under "Alice".
  2. Follows that pointer to the Employee.
  3. Modifies the Number field of that Employee.

That’s why the operation is valid.

This distinction is one of the most useful things to remember when working with structs and maps in Go.

12. Named Struct Types

Usually, structs that represent reusable concepts are given names.

package main

import "fmt"

type Product struct {
    Name  string
    Price float64
}

func main() {
    product := Product{
        Name:  "Keyboard",
        Price: 49.99,
    }

    fmt.Printf("%+v\n", product)
}

Output:

{Name:Keyboard Price:49.99}

The type Product can now be reused throughout the application.

13. Anonymous Structs

Go also allows anonymous structs.

package main

import "fmt"

func main() {
    product := struct {
        Name  string
        Price float64
    }{
        Name:  "Keyboard",
        Price: 49.99,
    }

    fmt.Printf("%+v\n", product)
}

Output:

{Name:Keyboard Price:49.99}

Anonymous structs are useful when the structure is small and only needed in one place.

For reusable domain concepts, named structs are generally more convenient.

14. Struct Type Compatibility

Go distinguishes between structural compatibility and named type identity.

Consider:

type ProductA struct {
    Name string
}

type ProductB struct {
    Name string
}

Even though both contain exactly the same field, they are different named types.

Therefore:

var a ProductA
var b ProductB
a = b

does not work directly.

When the underlying structures are compatible, an explicit conversion can be used:

a = ProductA(b)

The important idea is:

Same fields
    ≠
Same named type

Go cares about type identity.

15. Comparing Structs

Structs can be compared with == when all of their fields are comparable.

For example:

package main

import "fmt"

type Product struct {
    Name  string
    Price int
}

func main() {
    p1 := Product{
        Name:  "Keyboard",
        Price: 50,
    }

    p2 := Product{
        Name:  "Keyboard",
        Price: 50,
    }

    fmt.Println(p1 == p2)
}

Output:

true

The two structs contain equal field values.

However, not every field type is comparable. For example, slices cannot be compared directly with ==, so a struct containing a slice cannot itself be compared with ==.

16. Struct Tags

Struct tags allow us to attach metadata to fields.

A very common use is JSON.

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

The tags don’t change how the Go fields are accessed.

We still write:

user.Name
user.Email

The tags become important when another package, such as encoding/json, reads the type information.

17. Converting a Struct to JSON

Let’s use the standard encoding/json package.

package main

import (
    "encoding/json"
    "fmt"
)

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

func main() {
    user := User{
        Name:  "Alice",
        Email: "alice@example.com",
    }

    data, err := json.Marshal(user)
    if err != nil {
        panic(err)
    }

    fmt.Println(string(data))
}

Output:

{"name":"Alice","email":"alice@example.com"}

The transformation is:

Go struct
   ↓
json.Marshal
   ↓
JSON bytes

Notice that the Go field is:

Name

but the JSON property is:

"name"

That’s because of:

`json:"name"`

18. JSON Back Into a Struct

The reverse operation is json.Unmarshal.

package main

import (
    "encoding/json"
    "fmt"
)

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

func main() {
    data := []byte(`{
        "name": "Alice",
        "email": "alice@example.com"
    }`)

    var user User

    err := json.Unmarshal(data, &user)
    if err != nil {
        panic(err)
    }

    fmt.Println(user.Name)
    fmt.Println(user.Email)
}

Output:

Alice
alice@example.com

The complete process is:

JSON
 ↓
json.Unmarshal
 ↓
Go struct

Notice that we pass:

&user

to json.Unmarshal.

That’s because Unmarshal needs to populate the struct we provide.

19. Using omitempty

Sometimes we don’t want empty fields to appear in JSON.

That’s where omitempty is useful.

package main

import (
    "encoding/json"
    "fmt"
)

type Response struct {
    Page  int      `json:"page"`
    Words []string `json:"words,omitempty"`
}

func main() {
    response := Response{
        Page: 1,
    }

    data, err := json.Marshal(response)
    if err != nil {
        panic(err)
    }

    fmt.Println(string(data))
}

Output:

{"page":1}

Because Words is empty and the field uses:

`json:"words,omitempty"`

the words property is omitted.

This is particularly useful when designing APIs where unnecessary empty fields should not be included in responses.

20. Exported vs Unexported Fields

Go uses capitalization to determine whether a field is exported.

Consider:

type User struct {
    Name  string
    email string
}

Name is exported.

email is unexported.

This matters when using reflection-based packages such as encoding/json.

For example:

package main

import (
    "encoding/json"
    "fmt"
)

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

func main() {
    user := User{
        Name:  "Alice",
        email: "alice@example.com",
    }

    data, _ := json.Marshal(user)

    fmt.Println(string(data))
}

Output:

{"name":"Alice"}

The email field doesn't appear.

Why?

Because:

email string

is unexported.

Adding a struct tag does not make an unexported field accessible to the JSON package.

21. Reflection and Struct Tags

How does encoding/json know about tags such as:

`json:"name"`

The answer is reflection.

Reflection allows Go programs and libraries to inspect information about types at runtime.

Conceptually:

Struct
  │
  ├── Field: Name
  ├── Type: string
  └── Tag: json:"name"
          │
          ↓
      Reflection
          │
          ↓
     encoding/json
          │
          ↓
     {"name":"Alice"}

This mechanism isn’t limited to JSON.

Struct tags are also commonly used by libraries dealing with:

  • JSON
  • XML
  • databases
  • validation
  • serialization
  • API frameworks
  • other external data formats

22. Multiple Struct Tags

A field can contain metadata for different systems.

For example:

type User struct {
    ID int `json:"id" db:"user_id"`
}

Here:

json:"id"

can be interpreted by a JSON-related package, while:

db:"user_id"

can be interpreted by a database-related library.

The struct itself remains a normal Go type.

The tags provide additional metadata for libraries that know how to interpret them.

23. Designing Useful Zero Values

Consider a type such as a buffer.

A well-designed type can allow:

var buffer Buffer

to be immediately useful.

Its internal fields can have sensible zero values:

slice → nil
offset → 0
state → sensible default

This is an important design philosophy in Go.

If the zero value works naturally, users don’t always need a constructor before they can start using the type.

The goal is:

var x Type
   ↓
x is already usable

rather than:

var x Type
   ↓
initialize several internal fields
   ↓
finally usable

24. Empty Structs

Go has a special type with no fields:

struct{}

One practical use is creating a set.

package main

import "fmt"

func main() {
    languages := map[string]struct{}{}

    languages["Go"] = struct{}{}
    languages["Python"] = struct{}{}

    _, exists := languages["Go"]

    fmt.Println(exists)
}

Output:

true

The map’s keys represent membership.

The value doesn’t need to contain any information, so an empty struct is sufficient.

Conceptually:

languages
├── "Go"     → exists
└── "Python" → exists

25. Structs and JSON in Real Applications

The combination of structs and JSON becomes particularly useful when building APIs.

For example:

type Product struct {
    ID          int     `json:"id"`
    Name        string  `json:"name"`
    Price       float64 `json:"price"`
    Description string  `json:"description,omitempty"`
}

A product can then be represented as:

{
    "id": 101,
    "name": "Keyboard",
    "price": 49.99,
    "description": "Mechanical keyboard"
}

The same struct can also be populated from incoming JSON.

This makes structs a natural bridge between:

Go application
      ↕
    JSON
      ↕
External API / client

26. A Complete Example

Let’s combine several concepts into one example.

package main

import (
    "encoding/json"
    "fmt"
)

type Employee struct {
    ID     int       `json:"id"`
    Name   string    `json:"name"`
    Active bool      `json:"active"`
    Boss   *Employee `json:"boss,omitempty"`
}

func main() {
    boss := Employee{
        ID:     1,
        Name:   "Alice",
        Active: true,
    }

    employee := Employee{
        ID:     2,
        Name:   "Bob",
        Active: true,
        Boss:   &boss,
    }

    data, err := json.MarshalIndent(employee, "", "    ")
    if err != nil {
        panic(err)
    }

    fmt.Println(string(data))
}

Output:

{
    "id": 2,
    "name": "Bob",
    "active": true,
    "boss": {
        "id": 1,
        "name": "Alice",
        "active": true
    }
}

This single example demonstrates:

  • Structs
  • Nested structs through pointers
  • Struct literals
  • Exported fields
  • Struct tags
  • omitempty
  • JSON marshaling
  • Nested JSON objects

27. Error Handling

Examples often use:

data, err := json.Marshal(value)

if err != nil {
    panic(err)
}

In production applications, you will usually want to handle the error more gracefully rather than calling panic.

For example, an HTTP handler might return an appropriate error response.

The important habit is:

Don’t ignore errors simply because an example is small.

28. Key Takeaways

If you’re learning structs in Go, these are the concepts worth remembering.

Structs

type User struct {
    Name string
    Age  int
}

Structs group related data into a single type.

Zero values

var user User

Every field automatically receives its zero value.

Struct literals

user := User{
    Name: "Alice",
    Age:  30,
}

They provide a convenient way to initialize structs.

Pointers

func update(user *User)

Use pointers when you need to modify the original value or avoid copying larger structs.

Maps of structs

With:

map[string]User

a lookup gives you a value that cannot be used as a directly assignable struct-field location.

Therefore:

user := users["Alice"]
user.Age++
users["Alice"] = user

is the correct update pattern.

Maps of pointers

map[string]*User

can be more convenient when stored structs need to be mutated:

users["Alice"].Age++

because the map stores a pointer and the field is modified on the pointed-to User.

Struct tags

`json:"user_name"`

provide metadata for libraries and external representations.

JSON

json.Marshal(user)
json.Unmarshal(data, &user)

convert between Go values and JSON.

omitempty

`json:"email,omitempty"`

allows empty fields to be omitted from JSON output.

Exported fields

Name string

Uppercase fields are exported and can be accessed by other packages and reflection-based libraries.

Reflection

Reflection allows packages such as encoding/json to inspect struct fields and their metadata at runtime.

Final Thoughts

Structs may look simple at first, but they are one of the foundations of Go programming.

Once you understand how structs work with zero values, pointers, maps, functions, type compatibility, struct tags, reflection, and JSON, you have a strong foundation for building real Go applications.

A useful mental model is:

STRUCT
                    │
        ┌───────────┼───────────┐
        ↓           ↓           ↓
      Fields      Pointers     Values
        │           │           │
        ↓           ↓           ↓
   Struct Tags   Map Values   Functions
        │
        ↓
    Reflection
        │
        ↓
   JSON / APIs / Databases

The real power comes from combining these features rather than learning each one in isolation.

Once these concepts become familiar, structs stop being just a syntax feature and become a natural way to model the data flowing through a Go application.

Version note: This article targets Go 1.27.1, the current stable Go release as of October 2026. Go 1.27.1 was released on September 1, 2026. The Go project continues to maintain its compatibility promise, so the core struct, map, pointer, and JSON concepts discussed here remain compatible with earlier supported Go releases.