Last Updated: May 17, 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.
A few things worth pinning down. 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.
You can also use a collection initializer to build the whole structure in one expression. This is the form you'll see most often in real code:
new[] { 10, 20 } is the array-creation shorthand. The compiler infers int[] from the element type. You could spell it out as new int[] { 10, 20 } and the meaning is identical. The outer array's length comes from the number of inner arrays you listed.
You can also use the full element form when you want to be explicit about the inner type:
Notice the third row, new int[] { }, is a perfectly valid zero-length array. A jagged array can hold empty rows. You can't do that with T[,] because every row in a rectangle must have the same number of columns.
Cost: 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 single biggest syntactic difference between jagged and rectangular arrays is how you index into them. A jagged array uses two separate index operations: one to get the row, one to get 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: you can hold onto a row 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. You can't do that with T[,]. A rectangular array doesn't have "real rows" you can hand out as separate objects, only positions you can read with rect[row, col].
Walking a jagged array with nested for loops is the bread-and-butter 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. If you used a shared constant for the inner bound, you'd either skip data in long rows or overrun short rows.
foreach works just as well and is often cleaner when you don't need the indices:
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 to anyone reading the code.
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.
A few 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.
If you'd modeled this with string[,], you'd have had to pick a column count equal to the largest category (five) and pad 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 when you draw the heap. 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 happens to live 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. If your data is genuinely ragged, picking rectangular forces you to pad or use a sentinel.
Row-as-object. Each row of a jagged array is a real T[] you can pass to methods, assign to a variable, replace wholesale, or share with other code. A row of a rectangular array doesn't exist as a separate object.
Cost: 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) |
The 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 smoothly with T[][] (it round-trips as a nested JSON array) but doesn't handle T[,] cleanly. If you ever expect your data to flow through these APIs, jagged is almost always the better default.
Cost: A foreach over T[,] yields cells in row-major order but gives you no easy way to know when a row ends. To break it into row-aware processing, you usually need 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, you can swap an entire row by assigning a new array to that slot. This is something a multidimensional array can't do.
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.
You cannot grow the outer array itself, though. Like every array in C#, the outer array has a fixed length once it's created. To add a category, you'd allocate a new outer array of length oldLength + 1, copy the existing references over, and place the new row at the end. That's exactly what List<T[]> does for you behind the scenes, 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 is right when the row count is known up front; the list is right 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 ends up reaching for 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.
A few details worth noticing. history is a copy of the reference to that customer's row; it doesn't allocate, it just gives us 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 what you'd do over a flat array, just wrapped in an outer loop that walks customers.
You won't always know the inner sizes at compile time. When the row count and per-row sizes come from data (a database query, user input, an HTTP response), you allocate the outer array first and then fill each row in a loop.
Three things to note. 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 just nested indexing. If you forgot the orderQuantities[c] = new int[...] step, the next line would throw NullReferenceException, because the inner slot would still be null.
Cost: Allocating inner arrays one at a time in a loop is fine for a few rows, but if you're building 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.
A handful of mistakes show up over and over with jagged arrays.
Forgetting to allocate the inner arrays. new int[3][] creates the outer array with three null slots. You must assign an inner array to each slot 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 one of the most common bugs when porting code from one form to the other.
T[][], an array of arrays. Each inner array is a separate object on the heap and can have its own length. The outer array stores references to the inner arrays.new int[rows][], then assign each inner array with arr[i] = new int[size]. Or use a collection initializer: new[] { new[] { 1, 2 }, new[] { 3, 4, 5 } }.arr[i][j]. The first returns the inner array; the second returns the cell. This is different from T[,], which uses arr[i, j] as one indexer call.arr[i].Length, not a shared constant. foreach over T[][] yields each inner array, which is often cleaner than nested indexing.T[,] is one contiguous block; T[][] is one outer array plus one inner array per row. The rectangular form has better cache locality for tight loops, but the jagged form supports ragged rows and lets you hand out individual rows as standalone objects.null), using a single row's length for every row's iteration bound, mixing arr[i, j] with T[][] syntax, and assuming arr.Length is the total cell count when it's only the row count.