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 **ppand why you need them - Dynamic memory:
malloc,calloc,realloc,free - The classic pitfalls: dangling pointers, wild pointers, buffer overflows, memory leaks
constcorrectness and advanced patterns
📚 Prerequisites
| Topic | Level Required | Key Concepts |
|---|---|---|
| C Syntax | Beginner | Variables, data types, operators, control flow |
| Functions | Beginner | Parameters, return values, call stack |
| Arrays | Beginner | Declaration, indexing, passing to functions |
| Binary / Hex | Awareness | Memory addresses are shown in hex (0x...) |
| Compiler | Any | GCC, 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.
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 *p = &x;
Think: “Where in memory does x live?”
The * Operator — Dereference
Think: “Go to that address and fetch what’s there.”
#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:
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).
int* advances 4 bytes, not 1. The compiler multiplies your offset by sizeof(int) automatically.#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 &arris a pointer to the entire array;&ptris 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
#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.
#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.
pp → p → x. Each * takes you one level closer to the actual data.#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.
Allocate Uninitialised
Allocates n bytes. Memory is uninitialised — contains garbage. Returns NULL on failure. Always check.
Allocate and Zero
Like malloc but zero-initialises all bytes. Takes count and element size separately: calloc(n, sz).
Resize
Resize an existing allocation. May return a new address. Always assign the return value — never use the old pointer after calling it.
Release
Releases memory back to the OS. Every malloc / calloc / realloc must have one matching free.
#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
/* ✗ 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)
/* ✗ 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
/* ✗ 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 --analyzeorcppcheck
🔒 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:
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
/* 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
#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
/* 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:
free(p); p = NULL; prevents double-free and use-after-free
const T* signals no modification — use it everywhere
🌞 Where to Go From Here
🎯 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
sizeofin scope - Function pointers enable runtime dispatch — callbacks, strategies, vtables
- Dynamic memory requires discipline: every malloc has a free, every pointer starts initialised
constcorrectness makes your APIs self-documenting and safer

Comments