Pointers & Memory · intermediate · ~10 min
- Call `realloc` to grow or shrink an existing heap allocation while keeping the data you already stored. - Capture the result in a temporary pointer so a failed `realloc` never leaks or corrupts your block. - Understand when `realloc` resizes in place versus when it moves the block to a new address. - Update *every* pointer that referenced the old block after a move (including through a pointer-to-pointer). - Build a growable array (dynamic buffer) that expands as you append data. - Recognise and avoid the classic `realloc` bugs: self-assignment on failure, stale pointers, and `realloc(p, 0)`.
Real programs rarely know in advance exactly how much memory they need. You read a file whose size you don't know, collect input until the user stops typing, or grow a list one item at a time. In the malloc and the heap lesson you learned how to ask the operating system for a block of a fixed size at runtime. realloc is the next step: it lets you change your mind about that size after the fact.
In plain language, realloc takes a block you already own and hands you back a block of a different size, with your original contents copied over. You give it your old pointer and the new size in bytes; it gives you a pointer to a block that is now big enough (or smaller). Sometimes it can stretch the existing block where it sits; sometimes it has to find a fresh spot, copy your bytes there, and free the old spot for you.
The formal signature is void *realloc(void *ptr, size_t size), declared in <stdlib.h>. It is the resize operation of the trio malloc / realloc / free. The two ideas that make realloc tricky — and worth a whole lesson — are that the returned pointer may differ from the one you passed in, and that on failure it returns NULL but leaves your original block untouched. Get those two right and realloc becomes the workhorse behind every growable array you'll ever write.
Almost every data structure that grows — a text editor's buffer, a network packet you're assembling, a list of search results, the token array in a parser — sits on top of realloc. The standard trick is: allocate a small block, and whenever it fills up, realloc it larger. This is exactly how std::vector in C++, dynamic arrays in Python, and Go slices work under the hood.
Getting realloc wrong is a top source of real bugs. Assigning realloc's result straight back to your only pointer leaks memory the moment allocation fails. Forgetting that the block may have moved leaves you with dangling pointers into freed memory — a use-after-free, which is both a crash and a security hole. Because realloc sits at the boundary between "I have data" and "I need more room," learning to use it defensively is one of the highest-leverage memory skills in C.
realloc actually doesDefinition. void *realloc(void *ptr, size_t size) resizes the heap block that ptr points to so that it can hold size bytes, and returns a pointer to the (possibly relocated) block.
Plain-language explanation. Think of your allocation as a rented storage unit. realloc says "I want a unit of this new size instead." If the unit next door is empty, the manager just knocks down the wall and gives you a bigger unit in the same place. If it isn't, the manager finds a bigger unit elsewhere, moves all your boxes over for you, and frees your old unit. Either way you get keys to a unit of the size you asked for, with your boxes inside.
How it works internally. The allocator tracks the size of every block. When you call realloc:
memcpys the old contents up to min(old_size, new_size) bytes, frees the old block, and returns the new address.Your data is preserved up to the smaller of the two sizes. If you grew the block, the extra bytes at the end are uninitialised — treat them like fresh malloc memory. If you shrank it, the bytes beyond the new size are gone.
Grow, moved case:
before: ptr ─────────────► [ A B C D ] (16 bytes)
realloc(ptr, 32):
old block freed [ x x x x ] (returned to allocator)
new block [ A B C D ? ? ? ? ] (32 bytes)
^copied^ ^garbage^
after: newptr ──────────► [ A B C D ? ? ? ? ]
(ptr is now DANGLING — never use it again)
When to use it. When you have a buffer that needs to change size and you want to keep the current contents — growable arrays, reading input of unknown length, trimming a buffer to its exact used size.
When NOT to use it. Don't realloc in a tight inner loop by +1 element each time — that can be O(n²) copies. Grow geometrically (e.g. double the capacity) instead. And don't use realloc on a pointer that didn't come from malloc/calloc/realloc (e.g. a stack array or a pointer into the middle of a block) — that is undefined behaviour.
Common pitfall. Believing the pointer never changes. It often does, especially when growing. Any other copy of the old pointer is now dangling.
Definition. If realloc cannot satisfy the request, it returns NULL and leaves the original block valid and unchanged.
Plain-language explanation. A failed realloc is a no-op for your data: your old block is still there, still holding your bytes. The danger is purely in how you store the result.
The wrong way:
p = realloc(p, n); /* if this fails, p becomes NULL... */
/* ...and the old block is now unreachable = leaked */
The right way — always use a temporary:
T *tmp = realloc(p, n);
if (tmp == NULL) {
/* handle error; p is STILL VALID here */
} else {
p = tmp; /* only overwrite p once we know we succeeded */
}
How it works internally. On the failure path the allocator never touched your block. So the only way you can lose it is by clobbering the one pointer you had to it. The temporary preserves that pointer until you know success.
When to use it. Every single time. There is no correct use of realloc that assigns straight back to the same variable.
Common pitfall. "It never fails in my tests, so I'll skip the temp." Under memory pressure — or with a huge or overflowed size — it will fail, and now it's a leak in production.
| Situation | Return value | Your old block | What to do |
|---|---|---|---|
| Success, resized in place | same pointer | now the resized block | use returned pointer |
| Success, moved | new pointer | freed for you | use returned pointer, drop old one |
| Failure | NULL |
still valid | keep using old pointer or free it |
Knowledge check. Why is p = realloc(p, n); unsafe, and what single change fixes it?
Definition. After a successful realloc that relocates the block, the old address is invalid; only the returned pointer is safe to use.
Plain-language explanation. If several variables, struct fields, or another function held a copy of the old pointer, they are all now pointing at freed memory. You must funnel the update back to all of them. When a function needs to resize a caller's buffer, it takes a pointer-to-pointer so it can write the new address back into the caller's variable.
Resize through a pointer-to-pointer:
caller: int *arr; ───────────┐
▼
arr ──────────► [ 1 2 3 ]
resize(&arr, 3, 6):
*a = realloc(*a, 6*sizeof(int)); // *a == caller's arr
arr ──────────► [ 1 2 3 ? ? ? ] // updated in the caller too
How it works internally. &arr is the address of the caller's pointer variable. Inside the function, *a reads and writes that very variable, so assigning *a = tmp; updates the caller. Passing the pointer by value (just int *a) would only update the function's local copy — the caller would keep the stale, dangling pointer.
When to use it. Whenever a helper function resizes memory the caller owns. Alternatively, return the new pointer and require the caller to reassign (arr = grow(arr, ...)).
When NOT to use a bare by-value pointer. If a resize function takes int *a and calls realloc(a, ...), the caller's pointer is never updated — a classic dangling-pointer bug.
Common pitfall. Saving int *end = arr + n; before a realloc, then using end afterward. If the block moved, end dangles. Recompute such derived pointers after the resize.
Knowledge check. A function void grow(int *a, int newn) calls realloc(a, newn*sizeof(int)) and returns nothing. Why can't the caller ever see the resized array? What signature would fix it?
Definition. realloc(NULL, size) behaves exactly like malloc(size). realloc(ptr, 0) has implementation-defined behaviour.
Plain-language explanation. Passing NULL as the pointer is a legal shortcut — it just allocates fresh. That's handy for a grow-loop that starts from an empty buffer: the first iteration allocates, later ones resize, all with the same line of code.
realloc(ptr, 0) is the messy one. Historically it might free the block and return NULL, or free it and return a small non-NULL pointer you must not dereference. In C23 the behaviour is explicitly undefined. Don't rely on it — if you mean "free this," call free.
| Call | Meaning |
|---|---|
realloc(NULL, n) |
same as malloc(n) |
realloc(p, n) where n > 0 |
resize block to n bytes |
realloc(p, 0) |
implementation-defined / avoid — use free(p) |
Common pitfall. Writing p = realloc(p, 0) and assuming p is now safely freed and NULL. On some platforms p is non-NULL and points to nothing usable — and you may also have leaked if it returned NULL (per rule 2).
Knowledge check. You start with char *buf = NULL; and want your first realloc(buf, cap) call to "just work" like an allocation. Explain why it does.
#include <stdlib.h>
void *realloc(void *ptr, size_t size);
// ^ ^ ^
// returns new | new size in BYTES (not element count)
// pointer (maybe |
// different) block from malloc/calloc/realloc, or NULL
The canonical safe pattern, every time:
size_t new_count = count + 1;
int *tmp = realloc(arr, new_count * sizeof *arr); // sizeof *arr = size of one element
if (tmp == NULL) {
// realloc failed: arr is still valid. Clean up and bail.
free(arr);
return NULL;
}
arr = tmp; // commit only on success
arr[count] = value; // the newly grown slot is uninitialised until you set it
count = new_count;
Key points:
size is bytes. For an array of n elements use n * sizeof *arr.sizeof *arr (the dereferenced pointer) instead of sizeof(int) keeps the size correct even if you later change the element type.n comes from untrusted input (see Memory-safety notes).realloc doesvoid *realloc(void *p, size_t new_size);
realloc resizes the block pointed to by p so that it holds new_size bytes. It returns a pointer to the resized block.
Two important details:
p. The allocator can move the data to a new location if it cannot resize in place.min(old_size, new_size) bytes. If you grow the block, the extra bytes are uninitialized. If you shrink it, the data beyond the new size is lost.If realloc cannot satisfy the request, it returns NULL and the old block stays valid. That is why you should never assign the result directly back to p — a NULL would overwrite your only pointer to the old block, leaking it.
Use a temporary pointer instead:
T *tmp = realloc(p, n);
if (!tmp) {
/* p is still valid; free it or retry */
} else {
p = tmp;
}
#include <stdio.h> #include <stdlib.h>
/* Read integers from stdin into a heap array that grows on demand.
Returns the array (caller must free) and writes the count through *out_n.
Returns NULL on allocation failure. Uses geometric growth to keep the
total copying cost near O(n) instead of O(n^2). */ static int *read_ints(size_t *out_n) { int data = NULL; / realloc(NULL, ...) acts like malloc, so we can start empty / size_t count = 0; / number of ints actually stored / size_t cap = 0; / number of ints the block can hold */ int value;
while (scanf("%d", &value) == 1) { if (count == cap) { /* full: time to grow / size_t new_cap = (cap == 0) ? 4 : cap * 2; / double the capacity */ int *tmp = realloc(data, new_cap * sizeof data); if (tmp == NULL) { / failure: 'data' is still valid / free(data); return NULL; } data = tmp; / commit only after success / cap = new_cap; } data[count++] = value; / store into the guaranteed-free slot */ }
/* Optional: shrink the block to exactly what we used, trimming waste. */ if (count > 0) { int *tmp = realloc(data, count * sizeof data); if (tmp != NULL) { / if shrink fails, keep the bigger block */ data = tmp; } }
*out_n = count; return data; }
int main(void) { size_t n = 0; int nums = read_ints(&n); if (nums == NULL && n == 0) { / Either no input or allocation failure; both leave nothing to print. */ printf("No numbers read.\n"); return 0; }
long sum = 0;
printf("Read %zu numbers:", n);
for (size_t i = 0; i < n; i++) {
printf(" %d", nums[i]);
sum += nums[i];
}
printf("\nSum = %ld\n", sum);
free(nums); /* release the heap block exactly once */
return 0;
}
Feed the program the input 10 20 30 40 50 (then end-of-file, e.g. Ctrl-D).
read_ints starts with data = NULL, count = 0, cap = 0. Because data is NULL, the first realloc will behave like malloc.scanf reads 10. count == cap (0 == 0) is true, so we grow. new_cap = 4 (the cap == 0 branch). realloc(NULL, 4 * sizeof(int)) allocates a 16-byte block. tmp is non-NULL, so data = tmp, cap = 4. Then data[0] = 10, count = 1.20, 30, 40: each time count < cap (1, 2, 3 all < 4), so no growth. They land in data[1], data[2], data[3]; count becomes 4.50: now count == cap (4 == 4). Grow again: new_cap = 8. realloc may resize in place or move the block — either way tmp is the valid pointer, we assign data = tmp, cap = 8. Store data[4] = 50, count = 5.scanf hits end-of-file and returns EOF (not 1), so the loop ends.count is 5, so realloc(data, 5 * sizeof(int)) trims the 8-slot block down to 5 slots. If it succeeds we adopt the smaller block; if it fails we simply keep the 8-slot one (still correct, just slightly wasteful).*out_n = 5; return data.State of cap/count as the loop runs:
| after reading | count | cap | grew? |
|---|---|---|---|
| 10 | 1 | 4 | yes (0→4) |
| 20 | 2 | 4 | no |
| 30 | 3 | 4 | no |
| 40 | 4 | 4 | no |
| 50 | 5 | 8 | yes (4→8) |
main, nums is non-NULL and n == 5. The loop prints each value and accumulates sum. Expected output:Read 5 numbers: 10 20 30 40 50
Sum = 150
free(nums) releases the block once. Because every intermediate block was either resized in place or freed by realloc itself, there is exactly one live block to free at the end — no leaks.Edge cases. Empty input: the loop never runs, count stays 0, the shrink is skipped, data stays NULL, and main prints "No numbers read." Non-numeric input (e.g. 10 abc): scanf returns 0 on abc, the == 1 test fails, and the loop stops early — you'd read just 10.
Mistake 1 — Assigning realloc straight back to your only pointer.
/* WRONG */
arr = realloc(arr, n * sizeof *arr);
if (arr == NULL) { /* the old block is already leaked here */ }
If realloc fails it returns NULL, you overwrite arr, and the original block is now unreachable — a leak. Worse, you can no longer free it.
/* RIGHT */
int *tmp = realloc(arr, n * sizeof *arr);
if (tmp == NULL) { free(arr); return NULL; } /* arr still valid until here */
arr = tmp;
Recognise it: any line of the form x = realloc(x, ...) is suspect. Prevent it: make "realloc into a temp" a reflex.
Mistake 2 — Using a stale pointer after the block moved.
/* WRONG */
int *arr = malloc(4 * sizeof *arr);
int *third = &arr[2]; /* pointer into the block */
int *tmp = realloc(arr, 100 * sizeof *arr);
if (tmp) arr = tmp;
*third = 9; /* 'third' may now dangle if the block moved */
Any pointer derived from the old block (interior pointers, saved end pointers, copies) is invalid after a move. Fix: recompute them from the new base.
/* RIGHT */
arr = tmp;
int *third = &arr[2]; /* recompute AFTER the resize */
*third = 9;
Mistake 3 — Resizing a caller's pointer by value.
/* WRONG: caller's pointer never updates */
void grow(int *a, size_t n) { a = realloc(a, n * sizeof *a); }
The function only changes its local copy. Fix: take a pointer-to-pointer (or return the new pointer).
/* RIGHT */
int grow(int **a, size_t n) {
int *tmp = realloc(*a, n * sizeof **a);
if (!tmp) return -1;
*a = tmp;
return 0;
}
Mistake 4 — Growing by one element in a hot loop.
/* SLOW: O(n^2) copying */
for (size_t i = 0; i < n; i++) {
int *tmp = realloc(arr, (i + 1) * sizeof *arr);
if (tmp) arr = tmp;
arr[i] = i;
}
Each step may copy the whole array. Fix: track a separate capacity and grow geometrically (double it) as in the main example — amortised O(n).
Mistake 5 — sizeof the wrong thing. realloc(arr, n) when you meant n elements allocates n bytes. Always multiply by sizeof *arr.
Compiler warnings.
#include <stdlib.h>.realloc returns int.-Wall -Wextra and, for numeric safety, consider -fsanitize=undefined.Runtime errors and how to catch them.
-fsanitize=address -g. It reports "heap-use-after-free" with the exact line.realloc already freed (the old pointer after a move), or freed twice. ASan pinpoints the second free.p = realloc(p, n) failure leak. Run Valgrind: valgrind --leak-check=full ./prog shows "definitely lost" bytes with a stack trace.Logic errors.
calloc-style zeroing or set each slot explicitly.count == cap (correct) versus count > cap (too late — you already overran).Questions to ask when it doesn't work.
NULL?size argument in bytes (n * sizeof *p), not element count?n * sizeof *p have overflowed size_t?realloc sits on top of the heap, so every heap hazard applies — plus a few unique to resizing.
Ownership and lifetimes. After a successful move, the old pointer is dead. Using it is a use-after-free (undefined behaviour, and a common exploited vulnerability). After a failed realloc, the old pointer is the only live one — don't lose it. Rule of thumb: exactly one pointer is valid after every realloc call; make sure you know which.
Uninitialised memory. Grown bytes are uninitialised. Reading them before writing is undefined behaviour and can leak whatever was previously on the heap. Initialise new slots before use.
Integer overflow in the size computation. realloc(p, count * sizeof *p) can overflow size_t if count is attacker-controlled, wrapping to a small number. You then get a block far smaller than you think and write past it — a heap buffer overflow. Defensive pattern:
if (count > SIZE_MAX / sizeof *p) { /* would overflow: reject */ return NULL; }
int *tmp = realloc(p, count * sizeof *p);
Bounds. Growing the block does not initialise it and shrinking it silently discards the tail — never read the region you just shrank away.
The realloc(p, 0) trap. Implementation-defined / undefined in C23. Don't use it as a free; call free(p) explicitly and set p = NULL.
Defensive habits. Always: temp-pointer + NULL check; overflow-check the size; zero or fully initialise grown regions if you'll read them; set freed pointers to NULL to turn a later mistaken use into a clean crash rather than silent corruption.
Where it shows up.
realloc. This is precisely how C++'s std::vector and Go's slices reallocate when they exceed capacity.getline on POSIX grows its line buffer with realloc as long lines arrive. File readers, HTTP body accumulators, and log parsers do the same.realloc down to the exact used size returns wasted memory to the allocator — common in image decoders and serialisers.Professional best-practice habits.
Beginner:
NULL.sizeof *p, never a hard-coded type size.free exactly once and set the pointer to NULL afterward.Advanced:
count * sizeof *p before every resize when the count is untrusted.struct { T *data; size_t len, cap; } with push/reserve helpers so the resize logic lives in one audited place.Beginner 1 — Safe grow helper.
Write int *grow_by_one(int *arr, size_t count, int value) that reallocs arr from count to count+1 ints, stores value in the new last slot, and returns the (possibly moved) pointer. On failure, free arr and return NULL. Requirements: use a temporary pointer; size with sizeof *arr. Hint: this is the append pattern without the capacity optimisation. Concepts: failure contract, pointer may move.
Beginner 2 — Shrink to fit.
Given a buffer allocated with capacity 100 ints but only used of them filled, write code that reallocs it down to exactly used ints. Requirements: if the shrink fails, keep the original block (don't lose data). Input/output: start cap 100, used = 12 → block now holds 12 ints, same values. Hint: shrinking rarely fails, but handle it anyway. Concepts: shrink preserves the prefix, temp pointer.
Intermediate 1 — Growable string builder.
Implement char *sb_append(char *buf, size_t *len, size_t *cap, const char *s) that appends the C-string s (plus the terminating \0) to a heap buffer, growing geometrically when needed. Requirements: start from buf = NULL, *len = 0, *cap = 0; double capacity on overflow; keep *buf always NUL-terminated. Example: three calls appending "foo", "bar", "!" yield "foobar!". Hint: realloc(NULL, ...) bootstraps the first allocation. Concepts: geometric growth, capacity vs length.
Intermediate 2 — Resize through a pointer-to-pointer.
Write int resize(int **a, size_t oldn, size_t newn) that reallocs *a to newn elements, zero-fills any newly added slots (when newn > oldn), updates the caller's pointer, and returns 0 on success or -1 on failure (leaving *a unchanged). Requirements: no memory leak on failure; new slots must be 0, not garbage. Hint: fill [oldn, newn) after a successful grow. Concepts: pointer-to-pointer update, uninitialised-memory hazard.
Challenge — Round-trip dynamic array module.
Build a small module with struct vec { int *data; size_t len, cap; } and functions vec_push(struct vec *v, int x), vec_pop(struct vec *v, int *out), and vec_free(struct vec *v). push grows geometrically; pop removes the last element and, when len drops below cap/4, shrinks cap in half to reclaim memory. Requirements: overflow-check the growth multiplication; never leak; push and pop must be amortised O(1). Constraints: no global state; all memory owned by the struct vec. Hint: the cap/4 shrink threshold (rather than cap/2) avoids thrashing when the size oscillates. Concepts: every idea in this lesson — geometric growth, temp-pointer safety, overflow checks, pointer stability, cleanup.
realloc(ptr, size) resizes an existing heap block to size bytes and returns a pointer to it — which may differ from ptr if the block had to move. Your data is preserved up to min(old, new) bytes; grown bytes are uninitialised, shrunk bytes are gone.
The two rules that matter most:
p = realloc(p, n). On failure realloc returns NULL but leaves the old block valid, so self-assignment leaks it. Use a temporary: tmp = realloc(p, n); if (tmp) p = tmp;.Key syntax: int *tmp = realloc(arr, n * sizeof *arr); — size in bytes, sizeof *arr for type-safety.
Common mistakes: self-assignment leak, stale/interior pointers after a move, by-value resize that never updates the caller, +1 growth in a loop (grow geometrically instead), and forgetting sizeof.
Remember: realloc(NULL, n) == malloc(n); avoid realloc(p, 0) (use free); overflow-check n * sizeof *p for untrusted n; and exactly one pointer is valid after every realloc call — always know which one.