Skip to content
C++
Language
since C++11
Beginner

Understand and Use Pointers in C++

Learn to declare, dereference, and pass pointers so you can work directly with memory and write functions that modify their arguments.

By the end of this page, you will be able to declare a pointer, read and write through it, pass it to a function so the function can modify the original variable, and recognize the three most common pointer mistakes before they crash your program.

What and Why

Every variable in your program lives somewhere in memory. Think of memory as a long street of numbered houses β€” each house holds one value, and its address is a number like 0x7ffee4b2c8a0. Normally you refer to a variable by its name (int x = 5;) and the compiler handles the address for you.

A pointer is a variable that holds one of those addresses. Instead of storing a number like 5, it stores the location where a 5 lives. This matters for three big reasons:

  1. Shared mutation β€” a function can receive the address of your variable and change the original, not a copy.
  2. Efficient data passing β€” copying a 4-byte address is cheaper than copying a 400-byte struct.
  3. Dynamic memory β€” allocating objects whose size or lifetime is not known until runtime requires pointers.

Modern C++ gives you safer abstractions (references, smart pointers), but you need to understand raw pointers first because the rest of the language is built on them.

Step by Step

1 β€” Get an address with &

The address-of operator & gives you the memory address of any variable.

cpp
#include <iostream>

int main() {
    int score = 42;
    std::cout << "value:   " << score  << "\n";
    std::cout << "address: " << &score << "\n";
}

Run this and you will see something like:

cpp
value:   42
address: 0x7ffc1234abcd

The exact address changes every run, but the point stands: every variable has one.

2 β€” Declare a pointer

A pointer type is written as the pointee type followed by *. Read int* as "pointer to int".

cpp
#include <iostream>

int main() {
    int score = 42;
    int* ptr = &score;   // ptr holds the address of score

    std::cout << "ptr holds:  " << ptr  << "\n";   // an address
    std::cout << "score is at:" << &score << "\n"; // same address
}

ptr and &score print the same number, confirming that ptr genuinely points at score.

3 β€” Read through a pointer with *

The dereference operator * follows the address and gives you the value stored there.

cpp
#include <iostream>

int main() {
    int score = 42;
    int* ptr = &score;

    std::cout << *ptr << "\n";  // prints 42
}

Reading the pointer itself (ptr) gives an address. Dereferencing it (*ptr) gives the value at that address.

4 β€” Write through a pointer

You can also assign through a dereference, which changes the original variable.

cpp
#include <iostream>

int main() {
    int score = 42;
    int* ptr = &score;

    *ptr = 100;  // writes 100 into the memory that ptr points at

    std::cout << score << "\n";  // prints 100 β€” score itself changed
}

score and *ptr are two names for the same memory location. Writing to either one is the same operation.

5 β€” Pass a pointer to a function

This is the most important real-world use. Without a pointer (or reference), a function receives a copy and cannot change the original:

cpp
#include <iostream>

void addTen(int value) {
    value += 10;  // modifies only the local copy
}

int main() {
    int score = 42;
    addTen(score);
    std::cout << score << "\n";  // still 42
}

With a pointer, the function receives the address and can modify the original:

cpp
#include <iostream>

void addTen(int* value) {
    *value += 10;  // modifies the original through the pointer
}

int main() {
    int score = 42;
    addTen(&score);              // pass the address
    std::cout << score << "\n"; // now 52
}

Common Patterns

Pattern 1 β€” Output parameters

When a function needs to return more than one result, pointers (or references) let it write into caller-provided variables.

cpp
#include <iostream>

void minmax(const int* arr, int len, int* out_min, int* out_max) {
    *out_min = arr[0];
    *out_max = arr[0];
    for (int i = 1; i < len; ++i) {
        if (arr[i] < *out_min) *out_min = arr[i];
        if (arr[i] > *out_max) *out_max = arr[i];
    }
}

int main() {
    int data[] = {3, 1, 4, 1, 5, 9, 2, 6};
    int lo, hi;
    minmax(data, 8, &lo, &hi);
    std::cout << "min=" << lo << " max=" << hi << "\n";
}

Pattern 2 β€” Nullable parameter (optional input)

A pointer can be nullptr β€” the null address β€” to signal "no value provided". A reference cannot do this.

cpp
#include <iostream>

void greet(const char* name) {
    if (name == nullptr) {
        std::cout << "Hello, stranger!\n";
    } else {
        std::cout << "Hello, " << name << "!\n";
    }
}

int main() {
    greet("Alice");   // Hello, Alice!
    greet(nullptr);   // Hello, stranger!
}

Pattern 3 β€” Pointer arithmetic with arrays

An array name decays to a pointer to its first element. You can walk the array by incrementing the pointer.

cpp
#include <iostream>

int main() {
    int nums[] = {10, 20, 30, 40, 50};
    int* p = nums;  // points at nums[0]

    for (int i = 0; i < 5; ++i) {
        std::cout << *(p + i) << " ";  // p+i is the address of nums[i]
    }
    std::cout << "\n";
}

p + i advances the address by i * sizeof(int) bytes β€” the compiler scales the offset automatically.

What Can Go Wrong

Mistake 1 β€” Dereferencing an uninitialized pointer

cpp
// WRONG β€” undefined behavior, likely crash
int* p;
*p = 5;

p contains garbage. Dereferencing it reads or writes a random memory location. Always initialize pointers before use:

cpp
int value = 0;
int* p = &value;  // safe: p holds a valid address
*p = 5;

Mistake 2 β€” Dereferencing nullptr

cpp
// WRONG β€” immediate crash (segfault)
int* p = nullptr;
std::cout << *p;

Whenever a pointer might be null β€” especially one received as a parameter β€” check before dereferencing:

cpp
void print(int* p) {
    if (p != nullptr) {
        std::cout << *p << "\n";
    }
}

Mistake 3 β€” Dangling pointer (pointing to a destroyed variable)

cpp
// WRONG β€” ptr survives past score's lifetime
int* ptr;
{
    int score = 42;
    ptr = &score;
}  // score is destroyed here
*ptr = 10;  // undefined behavior: the memory no longer belongs to score

Never let a pointer outlive the object it points to. If you need an object to outlive its enclosing scope, allocate it on the heap and manage it with a smart pointer (see smart pointers).

Mistake 4 β€” Confusing * in declaration vs. expression

* means two different things depending on where it appears:

cpp
int* ptr = &x;  // declaration: ptr is a pointer-to-int
*ptr = 5;       // expression: dereference ptr, write 5

The declaration * is part of the type. The expression * is an operator. They look the same but do completely different things.

Similarly, & has two completely different meanings:

cpp
int x = 5;
int* ptr = &x;   // expression: address-of operator β€” takes the address of x
int& ref = x;    // declaration: reference type β€” ref is an alias for x

Both use & but one produces an address (operator) and the other declares an alias (type). Context β€” expression vs. type declaration β€” is what determines which meaning applies.

Quick Reference

SyntaxMeaning
int* p;Declare p as a pointer to int
p = &x;Store the address of x in p
*pRead the value at the address held by p
*p = v;Write v to the address held by p
p == nullptrCheck whether p holds the null address
p + nAddress of the element n positions ahead (pointer arithmetic)
p++Advance p to the next element (increments by sizeof(*p) bytes)

Rules to keep in mind:

  • Always initialize a pointer before using it.
  • Always check for nullptr before dereferencing a pointer that might be null.
  • Never dereference a pointer to a variable that has gone out of scope.
  • Prefer references when the pointer will never be null and never needs to be reseated.

Pointer Arithmetic and Arrays

Arrays and pointers are tightly linked in C++ β€” an array name decays to a pointer to its first element the moment you use it in most expressions. This means pointer arithmetic is the underlying mechanism behind every array index you have ever written. When you write arr[i], the compiler translates it to *(arr + i).

cpp
#include <iostream>

int main() {
    int nums[] = {10, 20, 30, 40, 50};

    // Array indexing and pointer arithmetic are exactly equivalent.
    int* p = nums;             // decays to &nums[0]
    std::cout << p[2]   << "\n";  // 30 β€” subscript via pointer
    std::cout << *(p+2) << "\n";  // 30 β€” same thing, explicit arithmetic

    // Walk the array with pointer increment.
    for (int* it = nums; it != nums + 5; ++it) {
        std::cout << *it << " ";  // 10 20 30 40 50
    }
    std::cout << "\n";
}

When you add n to a pointer, the compiler multiplies n by sizeof the pointed-to type and adds that many bytes to the address. So p + 2 on an int* advances by 2 * 4 = 8 bytes on a typical machine β€” landing exactly on nums[2].

The difference between two pointers into the same array has type std::ptrdiff_t (defined in <cstddef>), a signed integer type wide enough to hold any pointer distance:

cpp
#include <cstddef>
#include <iostream>

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    int* first = arr;
    int* last  = arr + 5;
    std::ptrdiff_t count = last - first;   // 5 β€” number of elements between them
    std::cout << count << "\n";
}

Keep arithmetic within the bounds of the array β€” or one past the end (for end-iterator comparisons). Going further is undefined behaviour.

Void Pointers and Type Erasure

void* is the generic pointer type β€” it can hold the address of any object without knowing its type. This makes it useful when you need to pass an arbitrary chunk of memory through an interface that cannot be templated, such as a C-style callback or a memory allocator.

cpp
#include <iostream>
#include <cstdlib>   // std::malloc, std::free

int main() {
    // malloc returns void* β€” it knows the size but not the type.
    void* raw = std::malloc(sizeof(int) * 4);

    // You must cast back to the real type before dereferencing.
    int* arr = static_cast<int*>(raw);
    arr[0] = 42;
    std::cout << arr[0] << "\n";  // 42

    std::free(raw);
}

Three rules apply to void*:

  1. You cannot dereference a void* directly β€” the compiler does not know the size of the pointee.
  2. You cannot do arithmetic on a void* for the same reason.
  3. Always cast back to the original type before use; casting to the wrong type is undefined behaviour.

In modern C++, void* appears mainly at boundaries with C code and in low-level allocators. Prefer templates or std::any / std::variant when you need type-erased storage in pure C++ code.

Function Pointers

A function pointer stores the address of a function and lets you call it indirectly β€” the building block of callbacks, dispatch tables, and plugin systems. The syntax mirrors ordinary pointer syntax, with the function's return type and parameter types as part of the pointer's type.

cpp
#include <iostream>

int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }

int main() {
    // Declare a pointer to a function that takes two ints and returns int.
    int (*op)(int, int);

    op = &add;
    std::cout << op(3, 4) << "\n";  // 7

    op = mul;                        // & is optional for functions
    std::cout << op(3, 4) << "\n";  // 12
}

The raw syntax is notoriously hard to read. A using alias makes it much clearer:

cpp
#include <iostream>

using BinaryOp = int(*)(int, int);   // named type alias

int subtract(int a, int b) { return a - b; }

void apply(BinaryOp op, int x, int y) {
    std::cout << op(x, y) << "\n";
}

int main() {
    apply(subtract, 10, 3);  // 7
}

For more flexibility β€” storing lambdas, member functions, or stateful callables β€” use std::function from <functional>:

cpp
#include <functional>
#include <iostream>

int main() {
    int factor = 5;
    // std::function can hold a lambda that captures local variables.
    std::function<int(int)> scale = [factor](int x) { return x * factor; };
    std::cout << scale(6) << "\n";  // 30
}

std::function is more flexible but carries a small overhead from type erasure. Prefer raw function pointers for simple C-style callbacks; use std::function or templates when you need to accept lambdas.

const-Correctness with Pointers

const can appear on either side of the *, and each position means something different. Getting this right is essential for writing APIs that protect their callers from accidental mutation.

There are three combinations to know:

cpp
int value = 10;
const int* const int_val = &value;   // will be used below

// 1. Pointer to const int β€” you can move the pointer, but not modify the int.
const int* p1 = &value;
// *p1 = 20;   // ERROR: value pointed to is const
p1 = nullptr;  // OK: the pointer itself can change

// 2. Const pointer to int β€” the pointer is fixed, but the int can change.
int* const p2 = &value;
*p2 = 20;      // OK: the int is not const
// p2 = nullptr;  // ERROR: the pointer itself is const

// 3. Const pointer to const int β€” neither can change.
const int* const p3 = &value;
// *p3 = 30;   // ERROR
// p3 = nullptr; // ERROR

A memory trick: read the type right-to-left. const int* reads "pointer to int-that-is-const". int* const reads "const-pointer to int". const int* const reads "const-pointer to int-that-is-const".

In practice, the most common form you will write is const int* (or const T*) for function parameters where you want to accept a pointer but promise not to modify the pointee:

cpp
// Promises not to modify the string β€” callers pass read-only data safely.
void print(const char* text) {
    while (*text) {
        std::cout << *text++;
    }
    std::cout << "\n";
}

Always add const to pointer (and reference) parameters when the function does not need to write through them. It makes intent explicit and enables the compiler to catch accidental writes.

Null Pointer Idioms β€” nullptr vs 0 vs NULL

Use nullptr β€” it is the only null pointer constant that is type-safe. Before C++11, code used 0 or the macro NULL (typically defined as 0 or (void*)0). Both are integers, which creates surprising ambiguity:

cpp
void f(int n)   { std::cout << "int\n"; }
void f(int* p)  { std::cout << "pointer\n"; }

int main() {
    f(0);        // calls f(int) β€” 0 is an integer, not a pointer
    f(NULL);     // might call f(int) β€” depends on how NULL is defined
    f(nullptr);  // always calls f(int*) β€” unambiguously a null pointer
}

nullptr has its own type, std::nullptr_t, which converts to any pointer type but never to an integer. This means overload resolution always picks the pointer overload, template deduction works correctly, and you can never accidentally pass a null pointer where an integer is expected.

The rule is simple: always write nullptr, never 0 or NULL, for null pointers. The older forms only exist in pre-C++11 code and in C headers.

cpp
// All of these are null pointers β€” only nullptr is correct modern C++.
int* a = nullptr;   // correct
int* b = 0;         // works, but misleading β€” 0 is an integer
int* c = NULL;      // works, but NULL might be defined as (void*)0 or 0

Common Pointer Bugs

Most pointer bugs fall into a handful of repeating patterns; recognizing them is half the battle. Here are the ones you are most likely to encounter as a beginner.

Dangling pointer

A dangling pointer holds the address of memory that has been freed or that belongs to a variable that has gone out of scope. Reading or writing through it is undefined behaviour β€” sometimes a crash, sometimes silent data corruption.

cpp
int* dangle() {
    int local = 42;
    return &local;   // WRONG: local is destroyed when the function returns
}

int main() {
    int* p = dangle();
    std::cout << *p;  // undefined behaviour
}

Fix: never return the address of a local variable. If the caller needs the data to outlive the function, allocate on the heap (preferably via a smart pointer) or pass storage in from the caller.

Wild pointer

A wild pointer is an uninitialized pointer β€” it contains whatever garbage was in memory at that location.

cpp
int* wild;         // uninitialized β€” holds a garbage address
*wild = 5;         // undefined behaviour: writes to a random memory location

Fix: always initialize pointers. If you do not have a valid address yet, initialize to nullptr and check before use.

Off-by-one in pointer arithmetic

Pointer arithmetic is zero-based and the valid range is [arr, arr + size] β€” the end pointer arr + size is a valid comparison target but must never be dereferenced.

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

// WRONG: reads one element past the end
for (int i = 0; i <= 5; ++i) {  // should be i < 5
    std::cout << *(p + i);
}

Fix: keep your loop condition to < size, not <= size. When writing iterators, use arr + size as the end sentinel but never dereference it.

When to Prefer References Over Pointers

References are safer and cleaner than pointers for the common case of providing a function with access to a caller's variable. A reference cannot be null, cannot be reseated to point at a different object after initialization, and uses the same syntax as a value β€” no * or -> needed.

Choose a reference when:

  • The argument is always required (never optional / never null).
  • You do not need to change which object the parameter refers to.
  • You want to avoid the calling site noise of & and *.
cpp
#include <iostream>
#include <string>

// Reference β€” clean syntax, guaranteed non-null.
void append_exclamation(std::string& s) {
    s += "!";
}

// Pointer β€” necessary when nullptr is a valid input.
void maybe_append(std::string* s) {
    if (s != nullptr) s->append("!");
}

int main() {
    std::string msg = "Hello";
    append_exclamation(msg);   // no & at the call site
    std::cout << msg << "\n";  // Hello!

    maybe_append(&msg);
    std::cout << msg << "\n";  // Hello!!
}

The guideline used widely in the C++ community: use a pointer when null is a meaningful value or when you need to rebind the parameter to a different object; use a reference otherwise. In modern C++ you rarely need raw owning pointers β€” prefer smart pointers for owned heap objects and references for non-owning access.

What's Next

  • Pointers β€” language reference β€” the full specification of pointer types, cv-qualifiers, and pointer conversions.
  • Function Pointers β€” store and call functions through a pointer, the building block of callbacks.
  • Smart Pointers β€” unique_ptr and shared_ptr automate lifetime management so you rarely need raw new/delete.
  • References β€” safer, non-nullable alternative to pointers for most everyday use cases.