Pointers are the single most powerful — and most feared — feature of the C programming language. They are the reason C can write operating systems, game engines, and embedded firmware. Master pointers, and you master C.

Unlike high-level languages that hide memory management behind garbage collectors, C gives you direct control over memory addresses. A pointer is simply a variable that stores a memory address. That one sentence contains everything — the rest is understanding what it means in practice.

🎯 What You Will Learn

This guide covers every essential aspect of pointer programming in C:

  • How computer memory is laid out and why addresses matter
  • Declaring, initialising, and dereferencing pointers with & and *
  • The relationship between pointers and arrays — and where they differ
  • Pointer arithmetic — moving through memory safely
  • Pointers to functions — callbacks and dispatch tables
  • Double pointers — int **pp and why you need them
  • Dynamic memory: malloc, calloc, realloc, free
  • The classic pitfalls: dangling pointers, wild pointers, buffer overflows, memory leaks
  • const correctness and advanced patterns

📚 Prerequisites

Topic Level Required Key Concepts
C SyntaxBeginnerVariables, data types, operators, control flow
FunctionsBeginnerParameters, return values, call stack
ArraysBeginnerDeclaration, indexing, passing to functions
Binary / HexAwarenessMemory addresses are shown in hex (0x...)
CompilerAnyGCC, Clang, MSVC — examples work on all

🏛 Chapter 1 — Understanding Memory

Before touching a single pointer, you must understand how your program’s memory is organized. Every variable you declare occupies one or more bytes at a specific memory address. Think of RAM as a gigantic array of numbered slots, where each slot holds exactly one byte. The slot number is the address.

When you declare int x = 42;, the compiler allocates 4 bytes somewhere in RAM and stores the value 42 across those bytes. A pointer is a variable that holds one of those slot numbers — it points to where another variable lives.

RAM Layout — int x = 42 and int *p = &x ADDRESS BYTE VALUE DESCRIPTION 0x1000 0x2A int x = 42 — low byte 0x1001 0x00 int x (byte 2) 0x1002 0x00 int x (byte 3) 0x1003 0x00 int x — high byte 0x1004 0x00 int *p — stores 0x1000 (byte 1) 0x1005 0x10 int *p — address of x (byte 2) 0x1006 — 1007 0x00 ... int *p (remaining bytes of address) Key Facts • int is 4 bytes on most platforms • pointer is 8 bytes on 64-bit • p stores the ADDRESS of x • *p follows p to read/write x p points to x
Figure 1 — RAM layout: int x = 42 occupies 4 bytes starting at 0x1000. Pointer p occupies 4–8 bytes starting at 0x1004 and stores the value 0x1000.

✎ Chapter 2 — Declaring and Using Pointers

Two operators do all the work with pointers. Learn these and pointer syntax becomes trivial:

The & Operator — Address-of

int x = 42;
int *p = &x;
Returns the memory address of variable x.
Think: “Where in memory does x live?”

The * Operator — Dereference

int val = *p;
Follows the pointer to the stored address and reads the value there.
Think: “Go to that address and fetch what’s there.”
C — pointer_basics.c
#include <stdio.h>

int main(void) {
    int x = 42;          /* regular integer variable          */
    int *p = &x;         /* p stores the ADDRESS of x         */

    /* Three ways to read x */
    printf("x   = %d\n", x);           /* 42  — direct access  */
    printf("*p  = %d\n", *p);          /* 42  — via pointer    */

    /* Print the address itself */
    printf("&x  = %p\n", (void *)&x);  /* e.g. 0x7ffd1000      */
    printf("p   = %p\n", (void *)p);   /* same value as &x     */

    /* Modify x through the pointer */
    *p = 100;
    printf("x   = %d\n", x);           /* 100 — x changed!     */

    return 0;
}
💡

Reading Pointer Declarations — The Right-to-Left Rule

Read int *p as: “p is a pointer to int.” The * in a declaration is not the dereference operator — it signals that the variable being declared is a pointer. The same symbol serves two entirely different roles depending on context.

2.1 Pointer Types — Always Match the Pointee

The type before the * tells the compiler how many bytes to read when you dereference, and how far to step when you do arithmetic. Using the wrong type leads to undefined behaviour:

C — pointer_types.c
char   c = 'A';
int    n = 1000;
double d = 3.14;

char   *pc = &c;    /* reads 1 byte on dereference  */
int    *pn = &n;    /* reads 4 bytes on dereference */
double *pd = &d;    /* reads 8 bytes on dereference */

/* sizeof a POINTER is always the same on a given architecture */
printf("%zu %zu %zu\n", sizeof(pc), sizeof(pn), sizeof(pd));
/* On 64-bit:  8  8  8  — all pointers are the same size */

/* void* is the generic pointer — carries no type info */
void *generic = pn;    /* OK — implicit conversion       */
/* *generic = 5;  — ERROR: cannot dereference void*   */

∞ Chapter 3 — Pointer Arithmetic

You can perform arithmetic on pointers, but the unit of movement is not bytes — it is the size of the pointed-to type. This is what makes pointers so powerful for traversing arrays: the compiler automatically scales your offset by sizeof(type).

Pointer Arithmetic — int array[5], sizeof(int) = 4 [0] [1] [2] [3] [4] 10 20 30 40 50 0x2000 0x2004 0x2008 0x200C 0x2010 p p+1 p+2 +4 bytes Rule p + n moves by n × sizeof(*p) bytes *(p+1) == p[1] always equivalent
Figure 2 — Adding 1 to an int* advances 4 bytes, not 1. The compiler multiplies your offset by sizeof(int) automatically.
C — ptr_arithmetic.c
#include <stdio.h>

int main(void) {
    int arr[] = {10, 20, 30, 40, 50};
    int *p = arr;   /* array name decays to pointer to first element */

    printf("%d\n", *p);       /* 10 — arr[0]  */
    printf("%d\n", *(p+1));   /* 20 — arr[1]  */
    printf("%d\n", *(p+4));   /* 50 — arr[4]  */

    /* Traversal with pointer increment */
    int *end = arr + 5;   /* one-past-the-end — valid address, never dereference */
    for (int *cur = arr; cur < end; cur++) {
        printf("%d ", *cur);
    }
    /* Output: 10 20 30 40 50 */

    /* Pointer subtraction gives element distance, not byte distance */
    int *q = &arr[3];
    printf("distance: %td\n", q - p);   /* 3, not 12 */

    return 0;
}

📦 Chapter 4 — Pointers and Arrays

In C, arrays and pointers are deeply intertwined. When you use an array name in an expression it decays into a pointer to its first element. This is why you can pass arrays to functions as pointers — but there are critical differences you must know:

❌ Where Arrays and Pointers Differ

  • sizeof(arr) gives total array bytes; sizeof(ptr) gives only the pointer size
  • An array name is not assignable: arr = p; is illegal
  • &arr is a pointer to the entire array; &ptr is a pointer to the pointer variable
  • Arrays have compile-time fixed size; pointers can point anywhere

✓ Where They Are Equivalent

  • arr[i] is exactly *(arr + i) — the compiler rewrites it
  • Passing an array to a function passes a pointer to its first element
  • Both support [] indexing notation
  • Both support pointer arithmetic within valid bounds
C — arrays_and_ptrs.c
#include <stdio.h>

/* arr decays to int* here — length info is LOST */
void print_array(int *arr, size_t len) {
    for (size_t i = 0; i < len; i++)
        printf("%d ", arr[i]);   /* arr[i] == *(arr + i) */
    printf("\n");
}

int main(void) {
    int nums[] = {1, 2, 3, 4, 5};

    printf("sizeof nums = %zu\n", sizeof(nums));   /* 20 — 5×4 bytes  */
    int *p = nums;
    printf("sizeof p   = %zu\n", sizeof(p));     /* 8  — pointer!   */

    /* All four expressions below are identical */
    printf("%d\n", nums[2]);           /* array indexing      */
    printf("%d\n", *(nums + 2));       /* pointer arithmetic  */
    printf("%d\n", p[2]);             /* pointer indexing    */
    printf("%d\n", *(p + 2));         /* all give: 3         */

    print_array(nums, 5);   /* always pass length separately */
    return 0;
}

4.1 Strings Are char Arrays — Pointed at by char*

⚠️

String Literals Are Read-Only

const char *s = "Hello"; points into read-only memory in the binary. Writing through that pointer (s[0] = 'h';) is undefined behaviour and will almost always cause a segfault. To get a mutable string, copy it into an array: char buf[] = "Hello";

📜 Chapter 5 — Function Pointers

Functions occupy memory just like variables. A function pointer stores the address of a function, allowing you to pass functions as arguments, build dispatch tables, and implement runtime polymorphism — the foundation of callbacks in every C library.

Function Pointer Callback — Strategy Pattern in C sort(arr, n, cmp) caller passes a function pointer void sort(int *arr, int(*cmp)(int,int)) calls cmp() at runtime cmp_ascending() cmp_descending() cmp_by_absolute() → ascending sort → descending sort → custom comparator Syntax: int (*fp)(int, int); — fp is a pointer to a function taking two ints, returning int Typedef: typedef int (*Comparator)(int, int); — strongly recommended for readability
Figure 3 — Function pointer callback pattern. The sort function is decoupled from the comparison strategy entirely.
C — function_pointers.c
#include <stdio.h>
#include <stdlib.h>

/* Typedef makes function pointer declarations readable */
typedef int (*Comparator)(const void *, const void *);

int cmp_ascending(const void *a, const void *b) {
    return *(int *)a - *(int *)b;
}

int cmp_descending(const void *a, const void *b) {
    return *(int *)b - *(int *)a;
}

int main(void) {
    int data[] = {5, 3, 8, 1, 9, 2};
    size_t n = sizeof(data) / sizeof(data[0]);

    /* Pass the comparator as a function pointer */
    qsort(data, n, sizeof(int), cmp_ascending);
    /* data: 1 2 3 5 8 9 */

    qsort(data, n, sizeof(int), cmp_descending);
    /* data: 9 8 5 3 2 1 */

    /* Dispatch table — array of function pointers */
    Comparator strategies[] = {cmp_ascending, cmp_descending};
    int mode = 0;
    qsort(data, n, sizeof(int), strategies[mode]);

    return 0;
}

⬇ Chapter 6 — Double Pointers

A double pointer (int **pp) stores the address of another pointer. This is essential when a function needs to modify the caller’s pointer variable itself — for example, allocating memory and making the caller’s pointer point to the newly allocated block.

Double Pointer — Three Levels of Indirection int **pp holds address of p addr: 0x3000 *pp int *p holds address of x addr: 0x1004 *p / **pp int x = 42 the actual value addr: 0x1000 Access Expressions pp → address of p *pp → value of p (= &x) **pp → value of x (= 42) *pp = q makes p point to q pp = 0x3000 *pp = p = 0x1004 **pp = *p = 42
Figure 4 — Double pointer: pp → p → x. Each * takes you one level closer to the actual data.
C — double_pointers.c
#include <stdio.h>
#include <stdlib.h>

/* Classic use: allocate memory and return via double pointer */
int allocate_array(int **out, size_t count) {
    *out = (int *) malloc(count * sizeof(int));
    if (*out == NULL) return -1;
    for (size_t i = 0; i < count; i++)
        (*out)[i] = (int)i * 10;
    return 0;
}

int main(void) {
    int *arr = NULL;

    /* Pass &arr so the function can modify our pointer variable */
    if (allocate_array(&arr, 5) != 0) {
        fprintf(stderr, "Allocation failed\n");
        return 1;
    }

    for (int i = 0; i < 5; i++)
        printf("%d ", arr[i]);   /* 0 10 20 30 40 */

    free(arr);
    arr = NULL;   /* prevent dangling pointer */
    return 0;
}

🏗 Chapter 7 — Dynamic Memory Allocation

The C standard library’s memory management functions let programs handle data of arbitrary size at runtime. These four functions — and their relationship with pointers — are non-negotiable knowledge for any C developer.

malloc

Allocate Uninitialised

Allocates n bytes. Memory is uninitialised — contains garbage. Returns NULL on failure. Always check.

calloc

Allocate and Zero

Like malloc but zero-initialises all bytes. Takes count and element size separately: calloc(n, sz).

realloc

Resize

Resize an existing allocation. May return a new address. Always assign the return value — never use the old pointer after calling it.

free

Release

Releases memory back to the OS. Every malloc / calloc / realloc must have one matching free.

C — dynamic_memory.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    size_t cap = 4;

    /* malloc — allocate uninitialized memory */
    int *buf = (int *) malloc(cap * sizeof(int));
    if (!buf) { perror("malloc"); return 1; }

    for (size_t i = 0; i < cap; i++) buf[i] = (int)(i + 1);

    /* realloc — grow to 8 elements */
    size_t new_cap = 8;
    int *tmp = (int *) realloc(buf, new_cap * sizeof(int));
    if (!tmp) { free(buf); return 1; }
    buf = tmp;   /* IMPORTANT: use the new address */
    for (size_t i = cap; i < new_cap; i++) buf[i] = (int)(i + 1);

    /* calloc — zero-initialised struct array */
    typedef struct { int x, y; } Point;
    Point *pts = (Point *) calloc(10, sizeof(Point));
    if (!pts) { free(buf); return 1; }
    /* pts[0].x == 0, pts[0].y == 0 — guaranteed */

    free(pts); pts = NULL;
    free(buf); buf = NULL;   /* NULL prevents double-free accidents */
    return 0;
}

⚠️ Chapter 8 — Classic Pitfalls

Pointer bugs cause silent corruption — the program often continues running with corrupted state, crashing somewhere completely unrelated to the actual bug. Here are the five killers every C developer must internalise:

8.1 The Dangling Pointer

C — pitfall_dangling.c
/* ✗ BAD: returning a pointer to a local (stack) variable */
int * dangerous(void) {
    int local = 42;
    return &local;   /* local is destroyed on return! */
}

/* ✗ BAD: using a pointer after free */
int *p = (int *) malloc(sizeof(int));
free(p);
*p = 42;   /* UNDEFINED BEHAVIOUR — heap already freed */

/* ✓ GOOD: NULL immediately after free */
free(p);
p = NULL;
if (p) *p = 42;   /* NULL check prevents dereference */

8.2 The Wild Pointer (Uninitialised)

C — pitfall_wild.c
/* ✗ BAD: uninitialised pointer contains a garbage address */
int *wild;
*wild = 42;   /* writing to a random memory location — crash or corruption */

/* ✓ GOOD: always initialise every pointer */
int *safe = NULL;                             /* no target yet           */
int x = 42; int *ok = &x;                   /* initialised to &x       */
int *heap = (int *) malloc(sizeof(int));       /* initialised by malloc   */

8.3 Buffer Overflow

C — pitfall_overflow.c
/* ✗ BAD: off-by-one — writes one element past the end */
int arr[5];
for (int i = 0; i <= 5; i++) arr[i] = i;   /* should be i < 5 */

/* ✗ BAD: strcpy without bounds checking */
char buf[8];
strcpy(buf, "This string is too long!");   /* classic overflow */

/* ✓ GOOD: use snprintf — always safe */
snprintf(buf, sizeof(buf), "%s", "Hello");
buf[sizeof(buf) - 1] = '\0';
🚫

Memory Leaks — The Silent Resource Drain

A memory leak occurs when allocated heap memory is never freed. In long-running applications, leaks accumulate until the process exhausts available memory. Detect them with:

  • Valgrind: valgrind --leak-check=full ./your_program
  • AddressSanitizer: compile with -fsanitize=address
  • LeakSanitizer: compile with -fsanitize=leak
  • Static analysis: clang --analyze or cppcheck

🔒 Chapter 9 — const and Pointer Correctness

The const keyword interacts with pointers in four distinct ways. Understanding these four combinations is essential for writing correct, self-documenting APIs:

C — const_pointers.c
int a = 10, b = 20;

/* 1. Pointer to const int — data is read-only, pointer is mutable */
const int *p1 = &a;
/* *p1 = 5;  ✗ ERROR */
p1 = &b;             /* ✓ OK */

/* 2. Const pointer to int — pointer is read-only, data is mutable */
int * const p2 = &a;
*p2 = 99;           /* ✓ OK */
/* p2 = &b;  ✗ ERROR */

/* 3. Const pointer to const int — everything read-only */
const int * const p3 = &a;
/* *p3 = 5;  ✗  p3 = &b;  ✗ */

/* 4. Regular pointer — fully mutable (default) */
int *p4 = &a;
*p4 = 77; p4 = &b;   /* ✓ both OK */

/* Read right-to-left:
   "const int *"      — pointer to int-that-is-const
   "int * const"      — const-pointer to int
   "const int* const" — const-pointer to int-that-is-const */
💡

Best Practice — Use const T* for Read-Only Parameters

When a function takes a pointer and does not modify the data, always declare it const T*. This communicates intent clearly, prevents accidental modification, allows the caller to pass const data without a cast, and often enables additional compiler optimisations.

⚡ Chapter 10 — Advanced Patterns

10.1 Void Pointers — Generic Programming in C

C — void_ptr.c
/* void* is C's generic pointer — no type info attached.
   qsort, bsearch, and memcpy all use it for this reason. */

void generic_print(const void *data, const char *type) {
    if (strcmp(type, "int") == 0)
        printf("int: %d\n", *(const int *)data);
    else if (strcmp(type, "double") == 0)
        printf("double: %f\n", *(const double *)data);
}

/* memcpy copies raw bytes regardless of type */
int src = 0xDEADBEEF;
int dst;
memcpy(&dst, &src, sizeof(int));   /* dst == src */

10.2 Flexible Array Members — Inline Dynamic Structs

C — flexible_array.c
#include <stdlib.h>

/* C99+ flexible array member — must be the last field */
typedef struct {
    size_t len;
    int    data[];   /* zero-size trailing array */
} IntVec;

IntVec * ivec_new(size_t n) {
    /* Struct + inline array in one allocation — great cache locality */
    IntVec *v = malloc(sizeof(IntVec) + n * sizeof(int));
    if (!v) return NULL;
    v->len = n;
    return v;
}

IntVec *vec = ivec_new(10);
vec->data[0] = 42;   /* in-place — no extra pointer hop */
free(vec);            /* one free for the whole thing    */

10.3 restrict — A Compiler Promise

C — restrict.c
/* restrict (C99): promises the pointers do NOT alias
   — enables SIMD vectorisation by the compiler            */

/* Without restrict: compiler must assume possible overlap */
void add_slow(float *dst, float *src, size_t n) {
    for (size_t i = 0; i < n; i++) dst[i] += src[i];
}

/* With restrict: compiler generates faster SIMD code */
void add_fast(float * restrict dst,
              const float * restrict src, size_t n) {
    for (size_t i = 0; i < n; i++) dst[i] += src[i];
}
/* The C standard memcpy uses restrict — that's why it's so fast */

✅ Production Pointer Checklist

Apply these rules to every pointer in every function before shipping:

Initialise ALL pointers To NULL, &var, or malloc() — never leave them uninitialised
Check malloc() return value Always handle the NULL case — allocation CAN fail
NULL after free() free(p); p = NULL; prevents double-free and use-after-free
Every malloc has a free Track ownership clearly — who allocates, who frees?
Bounds-check all accesses Never access arr[i] without ensuring i < length
Use const for read-only params const T* signals no modification — use it everywhere
Run Valgrind / ASan Catch leaks, overflows, and use-after-free in every test run
Never return stack addresses Local variables are destroyed when the function returns

🌞 Where to Go From Here

🧵

Linked Lists and Trees

The foundational data structures — singly/doubly linked lists, binary trees — are built entirely from structs with pointer members. The natural next step.

⚙️

Custom Memory Allocators

Implement your own arena, pool, or slab allocator using pointer arithmetic and mmap. The deepest insight into how C memory actually works.

🛡️

AddressSanitizer & Valgrind

Master the full suite of memory debugging tools. These catch the bugs that code review misses — essential for any production C codebase.

🔨

Pointers in C++ — Smart Pointers

C++ wraps raw pointers with unique_ptr, shared_ptr, and weak_ptr — RAII wrappers that manage lifetime automatically. The natural evolution.

🎯 Conclusion

Pointers are not magic — they are elegantly simple once you understand the memory model. Every confusing thing about pointers reduces to one of three operations: take an address (&), follow an address (*), or move between addresses (arithmetic).

  • A pointer is a variable that stores a memory address
  • & gives you an address; * follows one — two sides of the same coin
  • Pointer arithmetic steps in units of the pointed-to type, not bytes
  • Arrays decay to pointers — but retain their size only via sizeof in scope
  • Function pointers enable runtime dispatch — callbacks, strategies, vtables
  • Dynamic memory requires discipline: every malloc has a free, every pointer starts initialised
  • const correctness makes your APIs self-documenting and safer