Scientific Computing · Reproducible Research
What a Notebook File Stores and What It Omits
The version 4 notebook schema keeps source, rendered output, and a little kernel metadata in one JSON document, and drops the environment, the inputs, and the process that produced it.
Strip the tooling away and a notebook is a JSON object with a cells array, a metadata object, a nbformat integer, and an nbformat_minor integer. The major version fixes the structure; the minor version tracks backwards-compatible additions, so a reader that understands version 4 must tolerate unknown keys introduced by a later minor revision. Everything a reader can learn about the computation has to be found in those four keys, and the list of things that are not there is longer than the list of things that are.
Three cell types and one shared envelope
Every cell carries cell_type, metadata, and source. The source value is either a single string or an array of strings, one per line; both forms are valid and parsers must accept either, which is a common source of bugs in hand-written readers. The array form exists because line-oriented diffs of a large notebook are more readable when each line is a separate JSON string.
Code cells add execution_count, which is an integer or null, and outputs, an array. Markdown cells add attachments, a mapping from attachment name to a MIME bundle, which is how an image pasted into a text cell is embedded in the same file. Raw cells add nothing; their source is passed through untouched for whatever downstream converter wants it, such as a static site generator or a LaTeX pipeline.
The absence of execution_count on a Markdown cell is meaningful in a small way: only code cells consume kernel executions, so the counter sequence in a file interleaves with prose cells but never advances on them.
Outputs are stored as rendered results, not as computations
The outputs array holds one of four object shapes, distinguished by output_type.
A stream output carries name, either "stdout" or "stderr", and text in the same string-or-array form as source. Streams are the least structured output: text written by print, by a C library that bypasses Python’s buffering, or by a subprocess that inherited the file descriptor all lands here, and the ordering between the two streams is only as reliable as the flushing behaviour of whatever wrote them.
A execute_result output carries execution_count, data, and metadata. The data field is a MIME bundle: a dictionary keyed by media type, where the keys present depend on what the object’s repr methods offered. A pandas DataFrame offers text/plain and text/html; a matplotlib figure offers image/png and often text/plain; a SymPy expression offers text/latex and text/plain. The front end picks one key to render and falls back down a preference order, but all of them stay in the file, which is why a notebook with many plots grows quickly: each figure is stored base64-encoded inside JSON, roughly a third larger than the raw PNG bytes.
A display_data output has the same data and metadata fields without an execution_count, because display calls can occur mid-cell and any number of times per execution. A error output has ename, evalue, and traceback, where the traceback is an array of strings with the terminal escape sequences already applied.
The critical property is that the file stores what was displayed, not the object that produced it. A cell that ends with a DataFrame stores an HTML table and a text rendering of the first and last rows; the DataFrame itself, its dtypes, its index, and the code path that built it are gone. A figure stores pixels; the plotting code that generated them may live in a cell that has since been edited. Re-running the document does not reproduce the stored output from the stored output; it reproduces it from source and whatever state the kernel happens to hold.
Metadata records the kernel, loosely
The document-level metadata typically holds a kernelspec with a display name, a language, and a kernel name such as python3, plus a language_info block with the language name, version string, and mimetype. Some front ends add their own keys, such as a path to the notebook or a cell toolbar state.
That block is the only environment information the format asks for, and it is descriptive rather than constraining. A language_info.version of 3.11.4 records the interpreter patch level at save time; it does not record which packages were installed, at which versions, from which index, or built against which C library. It does not record the platform, the CPU architecture, the presence of a GPU, the value of any environment variable, or the working directory at execution.
Per-cell metadata can be arbitrary and is often used by extensions to store tags, collapsed state, or execution timing. Nothing in the schema requires any of it, so a reader cannot assume timing is present, and a converter that depends on a particular extension’s keys is depending on a convention rather than on the format.
What the file cannot tell a reader
The omissions fall into a few groups, and each maps to a different repair.
The environment is absent: package versions, system libraries, compiler, BLAS implementation, and the hardware itself. A notebook that imports NumPy records nothing about which NumPy, and results can differ between builds of the same version when the underlying linear algebra library dispatches to different kernels. Reconstructing the environment requires a separate artifact, which is what Lockfiles, Hash Pinning, and Reconstructed Environments describes.
The inputs are absent. A cell that reads data.csv stores the path string and, if it prints something, a few derived numbers. The file’s contents, its size, its checksum, and the moment it was read are not recorded, so a stale input and a fresh one are indistinguishable in the saved document. Recording them means hashing inputs at read time, as in Content-Addressed Provenance Graphs for Derived Outputs.
The process is absent. Exit status, wall time, peak memory, warnings that were filtered away, and the order in which cells actually ran all fail to appear in any structured form. Execution counts hint at the order, but as Execution Counts and the Hidden State of a Notebook Kernel explains, they record only the executions whose cells survived.
The failures are partly absent too. An error output proves that an exception surfaced once; it does not prove that the remaining cells ran afterwards, and it carries no count of how many times the cell was retried with edited source before the current text was saved.
Consequences for diffing and review
Because outputs live in the same JSON document as source, a version control diff of two saved notebooks is dominated by base64 image data and by reordered JSON keys. A one-line change to source can produce a diff of tens of thousands of lines if a cell’s output changed. Reviewers therefore cannot see the code change, and automated checks that look for output cells in committed files exist precisely because the format makes outputs hard to ignore.
There are three common mitigations, and each trades one property for another. Stripping outputs before committing keeps diffs readable at the cost of losing the record of what was displayed. Writing the JSON with sorted keys and stable indentation reduces spurious churn but does not shrink image payloads. Converting to a paired text format — a script plus a separate output store — makes review tractable and breaks the single-file property that makes a notebook portable.
A minimal reader shows how little interpretation the format requires, and how much of the interesting information is simply not in the tree.
import json, sys
nb = json.load(open(sys.argv[1]))
meta = nb.get("metadata", {})
print("format", nb["nbformat"], ".", nb.get("nbformat_minor"))
print("kernel", meta.get("kernelspec", {}).get("name"),
"lang", meta.get("language_info", {}).get("version"))
for i, cell in enumerate(nb["cells"]):
kinds = [o["output_type"] for o in cell.get("outputs", [])]
mimes = sorted({k for o in cell.get("outputs", [])
for k in o.get("data", {})})
print(i, cell["cell_type"], cell.get("execution_count"), kinds, mimes)
Running this against a saved notebook yields a compact inventory: which cells carry outputs, which media types they hold, and which kernel name was recorded. It also makes the absence concrete. There is no field to print for the installed package set, because no such field exists.
Where the format’s scope ends
A notebook is a document format with an execution model bolted on, and judging it by the standards of a build artifact misreads what it is for. It is good at preserving a narrative: prose next to code next to the output that prompted the next thought. It is not good at being the record of a computation, because it stores final states without transitions, rendered values without objects, and a kernel name without an environment.
The practical consequence is that a notebook is one of several artifacts a reproducible result needs, not the artifact. Alongside it belong a pinned environment specification, a record of input hashes, and a script or workflow that fixes the execution order independently of any session. When those exist, the notebook’s omissions stop mattering, because the missing facts are recorded elsewhere and cross-referenced. When they do not, a saved notebook is a picture of a result whose provenance has to be reconstructed by whoever tries to re-run it, usually unsuccessfully.
References
- The Jupyter Notebook Format — Jupyter documentation
- nbformat JSON Schema — jupyter/nbformat repository
- Reproducible Research in Computational Science — Roger D. Peng, Science, 2011