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.

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:
- Looks up the pointer stored under "Alice".
- Follows that pointer to the Employee.
- 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: "[email protected]",
}
data, err := json.Marshal(user)
if err != nil {
panic(err)
}
fmt.Println(string(data))
}
Output:
{"name":"Alice","email":"[email protected]"}
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": "[email protected]"
}`)
var user User
err := json.Unmarshal(data, &user)
if err != nil {
panic(err)
}
fmt.Println(user.Name)
fmt.Println(user.Email)
}
Output:
Alice
[email protected]
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: "[email protected]",
}
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.