Lesson 8 of 9 · about 12 minutes
Notebooks and branches
Your notebook is a JSON file in disguise. Teach Git to see the code inside it.
penguins-lab so that a notebook change on a feature branch shows up as a readable diff, and you'll know how to keep notebook merge conflicts rare.Why notebooks fight with branches
A .ipynb file is not a script. Its format "is defined with this JSON schema," and every code cell stores "a list of outputs associated with executing that code" plus an execution_count.1 Here's a trimmed example of one cell:
{
"cell_type": "code",
"execution_count": 7,
"source": ["df = drop_missing(df, [\"bill_length_mm\"])\n", "df.shape"],
"outputs": [
{ "output_type": "execute_result", "execution_count": 7,
"data": { "text/plain": ["(342, 8)"] } }
]
}
Re-run that cell and execution_count and outputs change, even if you never touched the code. Plots are stored inside outputs as base64-encoded image data,1 so a re-run plot rewrites a large block of the file. Three things follow:
- Noisy diffs.
git diff main...add-plotshows JSON churn instead of the one line of pandas that changed. That's the three-dot diff from Lesson 2, and it's the same diff a pull request shows. - Painful merge conflicts. Two branches that both re-ran the notebook have both changed the same lines, which is the textbook cause of a merge conflict.2 Conflict markers dropped into JSON can leave a file Jupyter won't open.
- Agents make it worse. If Claude Code executes a notebook while it works on an agent branch, every executed cell gets new counts and outputs. A small logic change turns into a big diff for you to review.
The nbdime project says it plainly: "primitive line-based diff and merge tools do not handle well the logical structure of notebook documents."3
Fix 1 — nbdime: notebook-aware diff and merge
nbdime "provides tools for diffing and merging of Jupyter Notebooks."3 Once you register it with Git, git diff gives "the standard diff for any non-notebook files, but use[s] nbdime's command-line diff for all .ipynb files," and "Nbdime will also be used for all merges on notebook files."4
$ pip install nbdime
$ nbdime config-git --enable # this repository only; add --global for all repos
$ git diff main...add-plot # notebooks now diff cell by cell
$ nbdiff-web # rich side-by-side diff in the browser
$ git mergetool --tool nbdime -- *.ipynb # resolve a notebook conflict visually
The setup writes a .gitattributes entry (*.ipynb diff=jupyternotebook).4 Commit that file so everyone who clones the repo, including CI, uses the same rule. Not every command uses the driver: "git add --patch will not use the driver."4
Fix 2 — Jupytext: version the code, not the JSON
Jupytext asks: "Have you always wished Jupyter notebooks were plain text documents?… And get clear and meaningful diffs when doing version control?"5 It pairs your notebook with a py:percent script that stores "Only the notebook inputs (and optionally, the metadata)." Such text notebooks "are well suited for version control."5
$ pip install jupytext
$ jupytext --set-formats ipynb,py:percent analysis.ipynb # creates analysis.py, paired
$ jupytext --sync analysis.py # bring the pair back in step
When a paired notebook opens, "the inputs are loaded from the most recent file in the pair, while the outputs are loaded from the .ipynb file."6 So you can commit analysis.py and, as Jupytext suggests, "exclude .ipynb files from version control (unless you want to see the outputs versioned!)."5 Your teammates' diff then "is nothing else than a standard diff on a Python script."5 To pair every notebook in a project, put formats = "ipynb,py:percent" in a jupytext.toml.5
Branch habits for data scientists
The tools help. These habits (course advice, built on the lessons so far) keep conflicts rare to begin with:
- Logic lives in
.pymodules. Keep functions likedrop_missinginclean.py, wheretest_clean.pyand CI (Lesson 5) can import and test them. The notebook only calls them. - One owner per notebook per branch. If two branches must both change
analysis.ipynb, merge one before starting the other. Short-lived branches (Lesson 5) make this easy. - Point Claude at the paired script. Brief your agent with "edit
analysis.py, not the.ipynb, and don't execute the notebook." Its diff stays a clean Python diff you can review in the PR.
Do it: make penguins-lab notebook-friendly
Check yourself
You re-run a notebook without editing any code. Why does git diff still show changes?
- Outputs and execution counts are stored
- Git rewrites notebooks on every switch
- Jupyter adds the branch name inside
- Notebook files are stored as binary
Code cells store their outputs and an execution_count in the JSON, so running a cell rewrites the file.
What does nbdime config-git --enable change?
- git diff and merge understand notebooks
- notebooks are deleted from the repository
- cell outputs are cleared on commit
- pull requests open inside Jupyter automatically
It registers nbdime as Git's diff and merge driver for .ipynb files; other files still use the standard diff.
With Jupytext pairing, which file gives reviewers the cleanest diff?
- the paired py:percent script
- the original ipynb notebook
- the generated gitattributes file
- the jupytext toml configuration
The py:percent file stores only the inputs, so its diff is a standard Python diff.
Sources
- nbformat, The Notebook file format.
- GitHub Docs, About merge conflicts.
- nbdime documentation (overview).
- nbdime, Version control integration — Git.
- Jupytext README (text notebooks, command line, notebooks under version control).
- Jupytext, Paired notebooks.