How to Create a Linked List in Go

Go does not provide a built-in linked list type, so you must define your own struct to represent nodes and manually manage the pointers connecting them.

The chain reaction

You are building a task scheduler. Tasks arrive, get processed, and sometimes a high-priority task needs to jump ahead of the current queue. Arrays force you to shift every subsequent element to make room. Slices grow by copying the whole backing array. You need a structure where inserting or removing an element in the middle costs a constant amount of work, regardless of how big the list gets. That is the job for a linked list.

Go does not include a linked list in its core language syntax. The language philosophy favors simple primitives that you compose into what you need. You get pointers, structs, and interfaces. You build the list from those pieces. This gives you full control over memory layout and behavior, but it also means you have to manage the connections yourself.

How linked lists work

A linked list is a chain of nodes. Each node holds a value and a pointer to the next node. The last node points to nothing, represented by nil in Go. The list itself usually tracks the first node, called the head. To find a value, you start at the head and follow the pointers until you reach the target or hit nil.

Think of a treasure hunt. The first clue tells you where to find the second clue. The second tells you where to find the third. You do not have all the clues in one box. You have to follow the trail. The "next" pointer is the direction to the next clue. If a clue is missing, the trail ends.

In Go, pointers are memory addresses. They are not magic. A pointer is just a number that tells the CPU where to look for data. When you define a struct with a pointer field, you are reserving space for that address. The garbage collector tracks pointers automatically, so you do not need to manually free memory. When the list goes out of scope and no pointers reference the nodes, the memory is reclaimed.

Pointers are references, not copies. Update the pointer, not the value.

Minimal implementation

Here is the skeleton of a singly linked list. You define a node, a list wrapper, and a method to add items. The receiver name is one letter matching the type, following Go convention.

package main

// Node holds a value and a pointer to the next node.
type Node struct {
	Value int
	Next  *Node
}

// LinkedList tracks the start of the chain.
type LinkedList struct {
	Head *Node
}

// Append adds a value to the end of the list.
func (l *LinkedList) Append(value int) {
	// Allocate a new node on the heap.
	newNode := &Node{Value: value}

	// If the list is empty, the new node becomes the head.
	if l.Head == nil {
		l.Head = newNode
		return
	}

	// Walk to the last node by chasing Next pointers.
	current := l.Head
	for current.Next != nil {
		current = current.Next
	}

	// Link the last node to the new one.
	current.Next = newNode
}

The Append method handles two cases. If the list is empty, it sets the head. If the list has nodes, it walks to the end and stitches the new node onto the tail. The loop condition current.Next != nil ensures you stop at the last node. If you forget to check for nil and try to access current.Next when current is nil, the runtime panics with runtime error: invalid memory address or nil pointer dereference.

The list grows by stitching nodes, not copying arrays.

Memory and performance reality

Linked lists have a reputation for being efficient at insertion. That is true for pointer manipulation. You update two pointers to insert a node. Slices require shifting elements, which can be expensive for large lists.

There is a catch. Modern CPUs care about cache locality. Slices store data in contiguous memory. When you access one element, the CPU loads a chunk of memory into the cache, likely fetching the next elements too. Linked lists scatter nodes across the heap. Each node might be in a different memory page. Following a pointer often causes a cache miss, forcing the CPU to wait for memory.

For small lists, the difference is negligible. For large lists with sequential access, a slice is often faster than a linked list, even with the shifting cost. The cache wins.

If you store large structs in a linked list, you usually store pointers to those structs. Storing the struct value copies the data. Storing a pointer keeps the data in one place. However, do not store pointers to strings. Strings are already cheap to pass by value in Go. A string value is just a pointer to a byte array plus a length. Passing a *string adds an extra level of indirection without saving memory.

gofmt aligns struct fields automatically. Trust the tool. Do not argue about indentation or alignment. Most editors run gofmt on save.

Generics and modern Go

Go 1.18 introduced generics. If you are writing a reusable list, you should use type parameters. This avoids boxing values into interface{} and keeps type safety.

// Node is a generic node for a linked list.
type Node[T any] struct {
	Value T
	Next  *Node[T]
}

// LinkedList is a generic singly linked list.
type LinkedList[T any] struct {
	Head *Node[T]
}

// Append adds a value to the end of the list.
func (l *LinkedList[T]) Append(value T) {
	newNode := &Node[T]{Value: value}
	if l.Head == nil {
		l.Head = newNode
		return
	}
	current := l.Head
	for current.Next != nil {
		current = current.Next
	}
	current.Next = newNode
}

The syntax [T any] declares a type parameter. any is an alias for interface{}, meaning the list can hold any type. The compiler generates specialized versions of the code for each type you use. This keeps performance high and type checks strict.

If you try to assign a Node to a *Node field, the compiler rejects this with cannot use node (variable of struct type Node) as *Node value in assignment. You must use the address operator & to get a pointer.

Generics make the list reusable without losing type safety.

Production code with container/list

In production, you rarely write linked lists from scratch. The standard library provides container/list, which implements a doubly linked list. It supports insertion and deletion at both ends and in the middle. It handles edge cases like empty lists and single-node lists. It is optimized and battle-tested.

Here is how you use it in a realistic scenario. You manage a cache where you need to move accessed items to the front.

package main

import (
	"container/list"
	"fmt"
)

func main() {
	// Create a doubly linked list.
	l := list.New()

	// PushBack adds an element to the back.
	l.PushBack(10)
	l.PushBack(20)
	l.PushBack(30)

	// Iterate from front to back.
	for e := l.Front(); e != nil; e = e.Next() {
		fmt.Println(e.Value)
	}

	// Move the last element to the front.
	last := l.Back()
	l.MoveToFront(last)

	fmt.Println("After move:")
	for e := l.Front(); e != nil; e = e.Next() {
		fmt.Println(e.Value)
	}
}
# output:
10
20
30
After move:
30
10
20

container/list stores values as interface{}. This means you can store any type, but you lose compile-time type checking. You often wrap the list in a struct that adds type safety, or you store pointers to your structs. The Element struct returned by iteration holds the value and pointers to the previous and next elements.

container/list is a doubly linked list. Each node has a Next and a Prev pointer. This allows efficient removal of any node if you have a reference to it. You do not need to traverse from the head.

Standard library handles the edge cases. You handle the logic.

Pitfalls and errors

Linked lists introduce pointer complexity. Common bugs include off-by-one errors, infinite loops, and nil dereferences.

If you create a cycle by pointing a node back to a previous node, your traversal loop will run forever. Always ensure the last node points to nil. If you are building a circular list, document it clearly and use a sentinel or a counter to break loops.

If you remove a node but forget to update the previous node's Next pointer, you break the chain. The list becomes truncated. In a doubly linked list, you must update both Next and Prev pointers.

Memory leaks are rare in Go because the garbage collector reclaims unreachable nodes. However, logical leaks can happen. If you hold a reference to a node that is no longer in the list, the memory stays allocated. Clear your references when you remove nodes.

If you try to use a method on a nil receiver, the program panics. For example, calling Print on a nil *LinkedList causes a panic. Check for nil receivers or document that the method requires a non-nil receiver.

A nil pointer panic is a logic error. Check your bounds.

Decision matrix

Use a slice when you need random access, iteration speed, or simple append operations. Slices are the default choice in Go for 95% of list-like needs. They benefit from cache locality and have highly optimized runtime support.

Use container/list when you need frequent insertions or deletions in the middle of a sequence and you do not want to manage pointers manually. It handles the bookkeeping for a doubly linked list and provides a clean API for moving elements.

Use a custom linked list implementation when you need a specific node structure with metadata, or when you are building a learning exercise to understand pointers and memory layout. Custom lists allow you to embed extra data in nodes, such as timestamps or priority levels, without wrapping values.

Use a ring buffer when your data wraps around and you need constant-time rotation. You will likely need to implement this yourself or find a third-party package, as the standard library does not provide one.

Slices are the default. Lists are the exception. Choose based on access patterns.

Where to go next