Skip to content

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:

messy_analysis.py
# BAD: One cell doing everything
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from 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:

notebook_structure.md
## 3. Feature Engineering
feature_engineering.py
# Create new feature
df['feature_new'] = df['a'] * df['b']
df['feature_new'].describe()
feature_engineering_note.md
**Decision:** Created interaction feature between columns a and b based on domain knowledge that these values often correlate with target.
data_preparation.py
# Prepare features and target
X = 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:

notebook_header.md
# Customer Churn Analysis
**Author:** Data Science Team
**Date:** 2026-03-07
**Purpose:** Identify factors contributing to customer churn
## Table of Contents
1. Setup and Configuration
2. Data Loading and Inspection
3. Data Cleaning
4. Exploratory Data Analysis
5. Feature Engineering
6. Model Development
7. Results and Conclusions

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

setup_configuration.py
# =============================================================================
# 1. SETUP AND CONFIGURATION
# =============================================================================
# Standard library imports
import os
import warnings
from pathlib import Path
# Data manipulation
import pandas as pd
import numpy as np
# Visualization
import matplotlib.pyplot as plt
import seaborn as sns
# Configure display settings
pd.set_option('display.max_columns', None)
pd.set_option('display.max_rows', 100)
pd.set_option('display.float_format', '{:.2f}'.format)
# Visualization settings
plt.style.use('seaborn-v0_8-whitegrid')
plt.rcParams['figure.figsize'] = (12, 6)
plt.rcParams['font.size'] = 12
# Suppress warnings for cleaner output
warnings.filterwarnings('ignore')
# Project paths
DATA_DIR = Path('../data')
OUTPUT_DIR = Path('../outputs')
print("Setup complete. Libraries loaded successfully.")

This serves multiple purposes:

  1. I can see all dependencies at a glance
  2. Someone else can quickly check if they have the required packages
  3. Configuration is centralized and easy to modify
  4. 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:

data_loading_doc.md
## 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.
data_loading.py
# Load the dataset
df = pd.read_csv(DATA_DIR / 'customers.csv')
# Display basic information
print(f"Dataset shape: {df.shape}")
print(f"Memory usage: {df.memory_usage(deep=True).sum() / 1024**2:.2f} MB")
# Show first few rows
df.head()
data_observations.md
**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:

notebook_diff.txt
- "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:

nbdev_cleaning.py
# 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_nb
from execnb.nbio import read_nb, write_nb
# Read, clean, and write back
nb = 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:

utils/data_helpers.py
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:

using_utils.py
from utils.data_helpers import calculate_churn_rate
churn_by_region = calculate_churn_rate(df, 'region')
churn_by_region

The 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_structure.txt
project/
├── 01_data_collection.ipynb
├── 02_data_cleaning.ipynb
├── 03_exploratory_analysis.ipynb
├── 04_feature_engineering.ipynb
├── 05_model_training.ipynb
└── 06_results_summary.ipynb

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

  1. Plan before coding: Define the notebook’s purpose and outline sections in Markdown
  2. Write top-to-bottom: Start with imports, load data early, build progressively, end with conclusions
  3. Document as I go: Add Markdown cells explaining decisions, not just code
  4. Keep cells focused: One concept per cell, small enough to debug easily
  5. Verify execution: Periodically “Restart & Run All” to catch ordering issues
  6. Clean before committing: Run nbdev_clean to remove metadata
  7. 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

ToolPurposeWhy It Helps
nbdevClean metadataGit-friendly notebooks without merge conflict nightmares
jupyterlabModern IDE interfaceBetter cell management and navigation
blackCode formattingConsistent style across all cells
isortImport sortingClean, organized import section
jupytextSync with Python scriptsVersion 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