The C Path — learn C, visually

🎯 Pointers & Memory

Multidimensional arrays: grids in a flat world

⏱ 12 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

Chess boards, Minesweeper fields, spreadsheets, every photo on your screen — all grids. But memory, as you've seen since Part 0, is one straight line of bytes, so C has to fold each grid into that line. Learn the fold and you'll be able to hand grids to functions without baffling compiler errors — and you'll see why looping over a big grid in the wrong order can make the exact same code several times slower.

Memory is one long line of bytes — there is no "up" or "down" in RAM. So how does C store a grid like int m[2][3]? By a beautifully simple trick: a 2-D array is an array of arrays. m is 2 elements long, and each element is itself an int[3] row.

Declaring and looping

grid.c
#include <stdio.h>

int main(void) {
    int m[2][3] = {
        {1, 2, 3},      /* row 0 */
        {4, 5, 6},      /* row 1 */
    };

    for (int r = 0; r < 2; r++) {
        for (int c = 0; c < 3; c++)
            printf("%d ", m[r][c]);
        printf("\n");
    }
    printf("sizeof m    = %zu\n", sizeof m);     /* whole grid */
    printf("sizeof m[0] = %zu\n", sizeof m[0]);  /* one row    */
    return 0;
}
terminal
$ gcc grid.c -o grid && ./grid
1 2 3
4 5 6
sizeof m    = 24
sizeof m[0] = 12

sizeof m[0] is 12 — one whole row. That confirms the "array of arrays" story: m[1] is a real int[3], and m[1][2] indexes into it.

🧠 Checkpoint: What exactly is m[1] for int m[2][3]?

  • An int
  • A pointer stored in memory next to m[0]
  • The second row — a real int[3] array
  • A syntax error without a second index
Show answer

The second row — a real int[3] array — A 2-D array is an array of arrays: m[1] is the second row, an int[3] living 12 bytes after the start. (In expressions it happily decays to an int* like any array.)

Row-major: the grid, flattened

The rows are laid end-to-end in one contiguous block — row 0 first, then row 1. This is called row-major order:

This spot has an interactive memgrid widget — open the interactive lesson to play with it.

So the address math for m[r][c] is: base + (r * COLS + c) * sizeof(int). Skip r full rows, then c elements into the row. We can prove the flatness by walking the whole grid with a single pointer:

flat.c
#include <stdio.h>

int main(void) {
    int m[2][3] = {{1, 2, 3}, {4, 5, 6}};
    int *flat = &m[0][0];             /* first int of the block */

    /* m[r][c] lives (r*3 + c) elements from the start: */
    printf("m[1][2]     = %d\n", m[1][2]);
    printf("flat[1*3+2] = %d\n", flat[1*3 + 2]);

    printf("&m[0][0] = %p\n", (void *)&m[0][0]);
    printf("&m[1][0] = %p\n", (void *)&m[1][0]);  /* +12 bytes */
    return 0;
}
terminal
$ gcc flat.c -o flat && ./flat
m[1][2]     = 6
flat[1*3+2] = 6
&m[0][0] = 0x7ffcd58e91b0
&m[1][0] = 0x7ffcd58e91bc
# 0x1bc - 0x1b0 = 0xc = 12 bytes = one full row
💡

Performance bonus: because rows are contiguous, looping row by row (the inner loop over columns) touches memory sequentially and keeps the CPU cache happy. Loop column-first over a big matrix and you can easily go several times slower — same math, worse order.

🧠 Checkpoint: For int m[4][5] (4-byte ints), what is the byte offset of m[2][3] from the start?

  • 23
  • 32
  • 52
  • 92
Show answer

52 — Offset = (r × COLS + c) × sizeof(int) = (2×5 + 3) × 4 = 13 × 4 = 52 bytes. Skip two full rows (40 bytes), then three ints (12 more).

Passing 2-D arrays to functions

When a 2-D array decays, it becomes a pointer to its first element — and the first element is a row. So int m[2][3] decays to int (*)[3]: "pointer to array of 3 ints". That's why the parameter must spell out the inner size:

pass2d.c
#include <stdio.h>

/* the inner size (3) is REQUIRED — it sets the row stride */
int sum(int rows, int m[][3]) {       /* same as int (*m)[3] */
    int s = 0;
    for (int r = 0; r < rows; r++)
        for (int c = 0; c < 3; c++)
            s += m[r][c];
    return s;
}

int main(void) {
    int grid[2][3] = {{1, 2, 3}, {4, 5, 6}};
    printf("sum = %d\n", sum(2, grid));
    return 0;
}
terminal
$ gcc pass2d.c -o pass2d && ./pass2d
sum = 21

Why is the 3 mandatory? Look at the address math above: computing m[r][c] needs COLS. Without the inner dimension the compiler literally cannot find row 1. (The outer size is still decorative, as always.)

🧠 Checkpoint: Why must a 2-D array parameter be written int m[][4] — what is the 4 for?

  • Pure documentation
  • The compiler needs the row width to compute the address of m[i][j]
  • It makes the compiler bounds-check columns
  • It limits callers to exactly 4 rows
Show answer

The compiler needs the row width to compute the address of m[i][j] — m[i][j] compiles to base + (i×4 + j)×sizeof(int). Drop the 4 and the stride is unknown — the compiler rejects it. The OUTER dimension can be omitted, as usual.

The impostor: arrays of pointers

char *menu[3] looks 2-D when you write menu[i][j], but it's a completely different animal: an array of 3 pointers, each aiming at a separately-stored string, possibly of different lengths ("jagged"). Two dereferences instead of one address calculation:

This spot has an interactive memgrid widget — open the interactive lesson to play with it.

Both support x[i][j] syntax — which is exactly why people mix them up. True 2-D: one block, address math. Array of pointers: a table of arrows. You'll build the jagged kind yourself once you can malloc — which is the very next lesson.

This spot has an interactive editor widget — open the interactive lesson to play with it.

▶ Practice this lesson interactively (with live gcc)