Rectangular int[,] versus jagged int[][] arrays: syntax, memory layout, bounds checks and performance, LINQ and Span support, and the flattened 1-D array alternative for grids, images and matrices.
A rectangular array int[,] is one object with every element stored contiguously in row-major order, and every row is the same length. A jagged array int[][] is an array of separate row arrays, so rows can have different lengths but each access goes through an extra reference. Rectangular arrays use helper-style access in IL and were historically slower, while each jagged row is a fast single-dimensional array that works with spans. The modern JIT has narrowed the gap, so I measure. For performance-critical grids I often flatten into one T[] with index = row * width + col, which gives contiguous memory, span and SIMD support. I always iterate row by row for cache locality.
C# has two ways to represent a grid: a rectangular (multi-dimensional) array int[,], which is one object with one contiguous block of rows × columns, and a jagged array int[][], which is an array of separate arrays, each possibly a different length. I explain the layout, why jagged arrays are usually faster in .NET, and why the fastest option for numeric grids is often to flatten into a single T[] and compute the index yourself.
// Rectangular
int[,] grid = new int[3, 4];
grid[1, 2] = 7;
int rows = grid.GetLength(0), cols = grid.GetLength(1);
int total = grid.Length; // 12 - total elements
// Jagged
int[][] jagged = new int[3][];
jagged[0] = new int[4];
jagged[1] = new int[2]; // rows can differ
jagged[2] = [1, 2, 3, 4, 5];
jagged[0][2] = 7;
int rowCount = jagged.Length; // 3 - number of rows
Rectangular arrays support any rank (int[,,]) and even non-zero lower bounds via Array.CreateInstance (rarely used). Jagged arrays can model uneven data naturally: triangular matrices, adjacency lists, lines of a file split into fields.
Rectangular int[3,4]: one object, header + bounds/lengths for each dimension + 12 contiguous ints in row-major order:
[hdr][MT][len][dims...][0,0][0,1][0,2][0,3][1,0][1,1]...[2,3]
Jagged int[3][]: one outer array of 3 references, plus 3 separate row arrays, each its own heap object that can live anywhere:
outer: [hdr][MT][len=3][ref0][ref1][ref2]
| | \
row0 row1 row2 (separate objects)
Jagged arrays cost more objects (rows + 1) and memory per row (~24 bytes overhead each), and rows may not be adjacent. Rectangular arrays are compact and contiguous.
Counter-intuitively, jagged arrays are usually faster for element access in .NET:
Span<T> access.(i - lowerBound0) * length1 + (j - lowerBound1) with two bounds checks, and the JIT eliminates fewer of them.var row = jagged[i];) and loop over it like a plain array.Rectangular arrays win on allocation (one object) and on cache locality for whole-grid traversals because all data is contiguous.
The best of both worlds for numeric grids:
public sealed class Grid<T>(int rows, int cols)
{
private readonly T[] _data = new T[rows * cols];
public int Rows { get; } = rows;
public int Cols { get; } = cols;
public ref T this[int r, int c] => ref _data[r * Cols + c]; // row-major
public Span<T> Row(int r) => _data.AsSpan(r * Cols, Cols); // a whole row as a span
public Span<T> AsSpan() => _data;
}
Benefits: one allocation, contiguous memory, a single bounds check per access, rows exposed as spans (so you can use Fill, CopyTo, TensorPrimitives and SIMD), and easy interop with libraries expecting flat buffers (images, ML tensors, GPU uploads).
I flatten when: the grid is rectangular, numeric/performance-sensitive, processed in bulk (image processing, simulations, dynamic programming tables, matrices), or must be passed to native code or serialised. I keep jagged arrays for ragged data, and rectangular T[,] for small, readability-first code (a 9×9 Sudoku board, a game board).
Both T[,] and flattened arrays are row-major: iterate rows in the outer loop and columns in the inner loop so memory is read sequentially. Swapping the loops (column-major traversal) jumps cols × sizeof(T) bytes per step and causes a cache miss per element on large grids — often a 3–10× slowdown.
A route-optimisation service computed a distance matrix for up to 2,000 delivery stops using double[,]. Profiling with dotnet-trace showed most time in the matrix-access helper. Changing to a flattened double[] with row spans, iterating row-major, and using TensorPrimitives for vector operations on each row made the hot loop roughly 3× faster. Because a 2,000 × 2,000 double matrix is 32 MB, it was also allocated once and reused via a pool between requests instead of creating a new LOH array per request.
Checks understanding of memory layout and how it drives performance.
Green flags
GetLength, Rank, and that jagged rows start null.Red flags
The runtime distinguishes:
T[]. Dedicated IL instructions (newarr, ldelem, stelem, ldlen) and heavily optimised in the JIT.T[,], T[,,], and also T[*] (single-dimensional with a non-zero lower bound). Accessed through runtime-provided Get, Set and Address methods on the array type.An int[,] object stores, after the header and MethodTable, the total length, then for each dimension its length and lower bound, then the data. Each element access does: (i - lb0) unsigned-compared to len0, (j - lb1) compared to len1, then computes offset = (i - lb0) * len1 + (j - lb1).
The JIT has improved MD array access significantly (since .NET 7 it inlines the accessors and can remove some checks), but SZ arrays still get more optimisations, especially loop cloning and vectorisation.
for (int i = 0; i < jagged.Length; i++)
{
int[] row = jagged[i]; // one bounds check for the outer array
for (int j = 0; j < row.Length; j++) // pattern recognised: no bounds check on row[j]
sum += row[j];
}
The inner loop is a plain SZ array loop: the JIT removes the bounds check and can vectorise if you use span APIs. The equivalent grid[i, j] loop keeps checks unless the JIT can prove both ranges.
CPUs fetch memory in 64-byte cache lines. Row-major traversal of a contiguous grid uses every byte of each line fetched (16 ints per line). With jagged arrays, each row is contiguous, but moving to the next row may jump to a distant address — usually fine for long rows, costly for many short rows. For column-wise access:
for j in cols: for i in rows: grid[i, j] // stride = cols * 4 bytes -> cache miss per access on big grids
Transposing the data or the algorithm (process rows, or store column-major when columns are hot) fixes this. Hardware prefetchers detect constant strides but not huge ones.
ArrayPool<T>.Shared.Rent(rows * cols) or a long-lived instance).GC.AllocateUninitializedArray<T>(n) skips zeroing for large buffers you'll overwrite immediately.Flattened buffers match how native libraries and tensors think: BLAS, ONNX Runtime, ML.NET, image libraries (ImageSharp exposes rows as spans), and .NET 9/10's System.Numerics.Tensors (Tensor<T>, TensorSpan<T>) all use contiguous memory with shape and strides. MemoryMarshal.CreateSpan(ref grid[0, 0], grid.Length) can view a rectangular array as a flat span when you need to bridge.
| Need | Choose |
|---|---|
| Rows of different lengths | Jagged T[][] |
| Small, readable fixed grid | Rectangular T[,] |
| Large numeric grid, performance-critical | Flattened T[] with index math and row spans |
| Very large or reused grids | Flattened + pooled/reused buffer |
| Interop with native/ML code | Flattened |
| Sparse data | Neither — Dictionary<(int,int),T> or a sparse matrix format |
int[,], int[][] and flattened int[], row-major and column-major.dotnet-gcdump for a 10,000-row jagged array vs a rectangular one.Grid<T> class above and use Row(r).Fill(0) and TensorPrimitives.Sum(Row(r)).grid.Rank, GetLength(0), GetLowerBound(0) for a rectangular array.using System.Numerics.Tensors;
namespace Arrays;
// A small grid type over a flattened array
public sealed class Grid<T>(int rows, int cols)
{
private readonly T[] _cells = new T[rows * cols];
public int Rows { get; } = rows;
public int Cols { get; } = cols;
public ref T this[int row, int col] => ref _cells[row * Cols + col];
public Span<T> Row(int row) => _cells.AsSpan(row * Cols, Cols);
}
public static class MultiDimensional
{
public static void Main()
{
// Rectangular
int[,] seats = new int[3, 4];
seats[1, 2] = 7;
Console.WriteLine($"{seats.Rank} {seats.GetLength(0)}x{seats.GetLength(1)} total {seats.Length}");
Console.WriteLine(seats.Cast<int>().Sum()); // LINQ needs Cast<int>()
// Jagged: ragged rows (Pascal's triangle)
int[][] pascal = new int[5][];
for (int r = 0; r < pascal.Length; r++)
{
var row = pascal[r] = new int[r + 1];
row[0] = row[r] = 1;
for (int c = 1; c < r; c++) row[c] = pascal[r - 1][c - 1] + pascal[r - 1][c];
}
Console.WriteLine(string.Join(" ", pascal[4])); // 1 4 6 4 1
// Flattened grid with span rows and SIMD sums
var distances = new Grid<float>(3, 3);
distances[0, 1] = 12.5f;
distances[1, 2] = 4f;
for (int r = 0; r < distances.Rows; r++)
Console.WriteLine($"Row {r} total {TensorPrimitives.Sum<float>(distances.Row(r))}");
}
// Row-first traversal with the row hoisted out of the inner loop
public static long SumJagged(int[][] data)
{
long total = 0;
foreach (var row in data)
foreach (var value in row)
total += value;
return total;
}
}
What happens: T[,] chosen for performance-critical numeric code.
Why it is wrong: MD array access is generally slower than SZ arrays; fewer bounds checks are eliminated and spans aren't directly available.
Do instead: benchmark; prefer jagged rows or a flattened array for hot loops.
What happens: outer loop over columns, inner loop over rows. Why it is wrong: each access jumps a whole row in memory, causing cache misses and 3–10× slowdowns on large grids. Do instead: iterate row by row, or store data in the order you read it.
What happens: int[][] j = new int[3][]; j[0][0] = 1;.
Why it is wrong: rows are null until allocated — NullReferenceException.
Do instead: allocate each row (or use a helper that creates the full structure).
Length incorrectlyWhat happens: for (int i = 0; i < grid.Length; i++) over a T[,].
Why it is wrong: Length is the total element count, not the row count.
Do instead: use GetLength(0) for rows and GetLength(1) for columns.
What happens: r * Rows + c instead of r * Cols + c, or mixing row-major and column-major conventions.
Why it is wrong: elements overwrite each other silently when the grid isn't square.
Do instead: encapsulate indexing in one type (Grid<T>), and test non-square grids.
What happens: a new double[2000, 2000] (32 MB) for every call.
Why it is wrong: LOH allocations trigger expensive Gen2 GCs and fragmentation.
Do instead: reuse buffers, pool them, or compute in streaming fashion.
What happens: a million-row jagged array with 2–3 elements per row. Why it is wrong: object overhead (~24+ bytes per row) dominates, and GC must trace a million objects. Do instead: flatten into one array plus an offsets array (CSR-style layout).
What happens: public int[,] GetMatrix().
Why it is wrong: MD arrays don't implement generic collection interfaces, serialise poorly (System.Text.Json doesn't support them), and callers can mutate them.
Do instead: expose a dedicated type, jagged arrays for serialisation, or read-only views.
What happens: a 100,000 × 100,000 grid allocated for data with a few thousand non-zero values.
Why it is wrong: impossible memory (tens of GB) for mostly empty cells.
Do instead: use a dictionary keyed by (row, col) or a sparse-matrix library.
What happens: element-by-element loops for row operations on flattened data.
Why it is wrong: misses SIMD speed-ups available through spans.
Do instead: use TensorPrimitives, Vector<T> or span helpers (Fill, CopyTo, IndexOf) on row spans.
Seniors choose the shape by data shape and access pattern, then hide it behind a small type.
| Data | Choice |
|---|---|
| Small fixed matrix, readability first | T[,] |
| Rows of different lengths, replaced independently | T[][] |
| Large numeric grid, images, game boards, ML | flattened T[] + wrapper with spans |
| Sparse grid | Dictionary<(int, int), T> |
| Matrix maths at scale | System.Numerics.Tensors / specialised libraries |
Grid<T>) lets you change layout later without touching callers.Memory is a hierarchy: the closer to the core, the faster and smaller. Registers (a few dozen per core) are read in under a cycle. L1 cache (about 32–64 KB per core) takes about 1 ns, L2 (about 1–2 MB per core) about 3–4 ns, L3 (tens of MB, shared by cores) about 10–20 ns, and RAM (gigabytes) about 60–100 ns, which is hundreds of cycles. Data moves in 64-byte cache lines. A program whose working set fits in cache and is read sequentially runs many times faster than one that chases pointers across a large heap. For .NET code, that means compact data (arrays, structs, fewer objects), fewer allocations, and benchmarking at realistic data sizes.
Every array or span access is guarded by a bounds check that compares the index against Length and throws IndexOutOfRangeException. That's what makes .NET memory-safe: no buffer overruns. The JIT removes checks it can prove are redundant, for example for (int i = 0; i < arr.Length; i++) or foreach over arrays and spans. They stay when you loop to some other variable, index with computed values, or read the array from a field that might change. I write loops against the array's or span's own Length, copy fields to locals, slice to spans, and check the generated assembly with DOTNET_JitDisasm or BenchmarkDotNet. Unsafe/MemoryMarshal tricks are a last resort for proven hot paths.
How to tell it (STAR)
**S:** Our route optimiser [at company] was missing its nightly window as the number of depots grew. **T:** I was asked to speed up the distance-matrix step. **A:** I profiled it, found column-first traversal over a rectangular array causing cache misses, flattened the matrix into a 1-D array behind a small Grid type, traversed rows in memory order and vectorised row operations. **R:** The step went from about 9 seconds to under [2], and the job met its SLA again.
Last updated 30 Oct 2026.