Introduction
Collections are the backbone of any real application—storing user IDs, pricing tiers, request batches, or cache entries. In Go, you have two fundamental types that handle nearly every collection scenario: slices and maps. Understanding how they work under the hood isn't academic; it directly affects memory efficiency, API latency, and whether your service scales smoothly or hits the wall at 10,000 concurrent requests.
This guide covers slices and maps in Go: a practical guide for developers. We'll walk through how these types behave, common pitfalls we've hit in production, and concrete patterns that keep your code fast and maintainable.
Slices — Dynamic views over arrays with length and capacity
A slice is a dynamic, resizable view into an underlying array. Unlike arrays (which have a fixed length), slices let you grow collections as needed. Internally, a slice holds three pieces of information: a pointer to the underlying array, a length (number of elements you're using), and a capacity (how many elements the underlying array can hold).
This distinction between length and capacity is crucial. When you create a slice with make([]string, 3), both length and capacity are 3—all elements are zero-initialized and immediately usable. But if you use make([]string, 0, 10), the length is 0 (nothing there yet) while the capacity is 10 (room to grow without reallocating).
package main
import "fmt"
func main() {
// Length 3, capacity 3. All slots initialized.
users := make([]string, 3)
users[0] = "alice"
users[1] = "bob"
users[2] = "charlie"
fmt.Printf("Length: %d, Capacity: %d\n", len(users), cap(users))
// Output: Length: 3, Capacity: 3
// Length 0, capacity 10. Empty, but room to grow.
queue := make([]int, 0, 10)
fmt.Printf("Length: %d, Capacity: %d\n", len(queue), cap(queue))
// Output: Length: 0, Capacity: 10
// Slice of slice (view into users, indices 1 to 2)
subset := users[1:3]
fmt.Println(subset) // [bob charlie]
fmt.Printf("Subset length: %d, capacity: %d\n", len(subset), cap(subset))
// Output: Subset length: 2, capacity: 2
}
In a real API handler, you might fetch a batch of orders from the database and store them in a slice. The slice's dynamic nature means you don't need to know the batch size upfront—it grows as you append. But that growth has a cost, which we'll cover next.
append — May allocate a new array — always use the returned slice
The append built-in function is deceptively simple: it adds one or more elements to a slice and returns the updated slice. The critical detail is that append may allocate a new underlying array if the current capacity is exhausted.
When capacity runs out, Go doesn't just add one more slot. Instead, it grows the underlying array—typically doubling the capacity (or multiplying by 1.25 for very large slices). This means that after a few appends, you might have allocated three or four different arrays in memory. The old ones become garbage once no slice references them anymore.
This is why you must always use the returned slice. If you ignore the return value, you might be operating on a stale slice that points to an old, exhausted array while new elements silently fail to be added.
package main
import "fmt"
func main() {
items := make([]int, 0, 2) // Capacity 2
// First two appends fit in existing capacity.
items = append(items, 10)
items = append(items, 20)
fmt.Printf("After 2 appends: len=%d, cap=%d\n", len(items), cap(items))
// Output: After 2 appends: len=2, cap=2
// Third append exceeds capacity. Go reallocates.
items = append(items, 30)
fmt.Printf("After 3 appends: len=%d, cap=%d\n", len(items), cap(items))
// Output: After 3 appends: len=3, cap=4
// Capacity doubled from 2 to 4.
// Appending multiple at once.
items = append(items, 40, 50)
fmt.Printf("After 5 items: len=%d, cap=%d\n", len(items), cap(items))
// Output: After 5 items: len=5, cap=8
}
A common mistake is forgetting the assignment:
items := make([]int, 0, 1)
append(items, 100) // ❌ Return value ignored!
// items is still empty.
items = append(items, 100) // ✓ Correct
In a typical e-commerce order service, you might accumulate order items in a slice within a loop. Each iteration might trigger an allocation. If you're processing 10,000 orders and repeatedly re-allocating the slice, you're hammering the garbage collector. That's where preallocation comes in.
Maps — Key-value lookups; check presence with the comma-ok idiom
A map is Go's built-in hash table. It stores key-value pairs and gives you constant-time (on average) lookups. You define a map with the syntax map[KeyType]ValueType, and you access values by key.
A critical aspect of Go maps is the comma-ok idiom for checking whether a key exists. Unlike some languages, reading a missing key in Go returns the zero value for that type (0 for int, empty string for string, nil for pointers, etc.) with no error. This means you can't tell the difference between "key doesn't exist" and "key exists with value 0".
The comma-ok idiom solves this by returning two values: the value and a boolean indicating whether the key was found.
package main
import "fmt"
func main() {
prices := map[string]float64{
"mug": 5.99,
"shirt": 19.99,
"hat": 12.50,
}
// Safe lookup with comma-ok.
price, ok := prices["mug"]
if ok {
fmt.Printf("Mug costs $%.2f\n", price)
} else {
fmt.Println("Mug not found")
}
// Output: Mug costs $5.99
// Checking for a key that doesn't exist.
price, ok = prices["socks"]
if !ok {
fmt.Println("Socks not found in inventory")
}
// Output: Socks not found in inventory
// Unsafe access (don't do this).
price = prices["socks"] // Returns 0.0, no error.
fmt.Println(price) // 0
// You can't tell if socks cost nothing or don't exist.
}
In a real system—say, a ride-hailing service—you might use a map to cache driver locations by driver ID. When a new ride request arrives, you need to check if a driver is already cached before querying the database. The comma-ok idiom is essential here:
driverLocations := make(map[string]Location)
// After some initialization...
driverID := "d123"
if loc, found := driverLocations[driverID]; found {
// Use cached location.
fmt.Printf("Driver at: %+v\n", loc)
} else {
// Query database.
loc, err := db.GetDriverLocation(driverID)
if err != nil {
// Handle error.
}
driverLocations[driverID] = loc
}
Maps are also useful for counting occurrences. You increment the value for each key you encounter, defaulting to 0 if not present (since int zero-initializes):
wordCounts := make(map[string]int)
words := []string{"go", "is", "go", "great"}
for _, word := range words {
wordCounts[word]++
}
fmt.Println(wordCounts) // map[go:2 is:1 great:1]
Preallocate — make([]T, 0, n) avoids repeated growth
Preallocation is a straightforward optimization: if you know roughly how many elements a slice will eventually hold, allocate that capacity upfront. This prevents Go from reallocating the underlying array multiple times.
Consider processing a batch of customer records. If you know there are 5,000 records, allocate that capacity at the start:
package main
import "fmt"
func processOrders(count int) []Order {
// Without preallocation: Go might allocate and copy the array
// 10+ times as it grows.
// orders := make([]Order, 0)
// With preallocation: single allocation, no reallocation.
orders := make([]Order, 0, count)
for i := 0; i < count; i++ {
// Simulate fetching and parsing an order.
order := Order{ID: i, Total: 99.99}
orders = append(orders, order)
}
return orders
}
type Order struct {
ID int
Total float64
}
func main() {
// Preallocation is especially valuable with large batches.
orders := processOrders(5000)
fmt.Printf("Processed %d orders\n", len(orders))
}
The performance difference is stark. Without preallocation, appending 5,000 items might trigger 10–15 reallocations. Each reallocation copies the entire existing array to a new location, then adds the new elements. With preallocation, it's a single allocation and 5,000 sequential writes—much faster and easier on the garbage collector.
The same principle applies to maps, though it's less critical since maps don't have the reallocation overhead that slices do. Still, you can hint at the initial size:
// Preallocate if you expect roughly 10,000 cache entries.
cache := make(map[string]string, 10000)
In a CRM system ingesting thousands of customer records, preallocation can be the difference between a 50ms batch import and a 500ms one. In a real-time analytics pipeline, it can mean the difference between handling a traffic spike smoothly and degrading under load.
Common mistakes
- Ignoring the returned slice from append. Always assign the result back:
items = append(items, x). Ignoring it silently loses data. - Using a map value directly without checking existence. If a key is missing, you get the zero value with no warning. Always use the comma-ok idiom when correctness depends on key presence.
- Mutating a slice returned from a function without realizing it shares the underlying array. If you modify a slice, you may inadvertently modify other slices pointing to the same array. Be explicit about whether you're sharing or copying.
- Preallocating too small and forcing reallocations anyway. If you estimate 1,000 items but actually get 10,000, preallocation helps less. Estimate conservatively or grow incrementally.
- Using maps where you need ordering. Maps have no guaranteed iteration order. If you need a specific order, use a slice of structs or a slice of keys plus a map for lookup.
- Forgetting that map keys must be comparable. Slices, maps, and functions cannot be map keys because they're not comparable. Structs can be keys only if all their fields are comparable.
Key takeaways
- Slices are dynamic views over arrays; they have both length (elements in use) and capacity (room to grow) for efficient memory management.
- Always assign the return value of append:
items = append(items, item). Ignoring it can lose data silently. - Use the comma-ok idiom to safely check map keys:
value, ok := m[key]. Never rely on zero values to mean "key doesn't exist." - Preallocate slices when you know the final size:
make([]T, 0, n). This eliminates reallocation overhead and keeps the garbage collector happy. - In high-throughput systems (APIs, data pipelines), preallocation and correct slice handling directly improve latency and reduce memory churn.






Comments
Loading comments…