ipynb vs py: What Breaks When a Notebook Becomes a Script (Tested Matrix)
ipynb vs py: What Breaks When a Notebook Becomes a Script (Tested Matrix)
Converting a notebook to a .py file is one command. Getting a .py file that does what the
notebook did is a different question, and it is the one this page answers.
Twelve small notebooks were built, one per construct that behaves differently inside a
Jupyter kernel than in a script. Each was executed in a real kernel, converted two ways —
jupyter nbconvert --to script and jupytext --to py:percent — and each converted file was
run twice, with python and with ipython. That is 48 script runs. Every cell in the matrix
below is an observed exit code and a check of the program’s own output, not a reading of the
docs — with one exception: for len? in the kernel, the help text goes to Jupyter’s pager
rather than to a cell output, so that one cell records only that the notebook ran to the end.
Two results are worth knowing before you read the table:
- The two converters fail in opposite ways. nbconvert keeps every magic as a
get_ipython()call, so under plainpythonthe script dies on the first one withNameError: name 'get_ipython' is not defined. Of the 10 constructs that work in a kernel, 9 failed underpython— 6 of them onget_ipython, the other 3 ondisplay, top-levelawaitand out-of-order state. jupytext comments magics out instead, so the script runs, exits 0, and silently skips them: 6 of 10 constructs exited cleanly with their effect missing. Loud failure versus quiet failure. ipython your_script.pyrescues nbconvert’s output, not jupytext’s. The same nbconvert files worked for 7 of 10 constructs underipython. jupytext’s files gained almost nothing (1 of 10), because a commented-out magic is a comment to every interpreter.
Tested on 2026-10-26: macOS 26.5.2 (arm64), Python 3.14.2, nbconvert 7.17.2, jupytext 1.19.6, IPython 9.17.1, ipykernel 7.4.0, nbclient 0.11.0, pandas 3.1.0, matplotlib 3.11.2. Nothing here was run on Windows or Linux; the behaviours tested are interpreter and converter behaviour rather than OS behaviour, but that is an expectation, not a measurement.
The matrix
✅ = exit 0 and the construct’s own output was seen. ⚠️ = exit 0, but the output the construct exists to produce was not there. ❌ = non-zero exit, with the last error line.
| Construct | In the kernel | nbconvert → python | nbconvert → ipython | jupytext → python | jupytext → ipython |
|---|---|---|---|---|---|
%matplotlib inline line magic | ✅ works | ❌ NameError: get_ipython | ✅ works | ⚠️ exits 0, effect lost | ⚠️ exits 0, effect lost |
%pip --version (the %pip magic) | ✅ works | ❌ NameError: get_ipython | ✅ works | ⚠️ exits 0, effect lost | ⚠️ exits 0, effect lost |
%%time cell magic | ✅ works | ❌ NameError: get_ipython | ✅ works | ⚠️ exits 0, effect lost | ⚠️ exits 0, effect lost |
!echo shell escape | ✅ works | ❌ NameError: get_ipython | ✅ works | ⚠️ exits 0, effect lost | ⚠️ exits 0, effect lost |
files = !ls (capture shell output) | ✅ works | ❌ NameError: get_ipython | ✅ works | ❌ NameError: files | ❌ NameError: files |
display(df) without importing it | ✅ works | ❌ NameError: display | ✅ works | ❌ NameError: display | ✅ works |
last line is a bare expression (df.describe()) | ✅ works | ⚠️ exits 0, effect lost | ⚠️ exits 0, effect lost | ⚠️ exits 0, effect lost | ⚠️ exits 0, effect lost |
top-level await | ✅ works | ❌ SyntaxError: await outside function | ❌ SyntaxError: await outside function | ❌ SyntaxError: await outside function | ❌ SyntaxError: await outside function |
len? help syntax | ✅ works | ❌ NameError: get_ipython | ✅ works | ⚠️ exits 0, effect lost | ⚠️ exits 0, effect lost |
Path(__file__) (works in a script, not in a kernel) | ❌ NameError | ✅ works | ✅ works | ✅ works | ✅ works |
argparse parse_args() with no arguments defined | ❌ SystemExit | ✅ works | ✅ works | ✅ works | ✅ works |
| cells saved after an out-of-order run (cell 2 defines what cell 1 uses) | ✅ works | ❌ NameError: y | ❌ NameError: y | ❌ NameError: y | ❌ NameError: y |
Totals over the 10 constructs that work in the kernel (works / exits 0 but effect lost / fails):
| Converter → runner | Works | Exit 0, effect lost | Fails |
|---|---|---|---|
nbconvert → python | 0 | 1 | 9 |
nbconvert → ipython | 7 | 1 | 2 |
jupytext → python | 0 | 6 | 4 |
jupytext → ipython | 1 | 6 | 3 |
“Effect lost” is judged by the thing the construct is for: the Wall time line for %%time,
the pip version line for %pip, shell-ran for !echo, the Signature: block for len?,
backend inline for %matplotlib inline (jupytext’s file printed backend macosx under
python: the default backend, because the magic was a comment). For %%time under jupytext
the cell’s body did run — total was computed and printed — only the timing went missing.
Why each row comes out the way it does
Magics and ! lines: what each converter writes
This is the whole difference between the two converters. For the %%time notebook,
nbconvert wrote:
# In[1]:
get_ipython().run_cell_magic('time', '', 'total = sum(range(10**6))\n')and jupytext wrote:
# %%
# %%time
total = sum(range(10**6))get_ipython is a name that exists only when IPython is running the code. Plain python
has never heard of it, so the first such line raises NameError and nothing after it runs.
ipython script.py defines it, so the same line works. jupytext’s version is valid Python
with a comment on top, which is why it runs everywhere and does nothing special anywhere.
The odd one out is files = !ls. jupytext comments out the whole line, so files is never
assigned and the next line that uses it fails — the one case where jupytext’s quiet strategy
turns into a crash, under both runners:
# %%
# files = !ls
print('captured', type(files).__name__)NameError: name 'files' is not defined. Did you mean: 'filter'?display() and the bare last line
display is injected into the namespace by IPython; it is not a builtin. Under python,
both converters’ output fails with NameError: name 'display' is not defined; under
ipython it works.
A bare expression on a cell’s last line (df.describe()) is the most common silent loss. In
a notebook, the kernel displays the value of the last expression. In a script — under
python and ipython — that value is computed and thrown away. All four runs exited 0 and
printed nothing for that cell. If the cell existed to show you a table, the script now
shows you nothing and reports success.
Top-level await: the whole file fails, not one line
IPython lets you await at the top level of a cell. A script cannot. This one is a
SyntaxError, which Python raises while compiling the file, before line 1 executes. The
test notebook had a print('cell 1 ran') cell before the await cell; in all four runs that
line never printed:
File ".../top_level_await.nbconvert.py", line 14
await asyncio.sleep(0)
^^^^^^^^^^^^^^^^^^^^^^
SyntaxError: 'await' outside functionipython script.py did not help here either — it compiles a .py file as ordinary Python.
The two rows that fail in the kernel and work in the script
These are the reverse direction, and they matter if you write code in a .py and paste it
into a notebook. __file__ is defined when Python runs a file; a kernel is not running a
file, so Path(__file__) raises NameError: name '__file__' is not defined.
argparse reads sys.argv, and inside a kernel sys.argv holds the kernel’s own launch
arguments. With no arguments defined, parse_args() rejected them and exited:
usage: ipykernel_launcher.py [-h]
ipykernel_launcher.py: error: unrecognized arguments: -f /var/folders/.../tmpmgvhfhg4.json --HistoryManager.hist_file=:memory:
SystemExit: 2(The extra --HistoryManager argument came from nbclient, which was the kernel launcher in
this test. A kernel started from JupyterLab will have its own connection-file argument; the
failure is the same unless the parser ignores unknown arguments.)
Out-of-order cells: the notebook was never a program
The last row is the one no converter can fix. The test notebook’s first cell prints y; the
second cell defines it. The author ran cell 2, then cell 1, and saved — so the saved notebook
shows y is 42 under the first cell and looks fine. nbconvert even preserved the evidence in
its cell markers:
# In[2]:
print('y is', y)
# In[1]:
y = 41 + 1A script runs top to bottom, so every converted file failed with NameError: name 'y' is not defined. So did the notebook itself when re-run top to bottom in a fresh kernel. That last
fact is the useful one, because it gives you a check you can run before converting (next
section).
Check before you convert
jupyter nbconvert --execute re-runs the notebook top to bottom in a fresh kernel before
writing anything. On the out-of-order notebook:
jupyter nbconvert --to script --execute out_of_order.ipynb --output ooo_checkedit exited with code 1, wrote no .py file, and printed the cell’s NameError: name 'y' is not defined. A notebook that fails this because of hidden or out-of-order state will fail as a
script too. (The converse is not guaranteed: the __file__ and argparse rows fail in the
kernel and work as scripts.)
What --execute does not check: whether the script runs under python. The kernel
understands magics, display() and top-level await, so a notebook full of them passes
--execute and still produces a script that fails. Use both checks:
jupyter nbconvert --to script --execute notebook.ipynb— catches hidden state and out-of-order cells.python notebook.pyon the result — catches every ❌ in the matrix: anything that crashes outside a kernel. It does not catch the ⚠️ cases, which exit 0: a bare last expression whose output is now discarded, and — in jupytext’s output — magics and!lines that became comments. Those you find by checking that the script produces the output you expect, or with thegrepbelow. Running withipython notebook.pyinstead turns nbconvert’s magic failures into working lines, but still fails on top-levelawaitand on jupytext’s commented-outfiles = !cmd, and still loses bare-expression output.
A grep for the lines that will need attention, on the .ipynb itself:
grep -nE '^ *"(%|!|[^"]*= *!|await |display\(|[^"]*\?(\\n)?",?$)' notebook.ipynbThat pattern is a rough screen written for this page, not a parser. It matches source lines
in the .ipynb JSON that start with %, !, await or display(, contain = !, or end
in ? — so a markdown line ending in a question mark will match too. Run against the twelve
test notebooks, it flagged all eight rows it is meant to flag (one line each) and nothing in
the other four. It cannot see the two rows that are not about syntax: a bare last expression,
and out-of-order state.
The plain-Python replacement for each
Each of these was saved as its own file and run with plain python; each exited 0 and
printed the expected output.
| Notebook construct | Replacement that works under python |
|---|---|
%matplotlib inline | Delete it. In a script, set a backend if you need one (matplotlib.use('Agg') for files only) and save figures with fig.savefig(...) |
%pip install x / %pip --version | Install outside the script, or subprocess.run([sys.executable, '-m', 'pip', ...], check=True) |
%%time | t0 = time.perf_counter() … print(f'wall {time.perf_counter() - t0:.4f} s') |
!cmd | subprocess.run(['cmd', ...], check=True) |
files = !ls | subprocess.run(['ls'], capture_output=True, text=True, check=True).stdout.splitlines() |
display(df) | print(df), or keep display with from IPython.display import display (it printed the frame under plain python too) |
| bare last expression | print(...) it, or write it to a file |
top-level await | Put the body in async def main(): and call asyncio.run(main()) |
Two constructs have no “replacement”, only a decision: out-of-order state (re-order the cells
until --execute passes) and __file__ / argparse in the reverse direction (use
Path.cwd() or parse_args([]) when the code must also run in a kernel).
So: .ipynb or .py?
The matrix is about converting. The question people usually search is which format to keep the work in. The measurable part of that question is what happens to version control.
The same six-cell notebook (one markdown cell, then five code cells: imports, a pandas summary, a bar chart and a timestamp print) was executed
twice with no code change, then once more after a one-word edit — adding 'max' to an
aggregation list. Each run was paired with a jupytext .py of the same notebook, and the
pairs were compared with git diff --numstat:
| Change | .ipynb lines +/− | paired .py lines +/− |
|---|---|---|
| Re-run, no code change (execution timing recorded) | +21 −21 | +0 −0 |
| Re-run, no code change (timing not recorded) | +1 −1 | +0 −0 |
| One-word code edit, then re-run | +32 −27 | +1 −1 |
The notebook file was 19,841 bytes and 280 lines; the .py was 806 bytes and 37 lines.
Where the .ipynb churn comes from, read off the diff:
- Execution timestamps. nbclient — the library behind
nbconvert --execute— records four ISO timestamps per code cell in cell metadata by default: 4 × 5 code cells = 20 of the 21 changed lines on a re-run with no edit. Whether your editor records them depends on its settings; that was not tested here. - Outputs that change. The one remaining line was the timestamp the notebook prints. With a fixed random seed, the 12,824-character base64 PNG of the chart came out byte-identical across re-runs, so a deterministic plot did not add diff noise in this test.
- Outputs that follow the code. Besides the one source line, the one-word edit rewrote the summary table in the outputs twice over — its plain-text and its HTML representation — plus the timestamps and the printed time.
The honest summary: keep exploratory work and anything whose outputs are the point (a report,
a figure you want to see in review) as .ipynb; keep anything that is imported, scheduled,
tested or reviewed line by line as .py. If you need both, jupytext’s paired-file mode keeps
a .py next to the notebook — and its default of commenting out magics is exactly the ⚠️
column above, so a paired .py is a review copy, not a script you can run unchanged.
Can you go back?
From jupytext’s .py, yes: converting the %%time notebook to py:percent and back with
jupytext --to notebook gave the same three code cells with the same sources, magic
included (the comment is un-commented on the way back). Outputs are gone, since the .py
never had them.
From nbconvert’s script, not cleanly: reading it back with jupytext produced four code cells
instead of three — an extra empty one at the top — and each source now starts with blank
lines and holds get_ipython().run_cell_magic(...)
instead of %%time.
The in-browser converter
The Jupyter to Python converter is the
quickest way to get a .py out of a notebook without installing anything; its page says the
conversion runs in your browser, with nothing sent to a server. It is the right tool when the
notebook passes the checks above — no magics, no ! lines, no top-level await.
When it does not, know what you will get. A notebook combining the magic and shell rows
of the matrix was run through the tool on 2026-10-26. Its output keeps markdown cells as
comments, adds a short header, and copies magic and ! lines through unchanged —
%matplotlib inline, %%time, !echo shell-ran, files = !ls and len? appear verbatim.
Unchanged, those lines are not Python, so the file did not compile:
$ python tool_probe.tool-output.py
File ".../tool_probe.tool-output.py", line 12
%matplotlib inline
^
SyntaxError: invalid syntaxSame result under ipython tool_probe.tool-output.py. Renamed to .ipy (which IPython parses
with magic syntax) it got further — the %pip line ran — and then stopped at the %%time
line, because a cell magic is only valid at the start of a cell:
UsageError: Line magic function `%%time` not found.So for a notebook with magics, either strip them first using the replacement table, or use
nbconvert --to script and run the result with ipython. This was one probe notebook on one
date; the tool may change.
Re-running this
Every number above comes from three scripts, run in a fresh venv
(pip install nbconvert nbclient ipykernel jupytext pandas matplotlib). The matrix harness
builds each feature notebook with nbformat, executes it with nbclient, converts it, and runs
the outputs. You can reproduce any single row by hand; for the %%time row:
cat > cell_magic.py <<'EOF'
import nbformat
from nbclient import NotebookClient
nb = nbformat.v4.new_notebook()
nb.metadata["kernelspec"] = {"name": "python3", "display_name": "Python 3", "language": "python"}
nb.cells = [nbformat.v4.new_code_cell("%%time\ntotal = sum(range(10**6))"),
nbformat.v4.new_code_cell("print('total', total)")]
NotebookClient(nb, kernel_name="python3").execute()
nbformat.write(nb, "cell_magic.ipynb")
EOF
python cell_magic.py
jupyter nbconvert --to script cell_magic.ipynb --output cell_magic.nbconvert
jupytext --to py:percent -o cell_magic.pct.py cell_magic.ipynb
python cell_magic.nbconvert.py # NameError: name 'get_ipython' is not defined
ipython cell_magic.nbconvert.py # CPU times ... Wall time ... total 499999500000
python cell_magic.pct.py # total 499999500000 (no timing line)If you build test notebooks by hand like this, note that --to script picks the file
extension from the notebook’s metadata.language_info, which the kernel fills in when the
notebook is executed. A notebook created with nbformat, never executed and without that field
converted to fresh.txt, not fresh.py, with exit code 0. Setting
language_info = {"name": "python", "file_extension": ".py"} by hand, or using --to python,
gave a .py without executing anything.
Known limits: one machine (macOS arm64), one version of each tool, small synthetic notebooks built to isolate one construct each. Real notebooks combine constructs, and the first ❌ in a script stops everything after it, so a real notebook’s script can fail earlier than any one row suggests.
Related reading
- Jupyter nbconvert: Convert Notebooks to HTML, PDF & Python — the rest of nbconvert’s formats; its Python-script section was re-checked against the same versions used here.
- ModuleNotFoundError in Jupyter: A Reproduced Kernel Failure Matrix
— when the script fails on an import instead: the
pythonyou run it with and the kernel’s Python are often not the same interpreter. - What Is .ipynb_checkpoints? — the other thing that makes notebooks awkward in git, with a reproduced cleanup.
- PyInstaller vs Nuitka vs cx_Freeze: A Measured Packaging Benchmark
— once the script runs under plain
python, what it takes to ship it as a standalone program.