Pointers & Memory · intermediate · ~12 min
## What you will learn - Read and write the type `T **` and explain it precisely as "a pointer to a `T *`". - Use a double pointer so a function can change **the caller's pointer itself**, not just the value it points to. - Track two separate levels of indirection: distinguish `out`, `*out`, and `**out` without guessing. - Build the classic `alloc` pattern (`int alloc_int(int **out)`) that hands a freshly allocated pointer back to the caller, with full error handling and cleanup. - Connect double pointers to real, everyday C: `char **argv` in `main`, swapping two pointers, and freeing-then-NULLing through a pointer. - Spot and fix the most common double-pointer bugs (wrong indirection level, dereferencing `NULL`, leaking the old allocation).
A double pointer is just a pointer whose pointed-to value happens to be another pointer. If you already understand single pointers from Pointer basics, you have everything you need: the rules do not change, you simply apply them one extra time.
Why would you ever want a pointer to a pointer? Because of how C passes arguments. C is pass-by-value: a function receives copies of its arguments. If you pass an int, the function can change its copy but not your original. The fix you already know is to pass &x (an int *) so the function can reach back and modify your int.
The exact same problem appears one level up. Sometimes a function needs to modify a pointer variable in the caller — for example, to allocate memory and store the new address into the caller's pointer. Passing the pointer by value only lets the function change its copy. So you pass the address of the pointer, which has type T **, and the function writes through it.
This pattern is everywhere in real C. int main(int argc, char **argv) uses it to hand your program a list of strings. Library functions like getline take a char ** so they can allocate (or grow) a buffer for you. A safe free-helper takes a T ** so it can set your pointer to NULL after freeing. Once you see double pointers as "the address-of trick, applied twice," they stop being mysterious.
This lesson builds on Pointer basics (declaration, &, *, dereferencing) and prepares you for malloc, where allocating and returning memory through an out-parameter is a routine task.
Functions that must update a caller's pointer are a core building block of real software:
create_widget(Widget **out)) allocate an object and store its address back into the caller's variable, while reserving the return value for an error code.getline accept a char **lineptr so they can malloc/realloc the buffer for you and write the new address back. Without a double pointer, the function could not tell you where the (possibly moved) buffer now lives.Node ** lets one function update that link directly, eliminating special-case code.argv is already using a char **. Understanding it turns "magic" boilerplate into something you can manipulate confidently.free_and_null(T **p) helper frees the block and sets the caller's pointer to NULL, preventing dangling-pointer and double-free bugs — a habit professionals rely on.Get the indirection level wrong and you write through a pointer you did not mean to, corrupt memory, or leak allocations. Getting it right is a marker of someone who truly understands how C passes data.
A double pointer T **p is a variable that stores the address of a T * variable. Reading it back:
p — the double pointer itself (an address of an int *).*p — the int * it points to (the caller's pointer).**p — the int that that pointer points to (the actual value).Each * you write "follows one arrow." The number of * in the type tells you how many arrows there are.
p *p (an int*) **p (an int)
+------+ +------+ +------+
| addr |------->| addr |----------->| 42 |
+------+ +------+ +------+
int** int* int
Knowledge check: Given int x = 7; int *q = &x; int **pp = &q;, what are the types of pp, *pp, and **pp, and what value does **pp produce?
C copies arguments into the function. To change something in the caller, you must pass its address and write through that address. This is true at every level:
| You want to change a... | Pass this type | Write through |
|---|---|---|
int in the caller |
int * |
*p = ... |
int * in the caller |
int ** |
*pp = ... |
Node * head of a list |
Node ** |
*head = ... |
If a function only takes int *out and does out = malloc(...), it reassigns its local copy of the pointer. The caller never sees the new address, and the allocation leaks. Taking int **out and doing *out = malloc(...) fixes this: it writes the new address into the caller's variable.
alloc_int(int **out) caller
----------------------- -------------------
out -----------------------> p (an int*, NULL)
*out = malloc(...); ===> p now holds new addr
Knowledge check (find the bug): A coworker writes void alloc_int(int *out){ out = malloc(sizeof(int)); } and is surprised the caller's pointer is still NULL. What is wrong, and what type should out be?
alloc / out-parameter patternA function that produces a pointer for the caller has two common shapes:
int *make(void)), returning NULL on failure. Simple, but you cannot also return an error code.int make(int **out)): the new pointer goes through *out, and the int return value is a status code (0 ok, -1 error). This is the standard when a function can fail for several reasons.When using the out-parameter form, always check the allocation and set *out to a sane value (often NULL) on failure so the caller is never left with garbage.
When NOT to use a double pointer: if a function only needs to read or modify the pointed-to value (not the pointer), a single pointer is correct and clearer. Reaching for T ** when T * suffices adds confusion.
Pitfall: Forgetting that *out may already hold a valid pointer. If you overwrite it without freeing the old block first, you leak. (For a pure allocate-once helper this is fine; for a "replace the buffer" helper, free first.)
char **argv argv (char**) each element is a char* characters
+------+ +-----+
| argv |-->| [0] |--> "prog\0"
+------+ +-----+
| [1] |--> "--verbose\0"
+-----+
| [2] |--> "file.txt\0"
+-----+
| NULL | (argv[argc] is NULL)
+-----+
argv is a pointer to the first of argc + 1 char * slots; each slot points to a NUL-terminated string. argv[i] is the same as *(argv + i). The array is guaranteed to end with argv[argc] == NULL.
Knowledge check (explain in your own words): Why is argv typed char ** and not just char *? What would be lost if it were a single pointer?
Declaring and using a pointer to a pointer:
int x = 42;
int *p = &x; // p points to x (one level)
int **pp = &p; // pp points to p (two levels)
*pp // -> p (type int*)
**pp // -> x (type int, value 42)
*pp = NULL // changes the CALLER's pointer p, not x
A function parameter that lets the callee modify the caller's pointer:
int alloc_int(int **out); // 'out' is the address of an int*
// ^^^^^ two stars: pointer to (int pointer)
// caller passes the ADDRESS of its pointer:
int *p = NULL;
alloc_int(&p); // &p has type int**
Rule of thumb: the number of * in the declaration is how many times you may dereference; passing & adds one level, each * removes one.
A T ** is a pointer to a T * (a pointer to a pointer).
Use it when a function needs to update the pointer itself in the caller, not just the value the pointer points to.
A common example is an alloc helper. The function allocates a fresh block of memory and hands that new pointer back to the caller.
mainThe same idea explains int main(int argc, char **argv).
Here argv is "a pointer to an array of pointers to char" — in other words, a list of strings.
#include <stdio.h>
#include <stdlib.h>
/* Allocate one int, store 42 in it, and hand the new pointer
back to the caller through *out. Returns 0 on success, -1 on failure. */
int alloc_int(int **out) {
if (out == NULL) return -1; /* defend against a NULL out-param */
*out = malloc(sizeof(int)); /* write the new address into caller's pointer */
if (*out == NULL) return -1; /* allocation failed: caller's pointer is NULL */
**out = 42; /* store the value through two levels */
return 0;
}
/* Free the block and NULL the caller's pointer to avoid a dangling pointer. */
void free_and_null(int **p) {
if (p == NULL) return;
free(*p); /* free(NULL) is safe, so no extra check needed */
*p = NULL; /* caller's pointer is now safely NULL */
}
int main(void) {
int *p = NULL;
if (alloc_int(&p) != 0) { /* pass the ADDRESS of p (an int**) */
fprintf(stderr, "allocation failed\n");
return 1;
}
printf("value = %d\n", *p); /* read the int through one level */
free_and_null(&p); /* frees and sets p back to NULL */
printf("p is %s\n", p == NULL ? "NULL" : "not NULL");
return 0;
}
What it does. alloc_int receives the address of main's pointer p. It allocates one int, writes that address back through *out, and stores 42 through **out. Back in main, p now points to the new block. free_and_null releases the block and resets p to NULL.
Expected output:
value = 42
p is NULL
Edge cases: If malloc fails, alloc_int returns -1 and leaves *out == NULL, so the caller's check catches it before any dereference. Passing NULL as the out-parameter is handled defensively. free(NULL) is defined as a no-op, so free_and_null is safe even if p was never allocated.
Walkthrough of the key example, with the state of main's p at each step:
| Step | Statement | What happens | p after |
|---|---|---|---|
| 1 | int *p = NULL; |
main creates a pointer, initialized to NULL. |
NULL |
| 2 | alloc_int(&p) |
&p (type int**) is passed; inside, out points at p. |
NULL |
| 3 | if (out == NULL) |
out is not NULL (it is &p), so continue. |
NULL |
| 4 | *out = malloc(...) |
malloc returns a new address; *out writes it into p. |
new addr |
| 5 | if (*out == NULL) |
Allocation succeeded, so skip the error path. | new addr |
| 6 | **out = 42; |
Follows both arrows: stores 42 in the new block. |
new addr |
| 7 | return 0; |
Success status flows back to main. |
new addr |
| 8 | printf("%d", *p) |
*p follows one arrow to read 42. |
new addr |
| 9 | free_and_null(&p) |
free(*p) releases the block; *p = NULL clears p. |
NULL |
The crucial line is step 4: because out is int **, writing *out reaches all the way into main's variable. A function taking int *out and doing out = malloc(...) would only change its own local copy, and p in main would still be NULL at step 8 — likely crashing on *p.
// WRONG: only changes the local copy of the pointer
void alloc_int(int *out) {
out = malloc(sizeof(int)); // 'out' is a copy; caller never sees this
*out = 42;
}
Why it's wrong: out is passed by value. Reassigning out updates the copy, not the caller's pointer. The caller's pointer stays NULL (and the allocation leaks). Fix: take int **out and write *out = malloc(...). Recognize it: the caller's pointer is unchanged after the call.
int alloc_int(int **out) {
*out = malloc(sizeof(int));
*out = 42; // WRONG: overwrites the pointer with the integer 42 (a bad address)
}
Why it's wrong: *out is the int *; assigning 42 to it makes it point at address 42. You wanted to store into the pointed-to int, which is **out. Fix: **out = 42;. Recognize it: compiler warns about assigning an int to a pointer, or you crash on later use.
int *p = NULL;
alloc_int(p); // WRONG: passes int*, but the parameter is int**
Why it's wrong: the function expects int **. Passing p (an int *) is a type mismatch; if it compiles after a bad cast, the function writes through a garbage address. Fix: alloc_int(&p);. Recognize it: compiler error "incompatible pointer type."
alloc_int(&p);
printf("%d\n", *p); // WRONG if malloc failed: *p dereferences NULL
Why it's wrong: on failure p is NULL; dereferencing it is undefined behavior. Fix: check the return value first (if (alloc_int(&p) != 0) { ... }). Recognize it: intermittent segfaults under memory pressure.
Compiler errors
incompatible pointer type ... 'int *' to parameter of type 'int **' — you passed p instead of &p. Add the &.assignment to 'int *' from 'int' makes pointer from integer — you wrote *out = 42 where you meant **out = 42.gcc -Wall -Wextra -std=c11 file.c -o prog. These mismatches are caught at compile time.Runtime errors
*p right after the call: the allocation probably failed (or you used Mistake 1) and p is NULL. Check the return value and print p before dereferencing.*out = 42 instead of **out = 42).gcc -fsanitize=address,undefined -g file.c then run. It pinpoints NULL dereferences, leaks, and bad writes.Logic errors
int ** and you assign through *out.*out but the caller never freed it, or you overwrote a non-NULL *out without freeing the old block.Questions to ask when it doesn't work
* does my parameter type have, and how many do I dereference?&p (address) or p (the pointer)?Double pointers concentrate several classic C hazards into one place, so apply these habits:
int *p = NULL; so a failed or skipped allocation leaves a checkable value, not garbage.NULL double pointer (if (out == NULL) return -1;) before writing *out; otherwise you dereference NULL.*out = malloc(...), verify *out != NULL and report failure; never dereference an unchecked allocation (**out).malloc with one free. Memory reached through *out is the caller's responsibility to free exactly once. Document who owns it.free(*out) before overwriting it.NULL (the free_and_null helper does this through T **). free(NULL) is a safe no-op, so re-freeing a NULLed pointer cannot double-free.*out and let it escape the function — the local dies when the function returns, leaving a dangling pointer. Return heap memory (or memory the caller owns) instead.*out vs **out) is undefined behavior: you may corrupt a pointer or write to an invalid address. The compiler's type warnings catch most of these — keep -Wall -Wextra on.Concrete uses
getline: ssize_t getline(char **lineptr, size_t *n, FILE *stream); allocates or grows the buffer and writes the (possibly new) address back through *lineptr. You must free(*lineptr) when done — a textbook double-pointer out-parameter.void push_front(Node **head, int v) lets one function update the caller's head pointer directly, so inserting at the front needs no special case.sqlite3_open(const char *path, sqlite3 **db) return a status code and hand the new handle back through db — exactly the alloc_int pattern at scale.char **argv: every command-line program already consumes a double pointer to read its arguments.Best-practice habits
Beginner rules:
NULL.&p, dereference with the matching number of *.free exactly once; set the pointer to NULL afterward.Advanced habits:
int f(T **out)) when a function can fail for multiple reasons, reserving the return value for a status code.free the memory returned through the out-parameter.*out = NULL on every error path so callers can rely on a single check.1. Print the three levels. Declare int x = 99; int *p = &x; int **pp = &p;. Print the value of x three ways: x, *p, and **pp. Confirm all three print 99. Concepts: declaration, dereferencing levels.
2. Read through two levels. Write int read2(int **pp) that returns **pp. In main, set up an int, a pointer to it, and a pointer-to-pointer, then call read2 and print the result. Hint: the function dereferences twice. Concepts: passing int **, double dereference.
3. Allocate-and-fill. Write int alloc_filled(int **out, int value) that allocates one int, stores value in it via **out, and returns 0, or returns -1 if malloc fails (leaving *out == NULL). Call it from main, check the return, print the value, and free. Example: alloc_filled(&p, 7) then printf("%d", *p) prints 7. Concepts: out-parameter pattern, error handling, cleanup.
4. Swap two pointers. Write void swap_ptrs(int **a, int **b) that exchanges the two int * values so each ends up pointing where the other did (the int values stay put). Test with two ints m and n and two pointers; print before and after. Hint: swap *a and *b using a temporary int *. Concepts: modifying caller pointers, double pointer as out-parameter.
5. Safe replace-buffer helper. Write int set_int(int **out, int value) that, if *out is already non-NULL, frees the old block first, then allocates a new int, stores value, and writes the new address back through *out (returning -1 and leaving *out == NULL on allocation failure). Demonstrate calling it twice on the same pointer with no leak (verify with -fsanitize=address). Constraints: exactly one live allocation at a time; no leaks; no use-after-free. Hint: free(*out) then *out = NULL before allocating, so a later failure is consistent. Concepts: ownership, leak avoidance, indirection levels, sanitizer verification.
T ** is a pointer to a T *. Use it when a function must change the caller's pointer itself, not just the value the pointer points to — because C passes arguments by value.int **out, out is the double pointer, *out is the caller's int *, and **out is the actual int. The number of * in the type is how many arrows you may follow.int alloc_int(int **out) writes the new pointer through *out and returns a status code. Always check the allocation and set *out = NULL on failure.int *out + out = malloc), wrong level (*out = 42 instead of **out = 42), passing p instead of &p, and dereferencing without checking the allocation.char **argv, getline's char **lineptr, list-head insertion (Node **head), and a free_and_null(T **) helper that NULLs your pointer after freeing.NULL, validate the out-parameter, check every malloc, free exactly once, NULL after free, and keep -Wall -Wextra on to catch indirection-level bugs at compile time.