The Python Environment & Resolution Model
Every tool covered in this section - venv, uv, Poetry, PDM - is solving one underlying problem: deciding which files on disk a given import statement actually loads, and doing that consistently across machines.
Environments & Packaging Basics walks through the hands-on commands for creating an environment and installing a package.
This page stays one level up: the search-path mechanics that make isolation possible in the first place, and the resolution model that turns a pyproject.toml into a concrete, reproducible set of installed packages.
Understanding this model is what turns "why did installing one package break an unrelated import" from a mystery into an expected, diagnosable outcome. It is also the foundation that Dependency Resolution & Lockfiles, pyproject.toml Explained, and uv - The Fast Toolchain all build on.
Summary
- An
importstatement is resolved by walking an ordered list of directories (sys.path); a virtual environment works by changing what that list contains, not by sandboxing the interpreter itself. - Insight: Most "wrong package version got imported" and "works on my machine" bugs trace back to a gap between the interpreter's search path and what a developer assumes is installed - understanding the search order closes that gap.
- Key Concepts:
sys.path, site-packages, the import system, dependency resolution, the lockfile, PEP 621 metadata. - When to Use This Model: Debugging why the wrong version of a package got imported, choosing between
uv, Poetry, and PDM for a team, reasoning about editable installs, and understanding why CI needs a lockfile even whenpyproject.tomlhasn't changed. - Limitations/Trade-offs: Version ranges express intent, not a guarantee - a dependency can still publish a technically-compatible release that breaks your code, and no lockfile protects against a package that was compromised from the moment it was published.
- Related Topics: the module import system, packaging and publishing, private package indexes, CI reproducibility.
Foundations
A Python interpreter does not know, at startup, where your project's dependencies live.
Every time code runs import httpx, the interpreter walks an ordered list of directories called sys.path, checking each one in turn for a matching module or package, and stops at the first match it finds.
That list is built from a few sources: the directory containing the script being run, the PYTHONPATH environment variable if set, and a set of standard and third-party library locations - the most important of which, for this topic, is site-packages, the directory where pip and its peers actually copy installed packages.
A virtual environment is best understood as a small trick played on that search order rather than as a sandbox around the interpreter binary itself.
Running python -m venv .venv creates a new directory with its own site-packages folder and a copy (or symlink) of the interpreter; activating it adjusts sys.path and the shell's PATH so that python and pip inside that shell point at the project-local site-packages first.
Two projects can therefore each depend on a different version of the same package with zero conflict, because each has its own site-packages directory and the interpreter never needs to reconcile the two.
A useful analogy: sys.path is a librarian's search order across several branches of the same library system.
Ask for a book and the librarian checks the nearest branch first (the project's own site-packages), then wider branches (the standard library, any global installs), returning whichever copy is found first - not necessarily the one you expected if two branches happen to hold different editions.
import sys
print(sys.path[:3])
# ['', '/home/dev/myapp/.venv/lib/python3.14/site-packages', ...]Mechanics & Interactions
Installing a package with pip, uv, or any other installer is, underneath the command-line ceremony, a two-step act: fetch metadata and a distribution artifact (a wheel, usually) from a package index like PyPI, then unpack that wheel's files into the active environment's site-packages.
Nothing more mysterious happens - there is no separate registry of "what's installed" beyond the files that physically exist in that directory, which is exactly why deleting a .venv and recreating it is a completely safe way to start over.
Dependency resolution is the harder problem sitting on top of that mechanism: a project rarely declares only packages with zero sub-dependencies, so the tool must read every version range declared - both the project's direct dependencies and each dependency's own transitive dependencies - and find one consistent set of versions that satisfies all of them simultaneously.
pyproject.toml expresses that intent as ranges (httpx>=0.28,<1), while a lockfile (uv.lock, poetry.lock) records the exact versions the resolver actually chose the last time it ran, including transitive packages the developer never explicitly named.
That separation - ranges express what's acceptable, the lockfile records what was actually chosen - is the single most important mental model in this entire section, because it explains why uv sync --frozen in CI behaves completely differently from a bare uv sync: the former refuses to touch the lockfile and installs exactly what it says, the latter is willing to re-resolve if pyproject.toml changed.
An editable install (pip install -e . or uv pip install -e .) modifies this picture in one specific way: instead of copying the package's files into site-packages, it drops a small pointer (historically a .pth file, now more often a lightweight finder hook) that tells the import system to look directly at the source directory.
That is why editing a file in src/myapp/ takes effect on the next import without reinstalling - the resolved "installed" location and the developer's working copy are, deliberately, the same files.
Advanced Considerations & Applications
Three tools dominate this space today - uv, Poetry, and PDM - and it is worth being precise about what actually differs between them, because it is not the resolution problem itself.
All three read pyproject.toml metadata (largely standardized by PEP 621), build a dependency graph, and write a lockfile; where they diverge is resolver implementation speed, lockfile format, and the surrounding workflow (dependency groups, monorepo/workspace support, publish commands).
uv, written in Rust, resolves and installs an order of magnitude faster than pip-based tooling in most projects, which is the main reason it has become the default recommendation for new Python 3.14 projects.
Private package indexes complicate the resolution model in a way worth naming explicitly: when a resolver is pointed at an internal index (via UV_INDEX_URL or [[tool.uv.index]]), it still performs the same graph-resolution algorithm, just against different metadata - the risk is that a misconfigured index can silently serve a different (or malicious) package under a name that shadows a public one, which is why credential and index configuration deserve the same scrutiny as the dependencies themselves.
Reproducing an environment "from scratch" - the test every packaging setup should pass - is really a claim about the lockfile's completeness: if uv sync --frozen (or the Poetry/PDM equivalent) on a clean checkout produces a working install without touching the network for anything beyond fetching pinned artifacts, the lockfile is doing its job.
| Tool | Strength | Weakness | Best Fit |
|---|---|---|---|
venv + pip | Zero extra install, ships with Python | No built-in lockfile or fast resolver | Small scripts, teaching, minimal dependencies |
uv | Very fast resolve/install, single binary, drop-in pip replacement | Newer tool, smaller plugin ecosystem than pip | Default choice for new Python 3.14 projects |
| Poetry | Mature workflow, dependency groups, publish tooling | Historically slower resolver than uv | Teams already invested in Poetry's project conventions |
| PDM | PEP 582/621-forward, flexible backends | Smaller community than Poetry | Teams wanting standards-first tooling without vendor lock-in |
Common Misconceptions
- "A virtual environment isolates the interpreter itself." It isolates
site-packagesand adjustssys.path- the same interpreter binary (or a copy of it) still runs; nothing about the language runtime changes between environments. - "
pyproject.tomltells you exactly what's installed." It only declares acceptable ranges - the lockfile records what was actually resolved, and that is what a reproducible install trusts. - "A caret or compatible-release range can never break my code." It can, whenever a publisher's release doesn't actually follow semantic versioning correctly; the range expresses an expectation, not an enforced contract.
- "Editable installs are just a convenience with no real difference from a normal install." They change where the import system looks for the package entirely, pointing at the working source tree instead of a copied artifact, which is exactly why they matter for local development and monorepos.
- "Faster tooling like
uvmeans a fundamentally different resolution algorithm." The resolution problem - find one consistent version set across a dependency graph - is the same;uv's advantage is a much faster Rust-based resolver and installer, not a different model.
FAQs
What actually happens when I run `import package_name`?
The interpreter walks sys.path in order, checking each directory for a matching module or package, and stops at the first match - usually the active environment's site-packages.
What is the practical difference between a virtual environment and installing packages globally?
A virtual environment gives a project its own site-packages directory spliced into sys.path, so its dependencies never conflict with another project's - a global install shares one site-packages across every script on the machine.
Why does `pip install some-package` sometimes install a different version than I expect?
The resolver is satisfying a range, not a specific version - if the range allows a newer release and one was published since your last install, resolution can legitimately pick it unless a lockfile pins the exact version.
What's the point of a lockfile if `pyproject.toml` already lists my dependencies?
pyproject.toml states what's acceptable; the lockfile states what was actually chosen, including every transitive dependency you never named directly - that's the only way to guarantee two machines install identical code.
Why does `uv sync --frozen` fail sometimes when `uv sync` alone would succeed?
--frozen refuses to re-resolve or touch the lockfile at all, so if pyproject.toml has changed since the lockfile was last generated, it fails loudly instead of silently drifting - that strictness is exactly what makes it safe for CI.
Does an editable install copy my code into `site-packages`?
No - it installs a small pointer that redirects the import system to your working source directory, so edits take effect immediately without reinstalling.
Why is `uv` faster than pip for the same resolution problem?
It's a from-scratch Rust implementation with a much faster resolver and a global content cache, not a different resolution algorithm - the underlying graph-satisfaction problem it solves is the same one pip and Poetry solve.
Can two different Python projects on the same machine depend on incompatible versions of the same package?
Yes, without any conflict - because each project's virtual environment has its own site-packages, the interpreter never has to reconcile the two installations against each other.
Does a private package index change how resolution works?
No, the resolver still builds and satisfies the same dependency graph - it just reads package metadata from a different (or additional) index, which is why misconfigured private indexes are a real security concern, not just a networking inconvenience.
Is `PYTHONPATH` still relevant with modern tooling like `uv`?
It's rarely set manually in projects using uv, Poetry, or PDM, since the virtual environment mechanism already adjusts sys.path correctly - PYTHONPATH mostly matters now for specialized cases like ad-hoc scripts outside a project structure.
Why do teams standardize on one lockfile format instead of mixing tools?
Each tool's lockfile format and resolver behavior differ slightly, and committing more than one (say, both uv.lock and poetry.lock) invites drift between them - only one tool's resolution result should be treated as the source of truth per project.
Does version pinning in the lockfile protect against a malicious package update?
It protects against a package being silently swapped or altered after the fact, since the lockfile can record integrity hashes - it does nothing about a package that was already malicious or compromised at the version you locked to.
Related
- Environments & Packaging Basics - the hands-on commands this model explains
- uv - The Fast Toolchain - the fast resolver/installer built on this model
- Poetry & PDM - alternative workflows solving the same resolution problem
- pyproject.toml Explained - where the version ranges in this model are declared
- Dependency Resolution & Lockfiles - the resolution algorithm and pinning strategies in depth
- Editable Installs & Local Packages - how the import pointer mechanism works in practice
Stack versions: This page was written for Python 3.14.0, FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.