Best practices for using Jupyter notebooks
Better Code, Better Science: Chapter 6, Part 6
This is a possible section from the open-source living textbook Better Code, Better Science, which is being released in sections on Substack. The entire book can be accessed here and the Github repository is here. This material is released under CC-BY-NC. Thanks to Steffen Bollman for helpful suggestions on a draft of this section.
Habitually restart kernel and run the full notebook
Most Jupyter users learn over time to restart their kernel and run the entire notebook (or at least the code above a cell of interest) whenever there is any sort of confusing bug. It’s the only foolproof way to make sure that there is no out-of-order execution and that all of the code was executed using the same module versions. A complete run of the notebook using a fresh kernel is the only way to definitively confirm the function of the notebook.
Keep notebooks short
One of the graduate students in my lab recently created a notebook that was so long that I began referring to it as their “big beautiful notebook.” A monster notebook will generally become unwieldy, because it often has dependencies that span across many different parts of the notebook. In addition, a large notebook will often take a very long time to run, making it more difficult to practice the “restart and run all” practice recommended above. Instead of having a single large notebook, it’s better to develop shorter notebooks that are targeted at specific functions. This will also help better encapsulate the data, since they will need to be shared explicitly across the different notebooks.
Parameterize the notebook
Because notebooks are often generated in a quick and dirty way, it’t not uncommon to see parameters such as directory names or function settings strewn across the entire notebook. This violates the principles of clean coding that we mentioned in Chapter 3, and makes changes very difficult to effectively implement. Instead, it’s better to define any parameters or settings in a cell at the top of the notebook. In this way, one can easily make changes and ensure that they are propagated throughout the notebook.
Extract functions into modules
It’s common for users of Jupyter notebooks to define functions within their notebook in order to modularize their code. This is of course a good practice, but suggest that these functions be moved to a Python module outside of the Jupyter notebook and imported, rather than being defined within the Jupyter notebook. The reason has to do with the fact that the variables defined in all of the cells within a Jupyter notebook have a global scope. As we discussed in Chapter Three, global variables are generally frowned upon because they can make it very difficult to debug problems. In the case of Jupyter notebooks, we have on more than one occasion been flummoxed by a difficult debugging problem, only to realize that it was due to our use of a global variable within a function. If a function is defined within the notebook then variables within the global scope are accessible within the function, whereas if a function is imported from another module those global variables are not accessible within the function. Another advantage of using a defined function is that having a explicit interface makes the dependencies of the function clearer.
As an example, if we execute the following code within a Jupyter notebook cell:
x = 1
def myfunc():
print(x)
myfunc()the output is 1; this is because the x variable is global, and thus is accessible within the function without being passed. If we instead create a separate python file called myfunc2.py containing the following:
def myfunc2():
print(x)and then import this within our Jupyter notebook:
from myfunc2 import myfunc2
x = 1
myfunc2()We will get an error reflecting the fact that x doesn’t exist within the scope of the imported function:
-----------------------------------------------------------------------
NameError Traceback (most recent call last)
Cell In[9], line 3
1 from myfunc2 import myfunc2
2 x = 1
----> 3 myfunc2()
File ~/Dropbox/code/coding_for_science/src/codingforscience/jupyter/myfunc2.py:2, in myfunc2()
1 def myfunc2():
----> 2 print(x)
NameError: name ‘x’ is not definedExtracting functions from notebooks into a Python module not only helps prevent problems due to the inadvertent use of global variables; it also makes those functions easier to test. And as we learned in Chapter 4, testing is the best way to keep our code base working and to make it easy to change when we need to. Extracting functions also helps keep the notebook clean and readable, abstracting away the details of the functions and showing primarily the results.
Avoid using autoreload
When using functions imported from a module, any changes made to the module need to be imported. However, simply re-rerunning the import statement won’t work, since it doesn’t reload any functions that have been previously imported. A trick to fix this is to use the %autoreload magic, which can reload all of the imported modules whenever code is run (using the %autoreload 2 command). This might seem to accelerate the pace of development, but it comes at a steep cost: The problem is that you can’t tell which cells have been run with which versions of the code, so you don’t know which version the current value of any particular variable came from, except those in the most recently run cell. This is a recipe for confusion. The only way to reduce this confusion would be to rerun the entire notebook, as noted above.
Use an environment manager to manage dependencies
The reproducibility of the computations within a notebook depend on the reproducibilty of the environment and dependencies, so it’s important to use an environment manager. As noted in Chapter 2, we prefer uv, but one can also use any of the other Python package managers.

Superb breakdown of the discipline required to keep notebooks reproducible. The kernel restart habit is the single most underappreciated practice in data science workflows.
The global scope problem you highlighted with the function example is where most notebook bugs hide. Jupyter's execution model creates this invisible coupling between cells that violates every principle of modular code. When a function defined in a notebook cell can silently access variables from the global namespace, you lose the explicitness that makes code debuggable. The example comparing in-notebook functions versus imported functions makes this concrete—imported functions fail loudly when dependencies are missing, while in-notebook functions fail silently or produce incorrect results.
What's particularly insidious is that this problem compounds over time. A notebook that starts small and clean gradually accumulates hidden state. By the time you have 50 cells and multiple interdependent functions, you're debugging a stateful system where cell execution order matters more than the code itself. The "restart and run all" discipline is the only way tobreak that cycle, but it requires discipline that most practitioners don't develop until they've been burned multiple times.
To expand on the first tip: Never trust any notebook output as "official" unless the notebook was run in batch mode, e.g., with nbconvert or papermill. Even better, the notebook should be incorporated into a pipeline so the entire project can be run all at once.