The problem with copying data
You are building a CLI tool that fetches a list of servers from an API. Each server has a hostname, an IP address, and a status. You need to collect them, filter out the offline ones, and pass the remaining list to a deployment function. In Python, you reach for a list of dictionaries. In JavaScript, you grab an array of objects. In Go, you reach for a slice of structs. The syntax looks straightforward until you hit the first memory allocation question or notice that changes inside a loop are not sticking.
What a slice actually is
A slice in Go is not a standalone container. It is a lightweight descriptor that points to a contiguous block of memory on the heap. Think of it like a movie ticket stub. The stub itself is small and cheap to pass around. It tells you which theater to go to, which row to sit in, and how many seats are reserved. The actual seats are elsewhere. When you create a slice of structs, you are asking the runtime to allocate a row of seats and hand you a stub that tracks how many seats are filled versus how many are available.
Under the hood, every slice carries three pieces of information: a pointer to the underlying array, the current length, and the total capacity. The length counts how many elements you have actually placed. The capacity counts how many slots the runtime reserved in memory. This distinction controls when Go copies data and when it reallocates. The slice header lives on the stack or inside another struct, but the heavy lifting happens in the heap array it references.
Building it dynamically
Most programs do not know how many items they will collect until runtime. You start with an empty slice and grow it as data arrives. The standard pattern uses make to reserve space and append to add elements.
Here is the simplest dynamic pattern: spawn a slice with a starting capacity, then feed it values one by one.
type Server struct {
Host string
Port int
}
// Reserve space for 2 structs to avoid immediate reallocation
servers := make([]Server, 0, 2)
// Add the first server. append returns the updated slice header.
servers = append(servers, Server{Host: "web-1", Port: 80})
// Add the second server. Capacity is still 2, so no copy happens.
servers = append(servers, Server{Host: "web-2", Port: 443})
// Read the first element directly by index
fmt.Println(servers[0].Host)
When the program runs, make allocates a contiguous block of memory large enough to hold two Server structs. The slice header points to that block, sets the length to zero, and sets the capacity to two. The first append writes the web-1 struct into the first slot and bumps the length to one. The second append writes web-2 into the next slot and bumps the length to two. No memory copying occurs because the pre-allocated capacity covers both writes.
If you append a third element, the runtime detects that the capacity is full. It allocates a new, larger block of memory, copies the existing two structs over, writes the new struct, and updates the slice header to point at the new block. This reallocation is fast for small slices but adds up in tight loops. Setting an initial capacity when you have a rough estimate saves the runtime from guessing.
Pre-filling when you know the data
Sometimes you already have the complete dataset at compile time. Hardcoding the values into a composite literal skips the allocation-and-append cycle entirely. The compiler lays out the exact memory layout and hands you a ready-to-use slice.
Here is how you define a fixed set of items in one declaration:
type Product struct {
ID int
Name string
}
// Compiler allocates exact space and fills it immediately
products := []Product{
{ID: 1, Name: "Laptop"},
{ID: 2, Name: "Mouse"},
}
// Iterate over the slice. _ discards the index since we only need the value
for _, p := range products {
fmt.Printf("ID: %d, Name: %s\n", p.ID, p.Name)
}
The composite literal []Product{...} tells the compiler to create a slice with length two and capacity two. It places the structs directly into the backing array during initialization. This approach is cleaner for configuration data, lookup tables, or test fixtures. It also avoids the subtle bug where forgetting to assign the result of append leaves the original slice unchanged.
Pointers versus values
The biggest decision after choosing how to build the slice is whether to store structs directly or store pointers to structs. Go copies values by default. When you append a struct to a slice, the runtime copies every field into the backing array. When you pass that slice to another function, the slice header is copied, but the underlying array is shared. Reading is cheap. Modifying requires care.
If you store values, changes made inside a loop or a helper function only affect the local copy. The original data in the slice remains untouched. This is usually what you want. It prevents accidental mutations and keeps data flow predictable.
If you store pointers, you are copying memory addresses instead of the actual data. The slice holds *Server instead of Server. This saves memory when structs are large, and it allows multiple parts of your program to mutate the same underlying object. It also introduces pointer indirection, which slows down iteration slightly and risks nil panics if you forget to initialize an element.
The compiler enforces type safety here. If you declare a slice of values but try to append a pointer, you get cannot use &s (value of type *Server) as type Server in argument to append. If you declare a slice of pointers but append a value, you get cannot use s (variable of struct type Server) as type *Server in argument to append. The error message is direct. Fix the type mismatch by either removing the address-of operator or adding it back.
Convention in the Go community leans toward storing values unless you have a specific reason to share or mutate the struct after it enters the slice. Strings and slices are already cheap to pass by value. A struct containing a few strings and integers is also cheap. Reach for pointers only when the struct exceeds a few hundred bytes, when you need to swap elements without copying, or when multiple functions must share the same object reference. Even then, wrap shared pointers in proper synchronization.
Reading and filtering in practice
Real code rarely stops at creation. You usually need to inspect, filter, or transform the slice before passing it downstream. Go favors explicit loops over hidden magic. A straightforward for loop with a conditional is easier to read and faster than chaining higher-order functions.
Here is a realistic workflow: fetch a slice, filter out inactive items, and pass the result to a handler.
type Task struct {
Title string
Done bool
}
// Simulate incoming data from a database or API
allTasks := []Task{
{Title: "Deploy v2", Done: false},
{Title: "Write docs", Done: true},
{Title: "Fix login", Done: false},
}
// Pre-allocate capacity to avoid reallocation during filtering
pending := make([]Task, 0, len(allTasks))
// Copy only the tasks that are not finished
for _, t := range allTasks {
if !t.Done {
pending = append(pending, t)
}
}
// Pass the filtered slice to a downstream function
processTasks(pending)
The loop iterates over the original slice by value. Each t is a copy of the struct in the backing array. The if condition checks the Done field. When the condition passes, append writes the copy into the pending slice. Pre-allocating pending with len(allTasks) as the capacity guarantees that the filtering step never triggers a reallocation, even if every item passes the filter. This pattern is the backbone of data processing in Go. It is verbose by design, but the verbosity makes the control flow impossible to miss.
Pitfalls and compiler guardrails
New Go developers often run into three predictable traps when working with slices of structs.
The first trap is ignoring zero values. Go initializes every field to its type's zero value if you omit it. An omitted int becomes 0. An omitted string becomes "". An omitted bool becomes false. This is convenient for simple cases but dangerous for configuration structs where a missing field should fail loudly. Explicit initialization removes ambiguity.
The second trap is modifying loop variables. If you range over a slice and take the address of the loop variable, every iteration reuses the same memory location. You end up with a slice of pointers that all point to the last element. The compiler now catches this in modern Go versions with loop variable p captured by func literal or similar warnings, but the pattern still appears in older codebases. Always range over the index and take the address of the slice element directly: &slice[i].
The third trap is capacity surprises. When you slice a slice using s[1:3], the new slice shares the same backing array. Appending to the smaller slice can overwrite data in the original slice if the capacity allows it. This is not a bug. It is a feature of how slices share memory. If you need an independent copy, use append([]Server(nil), s...) to force a new allocation.
Go's design philosophy favors explicit behavior over hidden magic. The language does not hide allocation costs. It does not silently convert types. It does not guess your intent. You write what you mean, and the compiler verifies it. Trust the type system. Wrap the value or change the design.
When to pick which approach
Use a composite literal when you know every element at compile time and want zero runtime allocation overhead. Use make with a pre-set capacity when you expect to append a predictable number of items and want to avoid reallocation cycles. Use append without a pre-set capacity when the data arrives unpredictably and you accept the runtime's growth strategy. Use a slice of pointers when structs are large, when you need to mutate elements in place, or when multiple functions must share the same object reference. Use a slice of values when structs are small, when you want to prevent accidental mutations, and when you prefer straightforward iteration without pointer indirection. Use a plain array when the size is fixed and you need stack allocation for performance-critical code.