Pointers & Memory · intermediate · ~10 min

realloc — grow or shrink a heap block

- 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)`.

Overview

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.

Why it matters

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.

Core concepts

1. What realloc actually does

Definition. 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:

  • If the new size fits where the block already lives (there is free space adjacent, or you shrank it), it adjusts the bookkeeping and returns the same address.
  • Otherwise it allocates a new block, 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.

2. The failure contract — why you need a temporary

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?

3. Pointers can move — keep every reference in sync

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?

4. Edge cases: NULL and zero

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.

Syntax notes

#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.
  • Using sizeof *arr (the dereferenced pointer) instead of sizeof(int) keeps the size correct even if you later change the element type.
  • Always guard the multiplication against overflow when n comes from untrusted input (see Memory-safety notes).

Lesson

What realloc does

void *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:

  • The returned pointer may be different from p. The allocator can move the data to a new location if it cannot resize in place.
  • Your existing data is preserved up to 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.

Handling failure safely

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;
}

Code examples

#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;

}

Line by line

Feed the program the input 10 20 30 40 50 (then end-of-file, e.g. Ctrl-D).

  1. read_ints starts with data = NULL, count = 0, cap = 0. Because data is NULL, the first realloc will behave like malloc.
  2. First loop iteration: 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.
  3. Reading 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.
  4. Reading 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.
  5. scanf hits end-of-file and returns EOF (not 1), so the loop ends.
  6. The shrink step: 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).
  7. *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)
  1. In 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
  1. 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.

Common mistakes

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.

Debugging tips

Compiler warnings.

  • "implicit declaration of function 'realloc'" — you forgot #include <stdlib.h>.
  • "assignment makes pointer from integer" — same missing include; the compiler assumed realloc returns int.
  • Enable -Wall -Wextra and, for numeric safety, consider -fsanitize=undefined.

Runtime errors and how to catch them.

  • Crash / segfault right after a resize — you used a stale pointer into a moved block, or forgot to update the caller's pointer. Run under AddressSanitizer: compile with -fsanitize=address -g. It reports "heap-use-after-free" with the exact line.
  • "double free or corruption" — you freed a block that realloc already freed (the old pointer after a move), or freed twice. ASan pinpoints the second free.
  • Slow leak that grows under load — likely the p = realloc(p, n) failure leak. Run Valgrind: valgrind --leak-check=full ./prog shows "definitely lost" bytes with a stack trace.

Logic errors.

  • Data looks garbled after growing — remember the new bytes are uninitialised; you must fill them before reading. Use calloc-style zeroing or set each slot explicitly.
  • Off-by-one when appending — check whether you grow when count == cap (correct) versus count > cap (too late — you already overran).

Questions to ask when it doesn't work.

  • Did I capture the result in a temporary and check it for NULL?
  • Did any other variable hold the old pointer? Is it updated?
  • Is my size argument in bytes (n * sizeof *p), not element count?
  • Could n * sizeof *p have overflowed size_t?

Memory safety

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.

Real-world uses

Where it shows up.

  • Dynamic arrays / vectors. Every growable container — a C implementation of a "vector," a JSON parser's token list, a shell's argument array — grows its backing store with realloc. This is precisely how C++'s std::vector and Go's slices reallocate when they exceed capacity.
  • Reading input of unknown size. getline on POSIX grows its line buffer with realloc as long lines arrive. File readers, HTTP body accumulators, and log parsers do the same.
  • Trimming to fit. After building a buffer with slack capacity, a final realloc down to the exact used size returns wasted memory to the allocator — common in image decoders and serialisers.

Professional best-practice habits.

Beginner:

  • Always use the temp-pointer pattern and check for NULL.
  • Size in bytes with sizeof *p, never a hard-coded type size.
  • free exactly once and set the pointer to NULL afterward.

Advanced:

  • Grow geometrically (typically ×1.5 or ×2) and track capacity separately from count for amortised O(1) appends.
  • Overflow-check count * sizeof *p before every resize when the count is untrusted.
  • Wrap the pattern in a small struct { T *data; size_t len, cap; } with push/reserve helpers so the resize logic lives in one audited place.
  • Prefer returning the new pointer or taking a pointer-to-pointer so callers can never keep a stale reference; document the ownership contract in the function comment.

Practice tasks

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.

Summary

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:

  1. Never write 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;.
  2. The block can move. After a successful resize, update every pointer that referenced it — return the new pointer or take a pointer-to-pointer so the caller stays in sync. Stale pointers become use-after-free bugs.

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.

Practice with these exercises