The problem with scattered variables
You are building a command-line tool that tracks project tasks. You start with a few variables: a string for the title, an integer for the priority, and a boolean for completion status. Everything works fine until you need to pass that task to three different functions. Suddenly your function signatures look like func processTask(title string, priority int, completed bool, assignee string, dueDate time.Time). You lose track of which string is the title and which is the assignee. You add a new field and have to update every call site. The code becomes fragile and hard to read.
Go solves this by letting you pack related data into a single, predictable unit. That unit is a struct.
What a struct actually is
A struct is a blueprint for a custom data type. Think of it like a standardized shipping container. Instead of carrying loose boxes, you pack related items into a container with a fixed layout. You move the container, not the individual items. In Go, a struct groups named fields into a single value that lives contiguously in memory. The compiler knows the exact size and layout of every field, which makes structs fast to allocate and cheap to pass around.
Structs do not have constructors. They do not have inheritance. They are purely data containers that you can extend with methods later. This simplicity is intentional. Go prefers composition over inheritance, and structs are the building blocks for that approach.
Structs are just blueprints. The compiler turns them into fast, predictable memory layouts.
Defining your first struct
You define a struct with the type keyword, followed by the struct name and the struct keyword. Inside the braces, you list each field with its name and type. Field names are capitalized if they should be visible outside the package, and lowercase if they should stay private.
Here is the simplest struct definition and instantiation:
package main
// Task groups related project data into a single value.
type Task struct {
Title string // holds the human readable description
Priority int // 1 is low, 5 is critical
Completed bool // tracks whether work is finished
}
func main() {
// struct literal initializes every field explicitly
t := Task{
Title: "Rewrite auth module",
Priority: 4,
Completed: false,
}
// access fields with dot notation
println(t.Title)
}
The compiler reads the type Task struct block and reserves a contiguous block of memory large enough to hold a string header, an integer, and a boolean. When you write Task{...}, you are creating a struct literal. The literal syntax requires you to name each field. You cannot rely on position alone, which prevents silent bugs when you reorder fields later.
If you try to access a field that does not exist, the compiler rejects the program with t.NonExistent undefined (type Task has no field or method NonExistent). This strict checking catches typos before runtime.
Structs are explicit by design. Name your fields and let the compiler enforce the shape.
Zero values and the compiler's safety net
Go never leaves memory uninitialized. Every type has a zero value. When you create a struct and omit fields, Go fills the gaps with those zero values automatically. Strings become empty strings. Integers become zero. Booleans become false. Pointers become nil. This behavior eliminates entire classes of nil pointer panics that plague other languages.
package main
import "fmt"
// Config holds server settings with sensible defaults.
type Config struct {
Host string // defaults to empty string
Port int // defaults to 0
Verbose bool // defaults to false
}
func main() {
// only override the port, let the rest zero out
c := Config{Port: 8080}
// zero values prevent unexpected nil or garbage data
fmt.Printf("host=%q port=%d verbose=%v\n", c.Host, c.Port, c.Verbose)
}
The output shows host="" port=8080 verbose=false. You did not have to write a constructor or call an initialization function. The language handles the baseline state for you.
This zero-value guarantee changes how you design APIs. You can safely return an empty struct from a function instead of returning a pointer and checking for nil. The caller gets a valid object they can inspect immediately.
Zero values are a feature, not a shortcut. Design your structs so the empty state is safe to use.
Attaching behavior with methods
Data without behavior is just a record. Go lets you attach methods to structs using receivers. A receiver is just a parameter that appears before the function name. By convention, the receiver name is one or two letters matching the type, like t for Task or c for Config. You never use this or self.
package main
import "fmt"
// Task tracks a single unit of work.
type Task struct {
Title string
Priority int
Completed bool
}
// MarkDone flips the completion flag to true.
func (t *Task) MarkDone() {
// pointer receiver allows mutation of the original struct
t.Completed = true
}
func main() {
t := Task{Title: "Deploy v2", Priority: 5}
t.MarkDone()
fmt.Println(t.Completed)
}
The method MarkDone takes a pointer receiver *Task. This means the method can modify the struct in place. If you used a value receiver (t Task), Go would pass a copy, and the mutation would disappear when the function returns. Choose pointer receivers when you need to mutate state or when the struct is large enough that copying it would hurt performance. Choose value receivers when the struct is small and you want to guarantee the original data stays untouched.
Methods on structs follow the same visibility rules as fields. Capitalized method names are exported. Lowercase names stay inside the package. This keeps your public API clean and your internal helpers hidden.
Methods belong to the type, not the instance. Pick the right receiver and keep mutations explicit.
Pass by value and the pointer tradeoff
Go always passes arguments by value. When you pass a struct to a function, the compiler copies every field. This is fast for small structs, but it becomes expensive when the struct holds large slices, maps, or embedded buffers. The copy itself is cheap, but the underlying data structures share references, which can lead to subtle bugs if you mutate them unexpectedly.
package main
import "fmt"
// Payload carries request data across service boundaries.
type Payload struct {
ID string
Tags []string
Details string
}
// LogPayload prints the current state of the payload.
func LogPayload(p Payload) {
// value receiver copies the struct header and slice metadata
fmt.Println("logged:", p.ID)
}
func main() {
p := Payload{ID: "req-123", Tags: []string{"web", "api"}}
LogPayload(p)
}
If you need to modify the struct inside a function, pass a pointer. The compiler will complain with cannot take the address of ... if you try to pass a pointer to a temporary value, but struct literals are addressable, so &Payload{...} works fine.
The community convention is simple: pass small structs by value. Pass large structs or structs that must be mutated by pointer. Never pass a *string or *int unless you specifically need to represent a missing value with nil. Primitive types are cheap to copy.
Copy semantics are predictable. Use pointers only when mutation or size demands it.
Pitfalls and compiler guardrails
Structs are straightforward, but a few patterns trip up newcomers. The first is mixing positional and named initialization. Go requires you to pick one style per literal. If you name one field, you must name all of them. The compiler rejects mixed syntax with cannot mix named and positional initialization.
The second pitfall is assuming struct equality works like deep equality. Go compares structs field by field. If a field contains a slice, map, or function, the struct becomes uncomparable. Trying to use == on such a struct triggers invalid operation: operator == not defined on .... Use reflect.DeepEqual or write a custom comparison method when you need to check complex structs.
The third issue is field ordering. The compiler packs fields in the order you declare them. Placing a small boolean after a large string can waste padding bytes. Group fields by size when memory layout matters, though the standard library's go vet and alignment rules usually handle this automatically. You rarely need to micro-optimize field order unless you are writing high-performance serialization code.
Structs enforce shape at compile time. Trust the errors and keep your layouts consistent.
When to reach for a struct
Use a struct when you need to group related data fields into a single, predictable unit. Use a map when you need dynamic key-value pairs where the shape changes at runtime. Use an interface when you only care about behavior and want to decouple implementation details. Use a slice of primitives when the data is homogeneous and does not need named fields. Use an anonymous struct when you need a temporary grouping for a single function call or JSON unmarshaling without polluting the package namespace.
Structs are the foundation of Go data modeling. Keep them small, name them clearly, and let the compiler handle the rest.