Move the Imports Before You Move the Files

▶ Listen to this article

A refactor that touches 211 Python files sounds like it should produce chaos. Things that worked before should break in surprising ways. There should be a two-hour period where python -m pytest outputs a wall of ImportError and ModuleNotFoundError while you untangle which file was supposed to go where.

Ours didn’t. The test suite stayed green at every intermediate commit.

The reason wasn’t clever tooling. It was a sequencing discipline that sounds obvious once you say it out loud, but that I’ve seen violated countless times in Python codebases:

Move the imports before you move the files.

Why Python Imports Are Location-Sensitive

Python has two flavors of import statement.

Absolute imports reference a module by its full package path, regardless of where the calling file lives:

from mypackage.models.user import User
from mypackage.utils.validation import validate_email

Relative imports reference a module by its position relative to the calling file:

from ..models.user import User      # two levels up, then into models/
from .validation import validate_email  # same directory

The absolute import doesn’t care where the calling file is. It works the same whether the caller is in mypackage/api/routes.py or mypackage/api/v2/routes.py or mypackage/services/auth.py.

The relative import breaks if you move the calling file. A from ..models import User in mypackage/api/routes.py resolves to mypackage/models/User. Move that file to mypackage/api/v2/routes.py and now from ..models import User resolves to… mypackage/api/models/User. Different thing. Probably doesn’t exist. Your code just broke.

This is the fundamental problem with any large-scale module reorganization: every relative import is a promise about where the file currently lives, not where it should live.

The Two-Phase Discipline

The mistake that produces the chaos scenario: trying to move files and update imports simultaneously. You rename the directory, then start manually fixing the import errors that appear, then more files break because the ones you haven’t fixed yet are still importing from the old location, and somewhere in the middle there’s a state where half the codebase has been updated and the other half hasn’t and pytest is furious.

The discipline that avoids it: two phases, with the codebase fully runnable between them.

Phase 1: Make everything location-independent. Before moving a single file, audit every import in the files you’re planning to move. Every relative import gets rewritten to an absolute import — or, if the module is moving too, to the absolute path it will have at the destination. The files haven’t moved yet; the imports are now correct for where they’re going.

After Phase 1, the codebase looks weird. You have files with absolute imports pointing to their own future locations. But it runs. pytest is green. There’s no broken intermediate state because nothing has moved yet.

Phase 2: Move the files. Now the actual restructuring happens. Files go to their new locations. The imports are already correct — you wrote them in Phase 1 to point at the right destination. Nothing breaks.

This sounds like more work. It is more work, by a small amount. But it’s linear work with a clear correctness criterion: at every step, the test suite passes. You can commit between phases, hand off to a colleague, stop for lunch, or deploy Phase 1 to production before Phase 2 is ready. The invariant “the codebase is runnable” is preserved throughout.

The Audit Step

One more piece of the discipline: before Phase 1, audit what you’re touching.

For each file you plan to move, trace every import path:

  • What does this file import? Are those imports absolute or relative?
  • What imports this file? Will those imports break after the move?
  • Are there any implicit assumptions about the module’s location — __file__, __name__, importlib.resources paths, dynamic import strings?

The audit takes time. But it’s time spent building a complete picture of the dependency graph, rather than time spent reactively fixing things that broke because you didn’t know they existed.

For the 211-file refactor: the audit revealed 144 files with imports that needed rewriting, broken down by depth — 76 absolute imports that needed updating, 63 two-dot relative imports (from .. import), and 8 three-dot relative imports (from ... import). Each category breaks in a different way when you move files, and knowing the breakdown in advance meant knowing exactly what Phase 1 had to accomplish before Phase 2 could begin.

The Invariant That Makes It Work

The underlying principle isn’t specific to Python imports. It’s a general discipline for any large-scale mechanical transformation: every intermediate state must be valid.

When you’re making a change that touches hundreds of files, you will commit and push multiple times. Each commit goes through CI. Each commit may be deployed. Any commit where the system is partially updated and partially not is a commit that could cause an incident at an inconvenient moment.

The two-phase approach guarantees that no commit is partially-updated-partially-not. Phase 1 is complete when every import is location-independent — the transform is self-consistent even though nothing has moved. Phase 2 is complete when every file is in its final location — the transform is self-consistent again.

The space between phases is safe to stop. The space within a phase is not.

This is the same discipline as a database migration that keeps old and new columns in sync until the old one is removed. The same discipline as a feature flag that lets you deploy the code before enabling the behavior. The same discipline as the expand/contract pattern for API changes. The specific mechanism is different; the shape is the same: never be in a state where part of the system expects the new world and part of it expects the old.

In Python: move the imports before you move the files. Know where everything is going before anything leaves.