C++ Pointers Explained: Addresses, Dereferencing, and the Mistakes That Crash Programs

Key takeaways

Learn C++ pointers using a simple address analogy: core ideas, hands-on examples with code, patterns you will see in practice, and mistakes to avoid.

Think of pointers as street addresses

Analogy: A pointer is like a street address.

variable = house (where the data actually lives)
pointer  = address (information that says where the house is)

The analogy captures the one idea that makes everything else click: a pointer is a separate variable whose value happens to be a location. Writing an address on a piece of paper does not build a house, copying the paper does not copy the house, and if the house is demolished the paper still shows the old address. Those three facts map directly to the three classic pointer bugs covered below: pointers to nothing, confusion between copying the pointer and copying the data, and dangling pointers.

Core concepts

Variables and addresses

int age = 25;        // variable: stores a value
int* ptr = &age;     // pointer: stores an address

// &age: take the address of age
// ptr: a pointer that stores age's address

Picture it:

Address     Name   Value
0x1000      age    25
0x2000      ptr    0x1000 (address of age)

The addresses are made up for illustration; real ones look like 0x7ffd5e8b1a4c and change between runs because operating systems randomize where the stack and heap start (ASLR). The important part is that ptr has its own address and its own storage (8 bytes on a typical 64-bit system, regardless of what it points to), and its value is age’s address.

Declaring pointers

int* ptr;      // pointer to int
double* ptr2;  // pointer to double
char* ptr3;    // pointer to char

// Asterisk placement is style-only:
int *ptr;      // same meaning
int * ptr;     // same meaning

Placement is style-only for one variable, but not when you declare several on one line: int* a, b; makes a a pointer and b a plain int, because the * binds to the name, not the type. This is the reason many style guides say “one declaration per line” for pointers.

The pointed-to type matters even though every pointer is just an address. It tells the compiler how many bytes to read when you dereference and how far to move when you do arithmetic (ptr + 1 on an int* advances by sizeof(int), usually 4 bytes; on a double* by 8).

Address-of operator (&)

int x = 10;
int* ptr = &x;  // store x's address in ptr

std::cout << x;     // 10 (value)
std::cout << &x;    // 0x7fff...  (address)
std::cout << ptr;   // 0x7fff...  (address, same as &x)

One surprise here: std::cout << ptr prints an address for int*, but for a char* it prints the characters starting at that address, because char* is treated as a C string. Printing a char* that does not point to a null-terminated string reads past the end of the data. Cast to void* (std::cout << static_cast<void*>(p)) when you want the address of a character buffer.

Dereference operator (*)

int x = 10;
int* ptr = &x;

std::cout << *ptr;  // 10 (value ptr points to)

*ptr = 20;          // change the value where ptr points
std::cout << x;     // 20 (x changed!)

The * symbol does two different jobs, which is a common source of confusion. In a declaration (int* ptr) it means “ptr is a pointer”. In an expression (*ptr = 20) it means “go to the address stored in ptr”. Likewise & means “address of” in an expression but “reference” in a declaration (int& ref). Reading declarations and expressions separately makes code like *ptr = *other; much easier to parse.

Hands-on examples

Example 1: swapping values (swap)

C++ passes arguments by value: the function receives copies. swap_wrong swaps its own copies and throws them away. Passing addresses instead lets the function reach back into the caller’s variables.

// Wrong: copies values only
void swap_wrong(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
    // When the function returns, a and b are back to the originals!
}

// Correct: use pointers
void swap(int* a, int* b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main() {
    int x = 10, y = 20;

    swap_wrong(x, y);
    std::cout << x << ", " << y;  // 10, 20 (unchanged)

    swap(&x, &y);
    std::cout << x << ", " << y;  // 20, 10 (swapped!)
}

Example 2: arrays and pointers

int arr[5] = {1, 2, 3, 4, 5};
int* ptr = arr;  // array name is the address of the first element

std::cout << *ptr;       // 1 (first element)
std::cout << *(ptr+1);   // 2 (second element)
std::cout << ptr[2];     // 3 (third element)

Strictly speaking, an array is not a pointer; it converts to a pointer to its first element in most expressions (this is called array-to-pointer decay). The difference shows up with sizeof: sizeof(arr) is 20 (five 4-byte ints), while sizeof(ptr) is the size of an address. When you pass an array to a function declared as void f(int a[]), the parameter is really int*, so the function no longer knows the length. That is why C APIs always take a separate size argument, and why modern C++ code passes std::span<int> (C++20) or a std::vector instead.

Common mistakes

Mistake 1: uninitialized pointer

// Dangerous
int* ptr;
*ptr = 10;  // undefined behavior: ptr holds whatever bits were there

// Correct
int* ptr = nullptr;  // points to nothing
// or
int x;
int* ptr = &x;  // points to x

A local pointer that is never initialized holds leftover data from whatever used that stack slot before. If that happens to be an invalid address you get a segmentation fault (on Windows, “Access violation writing location”). If it happens to be a valid address, the write silently overwrites some other variable, and the program fails somewhere unrelated much later. The second outcome is worse, and it is the reason to initialize every pointer, even to nullptr: dereferencing a null pointer fails fast and at the right line. GCC and Clang warn about this with -Wall (-Wuninitialized), though only when optimization lets them see the flow.

Mistake 2: confusing pointer and value

int x = 10;
int* ptr = &x;

// Wrong
ptr = 20;  // compile error! (assigning a value to an address slot)

// Correct
*ptr = 20;  // change the value ptr points to

The compiler catches this one: GCC reports invalid conversion from 'int' to 'int*'. The opposite mistake, int y = ptr;, is also an error. The version the compiler cannot catch is comparing pointers when you meant to compare values: if (p == q) asks “do they point to the same place?”, not “are the values equal?”. With two char* strings that is almost always a bug; use std::strcmp or, better, std::string.

Mistake 3: dangling pointer

// Dangerous
int* ptr;
{
    int x = 10;
    ptr = &x;
}  // x is gone
*ptr = 20;  // undefined behavior: x's lifetime has ended

// If the object must outlive the scope, allocate it dynamically
int* ptr = new int(10);
*ptr = 20;
delete ptr;  // release when done

This bug rarely crashes on the spot. The stack memory that held x is still there and still writable; it will simply be reused by the next function call. So the program usually “works” in a quick test and then produces garbage values later. The most common real form is a function that returns the address of a local variable (int* f() { int v = 1; return &v; }), which compilers warn about (address of local variable 'v' returned). AddressSanitizer with -fsanitize=address catches the scope case as stack-use-after-scope.

The dynamic-allocation fix only moves the problem: now you have to decide who calls delete and make sure nobody uses the pointer afterwards. That ownership question is exactly what smart pointers answer (see the FAQ below).

Pointers vs references

// Pointer
int x = 10;
int* ptr = &x;
*ptr = 20;  // must dereference

// Reference (often simpler)
int& ref = x;
ref = 20;   // no dereference

A reference is another name for an existing object. It must be initialized when declared, it cannot be null, and it cannot be re-pointed at a different object later: ref = y; copies y’s value into x, it does not rebind ref. A pointer can be null and can change targets. The practical rule: take a reference parameter when the function always needs an object, and a pointer when “no object” is a legitimate input.

Tip: Beginners often find references easier to learn first.

Dynamic memory allocation

new and delete

// Single object
int* ptr = new int;      // allocate
*ptr = 10;               // store value
delete ptr;              // free

// With initial value
int* ptr2 = new int(25);
delete ptr2;

// Array
int* arr = new int[100];  // array of 100 ints
arr[0] = 1;
delete[] arr;             // array delete ([] required!)

new int; leaves the value uninitialized, while new int(); or new int{} zeroes it. Mixing up delete and delete[] is undefined behavior: for int arrays it often appears to work, which is why the bug survives, but for arrays of objects with destructors delete without [] typically runs only the first destructor and may corrupt the heap.

Watch for memory leaks

// Leak
void leak() {
    int* ptr = new int(10);
    // forgot delete!
}  // ptr goes away but the memory stays allocated

// Correct
void no_leak() {
    int* ptr = new int(10);
    // ... use ...
    delete ptr;  // always pair with new
}

no_leak is still fragile. If code between new and delete returns early or throws an exception, delete never runs. That is the everyday way leaks happen in real code: not a forgotten delete, but an error path someone added later. In my experience it is the reason manual new/delete pairs rarely survive code review in modern C++ codebases; std::make_unique makes the cleanup automatic on every path.

Everyday analogy: The stack is like a desk: small, and things you put there are cleared away automatically when the function ends. The heap is like a rented storage unit: much more room, and it stays yours until you explicitly give it back. Reading from either is equally fast once the data is in cache; what costs more on the heap is renting and returning the space (new/delete). A pointer is the slip of paper with the unit number written on it—it records an address, not the contents.

Pointer arithmetic

int arr[5] = {10, 20, 30, 40, 50};
int* ptr = arr;

std::cout << *ptr;       // 10
std::cout << *(ptr+1);   // 20
std::cout << *(ptr+2);   // 30

// Increment the pointer
ptr++;
std::cout << *ptr;       // 20

// Array-style indexing
ptr[0];  // 20
ptr[1];  // 30

Pointer arithmetic counts in elements, not bytes: ptr + 1 moves forward by sizeof(int). ptr[i] is defined as *(ptr + i), so indexing is arithmetic in disguise. The rules are strict: you may move within the array and to one position past the last element (useful as an end marker), but even computing an address beyond that, or before the first element, is undefined behavior. Nothing checks this at runtime, which is why std::vector::at() and range-based for loops exist.

Why we use pointers

Changing values inside a function

void increment(int* num) {
    (*num)++;
}

int main() {
    int x = 10;
    increment(&x);
    std::cout << x;  // 11 (changed)
}

Passing large data efficiently

// Inefficient: copies the whole vector
void process(std::vector<int> data) {
    // ...
}

// Efficient: pass pointer
void process(std::vector<int>* data) {
    // ...
}

// Often better: const reference
void process(const std::vector<int>& data) {  // recommended
    // ...
}

The pointer and the const reference cost the same at runtime: both pass an address. The reference wins on the interface: callers cannot pass nullptr by accident, the function does not need a null check, and const documents that the data is read-only. (The three overloads above are shown side by side for comparison; in one program the by-value and const-reference versions would make calls like process(v) ambiguous.)

Arrays whose size is unknown at compile time

int n;
std::cin >> n;

// Size not known at compile time
int* arr = new int[n];  // size decided at runtime
// ... use ...
delete[] arr;

This is how it was done before the standard library; today std::vector<int> arr(n); does the same allocation, frees it automatically, and remembers its own size. You may also see int arr[n]; compile with GCC; that is a variable-length array, a C99 feature GCC accepts as an extension, and it is not valid standard C++ (MSVC rejects it).

FAQ

Q1: Why do people say pointers are hard?

A: The idea is simple, but small mistakes produce undefined behavior, which may crash immediately, much later, or never in testing.

Why it feels hard:

  • Uninitialized pointer → crash or silent memory corruption
  • Missing delete → leak
  • Double delete → heap corruption (glibc often aborts with free(): double free detected)
  • Use-after-free → wrong values or a crash far from the real bug

Mitigation: use smart pointers in modern C++:

#include <memory>

std::unique_ptr<int> ptr = std::make_unique<int>(10);
// delete happens automatically (safer)

Q2: What is nullptr?

A: A special value meaning “points to nothing.”

int* ptr = nullptr;  // points to nothing

if (ptr == nullptr) {
    std::cout << "Pointer is empty" << std::endl;
}

// Check before use (safe)
if (ptr != nullptr) {
    *ptr = 10;
}

Note: Prefer nullptr (C++11) over the old NULL macro.

Q3: How do pointers relate to arrays?

A: An array name is the address of its first element.

int arr[5] = {1, 2, 3, 4, 5};

// Array name = address of first element
int* ptr = arr;  // same as &arr[0]

// Equivalent forms
arr[2];      // 3
ptr[2];      // 3
*(arr+2);    // 3
*(ptr+2);    // 3

Q4: What is a double pointer?

A: A pointer that points to another pointer.

int x = 10;
int* ptr = &x;       // address of x
int** ptr2 = &ptr;   // address of ptr

std::cout << x;      // 10
std::cout << *ptr;   // 10
std::cout << **ptr2; // 10

// ptr2 → ptr → x

When it shows up:

  • Dynamic 2D arrays
  • Functions that must change a pointer value
  • Advanced structures (trees, graphs)

Q5: Can I learn C++ without raw pointers?

A: In modern C++, largely yes.

Instead of raw new/delete:

// Old style
int* ptr = new int(10);
delete ptr;

// Modern: smart pointer
auto ptr = std::make_unique<int>(10);
// automatic cleanup

// Reference (often simpler)
int x = 10;
int& ref = x;
ref = 20;

Suggested order:

  1. References
  2. Smart pointers
  3. Raw pointers when you actually need them

Q6: When must I still use raw pointers?

A: Typical cases:

Often required:

  • C libraries (OpenGL, SDL, etc.)
  • Systems programming
  • Embedded development
  • Maintaining legacy code

Often avoidable:

  • Ownership in application code: std::unique_ptr / std::shared_ptr
  • Passing objects to functions: references
  • Dynamic arrays: std::vector

Takeaway: Raw pointers are still normal as non-owning observers (“look at this object, but I don’t manage its lifetime”). What modern C++ discourages is raw pointers that own memory, because nothing in the type says who must call delete.


Deep dive: const pointer vs pointer to const (often confused at work)

int x = 1;
const int* p1 = &x;   // pointee is const (can't change *p1 through p1)
int* const p2 = &x;   // pointer itself is const (can't repoint p2)
const int* const p3 = &x; // both const

Reading tip: Many teams read declarations as: const before * → “what is pointed to”, const after * → “the pointer itself.” Reading right to left also works: int* const p2 is “p2 is a const pointer to int”.

const int* p1 = &x; does not make x constant. x itself can still change through its own name or another pointer; p1 only promises not to modify it. That is why const int* is the natural parameter type for “I only read this”, and why you can pass a non-const int* where a const int* is expected but not the other way round.


Deep dive: void*, alignment, and uintptr_t

When you touch C APIs you will see void*. In C++, cast back to the correct type with static_cast before dereferencing, and use uintptr_t (<cstdint>) when you treat an address as an integer.

alignas(int) std::byte buffer[64];
auto* p = new (buffer) int(42);  // placement new: construct an int inside buffer
// Without alignas, buffer might not be suitably aligned for int.
// Bad idea: reinterpret_cast<int*>(buffer + 1) — misaligned and breaks strict aliasing

Placement new constructs an object in memory you already own, and the resulting pointer p is the correct way to access it. Casting a byte buffer to int* without constructing an object there is a different thing, and it is undefined behavior even when it appears to work.

Performance: A dereference that hits the CPU cache is very cheap, but a cache miss costs far more, and cache misses, virtual calls, and synchronization usually dominate. Before blaming “pointers are slow,” look at memory access patterns: a std::vector of objects walked in order is much friendlier to the cache than a linked structure of separately allocated nodes.


Deep dive: debugging (GDB and sanitizers)

SituationTool
Random crashesAddressSanitizer (-fsanitize=address)
Suspected heap corruptionASan + -fsanitize=undefined (where supported)
LeaksLeakSanitizer (with ASan), Valgrind
“Where did it break?”GDB watch on *ptr, bt
// GDB: when you doubt whether a pointer still points to a valid object
// print ptr
// x/4wx ptr   (memory dump — invalid ptr can segfault)

Deep dive: more risky patterns

PatternWhy it is riskyAlternative
Double deleteUndefined behaviorSet to nullptr after delete, or use smart pointers
Old iterators after vector reallocationInvalidationUse indices or reserve to stabilize ranges
reinterpret_cast abuseStrict aliasing issuesstd::bit_cast (C++20) or redesign
C-style varargs + pointersType mismatchesPrefer modern APIs

Deep dive: mini example — linked list node (educational)

struct Node {
    int value{};
    Node* next{nullptr};
};

void push_front(Node*& head, int v) {
    auto* n = new Node{v, head};
    head = n;
}

void free_list(Node* head) {
    while (head) {
        Node* next = head->next;
        delete head;
        head = next;
    }
}

push_front takes Node*&, a reference to the caller’s pointer, because it must change which node the caller’s head points to. Taking Node* by value would update a copy and the caller’s list would never grow; this is the same by-value trap as swap_wrong above, one level up. In free_list, saving head->next before delete head is essential: reading head->next after the delete is a use-after-free.

In production you would usually use std::unique_ptr<Node> (or simply std::list / std::forward_list). The snippet shows why rules matter when a pointer implies ownership. One caveat if you do switch to unique_ptr<Node> next: destroying a very long list recursively calls each node’s destructor, which can overflow the stack, so linked lists built from unique_ptr usually still free nodes in a loop.