Imports, packaging & environments
Seniorimport 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
01What `import foo` does step by step — `sys.path`, `sys.modules`, and why the second import is free
#c-what-import-foo-does-step-by-step-syspath-sysmodules-and-whysys.modulesis 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. Onimport foo, Python first checkssys.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 secondimport fooanywhere 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 searchessys.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 intosys.modulesimmediately (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 insys.modulesrather 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.02Circular imports — why they partially work, and structural ways to resolve them
#c-circular-imports-why-they-partially-work-and-structural-waysA 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 insys.modules) or fail outright with anImportError/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 aTYPE_CHECKINGguard (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.03Regular packages vs namespace packages (PEP 420) — and when to omit `__init__.py`
#c-regular-packages-vs-namespace-packages-pep-420-and-when-to-oA regular package is a directory containing an
__init__.pyfile — 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__.pycan 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__.pyspecifically 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.04Loose version pins that break on a fresh install — `pip freeze` vs `pip-tools` vs lockfiles
#c-loose-version-pins-that-break-on-a-fresh-install-pip-freeze-A loosely-pinned requirement like
requests>=2.0places no upper bound — a freshpip installmonths 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-toolsrequirements.incompiled requirements.txtpip-compileresolves and pins everything;pip-syncmakes the venv match exactlypoetrypyproject.tomlpoetry.lockbundles resolution, virtualenv management, and packaging/publishing in one opinionated workflow uvpyproject.tomluv.lockfrom 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 itWhichever 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.