When slices get in the way
You are building a task scheduler. Tasks arrive, get paused, resume, and sometimes drop out entirely. You need to move items around the middle of a collection without shifting thousands of other elements out of the way. Slices copy memory on every insertion or deletion. A doubly linked list moves pointers instead.
Go ships a doubly linked list in the standard library under container/list. It is not the default data structure for most Go programs. Slices win on CPU cache locality and allocation overhead. But when your workload demands frequent middle insertions, constant-time removals, or stable element references across mutations, the linked list becomes the right tool.
How a doubly linked list works
A doubly linked list chains independent memory blocks together. Each block holds your data plus two pointers: one to the previous block, one to the next. Inserting or removing an element only rewires those two pointers. The cost is constant time, regardless of list length. The tradeoff is memory overhead and slower sequential access compared to contiguous slices.
Go's implementation uses a sentinel node pattern. The list always contains at least one dummy node that points to itself when empty. Every real element sits between the sentinel and other elements. This design eliminates null-pointer checks during iteration and deletion. The compiler never panics on an empty list because the sentinel guarantees a valid starting point.
Linked lists excel at structural mutations. They struggle with random access. Finding the tenth element requires walking nine pointers. Slices jump directly to the memory address. Pick the structure that matches your access pattern.
Minimal example
Here is the simplest way to create, populate, and iterate a list.
package main
import (
"container/list"
"fmt"
)
func main() {
// Sentinel node handles empty-state edge cases automatically
l := list.New()
// PushBack appends to the tail and returns a reusable Element pointer
e1 := l.PushBack("first")
l.PushBack("second")
// InsertBefore places a new node right before an existing Element
l.InsertBefore("middle", e1)
// Front returns the head Element, or nil if the list is empty
for e := l.Front(); e != nil; e = e.Next() {
fmt.Println(e.Value)
}
}
The loop walks from head to tail using Next(). Each Element exposes a Value field of type interface{}. The list does not care what you store. It only cares about pointer topology.
Under the hood
list.New() allocates a single Element struct on the heap. That struct contains Value interface{}, next *Element, and prev *Element. When the list is empty, next and prev both point back to the sentinel. Calling PushBack("first") allocates a second Element, wires the sentinel's next to it, wires the new element's prev to the sentinel, and updates the sentinel's prev to point to the new tail. The list remains circular.
Iteration follows the next chain until it hits the sentinel again. Front() returns the element after the sentinel. Back() returns the element before it. Remove(e) detaches e by stitching e.prev.next = e.next and e.next.prev = e.prev. The detached node becomes garbage unless you reuse it.
The interface{} type means every value undergoes type erasure. Storing an int or a custom struct requires a type assertion later. The compiler enforces this at the call site, not at insertion. If you assert the wrong type, the runtime panics with interface conversion: interface {} is string, not int. Keep your stored types consistent or wrap them in a small struct.
Modern Go favors generics, but container/list predates Go 1.18 and remains ungenericized for backward compatibility. The community convention is to accept this limitation for standard library stability. If you need type safety, write a thin wrapper or use a third-party generic list. Stick to the standard library when you want zero dependencies.
Realistic example
Here is a FIFO queue that wraps the list and handles empty-state safely.
package main
import (
"container/list"
"fmt"
)
// Queue wraps a linked list for safe FIFO operations
type Queue struct {
items *list.List
}
// NewQueue initializes the backing list
func NewQueue() *Queue {
return &Queue{items: list.New()}
}
// Enqueue adds an element to the tail
func (q *Queue) Enqueue(val interface{}) {
q.items.PushBack(val)
}
// Dequeue removes and returns the head element
func (q *Queue) Dequeue() (interface{}, bool) {
elem := q.items.Front()
if elem == nil {
return nil, false
}
// Remove detaches the node and returns it for reuse or GC
q.items.Remove(elem)
return elem.Value, true
}
func main() {
q := NewQueue()
q.Enqueue("task-a")
q.Enqueue("task-b")
val, ok := q.Dequeue()
if ok {
fmt.Println("processed:", val)
}
}
The receiver name q follows Go convention: one or two letters matching the type. The Dequeue method returns a boolean to signal empty state instead of panicking. This matches the idiomatic Go pattern of explicit error or state signaling. The list handles the pointer rewiring. Your code handles the business logic.
Pitfalls and runtime behavior
Holding stale *list.Element pointers is the most common bug. If you store an element pointer in a map or struct, then call Remove() on it, the pointer still exists in memory but no longer belongs to the list. Calling Next() or Prev() on a detached element returns garbage or panics. Always verify an element is still linked before traversing, or clear external references after removal.
Memory overhead matters in tight loops. Each Element allocates roughly 48 bytes plus heap metadata. A slice of ten thousand integers occupies a single contiguous block. A linked list of ten thousand integers allocates ten thousand separate blocks. The garbage collector works harder. Profile before optimizing. Most Go programs run faster with slices.
Thread safety is absent. container/list does not lock its pointers. Concurrent reads and writes will corrupt the next and prev chains. Wrap the list in a sync.Mutex or sync.RWMutex if multiple goroutines touch it. Channels often replace mutex-protected lists in concurrent Go code.
The compiler catches type mismatches at assertion time, not insertion time. If you store mixed types and assert blindly, the program crashes at runtime. Use type switches when the list holds heterogeneous data. Keep homogeneous lists simple.
When to reach for a list
Use a slice when you need fast sequential access, cache locality, or bulk operations. Use container/list when you frequently insert or delete elements in the middle of a collection and want O(1) pointer rewiring. Use a channel when multiple goroutines need to exchange data safely without manual locking. Use a custom array or ring buffer when you have a fixed capacity and want zero-allocation performance.
Linked lists are structural tools, not performance silver bullets. Measure your access patterns. Trust the standard library. Argue logic, not formatting.