DEV Community

Noor ul hassan
Noor ul hassan

Posted on

Build a Dynamic Array in C from Scratch

Build a Dynamic Array in C from Scratch

You've used push() in JavaScript, append() in Python, add() in Java. They feel instant. They never run out of space. You never think about them.

That's a problem — not because those abstractions are bad, but because you're using a tool without knowing what it costs. Every one of those methods is built on ideas you can understand completely. This article will show you how, in C, where the machine hides nothing from you.

By the end you'll have built a working dynamic array and, more importantly, a mental model that makes std::vector, Go slices, and Rust's Vec all obvious.


The Core Idea: Two Layers of Memory

Before writing a single line, you need to understand the central insight that makes a dynamic array possible.

A normal array is a fixed block of memory. You declare int numbers[8] and the compiler carves out exactly 32 bytes on the stack. That's it. If you need a 9th element, you're stuck.

A dynamic array gets around this by living on the heap — a region of memory that your program can request and release at runtime, in any size, at any time. The heap doesn't get freed automatically when a function returns. You control it. That control is what makes resizing possible, and what makes memory bugs possible too.

The dynamic array is two things at once: a block of integers sitting somewhere on the heap, and a small control structure that remembers where that block is, how big it is, and how many elements are currently in it. Everything we build is just bookkeeping those two things correctly.


What You Need to Know First

Memory: Stack vs Heap

When you declare a local variable, it goes on the stack. The stack is fast and automatic — variables appear when a function is called and vanish when it returns.

void foo() {
    int x = 10; // lives on the stack
}               // x is gone here
Enter fullscreen mode Exit fullscreen mode

The heap is different. Memory there persists until you explicitly free it. You ask the OS for a chunk, use it, and free it when you're done. This is what enables data structures that outlive the function that created them.

Pointers

A pointer is a variable that holds a memory address rather than a value.

int score = 99;
int *ptr = &score; // ptr holds the address of score, not 99
Enter fullscreen mode Exit fullscreen mode

&score means "give me the address of score." *ptr means "go to that address and read what's there." They're inverse operations.

Why does this matter? Because when you allocate memory on the heap, you get back an address — a pointer — to where that memory starts. There's no other way to refer to heap memory. Pointers aren't a language quirk; they're the only handle you have on dynamic allocation.

int *numbers = malloc(sizeof(int) * 8);
// numbers holds the address of a 32-byte block on the heap
// numbers[0], numbers[1], ... numbers[7] are all valid
Enter fullscreen mode Exit fullscreen mode

malloc returns a pointer to the allocated block. sizeof(int) gives the size in bytes of one integer (typically 4). Multiply by 8 and you get 32 bytes — room for 8 integers.

Structs

C has no classes. To group related data, you use a struct.

struct IntArray {
    int *data;     // pointer to the integers on the heap
    int length;    // how many elements are currently stored
    int capacity;  // how much space is allocated
};
Enter fullscreen mode Exit fullscreen mode

typedef lets you drop the struct keyword every time you use it:

typedef struct {
    int *data;
    int length;
    int capacity;
} IntArray;
Enter fullscreen mode Exit fullscreen mode

Now IntArray arr; is valid instead of struct IntArray arr;. Small thing, but it adds up.

The -> operator accesses a struct's field through a pointer:

array->length  // same as (*array).length
Enter fullscreen mode Exit fullscreen mode

You'll use -> constantly because our functions always receive a pointer to the struct, not the struct itself. Passing the struct by value would copy all three fields every time — wasteful, and it means modifications wouldn't be visible to the caller.

The Header Files

#include <stdio.h>   // printf, scanf
#include <stdlib.h>  // malloc, realloc, free, exit
#include <stdbool.h> // bool, true, false
Enter fullscreen mode Exit fullscreen mode

stdlib.h is the critical one. Every memory function we use lives there. Without it, malloc and free are undeclared.

stdbool.h exists because C didn't originally have a boolean type. It gives you bool, true, and false as proper names instead of int, 1, and 0.


The Structure

Now that the building blocks are clear, the struct makes obvious sense:

#define DEFAULT_CAPACITY 8

typedef struct {
    int *data;     // address of our integer block on the heap
    int length;    // elements currently stored (logical size)
    int capacity;  // elements we have room for (physical size)
} IntArray;
Enter fullscreen mode Exit fullscreen mode

length and capacity are not the same thing. This distinction is the heart of how a dynamic array works.

capacity = 8  (8 slots exist in memory)
length   = 3  (only 3 are in use)

[ 10 | 20 | 30 | __ | __ | __ | __ | __ ]
  [0]  [1]  [2]
Enter fullscreen mode Exit fullscreen mode

When length == capacity, the array is full and must grow before the next add.

#define DEFAULT_CAPACITY 8 is a compile-time constant. The preprocessor replaces every occurrence of DEFAULT_CAPACITY with 8 before the compiler sees the code. It's not a variable — it has no address and uses no runtime memory.


Creating the Array

IntArray *createArray(int capacity) {

    // Guard against nonsense input
    if (capacity <= 0)
        capacity = DEFAULT_CAPACITY;

    // Allocate the control struct on the heap
    // This gives us room for: one pointer, two ints
    IntArray *array = malloc(sizeof(IntArray));

    if (array == NULL) {
        printf("Memory allocation failed\n");
        exit(EXIT_FAILURE); // no safe way to recover here
    }

    // Allocate the integer block separately
    // The struct just holds a pointer to this block
    array->data = malloc(sizeof(int) * capacity);

    if (array->data == NULL) {
        printf("Memory allocation failed\n");
        free(array); // don't leak the struct we just allocated
        exit(EXIT_FAILURE);
    }

    array->length = 0;        // no elements yet
    array->capacity = capacity; // but we have space reserved

    return array; // return the address of the struct
}
Enter fullscreen mode Exit fullscreen mode

Notice we allocate twice. Once for the struct, and once for the integer data. These are two separate heap allocations, and that's why we must free both when we're done. The struct contains the address of the data block, but it doesn't own it in any automatic sense — we have to track and free it ourselves.

The return type is IntArray * — a pointer. We're not returning the struct itself; we're returning its address. This is the handle the caller will use for every subsequent operation.


Destroying the Array

void destroyArray(IntArray *array) {
    free(array->data); // free the integer block first
    free(array);       // then free the struct
}
Enter fullscreen mode Exit fullscreen mode

Order matters. Free the data block first, then the struct. If you freed the struct first, array->data would be a dangling read — you'd be accessing memory through a pointer to something that's already freed.

Every malloc must have exactly one matching free. This is the discipline C demands. Modern languages have garbage collectors to enforce this automatically. In C, it's on you.


Getting and Setting Elements

int get(IntArray *array, int index) {
    if (index < 0 || index >= array->length) {
        printf("Index out of bounds\n");
        exit(EXIT_FAILURE);
    }
    return array->data[index];
}

void set(IntArray *array, int index, int value) {
    if (index < 0 || index >= array->length) {
        printf("Index out of bounds\n");
        exit(EXIT_FAILURE);
    }
    array->data[index] = value;
}
Enter fullscreen mode Exit fullscreen mode

The bounds check is not optional. Without it, array->data[index] when index >= length reads memory that belongs to something else — possibly another variable, possibly the OS. C won't stop you. The behavior is undefined, which in practice means silent data corruption or a crash that's hard to trace.

Both operations are O(1) because array->data[index] computes directly to a memory address:

address = base_address + (index × sizeof(int))
Enter fullscreen mode Exit fullscreen mode

No searching. One arithmetic operation.


Adding Elements: The Core of a Dynamic Array

This is the function everything else builds toward.

void add(IntArray *array, int value) {

    // Is the array full?
    if (array->length == array->capacity) {

        // Double the capacity — we'll explain why below
        array->capacity *= 2;

        // Ask the OS to resize our integer block
        int *newData = realloc(
            array->data,
            sizeof(int) * array->capacity
        );

        // CRITICAL: store in a temp pointer, not array->data
        // If realloc fails, it returns NULL — if we assigned
        // directly to array->data we'd lose the original pointer
        // and have a memory leak with no way to recover
        if (newData == NULL) {
            printf("Reallocation failed\n");
            exit(EXIT_FAILURE);
        }

        // Only update once we know it succeeded
        array->data = newData;
    }

    // Store the value and advance the length in one step
    array->data[array->length++] = value;
}
Enter fullscreen mode Exit fullscreen mode

realloc is malloc's smarter sibling. It takes an existing pointer and a new size. If it can extend the block in place, it does. If not, it allocates a new block, copies the old data, frees the old block, and returns the new address. The existing contents are preserved either way.

Why double the capacity instead of growing by 1?

If you grew by 1 every time the array filled up, every single add would trigger a realloc and a copy of all existing elements. Adding n elements would cost O(n²) total.

Doubling means resizes happen at sizes 4, 8, 16, 32, 64... Each resize copies everything, but the number of copies per element approaches 2 as n grows. Over many insertions, the average cost per add is O(1). This is called amortized constant time — expensive occasionally, cheap on average.


Removing Elements

Remove by Index

void removeAt(IntArray *array, int index) {

    if (index < 0 || index >= array->length) {
        printf("Index out of bounds\n");
        exit(EXIT_FAILURE);
    }

    // Shift every element after the removed one left by one position
    for (int i = index; i < array->length - 1; i++) {
        array->data[i] = array->data[i + 1];
    }

    // The last position still holds the old value, but we don't
    // erase it — we just stop counting it as part of the array
    array->length--;
}
Enter fullscreen mode Exit fullscreen mode

The loop goes to array->length - 1, not array->length. At the final iteration, i is length - 2 and we read array->data[length - 1]. If the loop ran to length, the last iteration would read array->data[length], which is outside the allocated block.

This is O(n) in the worst case — removing from index 0 shifts every element.

Remove by Value

bool removeValue(IntArray *array, int value) {
    for (int i = 0; i < array->length; i++) {
        if (array->data[i] == value) {
            removeAt(array, i);
            return true;  // remove first match only
        }
    }
    return false; // value wasn't in the array
}
Enter fullscreen mode Exit fullscreen mode

Search linearly, delegate the actual removal to removeAt. Returns false when the value isn't found so the caller can detect the miss.


Reversing the Array

void reverse(IntArray *array) {
    int left = 0;
    int right = array->length - 1;

    while (left < right) {
        // Swap elements at left and right
        int temp = array->data[left];
        array->data[left] = array->data[right];
        array->data[right] = temp;

        // Move both pointers inward
        left++;
        right--;
    }
}
Enter fullscreen mode Exit fullscreen mode

The two-pointer technique. Start from opposite ends, swap, move inward, stop when they meet. O(n) time, O(1) extra space — no second array needed.

The condition is left < right, not left != right. For an odd-length array, they'll converge on the middle element. That element doesn't need swapping with itself, so the loop correctly stops before touching it.


Printing

void printArray(IntArray *array) {
    printf("[");
    for (int i = 0; i < array->length; i++) {
        printf("%d", array->data[i]);
        if (i != array->length - 1)
            printf(", ");
    }
    printf("]\n");
}
Enter fullscreen mode Exit fullscreen mode

The comma logic — print a comma after every element except the last — is a common pattern. The alternative (printing a comma before every element except the first) is equivalent; pick one and be consistent.


Putting It Together

int main() {
    IntArray *arr = createArray(4); // capacity 4, length 0

    add(arr, 3);   // length 1
    add(arr, 7);   // length 2
    add(arr, 6);   // length 3
    add(arr, -2);  // length 4, capacity 4 — full

    printArray(arr); // [3, 7, 6, -2]

    removeValue(arr, 7); // shift left, length becomes 3

    printArray(arr); // [3, 6, -2]

    reverse(arr); // two-pointer swap

    printArray(arr); // [-2, 6, 3]

    destroyArray(arr); // free data block, then free struct

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Note: the first add after createArray(4) that would require a 5th slot would trigger a resize — capacity doubles to 8. In this main we add exactly 4 elements, so no resize occurs. To see it in action, add a 5th element.


The Gotchas

Assigning realloc directly to the original pointer.

// Wrong — if realloc returns NULL, you've lost your data
array->data = realloc(array->data, newSize);

// Right — check before assigning
int *newData = realloc(array->data, newSize);
if (newData != NULL) array->data = newData;
Enter fullscreen mode Exit fullscreen mode

Freeing in the wrong order.
Free array->data before array. If you free the struct first, you're reading array->data through a dangling pointer.

Using length instead of capacity in the resize check.
The array is full when length == capacity. If you compare against the wrong field, you either resize too early (wasted allocations) or never resize at all (out-of-bounds writes).

Off-by-one in removeAt's loop.
The loop runs to array->length - 1, not array->length. The last iteration reads data[length - 1]. Going one further reads past the end of the allocated block.

Not checking malloc for NULL.
malloc returns NULL when the OS can't satisfy the request. Dereferencing a NULL pointer is undefined behavior. Always check.

Using the array after destroyArray.
After you free memory, the pointer still holds the old address. Reading through it is undefined behavior. Set pointers to NULL after freeing if you need to guard against accidental reuse.


Junior Pattern vs Senior Pattern

Resizing:

// Junior — assigns directly, leaks memory on failure
array->data = realloc(array->data, sizeof(int) * array->capacity);
if (array->data == NULL) exit(EXIT_FAILURE); // original pointer is gone

// Senior — uses temp, original is recoverable on failure
int *newData = realloc(array->data, sizeof(int) * array->capacity);
if (newData == NULL) {
    // array->data is still valid here, nothing was lost
    exit(EXIT_FAILURE);
}
array->data = newData;
Enter fullscreen mode Exit fullscreen mode

Cleanup:

// Junior — frees struct first, then reads dangling pointer
free(array);
free(array->data); // undefined behavior: array is already freed

// Senior — free data first, then the container
free(array->data);
free(array);
Enter fullscreen mode Exit fullscreen mode

Bounds checking:

// Junior — trusts the caller
return array->data[index];

// Senior — validates before accessing
if (index < 0 || index >= array->length) {
    // handle error
}
return array->data[index];
Enter fullscreen mode Exit fullscreen mode

Where This Lives in the Real World

std::vector in C++ is this exact structure. The underlying implementation uses new[] instead of malloc, but the capacity/length distinction, the doubling strategy, and the contiguous memory layout are identical.

Go slices have a header with ptr, len, and cap — those are our data, length, and capacity. When you append past cap, Go allocates a new backing array, copies, and returns a new slice header.

Rust's Vec<T> is the same: a heap-allocated contiguous buffer, a length, and a capacity. The ownership system handles the freeing automatically, but the memory layout underneath is what you just built.


Time Complexity

Operation Complexity Why
get O(1) Direct address calculation
set O(1) Direct address calculation
add (no resize) O(1) Write to data[length], increment
add (resize) O(n) realloc copies all elements
add (amortized) O(1) Resizes are infrequent enough
removeAt O(n) Shift all elements after index
removeValue O(n) Linear scan + shift
reverse O(n) Visit each element once

Your Turn

Extend the implementation with these two functions:

// Insert a value at a specific index, shifting everything right
void insertAt(IntArray *array, int index, int value);

// Return the index of the first occurrence of value, or -1 if not found
int indexOf(IntArray *array, int value);
Enter fullscreen mode Exit fullscreen mode

Constraints: insertAt must handle growth the same way add does. indexOf must return -1, not exit, when the value is absent.

You'll know you're done when this main produces the expected output:

IntArray *arr = createArray(4);
add(arr, 10);
add(arr, 30);
insertAt(arr, 1, 20); // insert 20 between 10 and 30
printArray(arr);      // [10, 20, 30]
printf("%d\n", indexOf(arr, 20)); // 1
printf("%d\n", indexOf(arr, 99)); // -1
destroyArray(arr);
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
aadhilkassim profile image
Aadhil Kassim P N

Damn! This is a good documentation for people who are trying to learn how C works.