A 1D array holds one row of values. Plenty of real data has two coordinates instead: stock counts for each warehouse across each product, ratings each customer gave each product, sales numbers for each week in each category. This lesson covers how to declare 2D arrays, how to read and write cells, how C++ lays them out in memory, and a brief look at 3D arrays.
1D arrays hold a flat list of values indexed by one number. That shape is fine when there's one value per item. It stops being fine the moment data has two coordinates.
Consider an online store tracking stock. There are 3 warehouses, and each warehouse stocks 4 products. The question "how many units of product 2 are in warehouse 1?" needs two numbers to answer. A 1D array of length 12 could fake it with a hand-computed index (warehouse * 4 + product), but the layout isn't visible from the code, and the math is easy to get wrong.
A 2D array makes the layout explicit. Rows are one coordinate, columns are the other:
| Product 0 | Product 1 | Product 2 | Product 3 | |
|---|---|---|---|---|
| Warehouse 0 | 25 | 17 | 8 | 4 |
| Warehouse 1 | 12 | 30 | 5 | 9 |
| Warehouse 2 | 18 | 22 | 14 | 7 |
The same shape covers many kinds of e-commerce data. Ratings indexed by customer and product. Weekly sales by month and category. Order counts by region and day. Whenever the answer to "how is this looked up?" needs two coordinates, a 2D array fits.
The syntax mirrors 1D arrays, with one extra pair of brackets:
That declares an array with 3 rows and 4 columns of int. The first number is the row count, the second is the column count. Both have to be compile-time constants for plain C-style arrays. The total cell count is 3 * 4 = 12, and each cell is one int.
A bare declaration like this leaves every cell uninitialized for a local array. Reading from stock[0][0] before writing to it is undefined behavior, the same trap as with 1D local arrays. The fix is to initialize at the same time as the declaration, covered next.
A useful naming convention: when the two dimensions mean different things, give the array a name that hints at which is which. warehouseStock[warehouse][product] reads better than grid[i][j], and the indices end up in the right order more often.
C++ offers a few ways to initialize a 2D array. Choose whichever matches what's known up front.
The most explicit form is fully nested. Each inner {...} is one row:
Three inner braces, four values each. The shape of the initializer matches the shape of the array, which makes the code self-documenting: the grid is laid out visibly.
Zero-initialization is concise. An empty pair of braces fills every cell with 0:
Partial initialization fills the cells provided and zero-fills the rest:
Row 0 got two values, so the last two cells default to 0. Row 1 got one value, so the last three cells default to 0. Row 2 wasn't mentioned at all, so the entire row defaults to 0. The rule is consistent: anything not provided is set to the type's zero value.
There's also a single-flat form that drops the inner braces. C++ fills the cells in row-major order (left to right, top to bottom):
The first three values fill row 0, the next three fill row 1. The compiler accepts this, but most code prefers the nested form because the row structure is visible. The flat form is fine for small arrays and gets unreadable fast.
A side-by-side of the four initialization styles:
| Style | Syntax | Effect |
|---|---|---|
| Fully nested | {{1,2},{3,4}} | Each inner brace is one row, values map 1:1 |
| Zero-init | {} | Every cell set to 0 |
| Partial nested | {{1,2},{3}} | Listed cells set, the rest zero-filled |
| Single flat | {1,2,3,4} | Filled row-major, no inner braces |
Indexing a 2D array uses two bracket pairs, in [row][col] order. The first index picks the row, the second picks the cell inside that row.
Reading and writing both use the same [row][col] form. Indices start at 0. The first row is row 0, the first column is column 0. The last row of a 3-row array is row 2, not row 3.
Unlike some languages, C++ does not check that indices are in range. Writing to weeklySales[5][0] when there are only 3 rows is undefined behavior. The program might appear to work, might print garbage, might crash, or might corrupt unrelated memory. The compiler won't catch it. There's no exception. The result is a broken program that may fail in ways that look unrelated.
Bounds checks are absent in raw arrays for a reason: every access stays a single load with no comparison. The tradeoff is that one bad index can overwrite a neighboring variable. For bounds-checking, std::array<T, N>::at() and std::vector<T>::at() throw on out-of-range access. Both are covered in later lessons.
To visit every cell, nest two for loops. The outer one walks the rows, the inner one walks the columns of the current row:
Each iteration of the outer loop produces one row of output. Inside it, the inner loop walks all four columns and prints them on the same line. After the inner loop ends, std::endl finishes the line so the next row starts fresh.
The same pattern handles aggregations. The total stock across all warehouses and products:
The accumulator total lives outside both loops so it survives every iteration. Declaring total inside the outer loop would compute a per-warehouse total instead, a different (and easy to write by accident) calculation.
Hardcoding the sizes (3 and 4) works, but it's fragile. Changing the array shape later requires updating every loop. A safer approach uses sizeof to compute the dimensions:
sizeof(stock) is the byte size of the entire 2D array. sizeof(stock[0]) is the byte size of one row. Dividing gives the row count. The same trick on a single row gives the column count. This only works while stock is still a real array. The section on passing 2D arrays to functions explains why that matters.
A C++ 2D array sits in memory as a single contiguous block. There are no separate row allocations, no pointers, no indirection. The cells of row 0 come first, then the cells of row 1 right after them, then row 2, and so on. This is row-major layout, the rule for any built-in array type in C++.
For int stock[3][4] on a typical system where int is 4 bytes, the 12 cells take 48 contiguous bytes:
Row 0's four cells take bytes 0 through 15. Row 1's start at byte 16, right after row 0 ends. Row 2 picks up at byte 32. The cells inside a row are adjacent. The cells across rows are also adjacent at the row boundary. There are no gaps and no extra metadata.
Printing addresses confirms this is one block:
Output: (exact addresses vary; the differences are the point)
The address of stock[1][0] is exactly one int past stock[0][3]. The address arithmetic between row starts is 4 (the column count). Every cell sits exactly where a row-major block would place it.
This matters in two practical ways. First, the convention "first index = row" matches how memory is laid out. Stick with it. Second, the CPU's cache loads memory in chunks (typically 64 bytes at a time). Walking the array row by row brings several upcoming cells into cache with each load, so the next few accesses are cheap. Walking column by column skips past most of each cache load and needs a fresh fetch for every cell.
Iterating in row-major order (outer loop = rows) is several times faster than column-major order on large arrays, because of cache locality. For a 1000x1000 array of ints, the difference can be 5 to 10x in real time. For small arrays it doesn't matter. For big ones, prefer row-major access unless there's a reason not to.
The contrast with some other languages is real but not relevant here. Java's 2D arrays, for example, are arrays of references to separate row arrays, so rows can live anywhere on the heap. C++ built-in 2D arrays are a single contiguous block. There's no indirection.
If two dimensions cover most cases, three dimensions show up occasionally. The syntax adds another set of brackets.
A natural e-commerce use is stock counts indexed by warehouse, product, and month:
The pattern extends without surprise. stock[2][3][4] is two layers of three rows of four columns. The memory is still one contiguous block, laid out in the same row-major scheme, with three coordinates instead of two. Walking every cell needs three nested loops.
Most code never goes past 2D. When a third dimension comes up, ask whether the data really has three independent coordinates, or whether a class with named fields would read better. A WarehouseInventory object with named members is often easier to maintain than stock[w][p][m], where each index's meaning has to be remembered or commented.
Passing a 2D array to a function is one of the parts of C++ that surprises beginners. It works, but there's a constraint to understand first.
A function that prints a 2D array:
The parameter type is int stock[][4]. The first dimension is empty, the second is 4. The row count gets passed separately as rows. The same parameter can be written as int (*stock)[4], which means the same thing in C++ (a pointer to an array of 4 ints), but int stock[][4] reads more naturally for beginners.
The column count cannot be left off:
g++ rejects this with declaration of 'stock' as multidimensional array must have bounds for all dimensions except the first. The column count must appear in the parameter type, even when it's also passed as a separate argument. The row count can be omitted (because the function takes an unknown number of rows), but the column count is part of the type and the compiler needs it.
Why? Because the compiler needs to know the column count to compute the address of stock[r][c]. The formula is base + r * column_count + c, and without column_count baked into the type, the formula doesn't work. The rule: inner dimensions must be in the parameter type.
A more flexible alternative is std::vector<std::vector<int>> or std::array<std::array<int, 4>, 3>. Those carry their dimensions with them and don't need any special parameter syntax. For raw C-style 2D arrays, the fixed-inner-dimension rule is something every C++ programmer ends up knowing.
A handful of mistakes show up repeatedly with 2D arrays. They're all easy to fix.
Confusing `[row][col]` with `[col][row]`. The two bracket positions look the same syntactically. Whether the first index means rows or columns is a convention, but it has to be consistent everywhere. Mixed conventions in the same codebase are a guaranteed bug. Pick "first index = row" (which matches row-major memory layout) and use it in declarations, loops, and function signatures.
Forgetting that partial initialization zero-fills. Writing int stock[3][4] = {{25, 17}}; only fills two cells of one row. The other ten cells are zero, not garbage, not the value of the cell next to them. This is sometimes the intended behavior and sometimes a surprise. Skipping the initializer entirely leaves cells uninitialized, but that's almost never a good idea.
Using the wrong size in nested loops. A common bug is reusing the outer loop bound for the inner loop:
For a square array this works by accident. For a rectangular array it either misses cells or runs past the end into undefined behavior. The fix is to use the right bound for each loop, computed with sizeof or stored as named constants:
Out-of-range indices. C++ doesn't check that indices are in range. stock[3][0] on a 3-row array reads memory past the end of the array. The program might keep running and print a plausible-looking value, but that value belongs to some other variable. Always match loop bounds to the array dimensions, and prefer std::vector::at() or std::array::at() (covered in the std::array lesson and the STL section) when a runtime check is needed.
Passing the array around without the inner dimension. As shown above, function parameters need the column count in the type. Skipping it is a compile error. The reason connects to how arrays decay to pointers, covered in the Pointers & Arrays lesson, but the practical takeaway: keep the inner dimension in the parameter type.
A picture of how the nested loop walks the grid:
The inner loop runs to completion for every iteration of the outer loop. Total work is rows * cols cell visits. For a 100x100 array that's 10,000 cells, which is nothing. For a 10000x10000 array that's 100 million, which is when cache locality starts to matter.
10 quizzes