Pointers vs Arrays
— when each one earns its place.
They look alike. They almost behave alike. The compiler will even forgive you for confusing them, most of the time. But behind the syntax, an array is a fixed shelf in memory and a pointer is a signpost that holds an address — and that single difference decides whether your code lives in firmware, in a kernel, or in a textbook.
The mental model.
Same destination, two different vehicles.
An array is born knowing where it lives. The compiler reserves a contiguous
block of memory at compile time and the array's name is the address of its first byte.
A pointer, by contrast, is just a small variable — typically eight bytes on a 64-bit machine —
whose only job is to hold an address. It can point to anything, including an array.
That's why arr[i] and *(arr + i) compile to the same instruction:
both forms ask the same question — what is at address arr + i × sizeof(element)?
A shelf of fixed boxes.
Five 4-byte cells, side by side, allocated in one go. The name arr
is shorthand for the first cell's address.
A signpost holding an address.
The pointer p is itself a variable, stored elsewhere. It contains
the address 0x1000, which it can be reassigned to forget at any moment.
How arr[2] and *(arr + 2) resolve.
Both forms compile to the same load. Click to watch the resolution.
Where they secretly differ.
Five tests that tell them apart.
An array name is often described as "a pointer to its first element," and the compiler encourages this lie by silently decaying arrays to pointers in most contexts. But the two are not interchangeable. Here are the five tests that expose the difference — and why each one matters in real code.
int arr[5]; int *p = arr; sizeof(arr); // 20 sizeof(p); // 8
int arr[5], other[5]; int *p = arr; arr = other; // ✗ error p = other; // ✓ ok
T*. The size is lost.void f(int a[5]) { sizeof(a); // 8, not 20! } // a is really int*
&arr has type int(*)[5] — a pointer to the whole array. Numerically the same address, different type.&p is the address of the pointer itself, type int**.&arr; // int (*)[5] &arr+1; // jumps 20 bytes &p; // int ** &p+1; // jumps 8 bytes
// stack: 20 bytes int arr[5]; // stack: 8 bytes, // heap: 20 bytes int *p = malloc(20);
Where arrays win.
Three places fixed shelves beat signposts.
When the size is known, the count is small, and the lifetime is the function's lifetime, arrays are faster, smaller, and safer than anything heap-allocated. The compiler can see them, the cache can hold them, and the linker can place them in the right segment. Three real cases where the choice writes itself:
A UART buffer that never mallocs.
On a microcontroller with 32 KB of RAM, there is no heap. Or rather, there is, but the safety-critical coding standards (MISRA, AUTOSAR, NASA's Power of Ten) forbid it after initialization. Allocations are nondeterministic — they can fragment, fail, or stall a real-time loop.
So a serial driver declares its receive buffer as a fixed array
in BSS: static uint8_t rx_buf[256];. The address is known at link time,
the memory is reserved before main() runs, and the ISR can write to it
without touching any allocator. Predictable, bounded, debuggable.
Every Linux kernel driver, every FreeRTOS task, every Arduino sketch you've ever seen does this. The pointer version doesn't even compile against half their toolchains.
Sine, CRC, glyphs — computed once, looked up forever.
An audio synthesizer needs sin(x) a million times a second.
Computing it from a Taylor series each time costs cycles you don't have. The fix is
ancient: precompute 1024 values, store them in a const array,
index by phase. One memory load replaces dozens of multiplications.
Because the array is const, the linker places it in
.rodata — read-only memory, often Flash on embedded devices.
It costs zero RAM. The same trick powers CRC tables in every TCP/IP stack,
bitmap font glyphs in your terminal, and the trigonometric tables inside
a CD player's DSP.
You can't put a heap-allocated buffer in ROM. Only an array.
int buf[256] on the stack beats malloc.
Allocating 256 ints on the stack is one instruction:
sub rsp, 1024. Deallocating them is another single instruction
when the function returns. The cache is already warm — the stack frame is
right where the CPU just was.
Calling malloc(256 * sizeof(int)) instead means: a function call,
free-list traversal, possibly a brk or mmap syscall,
a thread-safety lock, and a corresponding free() later. Hundreds
of cycles vs one. Plus a cache miss when you finally touch the heap memory.
The rule: if the buffer fits comfortably (a few KB) and lives no longer than the function, always use a stack array. Reach for the heap only when the size is huge or the lifetime extends beyond the call.
Where pointers win.
Three places signposts beat shelves.
The moment your data doesn't know its size at compile time, or its shape changes during the program, arrays surrender. Pointers are the only way to build structures that grow, branch, or refer to one another — and the only way to pass them around without copying.
Lists, trees, graphs — nodes that find their neighbours.
An array stores its elements in one contiguous block. That works beautifully until you need to insert in the middle (shift everyone) or grow past the allocated size (reallocate, copy, repoint). Both are O(n) operations dressed up as constant-time ones.
A linked list pays the opposite price: O(1) insertion, O(n) lookup.
Each node carries a next pointer to wherever the next node happens to live.
That "wherever" is the whole point — nodes can be allocated anywhere, freed
independently, rearranged without copying any data, only by rewiring pointers.
Trees and graphs generalize the idea. The Linux kernel's struct list_head
is one of the most-used pointer constructs in computing.
A 1 MB struct passed by pointer is O(1).
Pass a struct by value and the compiler must copy every byte into the callee's stack frame. For a 1 MB struct, that's a million bytes of memcpy — every call, every time. Cache obliterated, latency spiked.
Pass it by pointer and only 8 bytes move: the address. The callee dereferences when it needs the data. This is why every non-trivial C API takes pointers, why C++ has references, why Rust has borrows.
The pointer also lets the callee modify the original. That's how
fread(buf, ...) fills your buffer, how scanf("%d", &n)
writes to your variable, and how every output-parameter idiom in C ever written
actually works.
Function pointers — polymorphism in plain C.
How does the Linux kernel call the right read() for a file on
ext4, on a pipe, on a TCP socket, all through one syscall? It doesn't know.
Each filesystem registers a struct of function pointers — file_operations —
and the kernel dispatches through it: file->f_op->read(file, buf, ...).
This is virtual methods, but written by hand. Drivers, state machines,
callback APIs, libc's qsort taking a comparator, OpenGL's vtable,
every event loop ever — all the same trick. An array of function
pointers is a vtable.
Without pointers, "what to do next" must be hardcoded. With them, behavior is data — and data can be swapped at runtime.
The interactive playground.
Step through a program. Watch memory move.
Run the lines below one at a time. The right-hand panel shows what happens in memory: cells fill in, the pointer arrow physically moves, and the status reports what the machine just did. When you're ready, toggle a common mistake to watch the same code go subtly, instructively wrong.
Common mistakes — toggle to demo:
In production.
Real codebases, real tradeoffs.
The choice between pointer-driven flexibility and array-driven locality isn't academic. It's the dividing line between systems that fit in 32 KB and ones that don't, between databases that hit a million queries per second and ones that don't, between game engines that hold 60 FPS and ones that don't.
Linux kernel
Intrusive linked lists via struct list_head are
everywhere — task lists, page caches, network buffers. Each struct embeds the
list pointers directly, so traversal is just dereference-and-go. Zero allocations
per node.
SQLite
Database files are split into fixed-size pages (typically 4 KB) held as a contiguous array in the page cache. Predictable indexing, no fragmentation, and the whole B-tree algorithm assumes constant-time page lookup.
Redis · SDS
Simple Dynamic Strings prepend a small header (length, capacity)
to a regular C array of bytes. The user gets a char* compatible with
every libc function — but Redis still has O(1) length and bounds checks.
Array layout, pointer interface.
Embedded RTOS
FreeRTOS, Zephyr, AUTOSAR — many real-time systems forbid malloc
after init. Tasks, queues, semaphores all live in statically allocated arrays
sized at compile time. If it fits, it ships; if not, refactor.
Game engines · ECS
The data-oriented design revolution argued that pointer-heavy OOP destroys cache locality. Modern engines (Unity DOTS, Unreal Mass) store components as arrays of structs — iterating one frame is sequential memory, not pointer-chasing. Pointers lost this round.
PostgreSQL planner
Query plan trees are deeply nested tagged-union nodes connected by pointers. The planner builds, rewrites, and discards thousands of these per query. An array layout would force preallocation of the worst case — wasteful and rigid.
The closing thought is older than C itself: data structures are the program. Choose the wrong layout and no amount of clever code recovers the cycles, the cache lines, the deterministic memory you needed. Choose the right one and the algorithm almost writes itself. Arrays and pointers are the two atoms — everything else is just how you arrange them.