AboutBlogMediaTags

ipynb vs py: What Breaks When a Notebook Becomes a Script (Tested Matrix)

Runcell Team,

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:

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.

ConstructIn the kernelnbconvert → pythonnbconvert → ipythonjupytext → pythonjupytext → 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 → runnerWorksExit 0, effect lostFails
nbconvert → python019
nbconvert → ipython712
jupytext → python064
jupytext → ipython163

“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 function

ipython 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 + 1

A 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_checked

it 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:

  1. jupyter nbconvert --to script --execute notebook.ipynb — catches hidden state and out-of-order cells.
  2. python notebook.py on 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 the grep below. Running with ipython notebook.py instead turns nbconvert’s magic failures into working lines, but still fails on top-level await and on jupytext’s commented-out files = !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.ipynb

That 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 constructReplacement that works under python
%matplotlib inlineDelete 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 --versionInstall outside the script, or subprocess.run([sys.executable, '-m', 'pip', ...], check=True)
%%timet0 = time.perf_counter() … print(f'wall {time.perf_counter() - t0:.4f} s')
!cmdsubprocess.run(['cmd', ...], check=True)
files = !lssubprocess.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 expressionprint(...) it, or write it to a file
top-level awaitPut 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:

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 syntax

Same 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.


© Runcell.RSS