📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is the difference between multi-dimensional (rectangular) arrays and jagged arrays in C#? How are they laid out in memory, which is faster, and when would you flatten a grid into a single-dimensional array?

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.

Mid-Level Theory Intermediate arrays csharp jagged-arrays memory-layout multi-dimensional-arrays performance

🎯 Key points

  • A rectangular array (int[,]) is one object with all elements contiguous in row-major order; every row has the same length
  • A jagged array (int[][]) is an array of references to separate row arrays; rows can have different lengths or be null
  • GetLength(dimension) gives each dimension's size; Length is the total element count; Rank is the number of dimensions
  • Multi-dimensional arrays use method-style element access in IL, so historically they were slower than jagged arrays; modern JITs narrowed the gap, so measure
  • Jagged arrays cost an extra indirection and a bounds check per level, but each row is a fast single-dimensional array that works with Span<T>
  • int[,] only implements non-generic IEnumerable, so LINQ needs Cast<int>() or OfType<int>()
  • A flattened 1-D array (index = row * width + col) gives contiguous memory, span and SIMD support, and is often fastest
  • Iterate in memory order (row by row) for cache locality; column-first traversal can be several times slower

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Syntax

// 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.

2. Memory layout

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.

3. Which is faster

Counter-intuitively, jagged arrays are usually faster for element access in .NET:

  • Each row of a jagged array is a normal single-dimensional array (an "SZ array"), for which the JIT has its best optimisations: bounds-check elimination in loops, vectorisation, Span<T> access.
  • Rectangular arrays use generic multi-dimensional accessor code; every access computes (i - lowerBound0) * length1 + (j - lowerBound1) with two bounds checks, and the JIT eliminates fewer of them.
  • You can hoist a jagged row (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.

4. Flattening into a single-dimensional array

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).

5. Traversal order matters

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.

6. A production example

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.

🔍 What the interviewer is checking

Checks understanding of memory layout and how it drives performance.

Green flags

  • Draws or describes contiguous versus array-of-arrays layout.
  • Knows GetLength, Rank, and that jagged rows start null.
  • Mentions cache locality and traversal order.
  • Suggests flattening and measuring.

Red flags

  • Thinks jagged and rectangular arrays are the same.
  • Iterates column-first without concern.
  • Claims one is always faster without mentioning measurement.

📚 Learn in depth

1. SZ arrays vs MD arrays in the CLR

The runtime distinguishes:

  • SZ arrays (single-dimensional, zero-based): T[]. Dedicated IL instructions (newarr, ldelem, stelem, ldlen) and heavily optimised in the JIT.
  • MD arrays (multi-dimensional or non-zero-based): 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.

2. Bounds-check elimination with jagged rows

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.

3. Cache behaviour

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.

4. Allocation and GC

  • A jagged array of 10,000 rows = 10,001 objects. GC marking must visit each; rows can be fragmented across generations.
  • A rectangular or flattened grid is one object. If it exceeds ~85 KB it goes to the LOH — collected only in Gen2 and not compacted by default — so allocate it once and reuse (ArrayPool<T>.Shared.Rent(rows * cols) or a long-lived instance).
  • GC.AllocateUninitializedArray<T>(n) skips zeroing for large buffers you'll overwrite immediately.

5. Interop and libraries

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.

6. Choosing

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

Try it yourself

  1. Benchmark summing a 2,000 × 2,000 grid as int[,], int[][] and flattened int[], row-major and column-major.
  2. Count objects with dotnet-gcdump for a 10,000-row jagged array vs a rectangular one.
  3. Write the Grid<T> class above and use Row(r).Fill(0) and TensorPrimitives.Sum(Row(r)).
  4. Print grid.Rank, GetLength(0), GetLowerBound(0) for a rectangular array.

💻 Code

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;
    }
}

⚠ Common mistakes

1. Assuming rectangular arrays are always faster

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.

2. Column-major traversal of row-major data

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.

3. Forgetting to allocate jagged rows

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).

4. Using Length incorrectly

What 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.

5. Index math errors in flattened arrays

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.

6. Creating large grids per request

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.

7. Using jagged arrays with many tiny rows

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).

8. Exposing multi-dimensional arrays in public APIs

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.

9. Using dense arrays for sparse data

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.

10. Ignoring vectorisation opportunities

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.

🏛 Senior insight: trade-offs

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
  • Wrapping the storage in a type (Grid<T>) lets you change layout later without touching callers.
  • The biggest wins usually come from traversal order and cache locality, not the array syntax.
  • Always benchmark on the target runtime; JIT improvements in recent .NET versions have changed the classic "jagged is always faster" advice.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions row-major traversal order Why: Links layout to CPU cache behaviour
    How to answer

    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.

  2. When: Candidate says jagged arrays are faster Why: Checks they understand bounds-check elimination
    How to answer

    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.

🛠 Real-world problems: how you handle them

Distance-matrix calculation slowed by column-first traversal
Situation
A delivery route optimiser computed a 5,000 × 5,000 distance matrix nightly for depots across Australia using double[,] and nested loops.
Signals
The calculation step took about 9 seconds per run and dominated CPU in Application Insights profiling; the job missed its SLA as depots grew.
Investigation
Profiled with the Visual Studio CPU profiler and BenchmarkDotNet, found the inner loop iterated rows while the outer loop iterated columns, and that the rectangular array prevented span-based vectorisation.
Resolution
Switched to a flattened double[] wrapped in a Grid type, reordered loops row-first, and used TensorPrimitives on row spans for vector operations.
Prevention
Kept a BenchmarkDotNet test for the hot loop in CI and documented layout and traversal guidance in the team's performance notes.

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.

↩ Usually asked after

More in Arrays

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me