Python List of Lists: The Complete Guide (with the Mutation Trap, Flatten Benchmarks, and Patterns)

Everything you need to do with a Python list of lists — create, traverse, modify, flatten, transpose, sort, copy — plus the [[]]*3 mutation trap, shallow vs deep copy, flatten performance comparison, and when to leave for NumPy or pandas.

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])  # 6

The 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 strings

The 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 column

Slicing 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 columns

Beware: 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 rows

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

MethodRelative speed
chain.from_iterable1.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 list

Reverse 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 mutated

For an independent 2D copy, use copy.deepcopy:

import copy
deep = copy.deepcopy(grid)
deep[0][0] = 1
# original unchanged

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