Skip to content

Imports, packaging & environments

Senior

import foo step by step, circular imports, regular vs namespace packages, and version pinning with lockfiles, pip-tools, poetry and uv.

Created 2 September 2026 · Updated 23 October 2026

4 concepts · detail hidden

  1. 01What `import foo` does step by step — `sys.path`, `sys.modules`, and why the second import is free

    sys.modules is a dictionary, maintained by the interpreter itself, mapping every module name that’s ever been successfully imported in this running process to the actual, already-initialized module object. On import foo, Python first checks sys.modules — if "foo" is already there, it’s simply returned immediately, with no re-execution of the module’s code at all, which is exactly why a second import foo anywhere else in the same process is nearly free (a dict lookup) rather than repeating the (potentially expensive) work of loading and running the module’s top-level code again. If it’s not yet present, Python searches sys.path (a list of directories/locations to search, including the current script’s directory, installed-package locations, etc.) using a chain of finders and loaders (the import system’s pluggable machinery for locating and actually reading a module’s source) to locate the module, creates a new (initially empty) module object, inserts it into sys.modules immediately (before the module’s code has even finished running), and then executes the module’s top-level code to populate it. That “inserted before fully executing” detail is precisely what makes circular imports partially work in some cases — a module being circularly re-imported mid-initialization finds a partially-populated entry already sitting in sys.modules rather than infinitely recursing, though whatever hasn’t executed yet in that partial module simply isn’t available yet, which is the actual source of many circular-import bugs.

    #c-what-import-foo-does-step-by-step-syspath-sysmodules-and-why
  2. 02Circular imports — why they partially work, and structural ways to resolve them

    A circular import occurs when module A imports module B, and module B (directly or transitively) imports module A back — given the sys.modules-insertion mechanics described above, this can partially “work” by accident (finding A’s partially-initialized module object already in sys.modules) or fail outright with an ImportError/AttributeError, depending on exactly what each module needs from the other and in what order. Beyond the commonly-cited “just move the import inside the function” band-aid (a legitimate, if last-resort, tool — deferring the import to call time, after both modules have fully finished their own top-level initialization), two more structural fixes: restructuring module boundaries — the circular dependency is often a genuine signal that two modules are too tightly coupled and share responsibilities that should be pulled out into a third, lower-level module that both can depend on without depending on each other; and using a TYPE_CHECKING guard (if typing.TYPE_CHECKING: import Foo) specifically for imports that are needed only for type annotations, not at runtime — since type checkers evaluate that block but the actual running program never executes it, this cleanly breaks a circular dependency that only exists because of type hints, without needing any runtime restructuring at all.

    #c-circular-imports-why-they-partially-work-and-structural-ways
  3. 03Regular packages vs namespace packages (PEP 420) — and when to omit `__init__.py`

    A regular package is a directory containing an __init__.py file — that file’s code runs when the package is imported, and its presence is what historically marked a directory as “a real Python package” at all. PEP 420 (Python 3.3+) introduced namespace packages: a directory without an __init__.py can still function as an importable package, specifically enabling a single logical package’s contents to be split across multiple separate directories/distributions (potentially installed independently) that all get merged together at import time into one combined namespace — useful for large plugin ecosystems or a package deliberately designed to be extended by independently-distributed third-party components. You’d deliberately omit __init__.py specifically when you want that namespace-package splitting behavior; for an ordinary, self-contained package you control entirely, an explicit __init__.py (even if empty) remains the simpler, more predictable, and far more common default, since namespace packages introduce real subtleties around import resolution order that most projects have no actual need for.

    #c-regular-packages-vs-namespace-packages-pep-420-and-when-to-o
  4. 04Loose version pins that break on a fresh install — `pip freeze` vs `pip-tools` vs lockfiles

    A loosely-pinned requirement like requests>=2.0 places no upper bound — a fresh pip install months or years later can pull in a much newer major version with breaking changes the code was never tested against, causing a “worked before, broke after a clean install” failure that’s genuinely confusing to diagnose since no code changed. The immediate fix: pin to the specific version actually known to work (requests==2.31.0, or a narrower compatible range), ideally generated from what’s actually currently installed and working rather than guessed. Other common triggers for tightening dependency management: two libraries each pinning incompatible versions of a shared transitive dependency, a “works on my machine” bug traced to an unpinned requirement, or a production incident caused by a dependency silently updating between deploys.

    Lockfiles and the tooling options

    The long-term fix is a lockfile — a file pinning the exact resolved version (often with hashes) of every dependency, direct and transitive, so an install is reproducible across machines and over time — kept separate from the short, human-edited list of what you directly want, with its loose ranges.

    Tool Direct deps in Lock Notes
    pip freeze > requirements.txt (none) the freeze output blunt snapshot; no distinction between direct and transitive deps, hard to update intentionally
    pip-tools requirements.in compiled requirements.txt pip-compile resolves and pins everything; pip-sync makes the venv match exactly
    poetry pyproject.toml poetry.lock bundles resolution, virtualenv management, and packaging/publishing in one opinionated workflow
    uv pyproject.toml uv.lock from Astral (makers of ruff), written in Rust, dramatically faster resolves/installs; also manages Python versions; rapidly gaining adoption
    # pip-tools: loose wants in, exact pins out
    echo "requests>=2.31,<3" > requirements.in
    pip-compile requirements.in          # -> requirements.txt with requests==2.32.3, urllib3==..., etc.
    pip-sync requirements.txt
    
    # uv: the same idea, integrated
    uv add "requests>=2.31,<3"           # updates pyproject.toml and uv.lock
    uv sync --locked                     # CI: fail if the lock is out of date, install exactly it
    

    Whichever tool you use, commit the lockfile for applications and install from it in CI and production; libraries, by contrast, should publish loose ranges in their metadata (a library pinning == exact versions forces conflicts on every consumer) and use a lockfile only for their own dev/test environment.

    #c-loose-version-pins-that-break-on-a-fresh-install-pip-freeze-