DEV Community

Moya Richards
Moya Richards

Posted on

Fix Python Imports in Jupyter Notebooks

If you've ever opened a Jupyter notebook and seen this error:

ModuleNotFoundError: No module named 'app'
Enter fullscreen mode Exit fullscreen mode

โ€ฆeven though the file clearly exists โ€” you're running into one of the most common (and frustrating) notebook issues: Python import context.

This usually happens because Jupyter:

  • Runs inside an already-started Python process
  • Does not treat your code as part of a package
  • Depends entirely on the current working directory and sys.path

So even well-structured projects break when you try to import them from a notebook.

In normal Python execution, the fix is to use:

  • python -m app.module
  • or install your project with pip install -e .

But those solutions donโ€™t apply inside Jupyter notebooks.

When testing or debugging your app in a notebook, most developers fall back to hacking sys.path โ€” which works, but quickly becomes messy and hard to manage.

Instead, we can fix this cleanly with a reusable, scoped utility:

๐Ÿ‘‰ RelativeImportContext

๐Ÿ’ก Why this matters: RelativeImportContext gives you package-style imports inside a notebook without polluting global state, restarting the kernel, or restructuring your project.


What It Does

  • Temporarily adds directories to sys.path
  • Restores everything safely after execution
  • Captures logs during import/debugging
  • Works as both a context manager and decorator

Install:

pip install relative-import-context
Enter fullscreen mode Exit fullscreen mode

Example 1: Fix Local Imports

with RelativeImportContext("./app"):
    from services.user import get_user
Enter fullscreen mode Exit fullscreen mode

No more ModuleNotFoundError when running scripts from outside the project root.


Example 2: Multiple Package Roots

with RelativeImportContext(["./app", "./libs"]):
    import service
    import utils
Enter fullscreen mode Exit fullscreen mode

Example 3: Capture Logs During Import

with RelativeImportContext("./app") as ctx:
    import my_module

print(ctx.get_logs())
Enter fullscreen mode Exit fullscreen mode

Great for debugging import side effects.


Example 4: Target a Specific Logger

import logging

logger = logging.getLogger("my_app")

with RelativeImportContext("./app", logger_name="my_app") as ctx:
    logger.info("Starting...")

print(ctx.get_logs())
Enter fullscreen mode Exit fullscreen mode

Example 5: Use as a Decorator

@RelativeImportContext("./app")
def run():
    import my_module
    print("Loaded")

run()
Enter fullscreen mode Exit fullscreen mode

Example 6: Debug Failing Imports

try:
    with RelativeImportContext("./app") as ctx:
        import broken_module
except Exception as e:
    print("Error:", e)
    print(ctx.get_logs())
Enter fullscreen mode Exit fullscreen mode

Why This Is Better Than sys.path Hacks

Approach Problem
sys.path.append() scattered everywhere Hard to track, pollutes global state
Environment variables (PYTHONPATH) Not portable, hard to debug
RelativeImportContext Scoped, safe, debuggable

Where This Shines

This pattern is especially useful in:

  • Local development without packaging
  • Projects with scripts
  • CLI tools
  • Testing/Debugging

Key Benefits

  • โœ… Zero global side effects
  • โœ… Safe restoration of state
  • โœ… Debug-friendly logging
  • โœ… Reusable and composable
  • โœ… Works as context manager + decorator


The Better Long-Term Fix: python -m

While RelativeImportContext is useful, the real solution is understanding how Python runs modules.

What -m Does

When you run:

python -m app.tasks
Enter fullscreen mode Exit fullscreen mode

Python:

  1. Treats app as a package
  2. Adds the project root to sys.path
  3. Executes tasks.py as part of that package

Instead of treating it as a standalone file.


Why This Fixes Imports

Without -m:

python app/tasks.py
Enter fullscreen mode Exit fullscreen mode
  • Python sets sys.path to the folder containing the file (app/)
  • So app is no longer a top-level package
  • This breaks imports like:
from app.db import SessionLocal
Enter fullscreen mode Exit fullscreen mode

With -m:

python -m app.tasks
Enter fullscreen mode Exit fullscreen mode
  • Python runs from the project root
  • app stays a valid package
  • All absolute imports work correctly

Mental Model

Think of it like this:

Command How Python Thinks
python file.py "Just run this file"
python -m package.module "Run this as part of a package"

Real Project Example

Project structure:

project/
  app/
    __init__.py
    db.py
    tasks.py
Enter fullscreen mode Exit fullscreen mode

Correct way:

cd project
python -m app.tasks
Enter fullscreen mode Exit fullscreen mode

Now this works everywhere:

from app.db import SessionLocal
Enter fullscreen mode Exit fullscreen mode

When to Use Each Approach

Situation Best Tool
Running real app / workers python -m
Production-ready projects install with pip install -e .
Quick scripts / testing/debugging RelativeImportContext


Does python -m Work in Jupyter Notebooks?

Short answer: No โ€” not directly.

Why Not?

Jupyter notebooks do not run code the same way as the terminal.

When you execute a cell, Python:

  • Runs code inside an already-running interpreter
  • Does not treat cells as modules or packages
  • Does not support python -m execution semantics

So this will NOT work inside a notebook:

# โŒ This does NOT behave like terminal execution
python -m app.tasks
Enter fullscreen mode Exit fullscreen mode

What Actually Happens in Jupyter

Jupyter behaves more like running:

python
>>> # interactive session
Enter fullscreen mode Exit fullscreen mode

So imports depend entirely on:

  • Current working directory (os.getcwd())
  • Existing sys.path

How to Handle Imports in Jupyter

Option 1: Adjust sys.path

import sys
sys.path.append("/path/to/project")
Enter fullscreen mode Exit fullscreen mode

Works, but it's a global hack.


Option 2: Use RelativeImportContext (Cleanest for Notebooks)

๐Ÿ‘‰ Important: This is one of the best real-world uses for RelativeImportContext.

Unlike production apps where python -m or proper packaging is preferred, Jupyter runs in an already-active interpreter. That means you canโ€™t rely on module execution semantics โ€” but you still need clean, scoped imports.

RelativeImportContext solves this perfectly by:

  • Avoiding global sys.path pollution
  • Keeping imports scoped to a single cell or block
  • Making debugging easier with captured logs
with RelativeImportContext("./app"):
    from app.db import SessionLocal
Enter fullscreen mode Exit fullscreen mode

This gives you package-style imports inside a notebook, without restructuring your entire project or restarting the kernel.

with RelativeImportContext("./app"):
    from app.db import SessionLocal
Enter fullscreen mode Exit fullscreen mode

This is actually a perfect use case for your utility.


Option 3: Launch Jupyter from Project Root

Best practice:

cd project
jupyter notebook
Enter fullscreen mode Exit fullscreen mode

Then imports like this will work:

from app.db import SessionLocal
Enter fullscreen mode Exit fullscreen mode

Pro Tip

If you frequently use notebooks in a project:

  • Always open Jupyter from the project root
  • Or install your project:
pip install -e .
Enter fullscreen mode Exit fullscreen mode

Then imports work everywhere โ€” notebook, scripts, workers.


Summary

Environment Best Approach
Terminal scripts python -m
Jupyter notebooks project root OR RelativeImportContext
Production pip install -e .

Instead of fighting Pythonโ€™s import systemโ€ฆ

control it โ€” safely and locally.

RelativeImportContext turns a messy problem into a clean, reusable pattern.

Python Implementation

import logging
import sys
from contextlib import ContextDecorator
from io import StringIO
from pathlib import Path
from types import TracebackType
from typing import Iterable, Optional, Self, Type


class RelativeImportContext(ContextDecorator):
    def __init__(
        self,
        package_directories: str | Path | Iterable[str | Path],
        *,
        log_level: int = logging.DEBUG,
        logger_name: Optional[str] = None,
    ) -> None:
        if isinstance(package_directories, (str, Path)):
            package_directories = [package_directories]

        self.package_directories = [
            str(Path(directory).expanduser().resolve())
            for directory in package_directories
        ]

        self.log_level = log_level
        self.logger_name = logger_name

        self._original_sys_path: list[str] = []
        self._log_stream = StringIO()
        self._log_handler = logging.StreamHandler(self._log_stream)
        self._log_handler.setLevel(log_level)
        self._log_handler.setFormatter(
            logging.Formatter("%(levelname)s:%(name)s:%(message)s")
        )

        self._logger: logging.Logger | None = None
        self._old_logger_level: int | None = None
        self._entered = False

    def __enter__(self) -> Self:
        if self._entered:
            raise RuntimeError("Cannot re-enter the same context instance")

        self._entered = True
        self._original_sys_path = list(sys.path)

        for directory in reversed(self.package_directories):
            if directory not in sys.path:
                sys.path.insert(0, directory)

        self._logger = logging.getLogger(self.logger_name)
        self._old_logger_level = self._logger.level

        if self._logger.level > self.log_level or self._logger.level == logging.NOTSET:
            self._logger.setLevel(self.log_level)

        self._logger.addHandler(self._log_handler)
        return self

    def __exit__(
        self,
        exc_type: Type[BaseException] | None,
        exc_value: BaseException | None,
        traceback: TracebackType | None,
    ) -> bool:
        sys.path[:] = self._original_sys_path

        if self._logger is not None:
            self._logger.removeHandler(self._log_handler)

            if self._old_logger_level is not None:
                self._logger.setLevel(self._old_logger_level)

        self._log_handler.flush()
        self._entered = False
        return False

    def get_logs(self) -> str:
        return self._log_stream.getvalue()

    def clear_logs(self) -> None:
        self._log_stream.seek(0)
        self._log_stream.truncate(0)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)