Double Pointer in C (Pointer to Pointer): Complete Guide with Examples

A double pointer in C stores the address of another pointer. See the memory diagram, the key use cases, and tested examples of int** and char**.

Double Pointer (Pointer-to-Pointer) in C

Double pointers are where C stops being intimidating on paper and starts being intimidating in practice — and then, usually in one sitting, they click and never confuse you again. The trick is to stop thinking about the syntax and start thinking about the memory: every * is just one more hop through an address.

This guide gets you to that click: what int ** actually means in memory, the one use case that makes double pointers essential (functions that modify the caller’s pointer), dynamic 2D arrays done correctly, strings and argv, triple pointers, and the mistakes that cause real crashes. Every program compiles clean with gcc -std=c11 -Wall -Wextra, and the outputs, compiler warnings, and even the AddressSanitizer leak report shown below were captured from real runs.

Table of Contents

What Is a Double Pointer in C?

A double pointer (pointer to pointer) is a pointer variable that stores the address of another pointer. Declared with two asterisks — int **pptr; — it adds one more level of indirection: pptr holds the address of a pointer, *pptr gives you that pointer, and **pptr follows both hops to reach the actual value.

If pointers themselves are still settling in, read that guide first — everything here builds on it, and more fundamentals live in our C programming tutorials.

Here is the entire concept in one picture:

Double Pointer Memory Diagram: pptr → ptr → var int **pptr 0x2000 lives at 0x3000 holds ptr’s address int *ptr 0x1000 lives at 0x2000 holds var’s address int var 120 lives at 0x1000 What each expression gives you pptr → 0x2000 the address of ptr *pptr → 0x1000 one hop: ptr’s content **pptr → 120 two hops: the value Addresses are simplified for readability — real addresses look like 0x7ffd0adf69d4 and change every run.

Declaration and wiring:

int var = 120;
int *ptr = &var;      /* ptr holds the address of var  */
int **pptr = &ptr;    /* pptr holds the address of ptr */

Read declarations right to left and they explain themselves: pptr is a pointer (*) to a pointer (*) to an int.

Reading the Levels: One Program, Every Expression

This program prints the value through all three routes, then walks the address chain level by level — with every line labeled by what it actually is:

#include <stdio.h>

int main(void)
{
    int var = 120;
    int *ptr = &var;      /* ptr holds the address of var        */
    int **pptr = &ptr;    /* pptr holds the address of ptr       */

    /* three ways to reach the same value */
    printf("var    = %d\n", var);
    printf("*ptr   = %d\n", *ptr);      /* one hop  */
    printf("**pptr = %d\n", **pptr);    /* two hops */

    /* the address chain, level by level */
    printf("\n");
    printf("&var  = %p   (where var lives)\n",  (void *)&var);
    printf("ptr   = %p   (what ptr stores: the address of var)\n",  (void *)ptr);
    printf("&ptr  = %p   (where ptr itself lives)\n", (void *)&ptr);
    printf("pptr  = %p   (what pptr stores: the address of ptr)\n", (void *)pptr);
    printf("*pptr = %p   (one hop: the value inside ptr)\n", (void *)*pptr);
    return 0;
}

Output (addresses will differ on your machine and on every run):

var    = 120
*ptr   = 120
**pptr = 120

&var  = 0x7ffd0adf69d4   (where var lives)
ptr   = 0x7ffd0adf69d4   (what ptr stores: the address of var)
&ptr  = 0x7ffd0adf69d8   (where ptr itself lives)
pptr  = 0x7ffd0adf69d8   (what pptr stores: the address of ptr)
*pptr = 0x7ffd0adf69d4   (one hop: the value inside ptr)

Notice the pairs: ptr prints the same address as &var, and pptr prints the same address as &ptr — the chain in the diagram, confirmed live. (Small professional habit visible here: pointers are printed with %p and cast to (void *), which is what the standard actually requires.)

The #1 Use Case: Functions That Modify the Caller’s Pointer

Here is the situation that makes double pointers unavoidable. C passes everything by value — including pointers. So a function that receives char *out gets a copy of the caller’s pointer; assigning to it changes only the copy:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* WRONG: modifies only a local copy of the caller's pointer */
void make_greeting_broken(char *out)
{
    out = malloc(32);              /* caller never sees this */
    if (out) strcpy(out, "hello");
}

/* RIGHT: receives the ADDRESS of the caller's pointer */
int make_greeting(char **out)
{
    *out = malloc(32);             /* writes through to the caller */
    if (*out == NULL) return -1;
    strcpy(*out, "hello");
    return 0;
}

int main(void)
{
    char *broken = NULL;
    make_greeting_broken(broken);
    printf("after broken version:  %s\n", broken ? broken : "(still NULL)");

    char *msg = NULL;
    if (make_greeting(&msg) == 0) {
        printf("after correct version: %s\n", msg);
        free(msg);
    }
    return 0;
}

Output:

after broken version:  (still NULL)
after correct version: hello

The broken version fails silently — and it’s worse than it looks. That lost malloc is a leak, and AddressSanitizer confirms it when this exact program is built with -fsanitize=address:

SUMMARY: AddressSanitizer: 32 byte(s) leaked in 1 allocation(s).

The pattern to internalize: to modify a T inside a function, pass a T*. To modify a T*, pass a T**. It is the same rule applied twice — and you have already used it if you have built a linked list, where insertFront(struct Node **head, ...) must change which node the caller’s head points at. Our linked list guide is this exact idiom in production.

Dynamic 2D Arrays with Double Pointers

The classic double-pointer construction: an array of row pointers, each row separately allocated:

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int rows = 3, cols = 4;

    /* Step 1: one array of row POINTERS */
    int **matrix = malloc(rows * sizeof(int *));
    if (matrix == NULL) return 1;

    /* Step 2: one array of ints per row */
    for (int i = 0; i < rows; i++) {
        matrix[i] = malloc(cols * sizeof(int));
        if (matrix[i] == NULL) return 1;
    }

    /* use it exactly like a 2D array */
    for (int i = 0; i < rows; i++)
        for (int j = 0; j < cols; j++)
            matrix[i][j] = i * 10 + j;

    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++)
            printf("%3d", matrix[i][j]);
        printf("\n");
    }

    /* free in reverse: every row first, THEN the pointer array */
    for (int i = 0; i < rows; i++)
        free(matrix[i]);
    free(matrix);
    return 0;
}

Output (verified leak-free under AddressSanitizer):

  0  1  2  3
 10 11 12 13
 20 21 22 23

Two honest notes most tutorials skip:

  • This is not a “true” 2D array. A real 2D array (int m[3][4], or one covered in our arrays guide) is one contiguous block. The double-pointer version is rows+1 separate allocations scattered across the heap — matrix[i][j] works because matrix[i] is a pointer you follow first. The price is extra memory for the pointers and worse cache behavior.
  • The contiguous alternative is often better in real code: allocate once with int *m = malloc(rows * cols * sizeof(int)); and index with m[i * cols + j]. One malloc, one free, cache-friendly. Reach for int ** when rows genuinely need different lengths (jagged arrays) or must be swapped/reallocated individually — that flexibility is what our memory management guide calls buying dynamism with bookkeeping.

Strings, argv, and Lists of Lists

A char * is a string; a char ** is a list of strings. You have used one in every C program you ever wrote — main‘s second parameter:

#include <stdio.h>

/* argv IS a double pointer: an array of strings */
int main(int argc, char **argv)
{
    printf("Program received %d arguments:\n", argc);
    for (int i = 0; i < argc; i++)
        printf("  argv[%d] = %s\n", i, argv[i]);

    /* the same shape, built by hand: char ** = list of strings */
    char *words[] = { "double", "pointers", "hold", "string", "lists" };
    char **sentence = words;
    int count = sizeof(words) / sizeof(words[0]);

    printf("\nSentence:");
    for (int i = 0; i < count; i++)
        printf(" %s", sentence[i]);
    printf("\n");
    return 0;
}

Output of ./program hello world:

Program received 3 arguments:
  argv[0] = ./program
  argv[1] = hello
  argv[2] = world

Sentence: double pointers hold string lists

And the ladder keeps climbing. A triple pointer (char ***) is rare in practice but follows the identical logic — a list of lists of strings:

#include <stdio.h>

int main(void)
{
    /* one more level: a list of sentences = a paragraph */
    char *s1[] = { "Pointers", "nest.", NULL };
    char *s2[] = { "Levels", "add", "up.", NULL };
    char **sentences[] = { s1, s2, NULL };
    char ***paragraph = sentences;

    for (int i = 0; paragraph[i] != NULL; i++) {
        for (int j = 0; paragraph[i][j] != NULL; j++)
            printf("%s ", paragraph[i][j]);
        printf("\n");
    }
    return 0;
}

Output:

Pointers nest. 
Levels add up.

If you find yourself needing a fourth level, it is almost always a sign the design wants a struct instead.

Common Double Pointer Mistakes (All Verified)

1. Dereferencing the wrong level. *pptr is a pointer, not a value. GCC catches the mismatch — this warning was captured from a real build:

warning: initialization of 'int' from 'int *' makes integer from pointer
without a cast [-Wint-conversion]
    int x = *pptr;      /* WRONG level: *pptr is an int*, not an int */

Treat every -Wint-conversion involving pointers as an error: it means your levels don’t line up.

2. Using an uninitialized double pointer. int **pptr; **pptr = 42; hops through garbage. GCC warns ('pptr' is used uninitialized) — and here is the unsettling part: when we actually ran it, it didn’t crash. The garbage address happened to be writable, so it silently corrupted memory and exited “successfully.” That is undefined behavior’s worst mode — no crash, wrong state. Never ignore that warning.

3. Freeing in the wrong order — or only halfway. For the 2D matrix, free(matrix) alone leaks every row (the row pointers are gone, the rows aren’t). Free every matrix[i] first, then matrix — the reverse of allocation. The ASan leak summary in the out-parameter section is exactly what a lost allocation looks like; make -fsanitize=address a habit. The same discipline applies to FILE handles and everything else you acquire — see our file handling in C guide.

4. Forgetting & at the call site. make_greeting(msg) instead of make_greeting(&msg) won’t compile once the parameter is char ** — a helpful error. The dangerous variant is designing the function with char * in the first place and never learning why it silently does nothing (mistake #0, covered above).

Double Pointer vs C++ Reference

C++ programmers meet the same problem — “let a function modify my pointer” — and usually solve it differently:

ApproachLanguageCall siteInside the function
Double pointer T **pC and C++f(&ptr)*p = new_value;
Reference to pointer T *&pC++ onlyf(ptr)p = new_value;
Smart pointer by referenceC++ onlyf(uptr)uptr = std::make_unique<T>();

A C++ reference to a pointer (int *&) does exactly what int ** does with cleaner syntax and no NULL to worry about — but it exists only in C++. In C, the double pointer is the tool, which is why C library APIs are full of ** parameters. (Modern C++ mostly sidesteps the whole question with ownership types like std::unique_ptr — a topic our C++ classes guide sets up with RAII.)

Key Takeaways

  • A double pointer stores the address of a pointer: pptr → address of ptr, *pptrptr‘s content, **pptr → the value. Every * is one hop.
  • The essential use case: pass T ** when a function must modify the caller’s T * — allocation out-parameters, linked-list heads, any “give me back a pointer” API.
  • int ** 2D “arrays” are really arrays of row pointers — flexible (jagged rows) but non-contiguous; a single rows*cols block is often the better engineering choice.
  • char ** is a list of stringsargv is the double pointer you have been using all along; char *** just adds one more list level.
  • Free multi-level allocations in reverse order (rows, then the pointer array) and let AddressSanitizer confirm zero leaks.
  • Wrong-level dereferences show up as pointer/integer conversion warnings — treat them as errors, and never ship code with an “uninitialized” warning: UB can corrupt silently instead of crashing.

Frequently Asked Questions

Conclusion

The moment double pointers click is the moment you stop reading ** as syntax and start reading it as a map: every asterisk in a declaration promises a hop, and every asterisk in an expression spends one. From there, int **matrix, char **argv, and struct Node **head stop being three confusing things and become one idea wearing three outfits.

The best next step is to use the idiom until it is boring: write a function that returns an allocated string through a char **, then re-read the insertion functions in a linked list and notice you now understand why every one of them takes **head. When indirection feels routine, the rest of our C programming tutorials — file handling, data structures, memory management — read very differently.

Scroll to Top