How Double Pointers Work in Go (**T)

Go uses **T syntax for double pointers to allow functions to modify the address stored in a pointer variable.

The pointer variable problem

You have a variable p of type *int. You pass it to a function. The function changes the integer that p points to. You check p back in main. The value changed. Everything works.

Now you want the function to change p itself. You want the function to make p point to a completely different address. You pass p to the function. The function updates its local copy. p in main is unchanged. You're stuck.

This happens when you're building a linked list and need to update the head node, or when you're wrapping a C library that expects an output parameter, or when you're writing a function that initializes a resource and signals success by updating a pointer. The compiler won't stop you from passing a pointer, but the logic fails because Go passes arguments by value. Even pointers are values.

Pointers are values, too

Go is strictly pass-by-value. There is no pass-by-reference. When you call a function, Go copies every argument. If the argument is an integer, Go copies the integer. If the argument is a pointer, Go copies the pointer.

A pointer is just a number that holds a memory address. Copying a pointer gives the function a new variable that holds the same address. The function can follow that address to read or write the data. But the function cannot change the address stored in the caller's variable. The function only has its own copy of the address.

To change the caller's pointer variable, the function needs the address of that variable. The variable holding the pointer lives somewhere in memory. If you take the address of that variable, you get a pointer to a pointer. The type is **T.

Think of a pointer as a slip of paper with a house address. You give a copy of the slip to a friend. The friend can go to the house and repaint the door. But if the friend writes a new address on their slip, your slip still has the old address. To let the friend change your slip, you need to give them the location of your slip. In Go, you can't hand over the slip directly because arguments are copies. So you give the friend a slip with the address of where your slip is kept. That's a double pointer.

Minimal example

Here is the smallest program that uses a double pointer to update a pointer variable.

package main

import "fmt"

// SetInt allocates a new int and assigns it to the pointer pointed to by ptr.
func SetInt(ptr **int) {
	// Allocate a new int on the heap.
	val := new(int)
	*val = 42

	// Dereference ptr to access the original pointer variable.
	// ptr holds the address of the caller's variable.
	*ptr = val
}

func main() {
	// p is a pointer variable. It starts as nil.
	var p *int

	// Pass the address of p.
	// &p has type **int.
	SetInt(&p)

	// p now points to the new int.
	fmt.Println(*p) // Output: 42
}

The function SetInt takes **int. The caller passes &p. Inside the function, ptr holds the address of p. Writing *ptr accesses p itself. The assignment *ptr = val updates p to point to the new integer.

Pointers are values. Treat them like integers until you need the address.

Walking through the memory

Understanding double pointers requires tracking two levels of indirection. Here is what happens step by step.

  1. var p *int creates a variable p. It holds a pointer value. Initially, it is nil.
  2. &p computes the address of p. This address points to the storage location where p lives.
  3. SetInt(&p) copies that address into the parameter ptr. ptr is of type **int.
  4. new(int) allocates memory for an integer and returns a pointer to it. Let's call this address A.
  5. *ptr = val writes A into the location pointed to by ptr. Since ptr points to p, this writes A into p.
  6. Back in main, p now holds A. Dereferencing p with *p reads the integer at address A.

The key insight is that *ptr and p refer to the same storage. Modifying *ptr modifies p. This is the same mechanism that lets any function modify a variable: you pass the address of the variable. The only difference is that the variable happens to be a pointer.

Realistic example: updating a linked list head

Double pointers appear in real Go code when you need to modify a pointer that represents the root of a data structure. A classic case is a singly linked list where you insert a node at the head.

If the list is empty, the head pointer is nil. Inserting a node requires updating the head pointer to point to the new node. If the list is not empty, the head pointer stays the same, but the new node's Next field points to the old head.

package main

import "fmt"

// Node represents an element in a singly linked list.
type Node struct {
	Value int
	Next  *Node
}

// InsertHead adds a new node to the front of the list.
// It takes **Node because the head pointer itself might change.
func InsertHead(head **Node, value int) {
	// Create the new node.
	newNode := &Node{Value: value}

	// Link the new node to the current head.
	// *head is the current head pointer (could be nil).
	newNode.Next = *head

	// Update the head pointer to point to the new node.
	// This modifies the caller's head variable.
	*head = newNode
}

func main() {
	// Start with an empty list.
	var head *Node

	// Insert nodes. The head pointer updates each time.
	InsertHead(&head, 1)
	InsertHead(&head, 2)
	InsertHead(&head, 3)

	// Print the list.
	for n := head; n != nil; n = n.Next {
		fmt.Println(n.Value)
	}
	// Output:
	// 3
	// 2
	// 1
}

If InsertHead took *Node instead of **Node, the function could update Next fields, but it could not update head when the list is empty. The caller's head would remain nil. The double pointer lets the function mutate the pointer variable that tracks the list root.

Linked lists are rare in Go because slices handle most dynamic collection needs. When you do use them, double pointers solve the head-update problem cleanly.

Pitfalls and errors

Double pointers add indirection, which introduces new ways to crash or confuse the compiler.

Dereferencing a nil double pointer

If you pass a nil double pointer to a function, dereferencing it panics.

func Bad(ptr **int) {
	// This panics if ptr is nil.
	*ptr = new(int)
}

The runtime panics with invalid memory address or nil pointer dereference if you try to read or write through a nil pointer. Always check that the double pointer itself is not nil before dereferencing, or ensure the caller always passes a valid address.

Dereferencing a nil inner pointer

If the double pointer is valid but the inner pointer is nil, dereferencing twice crashes.

func ReadDouble(ptr **int) {
	// *ptr is valid, but *ptr might be nil.
	// **ptr panics if *ptr is nil.
	fmt.Println(**ptr)
}

The runtime panics with invalid memory address or nil pointer dereference. Check *ptr != nil before accessing **ptr.

Compiler rejects non-addressable values

You cannot take the address of a temporary value or a function result.

func GetInt() *int {
	return new(int)
}

func main() {
	// This fails to compile.
	// GetInt() returns a value, not a variable.
	// &GetInt() is invalid.
	var ptr **int
	ptr = &GetInt()
}

The compiler rejects this with cannot take the address of .... You must store the result in a variable first, then take its address.

p := GetInt()
ptr = &p

Overusing double pointers

Go has multiple return values. The idiomatic way to return a pointer is to return it, not to pass a double pointer as an output parameter.

// Idiomatic Go.
func CreateInt() *int {
	return new(int)
}

// C-style pattern, rarely needed in Go.
func CreateIntOld(ptr **int) {
	*ptr = new(int)
}

Use double pointers only when you genuinely need to modify a pointer variable from within a function. For most cases, returning the pointer is clearer and safer.

Return pointers. Save double pointers for the edge cases.

Conventions and details

Receiver naming

If you wrap a double-pointer operation in a method, follow Go's receiver naming convention. The receiver name is usually one or two letters matching the type.

// l is the receiver name for *List.
func (l *List) InsertHead(value int) {
	// l.Head is a *Node.
	// We need &l.Head to get **Node.
	InsertHead(&l.Head, value)
}

Do not use this or self. Use l, n, or lst.

Context as the first parameter

If a function taking a double pointer performs I/O or long-running work, it should accept a context.Context as the first parameter.

// InitConnection initializes the connection and updates ptr.
// ctx is the first parameter by convention.
func InitConnection(ctx context.Context, ptr **Connection) error {
	// ...
}

context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines.

Don't pass *string

Strings are cheap to pass by value. They are immutable and carry their own length. Never pass *string unless you need to represent a nilable string. Double pointers to strings (**string) are almost never needed.

Don't pass a *string. Strings are already cheap to pass by value.

gofmt handles formatting

Double pointers can look messy with asterisks. gofmt standardizes the spacing. Trust the tool.

gofmt is mandatory. Don't argue about indentation; let the tool decide. Most editors run it on save.

Decision matrix

Double pointers are a tool for a specific job. Choose the right mechanism based on what you need to modify.

Use a double pointer when you need to modify a pointer variable from within a function, such as updating the head of a linked list or implementing a C-style output parameter.

Use a return value when the function produces a pointer as a result: returning (T, error) is the idiomatic Go pattern and avoids the complexity of double pointers.

Use a slice when you need to pass a collection that the function can modify in place: slices carry their own length and capacity, so the function can append or resize without a double pointer.

Use a pointer to a struct when you need to modify the fields of a struct: passing *Struct lets the function change the contents without needing **Struct.

Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next