Dynamic Arrays in C — Safe realloc Patterns for Production
A realloc failure leaked 200K sensor readings and crashed a server.
20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Dynamic arrays allocate heap memory at runtime via malloc, grow with realloc, and release with free.
- Capacity doubling gives amortized O(1) append — growing by fixed increments causes O(n²) copying.
- Always realloc into a temporary pointer: direct assignment leaks memory if realloc fails.
- Shrink when count falls below capacity/4, shrink to capacity/2 — prevents thrashing on remove-then-add cycles.
- Memory fragmentation rises with many small reallocations; use power-of-two sizes to mitigate.
Imagine you're setting up chairs for a party but you don't know how many guests are coming. A normal array is like pre-booking exactly 10 chairs — if 15 people show up, you're stuck. A dynamic array is like having a warehouse next door: you start with 10 chairs, and the moment you run out, you grab more from the warehouse and rearrange the room. That rearranging is exactly what realloc does behind the scenes — it finds you a bigger block of memory and moves everything over.
Static arrays are fine until your data outgrows them. Dynamic arrays in C solve the exact problem of unknown size at compile time—giving you a growable buffer without the overhead of linked lists. Without them, you’re either hardcoding limits, wasting memory, or rewriting half your code when requirements change.
What Dynamic Arrays in C Actually Solve
A dynamic array in C is a contiguous block of memory that grows at runtime via realloc. Unlike fixed-size arrays, it decouples capacity from logical size: you track a length (elements used) and a capacity (memory allocated). The core mechanic is geometric growth — typically doubling capacity on each resize — to amortize O(1) append cost. Without this, every push would be O(n) due to full reallocation.
In practice, a dynamic array is a struct: a pointer to heap memory, a size_t for length, and a size_t for capacity. The critical property is that realloc may move the block, invalidating all existing pointers into the array. This is the single most common source of bugs in production C code. The pattern is: never hold a pointer to an element across a realloc call; always re-fetch the base address after any operation that could resize.
Use dynamic arrays when you need cache-friendly, contiguous storage with unpredictable size — reading lines from a file, building a list of network connections, or accumulating sensor data. They outperform linked lists for iteration and random access (O(1) vs O(n)) and use less memory per element. In real systems, they are the backbone of string builders, arena allocators, and serialization buffers.
Why malloc? Understanding Heap Allocation vs Stack Arrays
When you write int temperatures[50]; inside a function, C reserves exactly 200 bytes on the call stack the moment that function is entered. The stack is fast, automatic, and cleaned up when the function returns. But it has two hard limits: the size must be a compile-time constant (in standard C), and the memory vanishes the moment the function exits.
Heap allocation with malloc flips both of those constraints. You pass it a byte count at runtime — a value you can compute from user input, a file, or a loop — and it returns a pointer to a fresh block of memory. That block lives until you explicitly call free on it, regardless of which function is currently executing. This makes heap memory the right tool whenever you don't know size upfront, or when data needs to outlive the function that created it.
The cost is responsibility. The stack cleans itself up. The heap does not. If you forget to call free, that memory is gone for the lifetime of the process — that's a memory leak. If you call free and then keep using the pointer — that's a use-after-free bug, one of the most dangerous bugs in systems programming. Understanding this trade-off is the entire foundation of working with dynamic arrays in C.
Growing a Dynamic Array with realloc — The Doubling Strategy
Here's the real heart of dynamic arrays: what happens when you've filled your allocated space and a new item arrives? You have two options. You could allocate a completely new block, copy everything over, and free the old one — which is exactly what realloc does for you in one function call. The question is not whether to use realloc, but how much to grow by.
Growing by one slot each time you're full sounds sensible but is catastrophically slow. If you're inserting 10,000 items, you trigger 10,000 reallocations, each potentially copying the entire array. That's O(n²) work for what should be O(n) insertions. The standard solution is capacity doubling: when full, double the capacity. This ensures that the total copying work across all insertions stays proportional to n — amortized O(1) per insert. This is the exact strategy used by C++ std::vector, Java ArrayList, and Python lists.
The realloc call itself has an important gotcha: if it fails, it returns NULL — but the original pointer is still valid and still holds your data. That's why you must store the result in a temporary pointer first, check for NULL, and only then overwrite your original pointer. Failing to do this is one of the most common memory bugs in C.
Shrinking, Searching and Removing — Real-World Array Operations
Growing an array grabs the headlines, but real programs also need to remove items and reclaim wasted space. If a user deletes half their entries, keeping a capacity of 10,000 slots for 50 items wastes significant RAM — especially on embedded hardware.
Shrinking follows the same pattern as growing but in reverse: when count drops below a threshold (a common choice is one quarter of capacity), realloc down to half of capacity. This keeps wasted space bounded without thrashing — if you shrank every single time you removed one element, you'd just end up reallocating immediately on the next insert.
Removing an element from the middle requires shifting every element after it one position to the left to fill the gap. This is O(n) in the worst case. If your workload involves many random removals, a linked list might be a better structure — but if removals are rare or always happen at the end, a dynamic array beats a linked list on cache performance because its elements are contiguous in memory. CPUs love contiguous data.
Memory Fragmentation and Choosing the Right Growth Factor
You've mastered the doubling strategy, but in long-running production systems, doubling can silently create a new problem: memory fragmentation. Each time realloc runs, the operating system may place the new block at a different address, leaving a free hole behind. Over time, these holes fill the heap with unusable gaps — a condition known as external fragmentation. Your process might technically have enough free bytes, but no contiguous block large enough to satisfy the next allocation.
Doubling from a small initial size (say 4) to huge numbers (512, 1024, 2048) exacerbates fragmentation because each growing block is a different size, making it hard for the allocator to reuse free holes. The fix is to use a lower growth factor — 1.5 (or the golden ratio 1.618) is common — which generates more repeatable block sizes and gives the allocator a better chance at reusing freed memory. Many high-performance memory allocators (jemalloc, tcmalloc) already use size classes that align with such factors.
Another approach is to pre-allocate a large enough buffer upfront. If you can bound the maximum size of your dynamic array, allocate that full capacity at init time and avoid realloc entirely. This eliminates fragmentation at the cost of raw memory usage — a trade-off worth considering for real-time systems or embedded devices.
Debugging Dynamic Arrays – Tools and Techniques
Even with correct code, dynamic arrays can hide bugs that only surface after hours of production runtime. Buffer overflows, use-after-free, and off-by-one errors are the most common. The good news: modern tools catch them before they reach production if you run them in your test suite.
AddressSanitizer (ASan) is the fastest way to detect buffer overflows, use-after-free, and out-of-bounds accesses. Compile your code with -fsanitize=address and you get instant, detailed error reports on every violation. It's memory-efficient and integrates with valgrind for a second layer. Valgrind's memcheck tool is slower but catches some things ASan can't, like uninitialized memory reads.
Static analysis (like clang-tidy or PVS-Studio) can catch common patterns like direct realloc overwrite before runtime. But they can't catch everything. A good strategy: run ASan in unit tests, valgrind in integration tests, and use a memory profiler (valgrind massif) in long-running stress tests to detect fragmentation.
malloc vs calloc: When Zero-Init Costs You Performance
You've seen malloc in every tutorial. Here's when not to use it.
malloc grabs a slab of heap and returns a pointer. The bytes are uninitialised — stale data from whatever freed that block last. If you're building a dynamic array that will be immediately overwritten by hot data (say, reading frames from a socket ring buffer), malloc wins. No pointless zero-fill cycles.
calloc does two things: allocates and zero-initialises every byte. Intuition says "free stuff." Reality says calloc can be slower because memset must touch every page. On a 100-million-element uint32_t array, that's 400 MB of writes before your first assignment. The trade-off: deterministic startup state. No garbage pointers, no uninitialised reads that corrupt production silently.
Senior rule: Use calloc when your structure has pointers that must start NULL. Use malloc when you're filling the buffer immediately with known data. Never use either without checking the return value — NULL means the OS said no.
Flexible Array Members: The Zero-Overhead Struct Trick
Standard dynamic arrays need two allocations: one for the struct metadata, one for the data. Flexible array members (FAMs) collapse that into one. C99 introduced this, and most production codebases still ignore it.
The trick: declare a struct with fields, then a trailing array with no size. When allocating, malloc the struct size plus the array size. The array lives immediately after the fixed fields — one contiguous block, one free call.
Why this matters for real systems: Data locality. The metadata (length, capacity) and the data sit next to each other in cache. No pointer chasing through an indirection layer. Every access to arr->data[i] is a simple base-plus-offset, not two pointer dereferences.
Pitfall: The array must be the last member. You cannot have a FAM and then another field. Also, sizeof the struct returns the size ignoring the FAM — you track array length yourself.
Use FAMs for packet buffers, serialised messages, or any hot-path dynamic array where allocations dominate runtime.
Prerequisites: What You Must Understand Before Touching Dynamic Arrays
Dynamic arrays in C are not a beginner topic. You need a solid grasp of pointers, because every operation — resize, access, element removal — works through indirection. Understand pointer arithmetic and the difference between p[i] and (p + i). You must be comfortable with manual memory management: malloc, realloc, and free are your tools, and forgetting one free means a leak that accumulates silently. Heap allocation is slower than stack allocation; each malloc call involves an OS syscall or a bump into a free list. If you are writing real-time or embedded code, dynamic allocation is often banned outright. Finally, know your data types: sizeof is evaluated at compile time, and using the wrong type in realloc(sizeof(T) n) corrupts the heap. Without these foundations, dynamic arrays will crash your program in ways stack arrays never could.
Improvements — Struct Implementation: Encapsulating the Mess
Bare dynamic array code scatters size, capacity, and data across the scope. The struct implementation fixes this: bundle a pointer to the heap block, the logical count of elements, and the allocated capacity into one object. Every operation — da_append, da_remove, da_free — takes a pointer to this struct. This eliminates global variables and reduces function signatures from three parameters to one. The struct is small (typically three words on 64-bit), and passing it by pointer is cheap. A hidden improvement: you can now add a growth factor and a shrink threshold as struct fields, making the strategy configurable per array. Never expose the raw int*; expose typed access via da_get(da, i) that returns a pointer — then the caller can dereference or assign without knowing the internal layout. This is the minimal viable C abstraction: no vtables, no inheritance, just data + functions.
Growing Strategy: Doubling vs Fibonacci vs Exponential
Choosing the right growth factor for dynamic arrays is critical for performance. The classic doubling strategy (factor of 2) offers amortized O(1) insertion but can waste memory. Fibonacci growth (factor ~1.618) reduces memory overhead while maintaining good amortized performance. Exponential growth with a factor between 1.5 and 2 balances speed and memory. For example, a factor of 1.5 reduces wasted memory compared to doubling but still provides amortized constant time. The choice depends on your application: doubling for simplicity, Fibonacci for memory-constrained systems, and 1.5 for a middle ground. Below is a C implementation showing different strategies.
Flexible Array Members in C99+
Flexible array members (FAM) allow a struct to have a variable-length array at its end without allocating separate memory. Declared as type array[]; (no size), they enable zero-overhead dynamic arrays. The struct size excludes the array, so you allocate extra bytes for the array. This is useful for encapsulating a dynamic array with metadata. Example: a struct with length, capacity, and a flexible array. However, FAMs have limitations: they must be the last member, and you cannot have multiple flexible arrays. They are ideal for fixed-size headers with variable data. Below is a C99 example.
Arena Allocator for Dynamic Arrays
Arena allocators (or region-based allocators) manage memory in large blocks, reducing fragmentation and allocation overhead. For dynamic arrays, an arena can allocate contiguous memory for multiple arrays, and deallocation is a single operation. This is useful for batch processing or game engines. Implementation: create an arena with a fixed-size buffer, and allocate from it using an offset. Dynamic arrays can be grown by requesting more memory from the arena. However, arena allocators don't support individual frees; you reset the entire arena. Below is a simple arena allocator in C.
The Lost Sensor Readings – realloc Failure That Silently Corrupted a Server
- Never overwrite the original pointer with the result of realloc without checking for NULL first.
- Memory allocation can fail even on servers — always handle the failure gracefully.
- For production systems, cap growth to a reasonable maximum to avoid sudden huge allocations.
valgrind --leak-check=full --show-leak-kinds=all ./myprogram 2>&1 | grep 'definitely lost'valgrind --tool=memcheck --track-origins=yes ./myprogramfree() call for every malloc/realloc return. Use -fsanitize=address which logs unfreed allocations at exit.| File | Command / Code | Purpose |
|---|---|---|
| heap_vs_stack.c | int main(void) { | Why malloc? Understanding Heap Allocation vs Stack Arrays |
| dynamic_array_grow.c | typedef struct { | Growing a Dynamic Array with realloc |
| dynamic_array_remove.c | typedef struct { | Shrinking, Searching and Removing |
| growth_factor_comparison.c | int main(void) { | Memory Fragmentation and Choosing the Right Growth Factor |
| debug_demo.c | int main(void) { | Debugging Dynamic Arrays – Tools and Techniques |
| AllocShowdown.c | int main(void) { | malloc vs calloc |
| FlexibleArray.c | typedef struct { | Flexible Array Members |
| Prerequisites.cpp | int main() { | Prerequisites |
| StructImpl.cpp | typedef struct { | Improvements |
| growth_strategies.c | size_t grow_size(size_t current, double factor) { | Growing Strategy |
| flexible_array.c | typedef struct { | Flexible Array Members in C99+ |
| arena_allocator.c | typedef struct { | Arena Allocator for Dynamic Arrays |
Key takeaways
Interview Questions on This Topic
Why does the doubling strategy for dynamic array growth give amortised O(1) insertion, and what would happen to the time complexity if you grew by a fixed number of slots (e.g., always add 10) instead?
Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Lessons pulled from things that broke in production.
That's C Basics. Mark it forged?
8 min read · try the examples if you haven't