What Are the Best Practices for Organizing and Structuring Jupyter Notebooks?
I opened a Jupyter notebook from a project I worked on three months ago, and I had no idea what I was thinking. Cells were scattered everywhere, imports were buried in the middle of the file, and I had to run cells out of order just to get things working. Sound familiar?
The problem isn’t Jupyter itself - it’s that notebooks can easily become personal scratchpads that only make sense to the person who wrote them, and only while they’re writing them. After months of struggling with messy notebooks, I’ve developed a set of practices that keep my work reproducible and understandable.
The Core Problem: Notebooks Are Too Flexible
Jupyter’s biggest strength is also its weakness. You can run any cell at any time, in any order. This freedom is great for exploration, but it creates chaos when you need to reproduce your work or share it with others.
I’ve made all these mistakes:
- Running cells out of order and forgetting which ones depend on others
- Leaving debug code scattered throughout the notebook
- Not documenting why I made certain decisions
- Having cells with hundreds of lines of code doing multiple things
- Committing notebooks with outputs that cause massive merge conflicts
Here’s what I’ve learned about fixing these issues.
Practice 1: One Concept Per Cell
The most impactful change I made was keeping cells small and focused. Each cell should do one thing.
Here’s what I used to do:
# BAD: One cell doing everythingimport pandas as pdimport numpy as npimport matplotlib.pyplot as pltfrom sklearn.model_selection import train_test_splitfrom sklearn.ensemble import RandomForestClassifierfrom sklearn.metrics import classification_report
df = pd.read_csv('data.csv')df = df.dropna()df['feature_new'] = df['a'] * df['b']X = df.drop('target', axis=1)y = df['target']X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)model = RandomForestClassifier()model.fit(X_train, y_train)y_pred = model.predict(X_test)print(classification_report(y_test, y_pred))plt.bar(df.columns, df.sum())plt.show()Now I structure it like this:
## 3. Feature Engineering# Create new featuredf['feature_new'] = df['a'] * df['b']df['feature_new'].describe()**Decision:** Created interaction feature between columns a and b based on domain knowledge that these values often correlate with target.# Prepare features and targetX = df.drop('target', axis=1)y = df['target']print(f"Features: {X.shape}, Target: {y.shape}")Why does this matter? When something breaks, I know exactly which cell to look at. When I want to re-run just the feature engineering, I can do that without re-running everything else.
Practice 2: Structure with Clear Sections
I now start every notebook with a structure in mind. Before writing any code, I create the section headers:
# Customer Churn Analysis
**Author:** Data Science Team**Date:** 2026-03-07**Purpose:** Identify factors contributing to customer churn
## Table of Contents1. Setup and Configuration2. Data Loading and Inspection3. Data Cleaning4. Exploratory Data Analysis5. Feature Engineering6. Model Development7. Results and ConclusionsThis isn’t just for readers - it helps me stay focused on what I’m trying to accomplish. Each section has a clear purpose, and I can use Jupyter’s table of contents to navigate quickly.
Practice 3: Imports and Configuration at the Top
I used to add imports wherever I needed them. Now I put everything at the top in a dedicated section:
# =============================================================================# 1. SETUP AND CONFIGURATION# =============================================================================
# Standard library importsimport osimport warningsfrom pathlib import Path
# Data manipulationimport pandas as pdimport numpy as np
# Visualizationimport matplotlib.pyplot as pltimport seaborn as sns
# Configure display settingspd.set_option('display.max_columns', None)pd.set_option('display.max_rows', 100)pd.set_option('display.float_format', '{:.2f}'.format)
# Visualization settingsplt.style.use('seaborn-v0_8-whitegrid')plt.rcParams['figure.figsize'] = (12, 6)plt.rcParams['font.size'] = 12
# Suppress warnings for cleaner outputwarnings.filterwarnings('ignore')
# Project pathsDATA_DIR = Path('../data')OUTPUT_DIR = Path('../outputs')
print("Setup complete. Libraries loaded successfully.")This serves multiple purposes:
- I can see all dependencies at a glance
- Someone else can quickly check if they have the required packages
- Configuration is centralized and easy to modify
- Running this cell first ensures everything is set up before analysis begins
Practice 4: Document with Markdown, Not Just Comments
The key insight I had is that notebooks should tell a story. Code comments explain how something works, but Markdown cells explain why I’m doing it.
Here’s a pattern I follow:
## 2. Data Loading and Inspection
We'll load the customer dataset from the CSV file and perform an initial inspection to understand the data structure and identify any immediate issues.# Load the datasetdf = pd.read_csv(DATA_DIR / 'customers.csv')
# Display basic informationprint(f"Dataset shape: {df.shape}")print(f"Memory usage: {df.memory_usage(deep=True).sum() / 1024**2:.2f} MB")
# Show first few rowsdf.head()**Initial Observations:**- Dataset contains 10,000 customers with 25 features- Mix of numerical and categorical variables- Some columns have missing values (addressed in Section 3)The Markdown cells before and after the code explain the context and interpretation. This makes the notebook readable even without running the code.
Practice 5: Verify with “Restart & Run All”
This was a painful lesson. I had a notebook that worked perfectly when I was developing it, but when I tried to run it from scratch, it failed. Turns out I had been running cells in a non-linear order and certain variables weren’t defined where I thought they were.
Now I make it a habit to periodically use “Restart & Run All” from the Kernel menu. If the notebook doesn’t execute completely from top to bottom, it’s broken.
This forces me to:
- Keep cells in logical order
- Define all variables before using them
- Not rely on hidden state from previous runs
Practice 6: Clean Notebooks Before Committing
One of my worst experiences was a merge conflict in a notebook file. The JSON structure of .ipynb files includes execution counts, cell IDs, and output data - all of which change every time you run the notebook.
Git diffs become unreadable:
- "execution_count": 15,+ "execution_count": 3,- "output_type": "execute_result",- "data": {- "text/plain": [- " col1 col2\n0 1 2"- ]- }I now use nbdev to clean notebooks before committing:
# Install nbdev (run once)# !pip install nbdev
# Clean notebooks using nbdev CLI# In terminal:# nbdev_clean --fname my_notebook.ipynb
# Or in Python:from nbdev.clean import clean_nbfrom execnb.nbio import read_nb, write_nb
# Read, clean, and write backnb = read_nb('analysis.ipynb')cleaned_nb = clean_nb(nb)write_nb(cleaned_nb, 'analysis.ipynb')This removes execution counts, clears outputs, and strips metadata that causes merge conflicts. The result is a clean, diffable notebook file.
Practice 7: Extract Reusable Code to Modules
As notebooks grow, I find myself copying the same functions between notebooks. This is a sign that code should live in a Python module, not in the notebook.
I create a utils/ directory with shared functions:
def calculate_churn_rate(df, group_column): """ Calculate churn rate by specified group.
Parameters ---------- df : pd.DataFrame Input dataframe with 'churn' column group_column : str Column name to group by
Returns ------- pd.DataFrame Churn rate by group """ return ( df.groupby(group_column)['churn'] .agg(['sum', 'count']) .assign(churn_rate=lambda x: x['sum'] / x['count'] * 100) .sort_values('churn_rate', ascending=False) )Then in the notebook:
from utils.data_helpers import calculate_churn_rate
churn_by_region = calculate_churn_rate(df, 'region')churn_by_regionThe notebook stays focused on the analysis narrative, while reusable code lives where it can be tested and maintained.
Practice 8: Number Multiple Notebooks in a Project
For larger projects with multiple notebooks, I use a numbering scheme that makes the workflow obvious:
project/├── 01_data_collection.ipynb├── 02_data_cleaning.ipynb├── 03_exploratory_analysis.ipynb├── 04_feature_engineering.ipynb├── 05_model_training.ipynb└── 06_results_summary.ipynbThis makes it clear to anyone (including future me) what order to run the notebooks in and what each one contains.
My Current Workflow
Here’s the process I follow now:
- Plan before coding: Define the notebook’s purpose and outline sections in Markdown
- Write top-to-bottom: Start with imports, load data early, build progressively, end with conclusions
- Document as I go: Add Markdown cells explaining decisions, not just code
- Keep cells focused: One concept per cell, small enough to debug easily
- Verify execution: Periodically “Restart & Run All” to catch ordering issues
- Clean before committing: Run
nbdev_cleanto remove metadata - Extract shared code: Move reusable functions to utility modules
The difference is noticeable. I can pick up notebooks from months ago and understand them. Team members can review my work without scheduling a walkthrough. Merge requests are smaller and more reviewable.
Tools That Help
| Tool | Purpose | Why It Helps |
|---|---|---|
nbdev | Clean metadata | Git-friendly notebooks without merge conflict nightmares |
jupyterlab | Modern IDE interface | Better cell management and navigation |
black | Code formatting | Consistent style across all cells |
isort | Import sorting | Clean, organized import section |
jupytext | Sync with Python scripts | Version control friendly, can edit in your favorite editor |
These practices transformed notebooks from personal scratchpads into professional documents that serve as both analysis tools and communication artifacts. The upfront discipline pays off every time I need to revisit, share, or build upon previous work.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments