A nested list is a list whose elements are themselves lists. That's the whole definition. Once you have that, a 2D grid (rows and columns), a list of orders where each order is a list of items, or a seating chart for store pickup slots all fall out of the same idea. This lesson covers how to build nested lists safely, index into them, iterate them, and avoid the one pitfall that catches every beginner.
Lists can hold anything, including other lists. So writing this is perfectly legal:
This is an inventory grid for three warehouses. Each inner list holds the stock counts for four products in one warehouse. Row 0 is the first warehouse, row 1 is the second, and so on. The columns line up across rows: column 2 is the third product in every warehouse.
People reach for nested lists whenever the data has two axes:
The structure is the same in every case. An outer list holds inner lists, and each inner list holds the actual values.
The diagram shows what's really stored. inventory holds three references, one per row. Each row is a separate list holding its own four numbers. That detail (each row is a separate object) is the foundation for everything else in this lesson, especially the pitfall you'll meet in a moment.
The inner lists don't have to hold numbers. They can hold strings, tuples, or even other lists:
orders is a list of three carts. Each cart is itself a list of product names. The carts don't have to be the same length, which we'll come back to under "jagged lists".
To reach into a nested list, chain the brackets. The first index picks the row (or outer element), the second picks the column (or inner element).
Read inventory[0][1] left to right. inventory[0] evaluates first and gives you the list [12, 30, 0, 5]. Then [1] indexes into that list and gives you 30. It's just two indexing operations in a row.
Cost: matrix[r][c] is two list lookups, both O(1). Reaching any cell is constant time regardless of how big the grid gets. This is one of the reasons nested lists work as a quick-and-dirty 2D structure.
Negative indexing works on either side. The last row, the last column, the last cell:
The standard negative-index rules apply at every level. The outer list and each inner list are independent lists, so each one has its own indices going from 0, 1, 2, ... on the left and ..., -2, -1 on the right.
Assignment with chained indices works the same way. Suppose warehouse 1 ships its last unit of product 2:
Only one cell changed. The other rows are untouched because they are separate list objects.
There are three common ways to create a nested list, and one of them is a trap. Get the two safe forms into your fingers first, then we'll look at the trap.
When you know the contents up front, write them out:
This is the most common case for fixed, small grids: a pickup-slot map, a tic-tac-toe board, a hardcoded configuration. Each inner list is a distinct list, so mutating one row leaves the others alone.
When the grid is large or the contents are computed, build it with a comprehension:
Read the comprehension from the outside in. for _ in range(rows) runs three times, and on each pass it builds a fresh [0] * cols list. The result is a list of three independent rows. The comprehension form is the standard way to build a fresh 2D grid.
This is the form you'll write nine times out of ten when you need a grid initialized to a default value. It's safe, readable, and the rows are all separate lists.
Here's the form that looks like it should work but doesn't:
The output looks identical to the comprehension version. So what's the problem?
Watch what happens when you change one cell:
You changed one cell and all three rows changed. The reason is what list multiplication actually does: [[0] * cols] * rows builds one inner list [0, 0, 0, 0], then makes a new outer list that holds the same reference to that inner list three times. There's only one inner list in memory; the outer list just points at it from three different positions.
So inventory[0], inventory[1], and inventory[2] are all the same list. Mutating any of them mutates the one shared list, and the other "rows" see the change because there's nothing else to see.
Compare with the working comprehension version, where each row is a separate list:
The rule is straightforward: list multiplication copies references, not the objects they point to. For a flat list of numbers, this never bites you because numbers are immutable. For a list of lists, the references all land on the same mutable inner list, and the bug shows up the first time you assign through one of them.
Fix: Use the comprehension form. [[0] * cols for _ in range(rows)] builds a brand-new inner list on every iteration, so the rows are independent.
This shallow-reference behavior shows up in many other places (assignment, .copy(), slicing nested structures). For now, the rule of thumb is: never use * to repeat a list that contains a mutable object.
A single for loop walks the outer list and gives you one inner list at a time. A nested for loop walks both levels. The new piece here is using them to read or update a 2D grid.
To process every cell, nest the loops:
The outer loop binds row to one inner list per pass. The inner loop walks the values inside that row. The empty print() after the inner loop starts a new line so the output reads as a grid.
When you need the indices too (to know which warehouse and product you're looking at), pair enumerate() on both levels:
Same shape as before, just with both indices on the way through. This is the standard pattern for grid-scanning logic.
You can also build a new nested list with a comprehension. The form is one comprehension per axis:
The inner comprehension [stock * 2 for stock in row] builds one new row. The outer comprehension runs that inner one once per row in inventory. The result is a brand-new grid with every value doubled, and the original inventory is untouched because each cell is a new number assigned into a new row.
Because each row is a separate list (assuming you didn't build the grid with the * trap), mutating a row only affects that row. This is the property that makes nested lists useful in the first place.
Two different kinds of update happen here. inventory[0][2] = 25 reaches into the existing row and changes one cell, so warehouse 0 keeps its list identity but with one new value. inventory[1] = [0, 0, 0, 0] swaps the entire row out for a brand-new list, throwing away the old [8, 0, 14, 22] object. Both are legal; pick based on what you mean.
The same logic applies to inner-list methods like append(). If a row represents an order's items, you can append a new item to that order without touching any other order:
Order 2 grew by one item. Orders 0 and 1 are unchanged because they're separate list objects.
The exception, of course, is when the rows are not separate, which brings us back to the * trap. If you accidentally built the grid with [[0] * cols] * rows, then "mutating one row" mutates all of them. That's why the bug is sneaky: the failure mode looks like the data structure itself is broken, when really it's a single shared inner list pretending to be three.
len() works at each level. On the outer list, it gives you the number of rows. On an inner list, it gives you the number of columns in that row.
Three rows, four columns each. For a rectangular grid, len(inventory[0]) is the column count and it's the same for every row. To get the cell count, multiply: len(inventory) * len(inventory[0]).
But Python doesn't enforce that the inner lists are the same length. A nested list where the rows have different lengths is called jagged (or ragged), and it's a perfectly normal shape for some kinds of data:
The orders genuinely have different sizes (some customers buy one thing, some buy three), and a jagged list represents that honestly. There's no requirement to pad the shorter rows with None to "make it rectangular".
A few things to watch for with jagged lists:
len(grid[0]) is the column count for the whole grid. It's the length of row 0 only.for row in grid: for value in row: works fine on jagged data because the inner loop uses each row's own length.grid[2][5] only works if row 2 is at least six items long. Otherwise you get IndexError.Order 1 has only one item, so index 2 is off the end. The same indexing rules apply, just per row.
For data that's supposed to be rectangular (an inventory grid, a chessboard, a pixel buffer), it's worth a sanity check that every row is the expected length:
If the data is clean, nothing prints. If it isn't, you find out at the top of your program instead of three function calls deep when an index lookup raises.
Only one cell was updated, but all three rows show 99 in column 2. The cause is the [[0] * cols] * rows construction. The outer * rows repeats the reference to a single inner list rows times. Every row in inventory is the same list object, so assigning through any of them changes the value for all of them.
How to spot this bug in real code: write a quick test that mutates one cell, then print the grid. If a single assignment makes multiple cells change, you have aliased rows. You can also confirm it with id(): print(id(inventory[0]) == id(inventory[1])). If that prints True, your rows are the same list.
Fix:
The comprehension runs [0] * cols once per iteration, producing a fresh inner list each time. The inner * cols is safe because it's repeating an integer (immutable), not a list. The broader rule: any time you "duplicate" a structure that contains mutable objects, the duplicates share inner references unless you copy explicitly.
Cost: Flattening a nested list by repeated concatenation in a loop (result = result + row) is O(n^2) because each + allocates a new list and copies everything. Use result.extend(row) (O(n) total) or itertools.chain.from_iterable(grid) instead.
10 quizzes