Multidimensional Lists for Grid-Shaped Data
Learning Objectives
- You can hold two-dimensional data in a list of lists, and explain when this shape is useful.
- You can index a cell with
grid[row][col]and iterate over a grid with nested loops. - You can recognize that spreadsheets, images, and game boards share the same in-memory structure.
Reading a CSV with csv.reader produces a sequence of rows. Each row is a list of values, and the whole file — if you collect the rows — is a list of lists. That structure has a name: a two-dimensional list, or a grid, or a table, depending on what kind of data is in it. The structure shows up in many different contexts; when you can work with it, many seemingly different problems turn out to be the same.
A List of Rows
Take the study log CSV from the previous chapter:
subject,minutes,dateMath,45,2026-05-10Physics,90,2026-05-10History,30,2026-05-11Read into memory and gathered as a list, the data rows look like:
sessions = [ ["Math", 45, "2026-05-10"], ["Physics", 90, "2026-05-10"], ["History", 30, "2026-05-11"],]This is a list of three elements, and each of those elements is itself a list of three elements. The whole thing is a list of lists. Visualised:
| Row | Column 0 | Column 1 | Column 2 |
|---|---|---|---|
sessions[0] | "Math" | 45 | "2026-05-10" |
sessions[1] | "Physics" | 90 | "2026-05-10" |
sessions[2] | "History" | 30 | "2026-05-11" |
To get one row, you write sessions[row]. To get one cell, you write sessions[row][col]:
sessions[0] # ["Math", 45, "2026-05-10"]sessions[0][0] # "Math"sessions[0][1] # 45sessions[1][1] # 90The syntax is already familiar: sessions[0] is a list, and indexing into a list with [0] gives you its first element. With a two-dimensional list, the first index gets you one of the inner lists — a row — and the second index gets you an element of that inner list — a cell. Two indices, two levels.
The same pattern applies to three-dimensional lists (list of list of lists) and beyond. Each index gets you one level down. With a three-dimensional list, the first index gets you a two-dimensional slice, the second index gets you a row within that slice, and the third index gets you a cell within that row. And so on.
Iterating Over a Grid
To process every cell, you nest loops — one for rows, one for columns within a row:
for row in sessions: for value in row: print(value)
# Math# 45# 2026-05-10# Physics# 90# ...The outer loop steps through the rows. The inner loop, for each row, steps through the values in that row. The two together visit every cell exactly once.
A common variant uses indices instead, which is helpful when you need to know which row and column you are on:
for r in range(len(sessions)): for c in range(len(sessions[r])): print(f"sessions[{r}][{c}] = {sessions[r][c]}")Both forms come up. The index form is used when the position matters — when you are checking neighbours, comparing one cell to another, or building a new grid based on the current one.
Print a grid row by row
0 / 5 points
Arrange the lines into a function that prints each row of a grid on its own line, with no spaces between the cells.
Grids Are Everywhere
The list of lists shape is the foundation of many things that, on the surface, might look unrelated:
- A spreadsheet table — rows of records, columns of fields. This is the CSV case.
- A noughts-and-crosses board — 3 rows by 3 columns of
"X","O", or" ". - A chessboard — 8 rows by 8 columns of pieces (or empty squares).
- A grayscale image — a grid of brightness values from 0 (black) to 255 (white). A small one is a few rows by a few columns; a real photo is hundreds or thousands of rows and columns. A colour image just adds a third dimension — each cell becomes three numbers, for red, green, and blue.
- A maze — a grid of walls and corridors.
- A seating chart — rows and columns of names.
All of these can be viewed as list of lists. The cell type differs — it might be a string, a number, a piece, a True/False — but the structure is identical. And so are the operations: index a cell with [row][col], walk the grid with nested loops, look at neighbours, check the edges.
For instance, we could represent a small noughts-and-crosses board as a list of lists:
board = [ ["X", "O", " "], [" ", "X", " "], ["O", " ", "X"],]
for row in board: print(" ".join(row))
# X O# X# O XThe " ".join(row) is the same .join() from the writing chapter — given a list of strings, it puts a separator (here, a space) between every adjacent pair. The same pattern applied to a chessboard, or a small image would look almost identical in code, just with different cell values.
Once you can index, iterate, and print a grid, you can work with all of these things — and many you have not thought of yet — with the same tools.
A Tiny Image, Rendered as Text
To make “image as grid” concrete, here is an 8×8 grayscale image built by hand:
image = [ [255, 255, 255, 255, 255, 255, 255, 255], [255, 0, 0, 255, 255, 0, 0, 255], [255, 0, 0, 255, 255, 0, 0, 255], [255, 255, 255, 255, 255, 255, 255, 255], [255, 255, 255, 255, 255, 255, 255, 255], [255, 0, 255, 255, 255, 255, 0, 255], [255, 255, 0, 0, 0, 0, 255, 255], [255, 255, 255, 255, 255, 255, 255, 255],]
for row in image: for value in row: if value < 128: print("#", end="") else: print(".", end="") print()The output is roughly a smiley face. The image is a list of eight lists, each containing eight numbers; the renderer just turns dark cells into # and light cells into .. A real image library would map these numbers to actual pixels, but the data is exactly what is shown here.
The next chapter returns to this idea: images are, in the end, compact, encoded versions of a grid like this one.
The end="" argument to print tells it not to add a newline after each value — otherwise each cell would be on its own line. The bare print() at the end of the row writes the newline for the whole row.
Looking at Neighbours, Minding the Edges
Many grid problems involve looking at the cells around a given cell — neighbours. For a cell at position (r, c), r for row and c for column, the eight neighbours are at:
(r-1, c-1) (r-1, c) (r-1, c+1)(r, c-1) (r, c) (r, c+1)(r+1, c-1) (r+1, c) (r+1, c+1)The middle one is the cell itself; the other eight are the surrounding cells. (Sometimes “neighbours” means only the four straight-line neighbours; other times it includes all eight. The convention depends on the problem.)
The complication is the edge. A cell in the top-left corner has only three neighbours, not eight — the other five are off the grid. Asking for grid[-1][-1] does not give you “off the grid”; in Python that means the last cell, which is something else entirely. Indexing past the last row gives IndexError. Either case produces a bug.
The reliable pattern is to check the bounds explicitly:
rows = len(grid)cols = len(grid[0])
for dr in (-1, 0, 1): # dr = delta row for dc in (-1, 0, 1): # dc = delta column if dr == 0 and dc == 0: continue # skip the cell itself nr = r + dr # neighbor row nc = c + dc # neighbor column if 0 <= nr < rows and 0 <= nc < cols: # nr, nc is a valid neighbour ...The two if checks do the work. if dr == 0 and dc == 0: continue skips the cell at the centre — only the surrounding eight are neighbours. if 0 <= nr < rows and 0 <= nc < cols: keeps the indices inside the grid.
Looking at neighbours, while staying inside the grid, is the central skill of the Game of Life example below — and of a great many other problems involving grids.
Before combining these ideas into a full grid transformation, practice them on a smaller campus-map grid: counting one kind of cell, rendering rows as text, and counting neighbours without falling off the edges.
Example: Conway’s Game of Life
Conway’s Game of Life is the canonical grid-of-cells problem. The rules are short:
- A grid of cells. Each cell is either alive (
1) or dead (0). - Every step, every cell looks at its eight neighbours and updates simultaneously.
- A live cell with fewer than two live neighbours dies (underpopulation).
- A live cell with two or three live neighbours lives on.
- A live cell with more than three live neighbours dies (overcrowding).
- A dead cell with exactly three live neighbours becomes alive (reproduction).
Despite the simple rules, the resulting patterns are famous: gliders that move across the grid, oscillators that pulse, still-lifes that never change, and stranger things that produce other patterns indefinitely.
Here is one step of the simulation, broken into the pieces from above:
def count_live_neighbours(world, r, c): rows = len(world) cols = len(world[0]) count = 0 for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr == 0 and dc == 0: continue nr = r + dr nc = c + dc if 0 <= nr < rows and 0 <= nc < cols: count += world[nr][nc] return count
def next_world(world): rows = len(world) cols = len(world[0]) new_world = [] for r in range(rows): new_row = [] for c in range(cols): n = count_live_neighbours(world, r, c) alive = world[r][c] == 1 if alive and n in (2, 3): new_row.append(1) elif not alive and n == 3: new_row.append(1) else: new_row.append(0) new_world.append(new_row) return new_worldTwo design choices in this code are worth pointing out.
First, next_world returns a new grid rather than modifying the old one. Cells update simultaneously, based on the current state of all their neighbours. If you modified the grid in place, by the time you got to cell (0, 2), the cell at (0, 1) would already be in its new state — and you would be applying the rules to a half-updated world.
The “build a new grid” pattern appears in many grid problems.
Second, the helper count_live_neighbours is its own function, with the bounds check encapsulated. The main function calls it without thinking about edges, which keeps the main loop readable. As we learned in the last part, if a piece of work has a name and is reused, give it a function.
A small printer to see the state:
def show(world): for row in world: line = "" for cell in row: line += "#" if cell == 1 else "." print(line) print()And a starting world — a famous shape called a glider — with a few steps:
world = [ [0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 1, 0, 0, 0, 0, 0], [0, 0, 0, 1, 0, 0, 0, 0], [0, 1, 1, 1, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0],]
for step in range(5): print(f"Step {step}:") show(world) world = next_world(world)Running this will show the glider taking a step diagonally across the grid every few iterations.
In the next exercise sequence, you’ll practise these patterns on a different grid problem — the Minesweeper clue board.
Check Your Understanding
- Write the expression that gets the value in row 2, column 1 of a list of lists called
board. - Write a nested loop that prints every cell of a grid, with cells separated by a space and rows separated by a newline.
- Why does the
next_worldfunction in Game of Life build a brand-new grid instead of changing the original in place? - A cell is at row 0, column 0. How many neighbours does it have? Why?
- Give two examples of real-world data, other than a CSV file, that fits naturally into a list of lists. For each, what does a single cell of the grid mean?
AI Study Aid
Create a chapter diagram
We're looking into to what extent AI-generated diagrams could help with studying.
Use this study aid to generate an AI-generated visual summary of the material you just studied.
Each diagram style emphasizes the content in a different way, so you can choose the focus
that feels most useful.
Using the diagram generator is voluntary and does not affect your course progress. We encourage
you to try it out and see if it helps, but it's totally up to you! Your answers help us understand
how to make better study aids in the future.