BeeTool.Dev - Essential Tools for developers

Stories.Tool Tabs VaultPersonal Documents
BeeTool.Dev

Essential tools, knowledge, and playgrounds for developers.

Explore

  • Stories
  • Personal Documents
  • Features
  • Beetool for Mac
  • Algorithms Playground

Tool Tabs

  • JSON Compare
  • SQL Formatter
  • Text Diff
  • All tools →

Learn

  • Sorting algorithms
  • Searching algorithms
  • Algorithm Race Mode
© 2024 – 2026 BeeTool.Dev
AboutContactPrivacy policyTerms
All stories
GolangCourse

Structs in Go

DUKE434 viewsAugust 5, 2026

A struct allows us to group multiple related values into a single data type.

For example, instead of managing a person’s first name, last name, and age separately, we can group them into a Person type:

type Person struct {
FirstName string
LastName string
Age int
}

Each component inside a struct is called a field. Every field has its own name and data type.

When adjacent fields share the same type, Go allows us to declare them together:

type Person struct {
FirstName, LastName string
Age int
}

These two definitions are equivalent. The second form is simply more concise.

Declaring a struct and understanding its zero value

After defining Person, we can declare a variable of that type just like any other variable:

func main() {
var p Person
fmt.Println(p)
}

Output:

{  0}

At first, this output may look unusual. In reality, p has already been initialized with the zero value of Person.

The zero value of a struct is built from the zero value of each of its fields:

  • FirstName is a string, so its value is "".
  • LastName is a string, so its value is "".
  • Age is an int, so its value is 0.

Because empty strings do not display any visible characters, only the final 0 appears clearly in the output.

This is an important property of Go: a newly declared variable always has a valid value. There is no concept of uninitialized garbage memory as found in some lower-level languages.

Initializing a struct with a struct literal

To create a Person with specific values, we use a struct literal:

func main() {
p := Person{
FirstName: "Bee",
LastName: "Tool Dev",
Age: 22,
}

fmt.Println(p)
}

Output:

{Bee Tool Dev 22}

This form explicitly associates each value with a field, making the code easier to read and less dependent on field declaration order.

When a struct literal spans multiple lines, the trailing comma after the final field is required:

p := Person{
FirstName: "Bee",
LastName: "Tool Dev",
Age: 22,
}

Without the comma after 22, the program will not compile. This requirement is related to Go’s automatic semicolon insertion at the end of a line.

You do not need to initialize every field

A struct literal with named fields does not require values for every field:

func main() {
p := Person{
FirstName: "Tony",
LastName: "Stark",
}

fmt.Println(p)
}

Output:

{Tony Stark 0}

Because Age was not specified, it keeps its zero value of 0.

Partial initialization is common in Go, especially when a struct contains optional fields or when the zero value already represents a valid state.

Positional struct literals

Go also allows field names to be omitted, as long as the values are provided in declaration order:

p := Person{"Bruce", "Wayne", 40}

The first value is assigned to FirstName, the second to LastName, and the third to Age.

However, this form requires values for every field:

p := Person{"Bruce", "Wayne"}

The code does not compile:

too few values in struct literal of type Person

In most production code, named fields should be preferred:

p := Person{
FirstName: "Bruce",
LastName: "Wayne",
Age: 40,
}

This style has several benefits:

  • It is easier to read because every value is paired with a field name.
  • It remains correct if the order of the fields changes.
  • It is less likely to break when new fields are added to the struct.

Positional literals are generally suitable only for very small and stable structs used within a limited scope, such as certain test cases.

Anonymous structs

Not every struct needs a name.

Go allows us to define a struct type directly where it is used:

func main() {
p := struct {
Name string
}{
Name: "Golang",
}

fmt.Println(p)
}

Output:

{Golang}

This is called an anonymous struct.

Anonymous structs are useful for groups of data that:

  • Are used in only one place.
  • Do not need to be shared across functions or packages.
  • Are not important enough to justify a separate named type.

One of the most common uses of anonymous structs is in table-driven tests, which we will revisit later in this article.

Accessing and modifying fields

Struct fields are accessed using dot notation:

func main() {
p := Person{
FirstName: "Duke",
LastName: "Nguyen",
Age: 22,
}

fmt.Println(p.FirstName)
}

Output:

Duke

Fields can also be updated directly:

p.Age = 23

After this statement, the value of Age inside p becomes 23.

Accessing fields through a pointer

We can obtain the address of a struct using the & operator:

func main() {
p := Person{
FirstName: "Duke",
LastName: "Nguyen",
Age: 22,
}

ptr := &p
fmt.Println(ptr.FirstName)
}

The fully explicit form for accessing a field through a pointer would be:

fmt.Println((*ptr).FirstName)

However, Go automatically dereferences pointers when a field selector is used, so the following expression means the same thing:

fmt.Println(ptr.FirstName)

This behavior makes working with pointers to structs much more natural, especially once we begin using methods with pointer receivers.

Initializing a struct with new

The built-in new function creates a zero-initialized value and returns a pointer to it:

func main() {
p := new(Person)

p.FirstName = "Duke"
p.LastName = "Nguyen"
p.Age = 22

fmt.Println(p)
}

Output:

&{Duke Nguyen 22}

The & in the output indicates that p is a pointer to a Person, rather than a Person value itself.

For structs, the following two expressions are nearly equivalent:

p1 := new(Person)
p2 := &Person{}

Both create a zero-valued Person and return a *Person.

In practice, &Person{} is often preferred because it allows fields to be initialized immediately:

p := &Person{
FirstName: "Duke",
Age: 22,
}

Comparing structs

Two structs can be compared using == when all of their fields are comparable:

func main() {
p1 := Person{
FirstName: "Bruce",
LastName: "Wayne",
Age: 40,
}

p2 := Person{
FirstName: "Bruce",
LastName: "Wayne",
Age: 40,
}

fmt.Println(p1 == p2)
}

Output:

true

Go compares each corresponding field. If even one field differs, the result is false.

When is a struct not comparable?

A struct can use == only when all of its fields belong to comparable types.

Common comparable types include:

  • Booleans
  • Numbers
  • Strings
  • Pointers
  • Channels
  • Interfaces, when the dynamic value stored inside them is comparable
  • Arrays whose elements are comparable
  • Structs whose fields are all comparable

Slices, maps, and functions cannot be compared directly using ==.

For example:

type Playlist struct {
Name string
Songs []string
}

Because Playlist contains a []string field, the entire struct becomes non-comparable:

func main() {
a := Playlist{
Name: "Road Trip",
Songs: []string{"song1"},
}

b := Playlist{
Name: "Road Trip",
Songs: []string{"song1"},
}

fmt.Println(a == b)
}

The program does not compile:

invalid operation: a == b
struct containing []string cannot be compared

This is a compile-time error, not something that appears only while the program is running.

When deep comparison is needed for structs containing slices or maps, the go-cmp package is often a better choice in tests:

cmp.Equal(a, b)

reflect.DeepEqual can also perform deep comparisons, but its behavior has several details worth understanding, such as treating a nil slice and an empty slice as different:

var a []string = nil
b := []string{}

fmt.Println(reflect.DeepEqual(a, b)) // false

Another consequence is that a struct containing a non-comparable field cannot be used as a map key.

Exported and unexported fields

Go uses the first letter of an identifier to determine its visibility:

  • A name beginning with an uppercase letter is exported.
  • A name beginning with a lowercase letter is unexported.

For example:

type Person struct {
FirstName string
LastName string
Age int
zipCode string
}

Other packages can access:

p.FirstName
p.LastName
p.Age

But they cannot access:

p.zipCode

The same rule applies to type names:

type person struct {
Name string
}

Because person begins with a lowercase letter, other packages cannot refer to this type directly.

This rule is not only about access control. Many packages that rely on reflection, including encoding/json, can only read and write exported fields.

Embedding and composition

Go does not provide inheritance in the traditional class-based sense. Instead, larger types are usually built through composition.

One special form of composition in Go is embedding:

type Person struct {
FirstName string
LastName string
Age int
}

type SuperHero struct {
Person
Alias string
}

Inside SuperHero, the Person field does not have an explicit field name. It is called an embedded field.

The fields of Person can be accessed directly through SuperHero:

func main() {
s := SuperHero{}

s.FirstName = "Bruce"
s.LastName = "Wayne"
s.Age = 40
s.Alias = "Batman"

fmt.Println(s)
}

Output:

{{Bruce Wayne 40} Batman}

Fields such as FirstName are not physically moved out of Person. They are promoted, meaning that Go allows us to use a shorter selector:

s.FirstName

Instead of the fully qualified form:

s.Person.FirstName

Both expressions access the same field.

How does embedding differ from a named field?

We can also use ordinary composition:

type SuperHero struct {
Person Person
Alias string
}

In this case, the fields are not promoted and must be accessed through the named field:

s.Person.FirstName

The two approaches communicate different design intentions.

Embedding is useful when the outer type wants to expose the behavior or fields of the embedded type as a convenient part of its own API.

A named field is useful when the relationship should remain explicit:

superHero.Person.FirstName

Embedding does not make SuperHero a subtype of Person. It is still composition, not inheritance.

Method promotion

Embedding applies not only to fields but also to methods.

Suppose Person has the following method:

func (p Person) FullName() string {
return p.FirstName + " " + p.LastName
}

Because Person is embedded in SuperHero, we can call:

s.FullName()

Instead of:

s.Person.FullName()

The method still belongs to Person; Go simply provides a shorter selector through promotion.

This is one of the ways Go combines behavior from multiple types without relying on inheritance.

Shadowing with embedded fields

What happens when the outer struct and the embedded struct contain fields with the same name?

The field at the shallowest level takes precedence:

type Person struct {
Name string
}

type Employee struct {
Person
Name string
}

Initialization:

func main() {
e := Employee{
Person: Person{
Name: "Inner",
},
Name: "Outer",
}

fmt.Println(e.Name)
fmt.Println(e.Person.Name)
}

Output:

Outer
Inner

e.Name accesses the field declared directly on Employee.

The Person.Name field has not disappeared. It is merely shadowed when the shorter selector is used and remains accessible through its full path:

e.Person.Name

If two embedded structs at the same depth both provide a field with the same name, the short selector becomes ambiguous:

type A struct {
Name string
}

type B struct {
Name string
}

type C struct {
A
B
}

The following expression does not compile:

c.Name

Go does not attempt to guess whether we mean A.Name or B.Name. We must specify the path explicitly:

c.A.Name
c.B.Name

Struct tags

A struct tag is metadata attached to a field. It is written inside backticks after the field type:

type Animal struct {
Name string `key:"value1"`
Age int `key:"value2"`
}

A tag does not create behavior by itself. It is simply a metadata string that libraries can inspect through reflection.

Packages and libraries that commonly use struct tags include:

  • encoding/json
  • encoding/xml
  • YAML libraries
  • ORMs
  • Validation libraries
  • Dependency injection frameworks

For example, with JSON:

type Animal struct {
Name string `json:"name"`
Age int `json:"age"`
}

These tags instruct encoding/json to use the keys name and age instead of Name and Age.

omitempty and unexported fields

Consider the following struct:

type Movie struct {
Title string `json:"title"`
Director string `json:"director,omitempty"`
rating int
}

Now marshal a Movie value:

func main() {
m := Movie{
Title: "Inception",
rating: 5,
}

out, err := json.Marshal(m)
if err != nil {
log.Fatal(err)
}

fmt.Println(string(out))
}

Output:

{"title":"Inception"}

Three things happened here.

First, Title became the key title because of the tag:

json:"title"

Second, Director was omitted because it held its zero value, "", and was marked with:

omitempty

Third, rating was omitted because it is unexported. The encoding/json package cannot access this field, even if a tag were added to it.

Reading struct tags with reflection

Libraries commonly use the reflect package to inspect struct tags.

We can do the same:

t := reflect.TypeOf(Movie{})

field, ok := t.FieldByName("Title")
if !ok {
return
}

fmt.Println(field.Tag.Get("json"))

Output:

title

It is important to remember that a struct tag is ultimately just a string. An incorrectly formatted tag may still pass compilation:

Name string `json: "name"`

The code may compile, but encoding/json will not interpret the tag as intended.

The go vet command can detect many malformed struct tags:

go vet ./...

For important APIs, tests should still verify the marshaled output rather than assuming that every tag has been written correctly.

Structs are value types

Structs in Go have value semantics.

When a struct is assigned to another variable, Go copies all of its fields:

type Point struct {
X int
Y int
}

func main() {
p1 := Point{X: 1, Y: 2}
p2 := p1

p2.X = 10

fmt.Println(p1)
fmt.Println(p2)
}

Output:

{1 2}
{10 2}

Changing p2.X does not affect p1.X because the values of X and Y were copied into the new variable.

A struct is also copied when passed to a function:

func move(p Point) {
p.X = 100
}

func main() {
p := Point{X: 1, Y: 2}
move(p)

fmt.Println(p.X) // 1
}

The move function modifies only its local copy of p.

To allow the function to modify the caller’s value directly, pass a pointer:

func move(p *Point) {
p.X = 100
}

func main() {
p := Point{X: 1, Y: 2}
move(&p)

fmt.Println(p.X) // 100
}

Copying a struct does not always create completely independent data

The statement “a struct is copied” is correct, but it is important to understand exactly what is copied: its fields.

If the struct contains only numbers, booleans, strings, or value arrays, the two struct values can usually be treated as independent at the field level.

However, if the struct contains a pointer, slice, map, or another type that refers to underlying data, that reference-like value is copied as well.

For example:

type User struct {
Name string
Roles []string
}

func main() {
u1 := User{
Name: "Bruce",
Roles: []string{"admin"},
}

u2 := u1
u2.Name = "Tony"
u2.Roles[0] = "viewer"

fmt.Println(u1.Name) // Bruce
fmt.Println(u2.Name) // Tony
fmt.Println(u1.Roles[0]) // viewer
}

The Name field was copied, so assigning a new value to u2.Name does not affect u1.Name.

The Roles field, however, is a slice. Copying the struct copies the slice header, while both slice headers initially continue to refer to the same backing array. As a result:

u2.Roles[0] = "viewer"

also changes the data observed through u1.Roles.

Therefore, copying a struct performs a shallow field-by-field copy. It does not automatically create a deep copy of the entire object graph referenced by its fields.

Struct memory layout

Struct fields are placed in memory in declaration order.

However, CPUs generally require certain data types to be located at appropriately aligned addresses. To satisfy these alignment requirements, the compiler may insert invisible padding bytes between fields or at the end of the struct.

For example, on a 64-bit architecture:

type BadLayout struct {
A bool
B int64
C bool
}

Its memory layout may look roughly like this:

A       1 byte
padding 7 bytes
B 8 bytes
C 1 byte
padding 7 bytes

The total size is 24 bytes:

fmt.Println(unsafe.Sizeof(BadLayout{})) // 24

If the fields are reordered:

type BetterLayout struct {
B int64
A bool
C bool
}

The layout may become:

B       8 bytes
A 1 byte
C 1 byte
padding 6 bytes

The total size is now 16 bytes:

fmt.Println(unsafe.Sizeof(BetterLayout{})) // 16

The two structs contain exactly the same fields, but their sizes differ because of alignment and padding.

This difference rarely matters when only a handful of values exist. However, when a struct appears millions of times in a slice or is used on a hot path, a smaller representation may help:

  • Reduce memory usage
  • Reduce garbage collector pressure
  • Improve CPU cache efficiency
  • Reduce the amount of data copied

Even so, fields should not be reordered merely to save a few bytes in every situation. Readability and domain meaning are usually more important. Aggressive layout optimization should be driven by profiling evidence.

Size and alignment may also differ across machine architectures, so the same struct should not be assumed to have identical dimensions everywhere.

Empty structs

An empty struct is a struct with no fields:

struct{}

Its size is 0 bytes:

func main() {
var s struct{}
fmt.Println(unsafe.Sizeof(s))
}

Output:

0

Empty structs are useful when the presence of a value matters, but the value does not need to contain any data.

Using an empty struct to represent a set

Go does not have a built-in set type. A common representation is a map whose values are struct{}:

seen := map[string]struct{}{}

Add an element:

seen["golang"] = struct{}{}

Check whether an element exists:

if _, ok := seen["golang"]; ok {
fmt.Println("already seen")
}

In this structure, only the keys contain meaningful information. The values do not need to store any data.

Compared with:

map[string]bool

map[string]struct{} communicates the intention more clearly: the map represents a set of keys, rather than a mapping from keys to meaningful boolean states.

Using an empty struct as a channel signal

A channel is sometimes used only to indicate that a task has completed:

done := make(chan struct{})

go func() {
// Perform some work.
close(done)
}()

<-done
fmt.Println("worker finished")

No business data needs to be sent through done. Closing the channel already communicates everything the receiver needs to know: the worker has finished.

A chan struct{} expresses this intention more clearly than a type such as:

chan bool

Readers can immediately see that the channel is being used for signaling rather than carrying a meaningful boolean value.

In modern Go programs, cancellation and timeout propagation are often handled through context.Context. However, chan struct{} remains useful for simple internal synchronization signals.

Anonymous structs in table-driven tests

Anonymous structs are especially useful in table-driven tests, a pattern that appears throughout Go codebases.

Suppose we want to test the following function:

func double(n int) int {
return n * 2
}

The test can be organized like this:

func TestDouble(t *testing.T) {
tests := []struct {
name string
input int
want int
}{
{
name: "zero",
input: 0,
want: 0,
},
{
name: "positive number",
input: 2,
want: 4,
},
{
name: "negative number",
input: -3,
want: -6,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := double(tt.input)

if got != tt.want {
t.Errorf(
"double(%d) = %d, want %d",
tt.input,
got,
tt.want,
)
}
})
}
}

Each element in the slice represents one test case.

Because this type is used only inside TestDouble, defining a separate named type would usually add little value. An anonymous struct keeps the inputs, expected outputs, and test execution logic close together.

Adding a new test case requires only one more element in the slice, without duplicating the assertion logic.

Summary

Structs are the foundation of data modeling in Go.

A struct:

  • Groups multiple fields into a single data type.
  • Always has a valid zero value.
  • Can be initialized using named or positional literals.
  • Can contain exported and unexported fields.
  • Can be combined with other structs through composition and embedding.
  • Can carry metadata through struct tags.
  • Is copied with value semantics when assigned or passed to a function.
  • May still share underlying data when it contains slices, maps, or pointers.
  • Can use == only when all of its fields are comparable.
  • Has a memory layout influenced by alignment and padding.


#Backend
DUKE
Written byDUKEView all stories by DUKE

Comments