A notebook shows its cells in page order, but the kernel behind it remembers them in the order they were run. Build a kernel from a dictionary and exec, run three cells three ways, read a notebook file with the json module, and learn the one test that catches hidden state.
P02.9.1L1 Core25 minIn review (what this means)
The maths and code on this page are checked by automated tests on every change, but an independent reader has not signed it off yet. Spot a mistake or a confusing step? Tell us.
Explain what a notebook’s kernel is and why it remembers runs in time order, not page order.
Predict the values of x and y after the cells x = 10, x = x + 1 and y = x * 2 are run in any order.
Build a toy kernel in plain Python from one shared dictionary and exec.
Open a .ipynb file with the json module and find each cell’s source, outputs and execution count.
Use Restart and Run All as the test of a notebook, and say what execution counts can and cannot prove.
The question
A notebook on your screen says y is 24. You send it to a colleague, they run it from the top, and they get 22. Neither of you changed a single character of code. Where did the difference come from, and how can you tell, before you share a notebook, that its numbers will come out the same for everyone?
Intuition
A notebook is a file of cells. A code cell holds a few lines of Python; a text cell holds words. You run one cell at a time, in any order you like, and the output appears under it.
The code does not run inside the file. It runs in the kernel: a separate Python process that stays alive between cells. Every name a cell defines is kept in the kernel’s memory, and the next cell you run starts from whatever is there. The kernel does not know where a cell sits on the page. It only knows the order in which cells arrived.
P02.9.1-F1
The page shows three cells; the kernel saw four runs, because the middle cell was run twice. What the kernel holds depends on the runs, and the numbers in square brackets are all the file keeps of them.
That gap between the page and the runs is hidden state: values in the kernel that you cannot work out by reading the cells from top to bottom. It is the reason a notebook can show one result and rerun to another.
Build it up
Step 1. A cell is a piece of text. Before it runs, the cell x = x + 1 is a string of nine characters. Running it means handing that string to Python.
Step 2. The kernel’s memory is a dictionary. Python keeps the global names of a program in a dictionary from name to value (P01.4.4). A kernel is a program whose dictionary lives as long as the kernel does.
Step 3. Running a cell reads and writes that dictionary. The built-in exec(source, kernel) runs a string of code with the dictionary kernel as its global names. x = 10 writes the key x. x = x + 1 reads x, adds one and writes it back. y = x * 2 reads x and writes y.
Step 4. Run the three cells top to bottom in a fresh kernel. The dictionary grows one run at a time.
P02.9.1-F2
A clean run. Each cell reads the dictionary that the runs before it left behind, and writes its own names into it.
Step 5. Every run gets a number. The kernel counts the runs it has done: 1, 2, 3 and so on. After a run, the cell shows that number as its execution count, written [2]: beside it. A cell run again shows its newest number, so the count records the last run of each cell, not all of them.
The formal statement
NoteDefinition: kernel state and a clean run
Number the code cells \(1, 2, \dots, n\) in page order. The state of the kernel is its dictionary of names. Running cell \(i\) turns a state \(S\) into a new state \({c_i(S)}\).
A run of the cells \(i_1, i_2, \dots, i_k\), starting from the empty state \(S_0\), ends in
The clean run is the sequence \(1, 2, \dots, n\) from \(S_0\): every cell once, in page order, in a fresh kernel.
The end state depends on the whole sequence, not only on which cells ran. That is the formal face of hidden state. Two consequences follow.
Order matters. Cells that read a name another cell writes do not commute. Here cell 3 reads x and cell 2 writes it, so running 2 then 3 differs from running 3 then 2.
Repeats matter. Cell 2 reads x and writes it, so running it twice is not the same as running it once: \({c_2(c_2(S))} \neq {c_2(S)}\).
What the counts prove. A clean run leaves the counts \(1, 2, \dots, n\) down the page. So any other pattern, a gap or a number smaller than the one above it, proves that the saved outputs came from some other sequence. The reverse is not true. A cell edited after its last run keeps its old count and its old output, so even a perfect \(1, 2, \dots, n\) is a clue, not a proof.
The test. The only way to know what the cells produce is to make the clean run happen: restart the kernel, which empties its dictionary and sets the counter back to zero, and run every cell from the top. Jupyter calls this Restart and Run All. Rule et al. (2019, Rule 7) recommend it as a habit while working and as the final test of a notebook’s results.
The file that holds a notebook is the second half of the picture. A .ipynb file is a JSON document (P01.7.4): a dictionary whose key "cells" holds a list, one dictionary per cell.
P02.9.1-F5
A code cell on screen and in the file. The count, the source and the outputs are stored side by side, so the file keeps what the last runs printed, whatever order they ran in.
Worked example
Run x = 10, x = x + 1 and y = x * 2 in three orders, each in a fresh kernel.
Top to bottom, cells 1, 2, 3. Cell 1 sets \(x = 10\). Cell 2 reads 10 and sets \(x = 10 + 1 = 11\). Cell 3 reads 11 and sets \(y = {11 \times 2} = 22\). The result is \({(x, y)} = {(11, 22)}\), and the counts read 1, 2, 3.
Middle cell twice, cells 1, 2, 2, 3. Cell 1 sets \(x = 10\). Cell 2 sets \(x = 11\). Cell 2 again reads 11 and sets \(x = 12\). Cell 3 sets \(y = {12 \times 2} = 24\). The result is \({(12, 24)}\). The middle cell’s count is 3, its last run; count 2 has vanished from the page.
Last two swapped, cells 1, 3, 2. Cell 1 sets \(x = 10\). Cell 3 runs next and sets \(y = {10 \times 2} = 20\). Cell 2 then sets \(x = 11\), but \(y\) was already computed and nothing recomputes it. The result is \({(11, 20)}\): an x and a y that no reading of the page can explain, since the page says \(y = {x \times 2}\) and \({11 \times 2} = 22\).
P02.9.1-F3
One page, three histories, three answers. Only the counts under each panel hint at what happened.
Code
A toy kernel: one dictionary, and exec to run each cell’s text in it. Positions in code count from 0, so the cells 1, 2, 3 of the text are 0, 1, 2 here.
CELLS = ["x = 10", "x = x + 1", "y = x * 2"]def run(order, cells=CELLS):"""Run cells by position, in the given order, in one fresh shared dictionary.""" kernel = {} # the kernel's memory: name -> value counts = [None] *len(cells) # the execution count each cell showsfor n, i inenumerate(order, start=1):exec(cells[i], kernel) # the cell reads and writes kernel counts[i] = n # a cell keeps the number of its last run names = {k: v for k, v in kernel.items() ifnot k.startswith("__")}return names, countsfor order in ([0, 1, 2], [0, 1, 1, 2], [0, 2, 1]):print(order, *run(order))
exec puts a key __builtins__ into the dictionary, which is why the function leaves names that start with two underscores out of what it returns.
A deleted cell is hidden state too. Its names stay in the kernel until a restart.
kernel = {}exec("threshold = 25", kernel) # a cell, later deleted from the pageexec("hot = [t for t in [21, 27, 29] if t > threshold]", kernel)print("old kernel:", kernel["hot"])fresh = {} # Restart: a new, empty dictionarytry:exec("hot = [t for t in [21, 27, 29] if t > threshold]", fresh)exceptNameErroras error:print("fresh kernel:", type(error).__name__)
old kernel: [27, 29]
fresh kernel: NameError
Now a notebook file. The function below runs cells in a given order, catches what each one prints, and stores the result in the layout of nbformat 4, the format that Jupyter writes.
import contextlibimport ioimport jsonimport pathlibimport tempfiledef run_and_record(order, cells):"""Run cells in `order` in one kernel; return each cell's last count and printed text.""" kernel, counts, printed = {}, [None] *len(cells), [""] *len(cells)for n, i inenumerate(order, start=1):buffer= io.StringIO()with contextlib.redirect_stdout(buffer):exec(cells[i], kernel) counts[i], printed[i] = n, buffer.getvalue()return counts, printeddef notebook(cells, counts, printed):"""A dictionary in the layout of an .ipynb file (nbformat 4.5), code cells only."""def one(k): out = [{"output_type": "stream", "name": "stdout", "text": printed[k]}] if printed[k] else []return {"cell_type": "code", "execution_count": counts[k], "id": f"cell-{k +1}","metadata": {}, "outputs": out, "source": cells[k]}return {"cells": [one(k) for k inrange(len(cells))], "metadata": {},"nbformat": 4, "nbformat_minor": 5}SHOW = CELLS + ["print(x, y)"]counts, printed = run_and_record([0, 1, 1, 2, 3], SHOW) # the messy runfolder = tempfile.TemporaryDirectory()path = pathlib.Path(folder.name) /"report.ipynb"path.write_text(json.dumps(notebook(SHOW, counts, printed), indent=1), encoding="utf-8")saved = json.loads(path.read_text(encoding="utf-8")) # open it as plain JSONfor c in saved["cells"]: texts = [o["text"] for o in c["outputs"]]print(c["execution_count"], repr(c["source"]), texts)
The file kept 12 24. Restart and Run All is now a few lines: take the sources, run them once in page order in a fresh dictionary, and compare what they print with what the file stored. A real file may store a source as a list of lines, so join them first.
def restart_and_run_all(nb):"""Rerun the code cells in page order in a fresh kernel; list the cells whose output changes.""" code = [c for c in nb["cells"] if c["cell_type"] =="code"] sources = ["".join(c["source"]) for c in code] _, fresh = run_and_record(range(len(sources)), sources) stored = ["".join("".join(o.get("text", "")) for o in c["outputs"]) for c in code]return [(k +1, s, f) for k, (s, f) inenumerate(zip(stored, fresh)) if s != f]print(restart_and_run_all(saved))
[(4, '12 24\n', '11 22\n')]
Cell 4 stored 12 24; a clean run prints 11 22. This site builds every lesson page the same way: a fresh process runs the cells top to bottom and the assertions check every stated number.
Explore
P02.9-X1
Run-order sandbox
Run the three cells in any order, as often as you like, and watch the counts and the kernel’s names. Then press Restart and Run All and read whether the screen you had matched a clean run. Try running x = x + 1 first in a fresh kernel.
Pitfalls
P02.9.1-F4
Wrong beside right. The same four cells, saved after two different histories. Only the right-hand file can be reproduced by anyone who runs it.
“The output is right there in the file, so that is what the code produces.”
The file stores what the last runs printed, in whatever order they happened. In the left-hand notebook above, 12 24 is genuine output, yet no one who runs the cells from the top will ever see it. Before trusting or sharing saved output, restart the kernel and run all.
“I deleted that cell, so its variable is gone.”
Deleting a cell removes its text from the page, not its names from the kernel. Later cells go on working until the next restart, when they fail with NameError. The same holds for a cell you edit: the kernel keeps the values from the old version until you run the new one. This is the notebook form of the global-state trap of P01.4.4.
“The counts read 1, 2, 3, so the notebook is clean.”
Counts in order are necessary, not sufficient. Change x = 10 to x = 5 after running it, save, and the file still shows [1]: beside it with outputs computed from 10. In machine learning this is how a training notebook ends up reporting an accuracy that its own code no longer produces.
Check yourself
Recall
1. What is the kernel of a notebook, and what does it keep between cells?
Show answer
A separate Python process that runs the cells. It keeps every name the cells have defined, in a dictionary that lasts until the kernel is restarted.
2. Starting from a fresh kernel, which values do the cells x = 10, x = x + 1, y = x * 2 give when the middle cell is run twice?
Show answer
\(x = 12\) and \(y = 24\), where a clean run gives \(x = 11\) and \(y = 22\).
3. What does a cell’s execution count record?
Show answer
The number of that cell’s most recent run, counted from the last restart. Earlier runs of the same cell leave no trace on the page.
4. Which three things does a .ipynb file store for a code cell, and in what format is the file written?
Show answer
Its source, its outputs and its execution count (with a cell type, an id and metadata). The file is JSON, so the json module can read it.
5. What is the test of a notebook, and why does it work?
Show answer
Restart the kernel and run every cell from the top. The restart empties the kernel’s dictionary, so no hidden state is left, and the run in page order is the clean run that anyone else will make.
Exercises
1.★ In a fresh kernel, run the cells 1, 2, 3, 2 (the middle cell last). Give the final \(x\) and \(y\) and the counts the page shows.
Show solution
Cell 1 sets \(x = 10\), cell 2 sets \(x = 11\), cell 3 sets \(y = 22\), and cell 2 again sets \(x = 12\). So \({(x, y)} = {(12, 22)}\). The counts are 1 for cell 1, 4 for cell 2 (its last run) and 3 for cell 3, read down the page as 1, 4, 3. run([0, 1, 2, 1]) returns the same.
2.★★ In a fresh kernel, the first cell you run is x = x + 1. What happens, and which single change to the page would make the code end with the same \(x\) and \(y\) however often, and in whatever order, you run it?
Show solution
It fails with NameError: the kernel’s dictionary has no x yet, so x + 1 cannot be computed. For the second part, merge the cells into one, x = 10 then x = x + 1 then y = x * 2. A cell runs its lines in order and starts by setting x afresh, so every run, the first or the tenth, ends with \({(11, 22)}\). Fewer, longer cells that each start from what they need leave less room for hidden state.
3.★★★ Write looks_clean(nb), which returns True when the code cells of a notebook dictionary show the counts \(1, 2, \dots, n\) down the page. Apply it to the messy file of the Code section, and explain why True would still not prove the outputs reproducible.
Show solution
def looks_clean(nb): counts = [c["execution_count"] for c in nb["cells"] if c["cell_type"] =="code"]return counts ==list(range(1, len(counts) +1))
The messy file has counts 1, 3, 4, 5, so looks_clean returns False. A file can pass and still mislead: if a cell’s text was edited after its last run, the file keeps the old output beside the new code, and the counts do not change. Only rerunning, as restart_and_run_all does, compares the code with its outputs.
Summary card
The kernel remembers runs in time order; the page shows cells in page order.
P02.9.1-F1
Page order beside run order.
A cell’s text runs in the kernel’s one dictionary: exec(source, kernel).
End state \(S_k = {c_{i_k}(\cdots c_{i_1}(S_0))}\) depends on the order and on repeats.
Clean run: cells \(1, 2, \dots, n\) in a fresh kernel. Counts \(1, 2, \dots, n\) are a clue, not a proof.
A .ipynb file is JSON: each cell stores source, outputs and execution_count.
The test: Restart and Run All.
Sources and next steps
Sources. Each was opened and checked against this lesson on 15 November 2026.
Project Jupyter, Kernels (Programming Languages), Jupyter documentation: kernels are processes, specific to one language, that run independently and talk to the Jupyter applications; ipykernel, built on IPython, is the reference kernel.
Jupyter developers, The Notebook file format, nbformat 5 documentation. Top-level structure: a notebook is a dictionary with the keys metadata, nbformat, nbformat_minor and cells, and multi-line strings may be stored as a list of strings. Code cells carry an execution_count, an integer or null; Cell ids are strings of 1 to 64 characters; Code cell outputs lists stream, display_data, execute_result and error. The nbformat home page describes notebooks as JSON documents.
Python Software Foundation, exec, Built-in functions: with only a globals dictionary, it serves as both the global and the local names, and __builtins__ is inserted when it is missing.