Structs
A struct in Go is a composite type that groups together zero or more named fields, each with its own type, into a single value. Structs are how Go represents structured data — a point in space, a user record, a network request — without needing classes or inheritance. Once you understand structs and how methods attach to them, you have the foundation for almost everything else in Go, including interfaces and much of the standard library.
Overview: How Structs Work
Go does not have classes. Instead, it has struct types: plain data containers whose fields are laid out contiguously in memory, in the order you declare them (the compiler may insert padding bytes between fields so each field starts at an address suitable for its type — this is called alignment). A struct’s total size is roughly the sum of its field sizes plus any padding, which is why grouping fields of similar size together can sometimes shrink a struct’s memory footprint.
Every struct type has a well-defined zero value: a struct literal that omits a field, or a struct declared with var instead of a literal, gets each field set to that field’s own zero value (0 for numbers, “” for strings, nil for pointers/slices/maps, false for bools). This means a struct is always ready to use the moment it’s declared — there is no “uninitialized” state to guard against, unlike a nil pointer.
Structs have value semantics: assigning a struct to a new variable, or passing it to a function, copies every field. This is different from a slice or a map, which are small header values containing a pointer to shared underlying data — copying a slice header copies the pointer, not the data it points to. Copying a struct copies everything, including any embedded arrays. For large structs this copying has a real cost, which is one reason Go code often passes pointers to structs (*Person) instead of the structs themselves once they grow beyond a few fields.
Methods let you attach behavior to a type. A method is a regular function with an extra receiver parameter written before the function name: func (p Person) Greet() string. There is no class keyword and no implements keyword — a type simply has whatever methods you define for it, and it satisfies any interface whose method set it happens to match, entirely implicitly. The receiver can be a value (p Person) or a pointer (p *Person); this choice determines whether the method operates on a copy or on the original struct, and it’s one of the most important decisions you make about a type.
Finally, Go supports composition through embedding: placing one struct type inside another without giving it a field name. The outer struct automatically gets access to the embedded type’s fields and methods, “promoted” as if they were its own. This is resolved entirely at compile time by the compiler generating the necessary field and method lookups — it is not inheritance, there’s no dynamic dispatch, and the embedded type has no idea it’s being embedded.
Syntax
A struct type declaration and a method declaration follow these general forms:
type StructName struct {
FieldName1 FieldType1
FieldName2 FieldType2
// as many fields as you need
}
func (receiverName ReceiverType) MethodName(paramName ParamType) ReturnType {
// method body; use receiverName to access fields
}
| Part | Meaning |
|---|---|
type StructName struct { ... } |
Declares a new named struct type with the given fields. |
FieldName Type |
A field: an exported name (starts uppercase) is visible outside the package; unexported (lowercase) is package-private. |
(receiverName ReceiverType) |
The receiver: binds the method to ReceiverType. Use T for a value receiver or *T for a pointer receiver. |
StructName{Field: value, ...} |
A keyed struct literal — the safest way to construct a value, since it doesn’t depend on field order. |
By convention, receiver names are short abbreviations of the type (p for Person, r for Rectangle), not this or self.
Examples
Example 1: Defining and using a struct
package main
import "fmt"
type Person struct {
Name string
Age int
}
func main() {
p := Person{Name: "Alice", Age: 30}
fmt.Println(p.Name, "is", p.Age, "years old")
p.Age = 31
fmt.Println(p)
}
Output:
Alice is 30 years old
{Alice 31}
The keyed literal Person{Name: "Alice", Age: 30} creates a fully-initialized value in one step. Fields are accessed and reassigned with dot notation. When you print a struct value directly with fmt.Println, Go’s default formatter prints its fields in declaration order inside curly braces, with no field names — that’s what produces {Alice 31}.
Example 2: Methods with value and pointer receivers
package main
import "fmt"
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
func main() {
rect := Rectangle{Width: 3, Height: 4}
fmt.Println("Area:", rect.Area())
rect.Scale(2)
fmt.Println("After scaling:", rect)
fmt.Println("New area:", rect.Area())
}
Output:
Area: 12
After scaling: {6 8}
New area: 48
Area only reads the receiver’s fields, so it takes a value receiver — calling it never affects the caller’s rect. Scale needs to mutate the struct, so it takes a pointer receiver; Go automatically takes the address of rect for the call rect.Scale(2) because rect is an addressable variable, so you don’t need to write (&rect).Scale(2) yourself.
Example 3: Composition with embedding
package main
import "fmt"
type Address struct {
City string
Country string
}
type Employee struct {
Name string
Address
Salary float64
}
func main() {
e := Employee{
Name: "Bob",
Address: Address{
City: "Berlin",
Country: "Germany",
},
Salary: 55000,
}
fmt.Println(e.Name, "lives in", e.City, e.Country)
fmt.Printf("%+v\n", e)
}
Output:
Bob lives in Berlin Germany
{Name:Bob Address:{City:Berlin Country:Germany} Salary:55000}
Address is embedded in Employee without a field name, so its fields City and Country are promoted: you can write e.City directly instead of e.Address.City (though the longer form still works). The %+v verb in Printf prints field names alongside values, which is invaluable for debugging nested structs.
How It Works Step by Step
Walking through Example 2’s rect.Scale(2) call shows the mechanics that matter most when learning receivers:
- The compiler sees that
Scaleis declared on*Rectangle, butrectis a plainRectanglevalue. - Because
rectis an addressable variable (a local variable, not a temporary), the compiler automatically rewrites the call as(&rect).Scale(2). - Inside
Scale, the receiverris a pointer that points at the same memory asmain‘srect— there is no copy of the struct. r.Width *= factordereferences that pointer and writes through it, so the change is visible back inmainas soon asScalereturns.- By contrast, calling
rect.Area()(a value receiver) copies the wholeRectangleintor; anythingAreadid toritself (it doesn’t, here) would never reachrect.
The same automatic-addressing rule is why mixing value and pointer receivers on the same type usually works fine when you’re using a named variable, but breaks if you ever try to call a pointer-receiver method on a value that isn’t addressable (a map element, or a literal returned directly from a function) — the compiler will reject it because it can’t take that value’s address.
Common Mistakes
Mistake 1: Using a value receiver when you meant to mutate
This compiles cleanly but silently does nothing, because Increment only modifies its own copy of the struct:
package main
import "fmt"
type Counter struct {
Count int
}
func (c Counter) Increment() {
c.Count++
}
func main() {
c := Counter{Count: 0}
c.Increment()
c.Increment()
fmt.Println(c.Count)
}
Output:
0
The fix is a pointer receiver, so Increment operates on the original Counter:
package main
import "fmt"
type Counter struct {
Count int
}
func (c *Counter) Increment() {
c.Count++
}
func main() {
c := Counter{Count: 0}
c.Increment()
c.Increment()
fmt.Println(c.Count)
}
Output:
2
As a rule of thumb: if a type has even one method that needs a pointer receiver, make all of its methods pointer receivers, even the ones that only read fields. This keeps the method set consistent and avoids exactly this kind of half-fixed bug.
Mistake 2: Comparing structs that contain slices or maps
The == operator works on structs only if every field is itself comparable. Slices, maps, and functions are not comparable, so this doesn’t even compile:
package main
import "fmt"
type Data struct {
Values []int
}
func main() {
d1 := Data{Values: []int{1, 2, 3}}
d2 := Data{Values: []int{1, 2, 3}}
// invalid operation: d1 == d2
// (struct containing []int cannot be compared)
if d1 == d2 {
fmt.Println("equal")
}
}
Use reflect.DeepEqual (or write a manual field-by-field comparison for performance-sensitive code) instead:
package main
import (
"fmt"
"reflect"
)
type Data struct {
Values []int
}
func main() {
d1 := Data{Values: []int{1, 2, 3}}
d2 := Data{Values: []int{1, 2, 3}}
if reflect.DeepEqual(d1, d2) {
fmt.Println("equal")
}
}
Output:
equal
Best Practices
- Prefer keyed struct literals (
Person{Name: "Alice", Age: 30}) over positional ones (Person{"Alice", 30}) — positional literals break silently if a field is added or reordered. - Pick a receiver type (value or pointer) per type, not per method, and stick with it once any method needs a pointer.
- Pass small structs (a few machine words) by value; pass larger structs, or ones that must be mutated, by pointer.
- Use embedding for genuine composition relationships where promoted methods make sense, not just to save typing — embedding can leak internal details of the embedded type.
- Add struct tags (for example,
json:"name") when a struct is serialized withencoding/jsonor similar packages, so the wire format doesn’t depend on Go field names. - Never compare structs containing slices, maps, or funcs with
==; usereflect.DeepEqualor a hand-written comparison method instead.
Practice Exercises
- Define a
Bookstruct withTitle,Author, andPagesfields. Write a value-receiver methodSummary()that returns a string like"War and Peace by Leo Tolstoy (1225 pages)". - Define a
BankAccountstruct with aBalance float64field. Write pointer-receiver methodsDeposit(amount float64)andWithdraw(amount float64) error, whereWithdrawreturns an error if the amount exceeds the balance. Call both frommainand print the final balance. - Create a
Vehiclestruct withMakeandModel, then aCarstruct that embedsVehicleand adds aDoors intfield. Construct aCarand print a promoted field alongsideDoorsto confirm embedding works as expected.
Summary
- A struct groups named fields of different types into one value; fields are laid out in memory in declaration order.
- Structs have value semantics — assignment and function calls copy every field, unlike slices and maps.
- Methods attach behavior to a type via a receiver, written as
func (r T) Method(...)orfunc (r *T) Method(...). - Value receivers operate on a copy; pointer receivers operate on the original and are required for mutation.
- Embedding promotes an inner struct’s fields and methods to the outer struct at compile time — it’s composition, not inheritance.
- Structs with slice, map, or func fields cannot be compared with
==; usereflect.DeepEqualinstead.
