student@ubuntu:~$
c-foundations Lesson 13 10 min read

2D Arrays & Matrices

Declaring, traversing, and passing multidimensional arrays — the nested-loop patterns you'll use everywhere

Reading: Hanly & Koffman: §7.7–7.8 (pp. 413–430)

Quick check before you start: Can you write a for loop that iterates over a 1D array and prints each element? If so, you already have the building block for 2D arrays – you just nest one loop inside another.

Practice this topic: Arrays skill drills

After this lesson, you will be able to:

  • Declare and initialize 2D arrays using nested braces
  • Access individual elements with matrix[row][col]
  • Traverse a 2D array with nested for loops in row-major order
  • Explain how C stores 2D arrays contiguously in memory
  • Pass 2D arrays to functions (specifying the column dimension)
  • Apply common patterns: summing rows, finding column maximums, and basic matrix operations

Declaring 2D Arrays

A 2D array is an array of arrays. You specify two dimensions – rows and columns:

#define ROWS 3
#define COLS 4

int matrix[ROWS][COLS];  // 3 rows, 4 columns — 12 ints total, UNINITIALIZED

Each element is accessed with two subscripts: matrix[row][col]. The row subscript ranges from 0 to ROWS - 1, and the column subscript ranges from 0 to COLS - 1.

Common Pitfall: Just like 1D arrays, an uninitialized 2D array contains garbage values. Use = {0} to zero-fill the entire array, or initialize it explicitly with nested braces.


Initializing with Nested Braces

You can initialize a 2D array by grouping values by row:

int grid[2][3] = {
    {1, 2, 3},    // row 0
    {4, 5, 6}     // row 1
};

Each inner set of braces fills one row. If you provide fewer values than a row holds, the remaining elements are zero-filled:

int partial[2][3] = {
    {1, 2},        // row 0: {1, 2, 0}
    {4}            // row 1: {4, 0, 0}
};

You can also zero-fill the entire array in one shot:

int empty[10][10] = {0};  // All 100 elements set to 0

Accessing Elements

Each element requires two indices – row first, then column:

int grid[2][3] = {
    {1, 2, 3},
    {4, 5, 6}
};

printf("%d\n", grid[0][2]);  // 3 (row 0, column 2)
printf("%d\n", grid[1][0]);  // 4 (row 1, column 0)

grid[1][2] = 99;            // Modify element at row 1, column 2

Common Pitfall: C does not check bounds on either dimension. Writing to grid[2][0] on a [2][3] array is undefined behavior – it silently writes past the end of the array, potentially corrupting other variables or crashing.


Nested Loops for Traversal

The standard pattern for processing every element in a 2D array uses two nested for loops. The outer loop iterates over rows, the inner loop over columns:

#define ROWS 3
#define COLS 4

void print_matrix(const int m[][COLS], int rows)
{
    for (int r = 0; r < rows; r++)
    {
        for (int c = 0; c < COLS; c++)
        {
            printf("%4d", m[r][c]);
        }
        printf("\n");
    }
}

This traverses the array in row-major order – all of row 0 first, then all of row 1, and so on. This is the natural traversal order in C and the one that gives you the best cache performance.


Memory Layout: Row-Major Order

C stores 2D arrays as a single contiguous block of memory, with all elements of row 0 first, followed by all elements of row 1, and so on. For a int grid[2][3]:

Logical view:             Memory layout (contiguous):

  col  0   1   2          Address:  [0]  [1]  [2]  [3]  [4]  [5]
row 0: 1   2   3          Value:    1    2    3    4    5    6
row 1: 4   5   6                    |-- row 0 --|  |-- row 1 --|

The element grid[r][c] is stored at offset r * COLS + c from the start of the array. This means grid[0][3] and grid[1][0] refer to the same memory location in a [2][3] array – another reason bounds violations are dangerous.

From Java: In Java, int[][] grid = new int[2][3] creates an array of references, where each row is a separate object on the heap. Rows can even have different lengths (jagged arrays). In C, a 2D array is a single, flat, contiguous block of memory. There are no references, no row objects, and no jagged arrays. This contiguous layout is why C can process matrices so efficiently – and why you must specify the column count when passing 2D arrays to functions.


Passing 2D Arrays to Functions

When you pass a 2D array to a function, you must specify the column dimension. The compiler needs it to calculate the memory offset for each element:

#define COLS 4

/* Column dimension MUST be specified; row dimension is optional */
void fill_matrix(int m[][COLS], int rows)
{
    int count = 1;
    for (int r = 0; r < rows; r++)
    {
        for (int c = 0; c < COLS; c++)
        {
            m[r][c] = count++;
        }
    }
}

The row dimension can be omitted in the parameter declaration (the compiler does not need it for address calculations), but the column dimension cannot. You pass the number of rows as a separate int parameter, just like you pass the size of a 1D array.

Why does the compiler need the column count? Because m[r][c] translates to *(m + r * COLS + c). Without knowing COLS, the compiler cannot compute the correct offset.

Common Pitfall: If you declare a function parameter as int m[][3] but pass in an array declared as int data[5][4], the column counts do not match. The compiler may not warn you, but every row access after row 0 will read the wrong elements. Always ensure the column dimension in the function parameter matches the actual array.


Common Patterns

Sum Each Row

void sum_rows(const int m[][COLS], int rows)
{
    for (int r = 0; r < rows; r++)
    {
        int row_sum = 0;
        for (int c = 0; c < COLS; c++)
        {
            row_sum += m[r][c];
        }
        printf("Row %d sum: %d\n", r, row_sum);
    }
}

The outer loop selects the row. The inner loop accumulates every column value in that row. The accumulator row_sum resets to zero at the start of each row.

Find Maximum in Each Column

void max_per_column(const int m[][COLS], int rows)
{
    for (int c = 0; c < COLS; c++)
    {
        int col_max = m[0][c];
        for (int r = 1; r < rows; r++)
        {
            if (m[r][c] > col_max)
            {
                col_max = m[r][c];
            }
        }
        printf("Col %d max: %d\n", c, col_max);
    }
}

Notice the loop order is reversed – the outer loop iterates over columns, and the inner loop walks down the rows of that column. The pattern depends on which dimension you want to summarize.

Matrix Addition

Adding two matrices element by element:

void matrix_add(const int a[][COLS], const int b[][COLS],
                int result[][COLS], int rows)
{
    for (int r = 0; r < rows; r++)
    {
        for (int c = 0; c < COLS; c++)
        {
            result[r][c] = a[r][c] + b[r][c];
        }
    }
}

Both input matrices use const because the function only reads them. The output matrix does not use const because it is being written to. All three arrays must share the same dimensions.

Matrix Multiplication (Preview)

Matrix multiplication is a classic nested-loop problem. For two matrices A (size M x N) and B (size N x P), the result C is M x P:

#define M 2
#define N 3
#define P 2

void matrix_multiply(const int a[][N], const int b[][P],
                     int c[][P])
{
    for (int i = 0; i < M; i++)
    {
        for (int j = 0; j < P; j++)
        {
            c[i][j] = 0;
            for (int k = 0; k < N; k++)
            {
                c[i][j] += a[i][k] * b[k][j];
            }
        }
    }
}

This requires three nested loops. The innermost loop computes the dot product of row i from A and column j from B. You will not be asked to write this from scratch on an exam, but understanding the triple-loop pattern is useful preparation for later courses in linear algebra and graphics.


Check Your Understanding
You declare int m[3][4]; and pass it to a function with prototype void process(int arr[][3], int rows);. What happens?
AIt works correctly — the compiler ignores the column dimension
BThe compiler refuses to compile the code
CIt compiles but reads wrong elements — the column dimension mismatch causes incorrect address calculations
DIt works for row 0 only and crashes on row 1
Answer: C. The compiler uses the column dimension to calculate element offsets: arr[r][c] translates to *(arr + r * 3 + c) when the parameter says arr[][3]. But the actual array uses 4 columns, so the correct formula should be *(arr + r * 4 + c). The mismatch means every row after row 0 reads from the wrong position in memory. The code compiles (possibly with a warning) because C allows implicit pointer conversions, but the results are silently wrong. Always ensure the column dimension in the function parameter matches the declaration of the actual array.

What Comes Next

You now know how to work with 2D arrays – declaring them, initializing them with nested braces, traversing them with nested loops, and passing them to functions with the required column dimension. You also understand that C stores 2D arrays as contiguous memory in row-major order, which is fundamentally different from Java’s array-of-references model. In the next lesson, you will learn about pointers – the mechanism C actually uses when it passes arrays to functions, and the foundation for dynamic memory allocation.