A list of lists is Python's simplest way to model two-dimensional data — a matrix, a CSV table, a grid, a batch of records. This guide covers every operation you'll ever need: how to create one (and how not to), how to traverse, index, slice, modify, flatten, transpose, sort, and copy. It also covers the single most common bug in this corner of Python (the [[]] * 3 mutation trap) and when to leave plain lists for NumPy or pandas.
What is a list of lists?
A list of lists is exactly what it sounds like: a Python list whose elements are themselves lists. It's the language's built-in way to represent nested or rectangular data without reaching for an external library.
grid = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
]
print(grid[1][2]) # 6The outer list is the rows; each inner list is a row of values. To reach a single element you index twice: grid[row][col].
Use a list of lists when:
- You're processing rows of CSV-like data.
- You need a 2D grid (Sudoku, game board, image of small pixels).
- You're building intermediate data before handing off to NumPy, pandas, or a JSON serializer.
- You don't need vectorized math — that's where NumPy beats it by 100x.
Five ways to create a list of lists
1. Literal
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]Best when you know the values up front.
2. List comprehension (the idiomatic way)
rows, cols = 3, 4
grid = [[0 for _ in range(cols)] for _ in range(rows)]Or, equivalently with a multiplication shortcut only on the inner list of immutables:
grid = [[0] * cols for _ in range(rows)]This is the canonical pattern. Each row gets its own fresh list.
4. From flat data with chunking
flat = [1, 2, 3, 4, 5, 6, 7, 8, 9]
n = 3
matrix = [flat[i:i + n] for i in range(0, len(flat), n)]
# [[1, 2, 3], [4, 5, 6], [7, 8, 9]]5. From a CSV or iterator
import csv
with open("data.csv", newline="") as f:
rows = list(csv.reader(f))
# rows is now a list of lists of stringsThe trap: [[]] * 3 and [[0] * 3] * 3
This looks reasonable and almost works:
grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)
# [[1, 0, 0], [1, 0, 0], [1, 0, 0]]All three rows changed. Why? [X] * 3 creates a list of three references to the same object X. The outer multiplication doesn't deep-copy. Mutating one row mutates them all.
The fix is the comprehension form, which evaluates the inner list expression once per row:
grid = [[0] * 3 for _ in range(3)] # correct: three independent rows
grid[0][0] = 1
print(grid)
# [[1, 0, 0], [0, 0, 0], [0, 0, 0]]This is the single most common Python beginner bug in 2D-list territory. If your matrix mysteriously updates rows you never touched, this is why.
Accessing elements
Index twice, once for the row, once for the column:
grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
grid[0] # [1, 2, 3] — first row
grid[0][2] # 3 — first row, third column
grid[-1][-1] # 9 — last row, last columnSlicing works on each level independently:
grid[1:] # [[4, 5, 6], [7, 8, 9]] — rows 1 onwards
[row[:2] for row in grid]
# [[1, 2], [4, 5], [7, 8]] — first two columnsBeware: a slice of a list of lists shares the inner list references. Mutating an inner list still mutates the original.
Traversing a list of lists
Nested for loops (most readable)
for row in grid:
for value in row:
print(value)Enumerate when you need indices
for r, row in enumerate(grid):
for c, value in enumerate(row):
print(f"({r},{c}) = {value}")Flat traversal with itertools.chain
from itertools import chain
for value in chain.from_iterable(grid):
print(value)This avoids creating a flat copy in memory.
Modifying elements
grid = [[1, 2, 3], [4, 5, 6]]
# In place
grid[0][1] = 99 # change one cell
grid[1] = [7, 8, 9] # replace a row entirely
grid.append([10, 11, 12]) # add a new row
grid.insert(0, [0, 0, 0]) # add a row at the top
# Add a column
for row in grid:
row.append(None)Deleting rows and elements
del grid[1] # delete second row
grid.pop(0) # delete first row, return it
grid[2].remove(5) # delete first occurrence of 5 in row index 2
grid = [row for row in grid if sum(row) > 0] # filter out zero/negative-sum rowsAvoid mutating a list while iterating over it directly. Either iterate a copy (for row in grid[:]) or build a new list with a comprehension.
Flattening a list of lists
Flattening means turning a list of lists into a single flat list. There are at least five ways. Pick based on readability vs depth vs performance.
1. List comprehension (idiomatic)
flat = [x for row in grid for x in row]2. itertools.chain.from_iterable (fastest pure Python)
from itertools import chain
flat = list(chain.from_iterable(grid))3. sum() with empty-list start (concise but slow)
flat = sum(grid, [])This is O(n²) — every + creates a new list. Avoid for large data. Python 3.12 added itertools.batched but no native flatten; the recommended path remains chain.from_iterable.
4. functools.reduce
from functools import reduce
import operator
flat = reduce(operator.iconcat, grid, [])5. NumPy (only if you're using arrays)
import numpy as np
flat = np.array(grid).flatten().tolist()Deep flattening (arbitrary nesting)
The methods above flatten exactly one level. For arbitrary nesting:
def deep_flatten(items):
for item in items:
if isinstance(item, list):
yield from deep_flatten(item)
else:
yield item
flat = list(deep_flatten([[1, [2, 3]], [4, [5, [6, 7]]]]))
# [1, 2, 3, 4, 5, 6, 7]Performance comparison (10,000 sublists of 10 items)
| Method | Relative speed |
|---|---|
chain.from_iterable | 1.0x (fastest) |
| List comprehension | ~1.1x |
reduce(iconcat, ...) | ~1.3x |
numpy.flatten().tolist() | ~2x (extra conversion cost) |
sum(grid, []) | ~100x slower (O(n²)) |
Numbers are indicative — measure on your data with timeit if it matters.
Reversing
Reverse the row order
grid.reverse() # in place
reversed_grid = grid[::-1] # new listReverse each inner list
[row[::-1] for row in grid]Reverse both
[row[::-1] for row in grid[::-1]]Sorting
By the first element of each inner list
data = [[3, "c"], [1, "a"], [2, "b"]]
sorted(data)
# [[1, 'a'], [2, 'b'], [3, 'c']]Lists compare element-by-element by default — first differing element wins.
By a specific column with a key
people = [["Ada", 36], ["Linus", 54], ["Grace", 85]]
sorted(people, key=lambda p: p[1])
# [['Ada', 36], ['Linus', 54], ['Grace', 85]]Multi-key sort (descending age, then name)
sorted(people, key=lambda p: (-p[1], p[0]))operator.itemgetter is faster than lambda
from operator import itemgetter
sorted(people, key=itemgetter(1))Transposing
Swapping rows and columns is a one-liner with zip:
grid = [[1, 2, 3], [4, 5, 6]]
transposed = [list(row) for row in zip(*grid)]
# [[1, 4], [2, 5], [3, 6]]This works for any rectangular list of lists. For ragged data (rows of different lengths), use itertools.zip_longest with a fill value.
Concatenating
a = [[1, 2], [3, 4]]
b = [[5, 6], [7, 8]]
a + b # [[1, 2], [3, 4], [5, 6], [7, 8]] — stack rows
a.extend(b) # in place
# Stack columns (assumes equal row counts)
[ra + rb for ra, rb in zip(a, b)]
# [[1, 2, 5, 6], [3, 4, 7, 8]]Copying: shallow vs deep
This is a frequent source of bugs. The default list.copy() and slicing (grid[:]) make a shallow copy — the outer list is new, but it still references the same inner lists.
grid = [[1, 2, 3], [4, 5, 6]]
shallow = grid.copy()
shallow[0][0] = 99
print(grid[0][0]) # 99 — original is mutatedFor an independent 2D copy, use copy.deepcopy:
import copy
deep = copy.deepcopy(grid)
deep[0][0] = 1
# original unchangedFor a 2D list of immutable scalars (numbers, strings, tuples), this idiom is faster and equivalent:
deep = [row[:] for row in grid]Searching and membership
# Is 5 anywhere in the grid?
any(5 in row for row in grid)
# Find (row, col) of the first 5
def find(grid, target):
for r, row in enumerate(grid):
for c, v in enumerate(row):
if v == target:
return (r, c)
return None
# Get all rows where the second column equals "active"
[row for row in records if row[1] == "active"]When to leave plain lists for NumPy or pandas
Plain lists of lists are a fine choice for small (sub-1,000-element) heterogeneous data. They become a poor choice when:
- Your data is large (10,000+ elements) and homogeneous (all numbers).
- You need vectorized math (element-wise add/multiply, matrix ops, broadcasting).
- You're doing column-oriented analysis (group-by, aggregations, joins).
The migration is usually one line:
import numpy as np
arr = np.array(grid) # 2D ndarray, all elements coerced to one dtype
arr * 2 # element-wise; impossible on plain lists
import pandas as pd
df = pd.DataFrame(grid, columns=["a", "b", "c"])
df["a"].mean()For numerical workloads, NumPy is typically 10–100x faster than equivalent list-of-list code, and pandas adds named columns and SQL-shaped operations.
Common patterns
Build a histogram
from collections import Counter
counts = Counter(x for row in grid for x in row)Group rows by a key
from collections import defaultdict
buckets = defaultdict(list)
for row in records:
buckets[row[0]].append(row)Pad ragged rows to a rectangle
width = max(len(r) for r in ragged)
padded = [r + [None] * (width - len(r)) for r in ragged]Convert to and from a dict-of-lists
columns = list(zip(*rows)) # rows -> columns
data = dict(zip(headers, columns))Frequently asked questions
How do I declare an empty 2D list of size m × n in Python?
grid = [[0] * n for _ in range(m)]. Do not write [[0] * n] * m — every row would be the same reference, and editing one would edit all.
How do I find the largest element in a list of lists?
max(x for row in grid for x in row) or max(map(max, grid)).
How do I sum a list of lists element-wise?
[sum(col) for col in zip(*grid)] for column sums; [sum(row) for row in grid] for row sums.
How do I convert a list of lists to a string for printing?
"\n".join(" ".join(str(x) for x in row) for row in grid) for a grid layout, or print(*grid, sep="\n") for one row per line.
What is the time complexity of list.append vs concat?
append is amortized O(1). Concatenation with + is O(n) because it builds a new list. sum(grid, []) is O(n²) because each + rebuilds. chain.from_iterable is O(n).
How do I remove duplicate rows?
Lists aren't hashable, so the usual set trick doesn't work directly. Convert each row to a tuple: list(set(map(tuple, grid))). To preserve order: use dict.fromkeys(map(tuple, grid)).
Why does my list of lists keep changing when I modify a copy?
Slicing or list.copy() makes a shallow copy — the inner lists are still shared. Use copy.deepcopy or [row[:] for row in grid].
Is a list of lists the same as a 2D array?
Logically yes; mechanically no. A list of lists allows ragged rows and mixed types and is row-major in memory only by accident. A NumPy 2D array is rectangular, single-dtype, contiguous in memory, and supports vectorized operations. Use lists for tabular Python work; switch to NumPy for math.
Hire Python engineers who know what NaN actually equals
The mutation trap, shallow vs deep copy, the difference between chain.from_iterable and sum(...) — these are exactly the details that decide whether a Python codebase ages well or rots into bugs.
Codersera matches you with vetted remote Python engineers who have shipped production data and ML systems. Each developer is technically interviewed, reference-checked, and ready to extend your engineering team — with a risk-free trial period to confirm the fit.