Last Updated: May 22, 2026
A jagged array is an array whose elements are themselves arrays, where each inner array can have a different length. C# writes the type as T[][], said out loud as "array of arrays of T." That shape fits the messy real data an online store actually has: a customer's order history where each order contains a different number of line items, a weekly delivery schedule where some days have three drops and others have none, or a category that owns one product list and another that owns fifty. This lesson covers how to declare jagged arrays, how to fill them, how to walk them, how their memory differs from T[,], and when to pick one over the other.
A multidimensional array (T[,]) is a rectangle. Every row has exactly the same number of columns, and the storage is a single contiguous block sized rows * cols. That fits a chessboard or a fixed-size price grid, but it doesn't fit data where rows vary in length.
Consider three customer orders. The first has two line items, the second has five, the third has one. With a rectangular array you'd need a 3-by-5 grid and you'd waste the empty slots in rows one and three, plus you'd need a sentinel value (like null or -1) to mark "this slot has no item."
A jagged array drops that constraint. Each row is its own array, sized to fit its actual contents. Row zero holds two items, row one holds five, row two holds one, and no slots are wasted.
The outer array is a small header object that holds three references, one per inner array. Each inner array lives in its own block of memory and carries its own length. This is the structural difference that makes ragged rows possible.
The declaration syntax mirrors the shape: one set of brackets for the outer array and another set for the inner. There's an important rule: you only size the outer array at declaration time. The inner arrays are created separately.
new int[3][] allocates the outer array with three slots, but those slots start as null because the inner arrays haven't been created yet. Reading orders[0][0] before assigning orders[0] = new int[2] throws a NullReferenceException, not an IndexOutOfRangeException. Each row's Length is independent because each row is a separate array object with its own length field.
A collection initializer can build the whole structure in one expression. This is the common form:
new[] { 10, 20 } is the array-creation shorthand. The compiler infers int[] from the element type. The longer new int[] { 10, 20 } has identical meaning. The outer array's length comes from the number of inner arrays listed.
The full element form is also available, for explicit inner type:
The third row, new int[] { }, is a valid zero-length array. A jagged array can hold empty rows. T[,] cannot, because every row in a rectangle must have the same number of columns.
Each inner array is a separate heap allocation with its own object header. A jagged array with n rows costs one allocation for the outer array plus n allocations for the inner arrays. A rectangular T[,] of the same shape is a single allocation. For a handful of rows this difference is invisible, but for thousands of small rows the allocation overhead and garbage-collection pressure can matter.
The largest syntactic difference between jagged and rectangular arrays is the indexing. A jagged array uses two separate index operations: one for the row, one for the column. A rectangular array uses a single index expression with a comma inside.
Read jagged[0][1] as "first get the array at row zero, then get its element at index one." The first set of brackets returns an int[], the second set returns an int. That's two separate operations the runtime performs, in order. With rect[0, 1], the comma is part of a single indexer call; the runtime computes the flat offset from the row and column in one step.
The two-step nature of jagged indexing has a visible consequence: a row can be held as its own variable, because each row is a real array.
firstProductReviews holds the same reference as reviewsByProduct[0]. They're two names pointing to the same array on the heap, so modifying one is visible through the other. T[,] does not allow this. A rectangular array doesn't have "real rows" available as separate objects, only positions readable with rect[row, col].
Walking a jagged array with nested for loops is the standard pattern, but the inner loop uses each row's own Length rather than a shared column count. That's the iteration consequence of ragged rows.
The outer loop walks the rows using orders.Length. The inner loop walks the items in each specific row using orders[i].Length. A shared constant for the inner bound would either skip data in long rows or overrun short rows.
foreach works just as well and is often cleaner when the indices aren't needed:
The outer foreach produces each inner array as string[]; the inner foreach produces each string. The type of the outer loop variable, string[], makes the structure obvious.
A common aggregation pattern: total the items across all rows. The outer loop walks the customers, the inner loop walks the per-customer reviews, and a single running counter accumulates.
The averaging logic is exactly what it would be for a flat array, just with one extra loop wrapped around it. Eleven reviews, forty-four stars total, average four.
Several patterns from a typical e-commerce app fit the jagged shape naturally. They all share the same feature: each row's length depends on data, not on a fixed schema.
A catalog has categories, and each category owns a list of product names. Some categories have hundreds of products, others have a handful.
Modeling this with string[,] would require a column count equal to the largest category (five) and padding the shorter categories with null or "". The jagged version stores exactly the data that exists and nothing extra.
Pick-up windows vary by day. Some days have two windows, some have four, weekends might have none.
The two new string[0] entries are empty arrays. They're real array objects, just with zero elements, and they fit naturally into the string[][] shape. The check schedule[d].Length == 0 decides which days are closed.
The earlier int[][] order example becomes more realistic when each line item is itself a richer record. Even just storing an item count per line, plus a customer-by-customer breakdown, fits a jagged shape:
One pass per customer, aggregating along the row. This is what jagged arrays are for: the shape of the data and the shape of the storage match.
The structural difference between T[][] and T[,] shows up clearly in the heap layout. A multidimensional array is one contiguous block. A jagged array is a small outer array of references plus one separately allocated inner array per row.
Four practical consequences fall out of this picture.
One allocation versus many. The rectangular form is a single object on the heap. The jagged form is one outer object plus one inner object per row, so a 1000-row jagged array is 1001 heap allocations. For very small rows, the per-object overhead (the array header) becomes a noticeable fraction of total memory.
Locality. Reading neighboring cells of a rectangular array touches adjacent memory, which is friendly to the CPU's cache. Reading neighboring rows of a jagged array can jump to wherever each inner array lives in memory. For tight numerical loops over large grids, the rectangular form is usually faster because of this cache behavior.
Ragged rows. Rectangular arrays cannot have different row lengths. Jagged arrays can. For genuinely ragged data, rectangular requires padding or a sentinel.
Row-as-object. Each row of a jagged array is a real T[] that can be passed to methods, assigned to a variable, replaced wholesale, or shared with other code. A row of a rectangular array doesn't exist as a separate object.
Iterating a T[][] of size n * m touches n + 1 separate objects in memory. Iterating a T[,] of the same shape touches one. For small arrays the difference is noise; for large arrays accessed in tight loops, the rectangular form usually wins on raw throughput because of cache friendliness.
When the trade-offs are this varied, a table makes the picture clearer than prose.
| Aspect | Jagged T[][] | Multidim T[,] |
|---|---|---|
| Shape | Array of arrays; rows can vary in length | Fixed rectangle; all rows have the same length |
| Memory | One outer array plus one inner array per row | Single contiguous block |
| Heap allocations | 1 + rows | 1 |
| Indexing syntax | arr[i][j] (two operations) | arr[i, j] (one operation) |
| Row as a standalone object | Yes, each row is a real T[] | No, row is not a separate object |
| Length per row | arr[i].Length (per-row) | arr.GetLength(1) (same for all rows) |
| Total length | arr.Length is row count; sum the inner lengths for total | arr.Length is total cell count |
| Cache locality | Worse; rows scattered on the heap | Better; cells are contiguous |
| Iteration speed | Slower for tight numeric loops | Faster for tight numeric loops |
| Compile-time bounds checks | None (each inner length is a runtime value) | Same (still runtime), but fixed at construction |
| Initialization syntax | new[] { new[] { 1, 2 }, new[] { 3 } } | new int[2, 2] { { 1, 2 }, { 3, 4 } } |
| Resizing a row | Replace the entire row reference | Not possible; rectangle is fixed |
| BCL / framework support | Strong; widely used in LINQ, JSON, generics | Limited; many APIs prefer T[] or T[][] |
foreach over rows | Yields each T[] directly | Yields each cell directly, no row separation |
| Default value of an unfilled row | null (the row reference itself) | All cells default to default(T) |
Two rows worth lingering on are BCL support and foreach behavior. Most of the .NET ecosystem assumes either flat arrays or jagged arrays, not multidimensional ones. LINQ, for instance, treats T[,] as an opaque sequence of cells and loses the row structure entirely. JSON serialization through System.Text.Json works with T[][] (it round-trips as a nested JSON array) but doesn't handle T[,] cleanly. For data that flows through these APIs, jagged is almost always the better default.
A foreach over T[,] yields cells in row-major order but gives no signal when a row ends. Row-aware processing usually requires a nested for with GetLength(0) and GetLength(1). Jagged is structurally simpler for this case because the outer iteration already yields rows.
Because each row is its own array, an entire row can be swapped by assigning a new array to that slot. A multidimensional array can't do this.
The outer array's reference at index 0 now points to a four-element array instead of a two-element one. The old two-element array becomes eligible for garbage collection because nothing references it anymore.
The outer array itself cannot grow. Like every array in C#, the outer array has a fixed length once created. Adding a category requires allocating a new outer array of length oldLength + 1, copying the existing references over, and placing the new row at the end. List<T[]> does this internally, and for code that grows dynamically, List<T[]> or List<List<T>> is usually the better choice.
List<string[]> and string[][] look similar from the outside but solve different problems. The jagged array fits when the row count is known up front; the list fits when rows come and go.
A small set of questions answers "jagged or multidim?" reliably:
List<T[]>.In practice, most C# code that needs "two-dimensional" data uses jagged arrays or List<T> of something. The T[,] form has narrow but real uses (image processing, certain scientific computations, fixed game grids), and outside of those it's rare in modern C# codebases.
Pulling several ideas together: a program that prints, for each of a few customers, how many orders they've placed, the largest single order, and the total units shipped.
history is a copy of the reference to that customer's row; it doesn't allocate, it gives a clearer name inside the loop body. Dan's row is new int[0], so the inner loop runs zero times and largest and totalUnits stay at their initial values of 0. The single-pass aggregation per row mirrors a flat-array version, wrapped in an outer loop that walks customers.
The inner sizes aren't always known at compile time. When the row count and per-row sizes come from data (a database query, user input, an HTTP response), allocate the outer array first and then fill each row in a loop.
The outer array is sized once from ordersPerCustomer.Length. Each inner array is allocated with its own per-customer size inside the loop. The data-filling step is nested indexing. Omitting the orderQuantities[c] = new int[...] step makes the next line throw NullReferenceException, because the inner slot is still null.
Allocating inner arrays one at a time in a loop is fine for a few rows, but for thousands of small arrays in a hot path, consider ArrayPool<T> or a single flat array with stride math. Each new int[n] is a separate heap allocation that the GC will eventually have to clean up.
Common mistakes with jagged arrays:
Forgetting to allocate the inner arrays. new int[3][] creates the outer array with three null slots. Each slot must be assigned an inner array before reading or writing.
The fix is to allocate each row first:
Mixing jagged and multidim indexing. Trying to use jagged[i, j] or multi[i][j] is a compile error. The syntax is part of the type. T[,] needs the comma form; T[][] needs two bracket pairs.
Using a single row length for the inner bound. Hardcoding the inner loop's bound (or reusing the first row's length) breaks any row that's longer or shorter.
Use orders[i].Length so each row's bound matches its own size.
Assuming `arr.Length` is the total cell count. For T[][], arr.Length is the number of rows, not the total number of cells. To count cells, sum the inner lengths.
For T[,], by contrast, arr.Length is the product of dimensions. Mixing these up is a common bug when porting code from one form to the other.