Loading
All articles
Programming 14 min read

Structs in Go: A Visual, Hands-On Guide

Learn Go structs visually: defining and creating them, zero values, printing, copies and pointers, comparing with ==, methods, constructors, embedding, exported fields and JSON tags, anonymous structs, how field order changes a struct's size, and exercises.

nbkhope
On this page

In a 2D game, a player's position takes two numbers: x and y. You could keep them in two variables, but they really describe one thing. Add a name, a level and some health, and you're soon passing five separate values to every function.

A struct groups related values into a single type of your own. Structs are how you model almost everything in Go, from a point on a screen to a user in a database.

In this guide you'll define and create structs, see how they copy and compare, and give them methods. You'll also learn the constructor pattern, how embedding works, how to turn a struct into JSON, and how the order of the fields changes the struct's size in memory. Exercises wrap it up.

All outputs below come from real runs with Go 1.22 on 64-bit Windows. New to Go? Start with Your First Program in Go.

Your first struct

1package main
2
3import "fmt"
4
5type Position struct {
6	X int
7	Y int
8}
9
10func main() {
11	position := Position{X: 48, Y: 63}
12
13	fmt.Println(position)
14	fmt.Println(position.X)
15	fmt.Println(position.Y)
16
17	position.X = 87
18	fmt.Println(position)
19}
1{48 63}
248
363
4{87 63}

type Position struct { … } defines a new type with two fields, X and Y. Position{X: 48, Y: 63} creates a value of that type. A dot reads a field (position.X) or changes it (position.X = 87).

A struct groups related values under one name. On the left, two loose variables, x holding 48 and y holding 63, with nothing saying they belong together. On the right, a single variable named position, of type Position, is one box holding the fields X: 48 and Y: 63, one value you can pass around

Three ways to create a struct

1package main
2
3import "fmt"
4
5type Player struct {
6	Name   string
7	Level  int
8	Active bool
9}
10
11func main() {
12	byName := Player{Name: "Ana", Level: 3}
13	byOrder := Player{"Bruno", 5, true}
14	var empty Player
15
16	fmt.Printf("%+v\n", byName)
17	fmt.Printf("%+v\n", byOrder)
18	fmt.Printf("%+v\n", empty)
19}
{Name:Ana Level:3 Active:false}
{Name:Bruno Level:5 Active:true}
{Name: Level:0 Active:false}
  • By name: the fields can go in any order, and any you leave out get their zero value: "" for a string, 0 for a number and false for a bool.
  • By order: the values must follow the order of the fields, and you must give all of them.
  • With var: every field gets its zero value.

Prefer naming the fields. Your code stays readable, and it keeps compiling if someone adds a field later. The compiler rejects a partial list of values:

1package main
2
3import "fmt"
4
5type Position struct {
6	X int
7	Y int
8}
9
10func main() {
11	fmt.Println(Position{48})
12}
./main.go:11:25: too few values in struct literal of type Position

It also catches a field that doesn't exist:

1package main
2
3import "fmt"
4
5type Position struct {
6	X int
7	Y int
8}
9
10func main() {
11	fmt.Println(Position{Z: 48})
12}
./main.go:11:23: unknown field Z in struct literal of type Position

Printing a struct

The fmt package has several ways to show a struct, which helps a lot when debugging:

1package main
2
3import "fmt"
4
5type Position struct {
6	X int
7	Y int
8}
9
10func main() {
11	position := Position{48, 63}
12	fmt.Printf("%v\n", position)
13	fmt.Printf("%+v\n", position)
14	fmt.Printf("%#v\n", position)
15	fmt.Printf("%T\n", position)
16}
1{48 63}
2{X:48 Y:63}
3main.Position{X:48, Y:63}
4main.Position

%+v adds the field names and is the one you'll reach for most. %#v shows the value as Go code, and %T shows the type.

Structs are values

Assigning a struct copies every field. If you want to share one struct instead, use a pointer:

1package main
2
3import "fmt"
4
5type Position struct {
6	X int
7	Y int
8}
9
10func main() {
11	first := Position{1, 2}
12
13	second := first
14	second.X = 99
15	fmt.Println(first, second)
16
17	pointer := &first
18	pointer.X = 99
19	fmt.Println(first, second)
20}
{1 2} {99 2}
{99 2} {99 2}
Assigning a struct copies it, and a pointer shares it. On the left, second := first creates two separate structs: first holds X: 1 and Y: 2, second holds X: 99 and Y: 2, so changing second leaves first alone. On the right, pointer := &first creates one struct with two ways to reach it: an arrow leads from pointer to first, so changing pointer.X changes first

The same applies to function arguments: a function receives its own copy of a struct unless you pass a pointer. Notice that pointer.X needs no *, because Go follows the pointer for you.

Comparing structs

Two structs are equal with == when all their fields are equal. That also means a struct can be a map key:

1package main
2
3import "fmt"
4
5type Position struct {
6	X int
7	Y int
8}
9
10func main() {
11	first := Position{1, 2}
12	second := Position{1, 2}
13	third := Position{3, 4}
14
15	fmt.Println(first == second)
16	fmt.Println(first == third)
17
18	visited := map[Position]bool{}
19	visited[first] = true
20	fmt.Println(visited[second], visited[third])
21}
true
false
true false

second finds the entry stored under first, because the two are equal. This only works when every field can be compared. Slices, maps and functions can't, so a struct that holds one can't use ==:

1package main
2
3import "fmt"
4
5type Team struct {
6	Name    string
7	Members []string
8}
9
10func main() {
11	first := Team{Name: "Red"}
12	second := Team{Name: "Red"}
13	fmt.Println(first == second)
14}
./main.go:13:14: invalid operation: first == second (struct containing []string cannot be compared)

Methods

A method is a function attached to a type. The receiver, written before the method's name, is the value it works on:

1package main
2
3import "fmt"
4
5type Position struct {
6	X int
7	Y int
8}
9
10func (position Position) Moved(deltaX, deltaY int) Position {
11	return Position{position.X + deltaX, position.Y + deltaY}
12}
13
14func (position *Position) Move(deltaX, deltaY int) {
15	position.X += deltaX
16	position.Y += deltaY
17}
18
19func main() {
20	start := Position{1, 1}
21
22	next := start.Moved(2, 3)
23	fmt.Println(start, next)
24
25	start.Move(10, 10)
26	fmt.Println(start)
27}
{1 1} {3 4}
{11 11}

Moved has a value receiver: it works on a copy, so it returns a new Position and leaves start alone. Move has a pointer receiver, so it changes start itself. Pointers in Go covers how to choose.

One method is special. If a type has a String() string method, fmt uses it to print the value:

1package main
2
3import "fmt"
4
5type Position struct {
6	X int
7	Y int
8}
9
10func (position Position) String() string {
11	return fmt.Sprintf("(%d, %d)", position.X, position.Y)
12}
13
14func main() {
15	fmt.Println(Position{48, 63})
16}
(48, 63)

Constructor functions

Go has no constructors built into the language. By convention, you write a plain function named New… when a struct needs default values or validation:

1package main
2
3import (
4	"errors"
5	"fmt"
6)
7
8type Player struct {
9	Name   string
10	Level  int
11	Health int
12}
13
14func NewPlayer(name string) (*Player, error) {
15	if name == "" {
16		return nil, errors.New("name is required")
17	}
18	return &Player{Name: name, Level: 1, Health: 100}, nil
19}
20
21func main() {
22	fmt.Println(NewPlayer("Ana"))
23	fmt.Println(NewPlayer(""))
24}
&{Ana 1 100} <nil>
<nil> name is required

When the zero value is already useful, skip the constructor. A good Go type often works straight from var, as sync.Mutex and strings.Builder do.

Embedding: a struct inside a struct

Go has no classes and no inheritance. To reuse a struct, you embed it by writing its type without a field name:

1package main
2
3import "fmt"
4
5type Position struct {
6	X int
7	Y int
8}
9
10func (position *Position) Move(deltaX, deltaY int) {
11	position.X += deltaX
12	position.Y += deltaY
13}
14
15type Player struct {
16	Position
17	Name string
18}
19
20func main() {
21	hero := Player{Position: Position{48, 63}, Name: "Ana"}
22
23	hero.Move(2, 2)
24
25	fmt.Println(hero.X, hero.Position.X)
26	fmt.Printf("%+v\n", hero)
27}
50 50
{Position:{X:50 Y:65} Name:Ana}
Embedding puts one struct inside another. The variable hero, of type Player, is a box that holds a Position struct with X: 50 and Y: 65, next to the field Name: "Ana". hero.X is shorthand for hero.Position.X, and hero.Move(2, 2) is shorthand for hero.Position.Move(2, 2)

The fields and methods of Position are promoted, so hero.X and hero.Move work directly. It's still a struct inside a struct, not inheritance. A Player isn't a Position, as the compiler will tell you:

1package main
2
3import "fmt"
4
5type Position struct {
6	X int
7	Y int
8}
9
10type Player struct {
11	Position
12	Name string
13}
14
15func describe(position Position) string {
16	return fmt.Sprint(position.X, ",", position.Y)
17}
18
19func main() {
20	hero := Player{Name: "Ana"}
21	fmt.Println(describe(hero))
22}
./main.go:21:23: cannot use hero (variable of type Player) as Position value in argument to describe

Pass hero.Position instead.

Exported fields and JSON

In Go, a capital first letter means exported: visible outside the package. That matters for fields too, because packages like encoding/json can only see exported ones. A struct tag, the text in backticks, customizes the JSON name:

1package main
2
3import (
4	"encoding/json"
5	"fmt"
6)
7
8type Player struct {
9	Name     string `json:"name"`
10	Level    int    `json:"level"`
11	Nickname string `json:"nickname,omitempty"`
12	password string
13}
14
15func main() {
16	hero := Player{Name: "Ana", Level: 3, password: "secret"}
17	encoded, err := json.Marshal(hero)
18	if err != nil {
19		fmt.Println("error:", err)
20		return
21	}
22	fmt.Println(string(encoded))
23
24	var loaded Player
25	err = json.Unmarshal([]byte(`{"name":"Bruno","level":7}`), &loaded)
26	if err != nil {
27		fmt.Println("error:", err)
28		return
29	}
30	fmt.Printf("%+v\n", loaded)
31}
{"name":"Ana","level":3}
{Name:Bruno Level:7 Nickname: password:}

password is lowercase, so it stayed out of the JSON. omitempty dropped the empty Nickname. json.Unmarshal needs a pointer (&loaded) so that it can fill in your struct.

Anonymous structs

For a one-off shape, you can define a struct without naming it. It's common for lists of test cases:

1package main
2
3import "fmt"
4
5func main() {
6	scores := []struct {
7		Name  string
8		Value int
9	}{
10		{"Ana", 10},
11		{"Bruno", 20},
12	}
13
14	for _, score := range scores {
15		fmt.Println(score.Name, score.Value)
16	}
17}
Ana 10
Bruno 20

If you need the same shape in a second place, give it a name.

Field order changes the size

Here's something that surprises people. These two structs hold exactly the same fields, in a different order:

1package main
2
3import (
4	"fmt"
5	"unsafe"
6)
7
8type Scattered struct {
9	Active  bool
10	Score   int64
11	Visible bool
12}
13
14type Grouped struct {
15	Score   int64
16	Active  bool
17	Visible bool
18}
19
20func main() {
21	fmt.Println("Scattered:", unsafe.Sizeof(Scattered{}), "bytes")
22	fmt.Println("Grouped:", unsafe.Sizeof(Grouped{}), "bytes")
23}
Scattered: 24 bytes
Grouped: 16 bytes
Field order changes the size of a struct. Each square is one byte, on Go 1.22.5 on 64-bit Windows. Scattered takes 24 bytes: Active in 1 byte, 7 bytes of unused padding, Score in 8 bytes, Visible in 1 byte, and 7 more bytes of padding. Grouped takes 16 bytes: Score in 8 bytes, Active and Visible in 1 byte each, and 6 bytes of padding. An int64 must start at a multiple of 8, so the compiler leaves gaps

The processor reads an int64 most efficiently from an address that's a multiple of 8, so the compiler inserts unused padding to line it up, and Go doesn't reorder your fields for you. In a slice of a million elements, the difference would be 24 MB versus 16 MB.

Don't reorder every struct because of this. Group fields so they read logically, and think about size only when you're storing huge numbers of them.

Your turn: practice

Try these before peeking at the answers. Run each with go run ..

Exercise 1: Predict the output (warm-up). What does this print?

1package main
2
3import "fmt"
4
5type Box struct {
6	Width int
7}
8
9func main() {
10	first := Box{Width: 1}
11	second := first
12	pointer := &first
13
14	second.Width = 2
15	pointer.Width = 3
16
17	fmt.Println(first.Width, second.Width, pointer.Width)
18}

Exercise 2: Rectangle. Define a Rectangle struct with Width and Height, and give it an Area() int method. Print the area of a 3 by 4 rectangle.

Exercise 3: Predict the receivers. What does this print, and why?

1package main
2
3import "fmt"
4
5type Plant struct {
6	Height int
7}
8
9func (plant Plant) Grow() {
10	plant.Height += 1
11}
12
13func (plant *Plant) Water() {
14	plant.Height += 10
15}
16
17func main() {
18	plant := Plant{}
19	plant.Grow()
20	plant.Water()
21	fmt.Println(plant.Height)
22}

Exercise 4: A nice print. Define a Book struct with Title and Pages, and give it a String() method so that printing a book shows Dune (412 pages).

Exercise 5: Embedding (challenge). Define a Person with a Name and a Greet() string method that returns Hi, I'm Ana. Embed Person in an Employee with a Role field. Give Employee its own Greet that adds the role to what Person.Greet returns, then print both greetings.

Quick quiz

  1. What does second := first do when first is a struct?
  2. Why is Position{X: 1, Y: 2} usually better than Position{1, 2}?
  3. What's the value of a field you don't set?
  4. When does comparing two structs with == fail to compile?
  5. Is a struct that embeds Position also a Position?
  6. Why would a field be missing from the JSON that json.Marshal produces?

Answers

Exercise 1: second is a separate copy, while pointer leads to first:

3 2 3

Exercise 2: A value receiver is enough, because Area only reads the fields:

1package main
2
3import "fmt"
4
5type Rectangle struct {
6	Width  int
7	Height int
8}
9
10func (rectangle Rectangle) Area() int {
11	return rectangle.Width * rectangle.Height
12}
13
14func main() {
15	rectangle := Rectangle{Width: 3, Height: 4}
16	fmt.Println(rectangle.Area())
17}
12

Exercise 3: Grow changed a copy, so only Water, with its pointer receiver, had an effect:

10

Exercise 4: fmt calls String for you:

1package main
2
3import "fmt"
4
5type Book struct {
6	Title string
7	Pages int
8}
9
10func (book Book) String() string {
11	return fmt.Sprintf("%s (%d pages)", book.Title, book.Pages)
12}
13
14func main() {
15	fmt.Println(Book{Title: "Dune", Pages: 412})
16}
Dune (412 pages)

Exercise 5: Employee.Greet takes priority over the promoted method, and you can still reach the embedded one through employee.Person:

1package main
2
3import "fmt"
4
5type Person struct {
6	Name string
7}
8
9func (person Person) Greet() string {
10	return "Hi, I'm " + person.Name
11}
12
13type Employee struct {
14	Person
15	Role string
16}
17
18func (employee Employee) Greet() string {
19	return employee.Person.Greet() + ", and I work as an " + employee.Role
20}
21
22func main() {
23	employee := Employee{Person: Person{Name: "Ana"}, Role: "engineer"}
24	fmt.Println(employee.Person.Greet())
25	fmt.Println(employee.Greet())
26}
Hi, I'm Ana
Hi, I'm Ana, and I work as an engineer

Quiz: (1) It copies every field into a new, separate struct. (2) It stays readable, and it keeps compiling when fields are added. (3) The zero value of its type. (4) When a field can't be compared, such as a slice, a map or a function. (5) No. It contains a Position and gets its fields and methods, but it's a different type. (6) The field is unexported (lowercase), it's empty and tagged omitempty, or it's tagged json:"-".

Cheat sheet

I want to…Write
Define a structtype Position struct { X int; Y int }
Create one by field namePosition{X: 48, Y: 63}
Create one by field orderPosition{48, 63}
Start with all zero valuesvar position Position
Read or change a fieldposition.X, position.X = 87
Print with field namesfmt.Printf("%+v\n", position)
Share instead of copypointer := &position
Comparefirst == second
Add a read-only methodfunc (position Position) Moved(…) Position
Add a method that changes the structfunc (position *Position) Move(…)
Control how it printsfunc (position Position) String() string
Set defaults or validatefunc NewPlayer(name string) (*Player, error)
Embed a structtype Player struct { Position; Name string }
Name a JSON fieldName string `json:"name"`
Check the sizeunsafe.Sizeof(position)

Wrapping up

A struct gives related values a single name and type:

  • Fields group your data, and a dot reads or changes them.
  • Name the fields when you create a struct. Any you leave out get their zero value.
  • Structs are values. Assigning or passing one copies it, and a pointer shares it.
  • == compares every field, so it only works when all of them can be compared.
  • Methods attach behavior. Use a pointer receiver to change the struct, and String() to control how it prints.
  • New… functions are a convention, for when the zero value isn't enough.
  • Embedding promotes fields and methods, but it isn't inheritance.
  • Only exported fields reach JSON, and struct tags choose their names.
  • Field order affects size: 24 bytes versus 16 for the same three fields in our example.

Back through the series: Pointers in Go, How to Use defer in Go and Your First Program in Go. Next up: Maps in Go.

Thanks for reading!

Know someone who would enjoy this? Send it their way.

More articles

Read next

Programming 4 min read

Ruby Blocks and Iterators: Write Your Own

Learn how Ruby methods receive blocks, pass values with yield, handle a missing block, and return an Enumerator. Build and run your own iterator.

nbkhope
Programming 6 min read

C++ std::vector: Size, Capacity, and Safe Growth

Learn how std::vector grows in C++: the difference between size and capacity, when reserve helps, why resize is different, and how growth can invalidate references.

nbkhope
Programming 5 min read

How to Handle Errors in Go: A Hands-On Guide

Learn Go's everyday error pattern with runnable examples: return an error, check it, add context with %w, and recognize a missing file with errors.Is.

nbkhope

Comments

Be kind and specific. Comments with links from new accounts appear after a moderator reviews them.