Dynamic Arrays in C: malloc, realloc and resize patterns explained
Every non-trivial C program eventually hits the same wall: you need to store a collection of things, but you have no idea at compile time how many things there'll be. Maybe you're reading lines from a log file, collecting sensor readings, or building a search result list. Static arrays — the ones declared with a fixed size like int scores[100] — force you to make a guess upfront, and that guess is almost always wrong. Too small and your program crashes or corrupts memory. Too large and you waste RAM that another process could have used.
Dynamic arrays solve this by shifting memory allocation from compile time to runtime. Instead of carving out space in the stack at the moment your function is called, you ask the operating system for heap memory exactly when you need it, in exactly the quantity you need. The heap is a large, flexible pool of memory your process can borrow from and return to at any time. This is the mechanism that powers virtually every data structure you'll ever use: vectors in C++, lists in Python, ArrayLists in Java — they all implement this same idea under the hood.
By the end of this article you'll know how to allocate a dynamic array with malloc, grow it safely with realloc using a doubling strategy, and release it properly with free. You'll also understand the common failure modes — memory leaks, dangling pointers, and the realloc trap — and exactly how to avoid them. Whether you're preparing for a systems programming interview or just trying to write C that doesn't blow up in production, this is the mental model you need.
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.
#include <stdio.h> #include <stdlib.h> // Demonstrates why stack arrays fail when size is runtime-determined // and how malloc solves the problem cleanly. int main(void) { int item_count; printf("How many temperature readings do you want to store? "); scanf("%d", &item_count); // STACK approach — ILLEGAL in standard C89 and risky even in C99: // int temperatures[item_count]; // <-- Variable Length Array, avoid in production // HEAP approach — safe, portable, works at any size: // malloc(n * sizeof(double)) asks the OS for exactly n doubles worth of bytes double *temperatures = malloc(item_count * sizeof(double)); // malloc returns NULL if allocation fails (e.g., out of memory) // ALWAYS check — ignoring this causes a segfault on the next line if (temperatures == NULL) { fprintf(stderr, "Memory allocation failed. Cannot store %d readings.\n", item_count); return 1; // exit with error code, not just 0 } // Populate with dummy sensor readings for (int index = 0; index < item_count; index++) { temperatures[index] = 20.0 + (index * 0.5); // simulate rising temps } // Use the data printf("\nStored %d readings. First: %.1f°C Last: %.1f°C\n", item_count, temperatures[0], temperatures[item_count - 1]); // ALWAYS free heap memory when done — the OS won't do this for you free(temperatures); temperatures = NULL; // null the pointer so it can't be accidentally used again return 0; }
Stored 5 readings. First: 20.0°C Last: 22.0°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.
#include <stdio.h> #include <stdlib.h> #include <string.h> // A reusable dynamic integer array with automatic growth. // Models exactly how a C++ vector works internally. typedef struct { int *data; // pointer to the heap block holding our integers int count; // how many items are currently stored int capacity; // how many items the current allocation can hold } IntArray; // Initialise the array with a small starting capacity void array_init(IntArray *arr) { arr->capacity = 4; // start small — we'll grow as needed arr->count = 0; arr->data = malloc(arr->capacity * sizeof(int)); if (arr->data == NULL) { fprintf(stderr, "Failed to initialise dynamic array.\n"); exit(1); } } // Append one integer, growing the backing array if necessary void array_push(IntArray *arr, int value) { if (arr->count == arr->capacity) { // We're full — double the capacity int new_capacity = arr->capacity * 2; // Use a temporary pointer — if realloc fails we still have our old data int *resized = realloc(arr->data, new_capacity * sizeof(int)); if (resized == NULL) { // realloc failed; arr->data is still valid, we just can't grow right now fprintf(stderr, "Could not grow array to capacity %d. Aborting push.\n", new_capacity); return; } // Safe to update now that we confirmed success arr->data = resized; arr->capacity = new_capacity; printf(" [resize] Grew to capacity %d\n", new_capacity); } // Store the value and advance the count arr->data[arr->count] = value; arr->count++; } // Print every element with its index void array_print(const IntArray *arr) { printf("Array (count=%d, capacity=%d): [", arr->count, arr->capacity); for (int i = 0; i < arr->count; i++) { printf("%d", arr->data[i]); if (i < arr->count - 1) printf(", "); } printf("]\n"); } // Release heap memory — always call this when done void array_free(IntArray *arr) { free(arr->data); arr->data = NULL; // prevent use-after-free arr->count = 0; arr->capacity = 0; } int main(void) { IntArray scores; array_init(&scores); // Push 10 scores — watch the array resize automatically int game_scores[] = {42, 87, 15, 93, 56, 71, 34, 88, 62, 99}; int total_games = sizeof(game_scores) / sizeof(game_scores[0]); printf("Inserting %d scores into the dynamic array:\n", total_games); for (int i = 0; i < total_games; i++) { array_push(&scores, game_scores[i]); } printf("\nFinal state:\n"); array_print(&scores); array_free(&scores); return 0; }
[resize] Grew to capacity 8
[resize] Grew to capacity 16
Final state:
Array (count=10, capacity=16): [42, 87, 15, 93, 56, 71, 34, 88, 62, 99]
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.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
int *data;
int count;
int capacity;
} IntArray;
void array_init(IntArray *arr) {
arr->capacity = 8;
arr->count = 0;
arr->data = malloc(arr->capacity * sizeof(int));
if (!arr->data) { fprintf(stderr, "Init failed\n"); exit(1); }
}
void array_push(IntArray *arr, int value) {
if (arr->count == arr->capacity) {
int new_cap = arr->capacity * 2;
int *resized = realloc(arr->data, new_cap * sizeof(int));
if (!resized) { fprintf(stderr, "Grow failed\n"); return; }
arr->data = resized;
arr->capacity = new_cap;
}
arr->data[arr->count++] = value;
}
// Remove the element at position `index`
// Shifts everything after it left by one slot
int array_remove(IntArray *arr, int index) {
if (index < 0 || index >= arr->count) {
fprintf(stderr, "Remove index %d out of bounds (count=%d)\n", index, arr->count);
return -1; // signal failure
}
int removed_value = arr->data[index];
// Shift elements left — memmove handles overlapping regions safely
memmove(
&arr->data[index], // destination: where the gap is
&arr->data[index + 1], // source: one past the gap
(arr->count - index - 1) * sizeof(int) // bytes to move
);
arr->count--;
// Shrink if we're only using a quarter of our capacity
// Floor at a minimum capacity of 4 to avoid tiny allocations
if (arr->count > 0 && arr->count <= arr->capacity / 4 && arr->capacity > 4) {
int new_cap = arr->capacity / 2;
int *shrunk = realloc(arr->data, new_cap * sizeof(int));
if (shrunk) { // shrink is optional — if it fails, just keep the bigger block
arr->data = shrunk;
arr->capacity = new_cap;
printf(" [resize] Shrunk to capacity %d\n", new_cap);
}
}
return removed_value;
}
void array_print(const IntArray *arr) {
printf("[count=%d cap=%d]: ", arr->count, arr->capacity);
for (int i = 0; i < arr->count; i++) printf("%d ", arr->data[i]);
printf("\n");
}
void array_free(IntArray *arr) {
free(arr->data);
arr->data = NULL;
arr->count = arr->capacity = 0;
}
int main(void) {
IntArray task_ids;
array_init(&task_ids);
// Simulate a task queue with 8 tasks
for (int id = 101; id <= 108; id++) {
array_push(&task_ids, id);
}
printf("Initial queue:\n");
array_print(&task_ids);
// Remove task 103 (at index 2)
int removed = array_remove(&task_ids, 2);
printf("\nRemoved task ID %d\n", removed);
array_print(&task_ids);
// Remove several more to trigger shrink
array_remove(&task_ids, 0);
array_remove(&task_ids, 0);
array_remove(&task_ids, 0);
array_remove(&task_ids, 0);
printf("\nAfter 4 more removals:\n");
array_print(&task_ids);
array_free(&task_ids);
return 0;
}
[count=8 cap=8]: 101 102 103 104 105 106 107 108
Removed task ID 103
[count=7 cap=8]: 101 102 104 105 106 107 108
After 4 more removals:
[resize] Shrunk to capacity 4
[count=3 cap=4]: 105 106 107
| Feature / Aspect | Static Array (stack) | Dynamic Array (heap) |
|---|---|---|
| Size known at compile time? | Required — must be a constant | Not needed — set at runtime |
| Memory location | Stack — automatic cleanup | Heap — manual free required |
| Resize after creation | Impossible | Yes, via realloc |
| Access speed (indexing) | O(1) — identical | O(1) — identical |
| Insert at middle | Impossible after declaration | O(n) — requires shifting |
| Append to end (amortised) | N/A — fixed size | O(1) — with doubling strategy |
| Risk of memory leak | None — stack auto-cleans | Yes — must call free |
| Risk of stack overflow | Yes — large arrays overflow stack | No — heap is much larger |
| Cache friendliness | Excellent — contiguous | Excellent — contiguous |
| Lifetime | Until function returns | Until free is called |
🎯 Key Takeaways
- malloc allocates on the heap at runtime — this is the only way to create arrays whose size depends on user input, file contents, or any value you don't know at compile time.
- Always double capacity on growth, not increment by one — this keeps amortised insertion O(1) and is the strategy used by every major language's built-in list type.
- realloc must go into a temporary pointer first — overwriting your original pointer directly causes an unrecoverable memory leak if the allocation fails.
- After free, set the pointer to NULL immediately — stale non-NULL pointers that point to freed memory are one of the hardest bug classes to diagnose in C.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Writing arr->data = realloc(arr->data, new_size) directly — Symptom: if realloc returns NULL (out of memory), you've overwritten your only pointer to the old block, leaking it permanently with no way to recover or free it — Fix: always realloc into a separate temporary pointer, check it for NULL, then assign to arr->data only on success.
- ✕Mistake 2: Not nullifying a pointer after calling free — Symptom: a later code path checks 'if (ptr)' expecting NULL to mean unallocated, but the freed pointer still holds a non-NULL garbage address, so the check passes and you access deallocated memory causing silent data corruption or a crash — Fix: immediately after free(ptr), write ptr = NULL so the pointer honestly reflects that it points to nothing.
- ✕Mistake 3: Calculating realloc size in elements instead of bytes — Symptom: you write realloc(arr, new_capacity) when you meant realloc(arr, new_capacity * sizeof(int)), silently allocating four times fewer bytes than intended, causing writes past the allocation boundary that corrupt adjacent heap memory — Fix: always multiply element count by sizeof(element_type) in every malloc and realloc call — make it a habit you cannot skip.
Interview Questions on This Topic
- QWhy 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?
- QWhat is the correct way to use realloc so you don't leak memory if it fails? Write the pattern out.
- QA colleague says 'dynamic arrays and linked lists both resize at runtime, so just pick whichever'. Where would you push back — and what specific workload characteristic would make you choose one over the other?
Frequently Asked Questions
What is the difference between malloc and calloc when creating a dynamic array in C?
malloc allocates a block of the requested size and leaves its contents uninitialised — you get whatever bytes happen to be sitting in that heap region. calloc allocates the same block but zeroes every byte before returning. For a dynamic array of integers, use calloc if you need a guaranteed starting value of zero; use malloc if you're going to overwrite every element anyway (e.g., reading from a file), since the zero-initialisation step is wasted work.
Can I use a Variable Length Array (VLA) in C instead of malloc for a runtime-sized array?
VLAs (int arr[n] where n is a variable) were added in C99 and let you put a runtime-sized array on the stack. They're gone from stack when the function returns, which is often not what you want. More importantly, they were made optional in C11 and are absent from many embedded toolchains, and a large VLA can silently overflow the stack with no error message. For production C code, malloc is the reliable, portable choice.
How do I know when my dynamic array should shrink, and by how much?
The standard heuristic is: shrink when count drops below one quarter of capacity, and shrink to half of capacity. This creates hysteresis — the array must lose three quarters of its entries before shrinking, so a single remove-then-add cycle doesn't trigger a pointless reallocate-grow loop. Never shrink below a sensible minimum capacity (such as 4 or 8 elements) to avoid thrashing on very small arrays.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.