When backtracking saves the day
You are building a maze solver. Your character moves forward, hits a dead end, and needs to backtrack to the last junction. You do not just teleport back. You retrace steps in reverse order. The last place you went is the first place you need to return to. That last-in, first-out rhythm is the heartbeat of a stack.
Stacks appear everywhere in programming. Parsers use them to track nested parentheses. Recursion uses the call stack to remember where to return. Undo features in editors push actions so you can pop them back. Go does not provide a dedicated stack type in the standard library. You build one using slices, which are already optimized for this pattern.
Slices are stacks in disguise
A stack is a collection where you add items to the top and remove items from the top. The last item you push is the first one you pop. You can also peek at the top item without removing it.
In Go, a slice is a reference to a contiguous array. It tracks a pointer to the data, a length, and a capacity. Adding an element to the end of a slice is fast. Removing an element from the end is fast. The slice header manages the length for you. When you increase the length, you push. When you decrease the length, you pop.
Slices grow dynamically. When the backing array fills up, Go allocates a larger array and copies the data. This growth is amortized constant time. You get the simplicity of a dynamic list with the performance of a fixed buffer.
Here is the core stack implementation using a slice.
package main
// Stack uses a slice for dynamic LIFO storage.
type Stack struct {
items []int
}
// Push adds an item. append handles capacity growth.
func (s *Stack) Push(item int) {
s.items = append(s.items, item)
}
// Pop removes the top item. Returns false if empty.
func (s *Stack) Pop() (int, bool) {
if len(s.items) == 0 {
return 0, false
}
// Save value before shrinking.
index := len(s.items) - 1
item := s.items[index]
// Slice truncation drops the last element.
s.items = s.items[:index]
return item, true
}
How push and pop work under the hood
When you call Push, the append function checks the slice capacity. If the length is less than the capacity, append writes the new value at the next index and increments the length. This is a single memory write.
If the length equals the capacity, Go allocates a new, larger backing array. For small slices, the new capacity is usually double the old capacity. For larger slices, the growth factor is closer to 1.25. Go copies the existing elements to the new array and updates the slice header. This resizing happens infrequently enough that the average cost of a push remains constant.
Pop reads the value at len(s.items) - 1. It then truncates the slice by setting the length to index. The backing array stays allocated. The next Push reuses the space. This reuse avoids allocation overhead. The slice header is a small struct with a pointer, length, and capacity. You manipulate the length to manage the stack. The capacity is just the buffer size.
Slices are stacks in disguise. Trust the slice header.
Real code uses generics
Real applications often need stacks of different types. A parser might need a stack of tokens. An undo system might need a stack of commands. Go 1.18+ supports generics, so you can write one stack that handles any type.
Generics give you compile-time type safety. You do not need interface{} and type assertions. The compiler generates specialized code for each type you use. This keeps performance high and eliminates runtime type errors.
Here is a generic stack implementation.
package main
// GenericStack works with any type.
// Type parameters let you reuse logic without type assertions.
type GenericStack[T any] struct {
items []T
}
// Push adds an element of type T.
func (s *GenericStack[T]) Push(item T) {
s.items = append(s.items, item)
}
// Pop returns the top element.
// The second return value signals whether the stack was empty.
func (s *GenericStack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
var zero T
return zero, false
}
// Retrieve and remove the last item.
index := len(s.items) - 1
item := s.items[index]
s.items = s.items[:index]
return item, true
}
The var zero T line creates the zero value for the type. If T is int, it returns 0. If T is string, it returns "". If T is a pointer, it returns nil. This works for any type without hardcoding values.
Generics replaced the old pattern of using interface{} stacks. The old pattern required type switches and lost type information. Generics keep the types explicit.
Pitfalls and runtime panics
Popping from an empty stack causes a panic if you do not check the length. The compiler does not stop you. The runtime crashes with runtime error: index out of range. Always check len or return a boolean to signal absence.
// This panics if the stack is empty.
item := s.items[len(s.items)-1]
The compiler allows s.items[len(s.items)-1] because len is a function call, not a constant. It cannot prove the index is valid. You must guard the access yourself.
Memory retention is a subtle issue. Popping does not free memory. The slice keeps its capacity. If you push one million items and then pop them all, the slice still holds a one-million-item array. If the stack struct lives on, that memory stays allocated. The garbage collector cannot reclaim it because the slice header still points to the array.
To release memory, assign the slice to nil.
// Release memory by dropping the backing array.
s.items = nil
This sets the pointer to nil, length to zero, and capacity to zero. The garbage collector can now free the array. Use this pattern in long-running services where stacks grow large and then shrink.
Receiver naming follows Go conventions. Use a short name matching the type. (s *Stack) is idiomatic. (this *Stack) looks like Java. (self *Stack) looks like Python. Go prefers brevity. The receiver name is usually one or two letters.
Guard the length. Panics are for bugs, not control flow.
When to use a stack
Use a slice-based stack when you need dynamic size and simple LIFO access. Use a fixed-size array when the maximum depth is known and you want to avoid heap allocations. Use a generic stack when your code handles multiple types and you want compile-time type safety. Use a linked list when you need to merge stacks or require O(1) removal from the middle, though this is rare for pure stacks. Use a slice of pointers when the elements are large structs and you want to avoid copying them on push and pop.
Slice stacks win on simplicity and speed. Reach for complexity only when you measure a bottleneck.