What Is a Pointer to a Pointer?

A pointer to a pointer is a variable whose stored address belongs to another pointer rather than to ordinary data. Reaching the data therefore takes two hops instead of one. The type is written with two asterisks, and the extra level exists for two practical jobs: building a table whose elements are themselves pointers, and letting a function change which object a caller's pointer refers to.

Everything in this lesson follows from one rule. A pointer variable is an object like any other, so it has an address, and that address has a type.

Declaration What the variable stores Reaching the int
int seatPrice{}; an int seatPrice
int* handle{}; the address of an int *handle
int** handleSlot{}; the address of an int* **handleSlot
int*** deepSlot{}; the address of an int** ***deepSlot

Each asterisk in the type adds one link to the chain, and each asterisk in an expression removes one.

handleSlot           handle               seatPrice
+------------+      +------------+      +------------+
|  &handle   | ---> | &seatPrice | ---> |     26     |
+------------+      +------------+      +------------+
   int**                int*                 int

Here is that chain as a program. Notice that the middle box is a real object with a real address, which is what makes the third box possible.

#include <iostream>

int main()
{
    int seatPrice{ 26 };

    int* handle{ &seatPrice };
    int** handleSlot{ &handle };

    std::cout << std::boolalpha;
    std::cout << "one hop:  " << *handle << '\n';
    std::cout << "two hops: " << **handleSlot << '\n';
    std::cout << "*handleSlot names the same pointer as handle: "
              << (*handleSlot == handle) << '\n';
    std::cout << "**handleSlot names the same object as seatPrice: "
              << (&**handleSlot == &seatPrice) << '\n';

    **handleSlot = 31;
    std::cout << "seatPrice after writing through two levels: " << seatPrice << '\n';

    return 0;
}

Output:

one hop:  26
two hops: 26
*handleSlot names the same pointer as handle: true
**handleSlot names the same object as seatPrice: true
seatPrice after writing through two levels: 31
Key Concept
One dereference of an int** gives you an int*, not an int. *handleSlot is the pointer itself, so assigning to it repoints handle; **handleSlot is the number, so assigning to it changes seatPrice. Getting these two confused is the single most common mistake with double pointers.

Why You Cannot Write &&seatPrice

A chain has to be built one link at a time. You might expect to skip the middle variable by taking the address twice in one go, but this does not compile:

Broken on purpose, shown so you recognise the error:

#include <iostream>

int main()
{
    int seatPrice{ 26 };
    int** handleSlot{ &&seatPrice };

    std::cout << **handleSlot << '\n';

    return 0;
}

GCC reports:

s.cpp: In function 'int main()':
s.cpp:6:23: error: invalid conversion from 'void*' to 'int**' [-fpermissive]
    6 |     int** handleSlot{ &&seatPrice };
      |                       ^~~~~~~~~~~
      |                       |
      |                       void*
s.cpp:5:9: warning: unused variable 'seatPrice' [-Wunused-variable]
    5 |     int seatPrice{ 26 };
      |         ^~~~~~~~~
s.cpp:6:25: error: label 'seatPrice' used but not defined
    6 |     int** handleSlot{ &&seatPrice };
      |                         ^~~~~~~~~

The message is a surprise until you realise && is a single token, so the compiler never sees two address-of operators at all. Even if it did, the attempt would still fail: operator& needs an lvalue, and the result of &seatPrice is a temporary address value with nowhere to live. A pointer to a pointer can only point at a pointer that is a named object, so the middle variable is mandatory.

A double pointer can, however, hold null, and a null check has to be done at every level you intend to follow:

#include <iostream>

int main()
{
    int** handleSlot{ nullptr };

    if (handleSlot != nullptr && *handleSlot != nullptr)
    {
        std::cout << **handleSlot << '\n';
    }
    else
    {
        std::cout << "nothing to read yet\n";
    }

    return 0;
}

Output:

nothing to read yet
Undefined Behavior
Checking only handleSlot != nullptr and then writing **handleSlot is a crash waiting to happen: the outer pointer can be perfectly valid while the inner one is null or dangling. Two levels of indirection means two chances to follow a bad address.

Choosing a Layout for a Runtime-Sized Table

The main reason double pointers turn up in real code is a two-dimensional table whose size is not known until the program runs. There is more than one way to build one, and int** is only the right answer in a narrow case. Work down this table and stop at the first row that fits.

Layout Use when How it is created How it is freed Memory shape
Fixed C-style array both extents are compile-time constants int hall[3][6]{}; nothing to free one contiguous block
Pointer to array of N only the trailing extent is a compile-time constant auto hall{ new int[tierTotal][6] }; a single delete[] one contiguous block
One flat block both extents are runtime values and the table is rectangular int* hall{ new int[tierTotal * seatsPerTier] }; a single delete[] one contiguous block
Array of pointers (int**) both extents are runtime values and rows may differ in length new int*[tierTotal], then a new int[] per row one delete[] per row, then one more scattered blocks

The three dynamic layouts are shown below building the same thing: a price chart for a small theatre with three seating tiers of six seats each, where the two centre seats in every tier carry a premium. All three print the identical chart, which is the point. The layout is an implementation choice, not a change in what the data means.

Layout 1: One Allocation, Trailing Extent Fixed

If the rightmost extent is a compile-time constant, you do not need a double pointer at all. new int[tierTotal][seatsPerTier] hands back a pointer to an array of seatsPerTier ints, written int (*)[seatsPerTier]. The parentheses matter: without them, int* hall[seatsPerTier] would declare an array of six pointers instead.

#include <iostream>

int priceFor(int tier, int seat)
{
    const int base{ 44 - tier * 8 };
    return (seat == 2 || seat == 3) ? base + 6 : base;
}

int main()
{
    constexpr int seatsPerTier{ 6 };
    int tierTotal{ 3 };

    int (*hall)[seatsPerTier]{ new int[tierTotal][seatsPerTier] };

    for (int tier{ 0 }; tier < tierTotal; ++tier)
    {
        for (int seat{ 0 }; seat < seatsPerTier; ++seat)
        {
            hall[tier][seat] = priceFor(tier, seat);
        }
    }

    for (int tier{ 0 }; tier < tierTotal; ++tier)
    {
        std::cout << "tier " << tier << ':';
        for (int seat{ 0 }; seat < seatsPerTier; ++seat)
        {
            std::cout << ' ' << hall[tier][seat];
        }
        std::cout << '\n';
    }

    delete[] hall;

    return 0;
}

Output:

tier 0: 44 44 50 50 44 44
tier 1: 36 36 42 42 36 36
tier 2: 28 28 34 34 28 28

That declaration is awkward to read, and there is no reason to type it out. Let the compiler write the type:

#include <iostream>

int main()
{
    constexpr int seatsPerTier{ 6 };
    int tierTotal{ 3 };

    auto hall{ new int[tierTotal][seatsPerTier] };

    hall[1][4] = 19;
    std::cout << hall[1][4] << '\n';

    delete[] hall;

    return 0;
}

Output:

19
Different Types, Not Interchangeable
int (*)[6] and int** are unrelated types and neither converts to the other, so int** hall{ new int[tierTotal][seatsPerTier] }; fails to compile with error: cannot convert 'int (*)[6]' to 'int**' in initialization. One is a pointer into a single block; the other is a pointer to a table of addresses. They only look alike because both support hall[a][b].

Layout 2: An Array of Pointers

When neither extent is known at compile time, the double pointer earns its place. You allocate a table whose elements are of type int*, then give every element in that table a heap block of its own. What you end up with is a directory of addresses sitting in front of the data it describes.

seating (int**)              separate heap blocks (int*)
+---------------+
|  seating[0]   | ---------> [ 44 44 50 50 44 44 ]
|  seating[1]   | ---------> [ 36 36 42 42 36 36 ]
|  seating[2]   | ---------> [ 28 28 34 34 28 28 ]
+---------------+

seating[tier][seat] reads left to right through the diagram: index the table of addresses first, then index the block that address points at.

#include <iostream>

int priceFor(int tier, int seat)
{
    const int base{ 44 - tier * 8 };
    return (seat == 2 || seat == 3) ? base + 6 : base;
}

int main()
{
    int tierTotal{ 3 };
    int seatsPerTier{ 6 };

    int** seating{ new int*[tierTotal] };

    for (int tier{ 0 }; tier < tierTotal; ++tier)
    {
        seating[tier] = new int[seatsPerTier];
    }

    for (int tier{ 0 }; tier < tierTotal; ++tier)
    {
        for (int seat{ 0 }; seat < seatsPerTier; ++seat)
        {
            seating[tier][seat] = priceFor(tier, seat);
        }
    }

    for (int tier{ 0 }; tier < tierTotal; ++tier)
    {
        std::cout << "tier " << tier << ':';
        for (int seat{ 0 }; seat < seatsPerTier; ++seat)
        {
            std::cout << ' ' << seating[tier][seat];
        }
        std::cout << '\n';
    }

    for (int tier{ 0 }; tier < tierTotal; ++tier)
    {
        delete[] seating[tier];
    }
    delete[] seating;

    return 0;
}

Output:

tier 0: 44 44 50 50 44 44
tier 1: 36 36 42 42 36 36
tier 2: 28 28 34 34 28 28

Four allocations produced that chart where the previous layout needed one, and the three tiers are almost certainly not next to each other in memory. That is the cost of the flexibility described next.

Rows That Need Not Match

Because every row is allocated on its own, the rows do not have to be the same length. A hall that fans out as it goes back is a natural fit: each tier gets exactly the seats it has, with nothing wasted on padding.

#include <iostream>

int main()
{
    constexpr int tierTotal{ 3 };
    const int seatsInTier[tierTotal]{ 4, 6, 8 };

    int** seating{ new int*[tierTotal] };

    for (int tier{ 0 }; tier < tierTotal; ++tier)
    {
        seating[tier] = new int[seatsInTier[tier]];

        for (int seat{ 0 }; seat < seatsInTier[tier]; ++seat)
        {
            seating[tier][seat] = 44 - tier * 8;
        }
    }

    for (int tier{ 0 }; tier < tierTotal; ++tier)
    {
        std::cout << "tier " << tier << " (" << seatsInTier[tier] << " seats):";
        for (int seat{ 0 }; seat < seatsInTier[tier]; ++seat)
        {
            std::cout << ' ' << seating[tier][seat];
        }
        std::cout << '\n';
    }

    for (int tier{ 0 }; tier < tierTotal; ++tier)
    {
        delete[] seating[tier];
    }
    delete[] seating;

    return 0;
}

Output:

tier 0 (4 seats): 44 44 44 44
tier 1 (6 seats): 36 36 36 36 36 36
tier 2 (8 seats): 28 28 28 28 28 28 28 28

Note that nothing in seating records how long each row is. You have to keep the lengths yourself, which is one more thing that can drift out of step with reality.

Freeing Runs Backwards

Deallocation mirrors allocation in reverse: the rows go first, the table of addresses goes last.

for (int tier{ 0 }; tier < tierTotal; ++tier)
{
    delete[] seating[tier];
}
delete[] seating;

Swapping those two steps compiles without a murmur and is undefined behavior.

Broken on purpose, shown so you recognise the shape:

delete[] seating;

for (int tier{ 0 }; tier < tierTotal; ++tier)
{
    delete[] seating[tier];   // reads freed memory to find each row
}

Once delete[] seating has run, the table of addresses is gone. The row blocks are still allocated, but the only record of where they are was inside the block you just released, so seating[tier] reads freed memory to fetch each address. Whatever it finds is then passed to delete[]. The rows leak in the best case and the heap is corrupted in the worst, and this can appear to work for a long time before it does not.

Free Inner Before Outer
Every new int[] in the row loop needs its own delete[], and all of them must happen before the delete[] that releases the array of pointers. An early return or a thrown exception between the two loops leaks every row.

Layout 3: One Flat Block and a Little Arithmetic

If the table is rectangular, all of that bookkeeping disappears. Allocate tierTotal * seatsPerTier ints as a single block and compute the offset yourself.

hall (int*)
[ 44 44 50 50 44 44 | 36 36 42 42 36 36 | 28 28 34 34 28 28 ]
        tier 0               tier 1               tier 2

The offset for a given coordinate is the number of complete rows in front of it, times the row length, plus the position within the row.

#include <iostream>

int priceFor(int tier, int seat)
{
    const int base{ 44 - tier * 8 };
    return (seat == 2 || seat == 3) ? base + 6 : base;
}

int flatOffset(int tier, int seat, int seatsAcross)
{
    return (tier * seatsAcross) + seat;
}

int main()
{
    int tierTotal{ 3 };
    int seatsPerTier{ 6 };

    int* hall{ new int[tierTotal * seatsPerTier] };

    for (int tier{ 0 }; tier < tierTotal; ++tier)
    {
        for (int seat{ 0 }; seat < seatsPerTier; ++seat)
        {
            hall[flatOffset(tier, seat, seatsPerTier)] = priceFor(tier, seat);
        }
    }

    for (int tier{ 0 }; tier < tierTotal; ++tier)
    {
        std::cout << "tier " << tier << ':';
        for (int seat{ 0 }; seat < seatsPerTier; ++seat)
        {
            std::cout << ' ' << hall[flatOffset(tier, seat, seatsPerTier)];
        }
        std::cout << '\n';
    }

    delete[] hall;

    return 0;
}

Output:

tier 0: 44 44 50 50 44 44
tier 1: 36 36 42 42 36 36
tier 2: 28 28 34 34 28 28

One new, one delete[], no chance of half-freeing anything, and the whole chart sits in one cache-friendly run of memory. The only thing given up is the [a][b] syntax and the ability to have uneven rows.

Reach for a double pointer last. Prefer a container such as std::vector, and if you are managing raw memory yourself, prefer a flat block with index arithmetic for rectangular tables and auto ... new T[n][C] when the trailing extent is constexpr. Keep T** for the case it actually solves: runtime extents with rows of differing lengths.

Repointing a Pointer Through a Parameter

The other use of a double pointer has nothing to do with tables. A function that takes an int* can change the pointed-to number but not the caller's pointer, because the parameter is a copy of it. Pass the address of the pointer and the function can rewrite the pointer itself. In C++ a reference to a pointer expresses the same thing without the extra asterisk.

#include <iostream>

void repointBySlot(int** slot, int* target)
{
    *slot = target;
}

void repointByReference(int*& link, int* target)
{
    link = target;
}

int main()
{
    int matineePrice{ 22 };
    int eveningPrice{ 35 };

    int* current{ nullptr };

    repointBySlot(&current, &matineePrice);
    std::cout << "after repointBySlot:      " << *current << '\n';

    repointByReference(current, &eveningPrice);
    std::cout << "after repointByReference: " << *current << '\n';

    return 0;
}

Output:

after repointBySlot:      22
after repointByReference: 35

Both functions do exactly the same work. The reference version cannot be handed a null argument by accident, needs no & at the call site, and needs no dereference in the body, so prefer it in C++ and keep T** parameters for interoperating with C APIs that have no other option.

Past Two Levels

Nothing stops you adding more asterisks. int*** holds the address of an int**, int**** holds the address of an int***, and the dereference chain grows to match.

#include <iostream>

int main()
{
    int seatPrice{ 26 };
    int* handle{ &seatPrice };
    int** handleSlot{ &handle };
    int*** deepSlot{ &handleSlot };

    std::cout << ***deepSlot << '\n';

    return 0;
}

Output:

26

Three levels would let you allocate a dynamic three-dimensional table, at the price of a loop inside a loop to build it and the same nesting again to free it, with a leak available at every level. In practice this is where the flat-block approach stops being a preference and becomes the only sane option: a three-dimensional offset is just (a * extent1 + b) * extent2 + c.

Summary

A pointer to a pointer stores the address of a pointer object. int** handleSlot{ &handle }; requires handle to be a named int*, because only objects have addresses. Each asterisk in the type adds a link; each asterisk in an expression removes one, so *handleSlot is an int* and **handleSlot is the int.

&&seatPrice does not build one. && is a single token, and even read as two operators it would fail because &seatPrice is not an lvalue. Build the chain one variable at a time. A double pointer may hold nullptr, and every level you follow needs its own null check.

Arrays of pointers are the common use. int** seating{ new int*[tierTotal] }; allocates a table whose elements are int*, and a loop then gives each element its own new int[...] block. Because the rows are allocated separately they may have different lengths, and nothing in the structure records those lengths for you.

Free the rows before the table. Loop delete[] seating[tier] for every row first, then delete[] seating. Releasing the outer array first destroys the only record of the row addresses, so reading seating[tier] afterwards is undefined behavior.

A constexpr trailing extent avoids double pointers entirely. int (*hall)[seatsPerTier]{ new int[tierTotal][seatsPerTier] }; allocates one contiguous block and frees with a single delete[]; auto hall{ new int[tierTotal][seatsPerTier] }; says the same thing far more legibly. int (*)[6] and int** are different, non-convertible types.

Flattening is the better default for rectangular tables. Allocate tierTotal * seatsPerTier elements in one block and index it with (tier * seatsAcross) + seat. One allocation, one deallocation, contiguous memory, and no partial-cleanup failure mode.

Prefer a reference to a pointer over a pointer parameter. void repointByReference(int*& link, int* target) modifies the caller's pointer as effectively as int** slot does, with less syntax and no null argument to guard against.

Deeper levels exist but rarely pay. int*** and beyond are legal and occasionally necessary for C interoperability, but the allocation and cleanup burden grows with every level. Prefer std::vector or a flat block, and treat T** as the tool you use when the runtime shape genuinely demands rows of unequal length.