# Python Packaging: pip, venv, Poetry, uv

> Taming Python environments: virtual environments, pip and requirements, the modern pyproject.toml with Poetry, and uv's blazing resolver - without dependency hell.


---

# Python Packaging: pip, venv, Poetry, uv

You ran `pip install` once, it worked, and then six months later a different project needed a different version of the same library and everything caught fire. Or a teammate cloned your repo, ran your code, and got an error you've never seen. That's not bad luck - it's the predictable result of treating one shared Python install as if it belonged to every project at once. This guide gives you the model and the tools to make "works on my machine" mean "works on every machine."

## How to read this

Read it in order the first time. Phase 1 is the mental model - *why* isolated environments exist, and why the global install is the trap. Don't skip it even if you've used `pip` for years; the model is the part that makes everything else stop being magic. Phase 2 is the daily driver: pip, requirements files, and the move to `pyproject.toml` with Poetry. Phase 3 is uv, lockfiles done right, pinning strategy, and the gotchas that bite in production.

If you're brand new to Python itself, start with [/guides/python-from-zero](/guides/python-from-zero) and come back here when you're ready to share code with other people or machines.

## The phases

1. [Why Environments Exist (and the Global-Install Trap)](01-why-environments-exist.md) - the mental model: isolation, the `site-packages` problem, what a virtual environment really is.
2. [The Daily Driver: pip, requirements, and Poetry](02-pip-requirements-poetry.md) - installing, freezing, and the jump to `pyproject.toml` with dependency groups and a lockfile.
3. [uv, Lockfiles, and Surviving Production](03-uv-lockfiles-production.md) - uv's fast resolver, pinning vs ranges, reproducible installs, and the gotchas.


---

# Why Environments Exist (and the Global-Install Trap)

Here's the moment this guide is really about. You've got Project A that needs an old version of a library - call it `requests 2.20` - because some other piece of A depends on its exact behavior. Project B, written last week, needs `requests 2.31`. Both projects run on the same laptop, with the same Python. You install one, and you break the other. There is no version of `pip install` that fixes this by itself, because the problem isn't pip - it's that you only have one place to put libraries.

That one place has a name, and once you understand it, everything else clicks.

## The one shelf problem

When you install Python and run `pip install requests`, the library doesn't go "into Python" in some abstract way. It gets copied into a real folder on disk called `site-packages`. Every package you install lands on that same shelf. There's one shelf per Python installation, and by default every project shares it.

```console
$ python -c "import site; print(site.getsitepackages())"
['/usr/lib/python3.11/site-packages']
```

*What just happened:* Python told you the actual directory where installed packages live. That's the shelf. A library is "installed" when its files sit in that folder; it's "available" to your code because Python searches that folder on `import`.

Now the trap is obvious. A folder can only hold one version of a file. If Project A and Project B both want `requests` but different versions, the shelf can't satisfy both - whoever installed last wins, and the other project silently runs against the wrong version. Pin enough projects to one shelf and you get *dependency hell*: a tangle where upgrading anything for one project risks breaking another, and you're afraid to touch `pip install` at all.

> The global shelf isn't only inconvenient - on many systems it's dangerous. Your operating system uses its system Python for its own tools. Running `sudo pip install` to force something onto that shelf can overwrite a library the OS depends on and break parts of your machine. The rule that prevents this is short: never install project libraries into the system Python.

## A virtual environment is a second shelf

The fix is not cleverer version management. It's *more shelves* - one per project. That's all a virtual environment is: a private copy of the Python machinery with its own `site-packages` folder, belonging to a single project.

```console
$ python -m venv .venv          # create a fresh environment in ./.venv
$ source .venv/bin/activate     # macOS / Linux
$ .venv\Scripts\activate        # Windows (PowerShell)
(.venv) $ which python
/home/you/projectA/.venv/bin/python
```

*What just happened:* `python -m venv .venv` built a brand-new, empty environment in a folder named `.venv`. Activating it rewired your shell so that `python` and `pip` now point *inside* that folder. The `(.venv)` prefix on your prompt is the visible proof: any `pip install` from here lands on Project A's private shelf, untouched by Project B.

The `venv` module ships with Python itself - nothing to install. The folder it creates is disposable: delete `.venv` and you've deleted the environment with zero consequences, because nothing of value lives there except copies of libraries you can reinstall. That disposability is a feature, not a footnote - it's why you should *never* commit `.venv` to git and why the cure for a corrupted environment is "delete it and rebuild," not "debug it."

```text
projectA/
├── .venv/            ← private shelf, git-ignored, disposable
│   └── lib/python3.11/site-packages/   ← requests 2.20 lives here
├── src/
└── pyproject.toml    ← the recipe to rebuild .venv (you DO commit this)
```

*What just happened:* the layout shows the split that makes everything reproducible. The environment is throwaway. The *recipe* for it - which we'll build in the next phase - is what you keep and share.

## Activate is convenience, not magic

A lot of confusion melts away once you see what "activate" actually does: it puts the environment's `bin` folder first on your shell's `PATH`. You don't strictly need it. Running the environment's Python directly works identically:

```console
$ .venv/bin/python -m pip install requests   # no activation needed
$ .venv/bin/python script.py
```

*What just happened:* you called the environment's own Python by its full path, so its `pip` installed into its own shelf and its interpreter ran your script - all without ever typing `activate`. This is exactly how editors, CI pipelines, and tools like uv operate under the hood. Activation is a human convenience for an interactive shell; the real mechanism is just "which Python binary am I running."

## For builders

Every modern tool in this guide - Poetry, uv, even plain pip workflows - is built on this one idea: each project gets its own isolated shelf, and the recipe to rebuild that shelf lives in version control. The tools differ in how *nicely* they create the environment and how *precisely* they record the recipe. They do not differ on the fundamental model. Get the model and the tools become interchangeable details.

```quiz
[
  {
    "q": "What is a virtual environment, concretely?",
    "choices": [
      "A cloud server that runs your Python code remotely",
      "A private copy of Python's machinery with its own site-packages folder for one project",
      "A setting that tells pip to download faster",
      "A separate operating system for each project"
    ],
    "answer": 1,
    "explain": "It's a per-project shelf: an isolated site-packages plus interpreter, so projects don't fight over library versions."
  },
  {
    "q": "Why can't two projects with conflicting library versions safely share the global Python install?",
    "choices": [
      "pip refuses to run without a virtual environment",
      "The global install is read-only by design",
      "There's one shared site-packages folder, which can hold only one version of a package at a time",
      "Python caches the first version permanently in memory"
    ],
    "answer": 2,
    "explain": "One shelf, one version. Whoever installs last wins, silently breaking the other project."
  },
  {
    "q": "What does activating a virtual environment actually do?",
    "choices": [
      "Compiles your dependencies into a binary",
      "Puts the environment's bin folder first on PATH so 'python' and 'pip' point inside it",
      "Uploads your packages to a registry",
      "Permanently modifies the system Python"
    ],
    "answer": 1,
    "explain": "Activation is a PATH convenience. Calling .venv/bin/python directly does the same thing without it."
  }
]
```


---

# The Daily Driver: pip, requirements, and Poetry

You've got the model: each project gets its own shelf, and you keep the *recipe* in git. This phase is about writing that recipe well. We'll start where almost everyone starts - `pip` and a `requirements.txt` - see exactly where that recipe falls short, and then move to `pyproject.toml` with Poetry, which fixes the gaps without changing the model underneath.

## pip and requirements.txt: the plain baseline

Inside an activated environment, `pip install` puts a package on the shelf. To make that repeatable, you write down what you installed. The classic way is a plain text file:

```text
# requirements.txt
requests
flask
```

```console
(.venv) $ pip install -r requirements.txt
```

*What just happened:* pip read the file line by line and installed each named package - and, quietly, the latest compatible version of each, *plus* every library those packages themselves depend on. That last part matters: `flask` pulls in a half-dozen other packages you never named.

Now the gap. Your file says `flask` with no version. Install it today and you get one version; install it next year and you get a newer one that might behave differently. The recipe isn't reproducible - it's a wish. The common patch is `pip freeze`:

```console
(.venv) $ pip freeze > requirements.txt
```

```text
# requirements.txt after freeze
certifi==2024.2.2
charset-normalizer==3.3.2
click==8.1.7
flask==3.0.2
requests==2.31.0
...
```

*What just happened:* `pip freeze` dumped *every* package on the shelf with its exact version - including all the indirect dependencies you never asked for. Now the install is reproducible. But you've created a new problem: this flat list can't tell you which packages you actually chose (`flask`, `requests`) and which are just along for the ride. When you want to remove `flask`, you can't know which of those other lines are safe to delete. There's no record of *intent*, only of *result*.

That's the core limitation of `requirements.txt`: it's a single flat list that conflates "what I want" with "what that dragged in," and on its own it doesn't separate your runtime needs from your test-only tools either.

> `requirements.txt` is not dead - it's a fine, dependency-light format for simple scripts, container builds, and deployment targets that expect it. The trouble starts when a project grows and you need to reason about *why* each package is there.

## pyproject.toml: declaring intent

The modern Python recipe is a file called `pyproject.toml`. It's a standardized config file (defined across several PEPs) that declares what your project *is* and what it *directly* depends on - your intent, separate from the resolved result.

```toml
# pyproject.toml
[project]
name = "billing-service"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
    "requests>=2.31",
    "flask>=3.0",
]
```

*What just happened:* you declared the two libraries you actually chose, with version *floors* (`>=`), and which Python versions the project supports. Nothing about `certifi` or `click` - those are consequences, recorded elsewhere, not intent. This is the recipe a human reads to understand the project.

But `pyproject.toml` declares intent; it doesn't, by itself, *pin* the exact resolved versions for reproducibility, and standard tooling won't manage the environment for you. That's the job a project manager fills. Poetry is the long-established one.

## Poetry: intent plus a lockfile, in one tool

Poetry reads and writes `pyproject.toml`, creates and manages the virtual environment for you, resolves the full dependency tree, and - the key part - records the exact resolution in a `poetry.lock` file.

```console
$ poetry new billing-service        # scaffold a project, or `poetry init` in an existing one
$ cd billing-service
$ poetry add requests flask         # adds to pyproject.toml AND installs AND updates the lock
$ poetry add --group dev pytest     # a dev-only dependency, kept out of production installs
```

*What just happened:* `poetry add` did three things at once - wrote the dependency into `pyproject.toml`, installed it into a managed virtual environment, and updated `poetry.lock` with the precise versions of everything in the resolved tree. The `--group dev` flag put `pytest` in a separate bucket, so your production install can skip test tooling entirely. That dependency-group split is exactly the "which packages are along for the ride / which are test-only" problem that flat `requirements.txt` couldn't express.

The lockfile is the payoff. `pyproject.toml` says "Flask 3.x or newer"; `poetry.lock` says "this exact build of Flask 3.0.2, and these exact 14 other packages, with these hashes." Two files, two jobs:

```text
pyproject.toml   →  human intent, version RANGES, hand-edited, committed
poetry.lock      →  machine resolution, exact PINS + hashes, tool-generated, committed
```

*What just happened:* the split solves the `requirements.txt` confusion cleanly. You read and edit the ranges; the tool owns the pins. Commit both. To reproduce the exact environment anywhere, one command reads the lock:

```console
$ poetry install        # builds the environment from poetry.lock - identical every time
$ poetry run pytest     # run a command inside the managed environment, no manual activate
```

*What just happened:* `poetry install` rebuilt the shelf to match the lockfile exactly - same versions on your machine, your teammate's, and CI. `poetry run` executed a command inside that environment without you having to activate it. This is the literal cure for "works on my machine": everyone resolves to the same locked versions instead of each grabbing whatever is newest that day.

## In the wild

A healthy repo commits `pyproject.toml` and the lockfile, and git-ignores `.venv`. A new contributor clones, runs one install command, and gets a byte-for-byte reproduction of everyone else's dependencies. The flow is: edit ranges in `pyproject.toml` (or via `poetry add`), let the tool re-resolve and re-lock, commit both files. Nobody hand-edits the lockfile, and nobody commits the environment.

```quiz
[
  {
    "q": "What's the main weakness of a flat requirements.txt produced by `pip freeze`?",
    "choices": [
      "It can't pin exact versions",
      "It mixes packages you chose with their indirect dependencies, losing the record of intent",
      "It only works on Linux",
      "It installs packages globally"
    ],
    "answer": 1,
    "explain": "Freeze records the result, not the intent - you can't tell which lines you wanted vs. which were dragged in."
  },
  {
    "q": "What's the division of labor between pyproject.toml and poetry.lock?",
    "choices": [
      "pyproject.toml is for production, poetry.lock is for development",
      "pyproject.toml holds human intent and version ranges; poetry.lock holds the exact resolved pins",
      "They're duplicates kept in sync for backup",
      "poetry.lock replaces pyproject.toml once you ship"
    ],
    "answer": 1,
    "explain": "You edit ranges in pyproject.toml; the tool generates exact pins + hashes in the lockfile. Commit both."
  },
  {
    "q": "What does `poetry add --group dev pytest` accomplish?",
    "choices": [
      "Installs pytest globally for all projects",
      "Adds pytest as a dev-only dependency so production installs can skip it",
      "Removes pytest from the lockfile",
      "Pins every dependency to its latest version"
    ],
    "answer": 1,
    "explain": "Dependency groups separate test/dev tooling from runtime deps - something flat requirements.txt can't express."
  }
]
```


---

# uv, Lockfiles, and Surviving Production

By now the model is solid and Poetry gives you intent plus a lockfile. So why is everyone talking about uv? Two reasons: speed, and the fact that it's a single tool that covers the whole job - environments, installing, locking, and even installing Python itself. This phase shows where uv fits, how to think about pinning vs ranges when it counts, and the gotchas that turn a green CI run into a 2am page.

## uv: the fast resolver that speaks pip and pyproject

uv is a packaging tool written in Rust. The headline is speed - resolving and installing dependencies is dramatically faster than the older Python-based tools, fast enough that you stop waiting and start trusting it for everything. But the reason it caught on so quickly is that it meets you where you already are. It has a `pip`-compatible interface, so you can adopt it without rewriting your habits:

```console
$ uv venv                              # create a virtual environment (like python -m venv)
$ uv pip install -r requirements.txt   # drop-in replacement for `pip install`
$ uv pip compile requirements.in -o requirements.txt   # resolve loose deps into pinned ones
```

*What just happened:* `uv venv` made a virtual environment, and `uv pip install` behaved exactly like pip - same arguments, same `requirements.txt` - only faster. The `uv pip compile` command took a loose input file of what you *want* and produced a fully pinned `requirements.txt` of what you'll *get*, the same intent-vs-result split you saw with lockfiles, in the requirements-file world.

That `pip`-compatible mode is the gentle on-ramp. The fuller mode is the project workflow, which mirrors Poetry's: it owns `pyproject.toml`, generates a `uv.lock`, and manages everything for you.

```console
$ uv init billing-service        # scaffold with a pyproject.toml
$ cd billing-service
$ uv add requests flask          # add deps, install, and update uv.lock in one step
$ uv add --dev pytest            # dev-only dependency group
$ uv sync                        # build the environment to exactly match uv.lock
$ uv run pytest                  # run a command inside the managed environment
```

*What just happened:* every command maps to a Poetry equivalent you already know - `uv add` is `poetry add`, `uv sync` is `poetry install`, `uv run` is `poetry run`. The model didn't change at all: per-project shelf, intent in `pyproject.toml`, exact pins in a lockfile, environment rebuilt from the lock. uv is a faster, more all-in-one implementation of the same ideas. As a bonus, uv can also download and manage Python interpreter versions for you, so the project's `requires-python` becomes something the tool can actually satisfy rather than something you set up by hand.

> Which tool should you reach for? If a repo already uses Poetry and it's working, there's no prize for switching. For a new project, or one drowning in slow installs, uv is the strong default in 2026 - same model, much less waiting. Both are fine; the model is what matters, and it's identical across them.

## Pinning vs ranges: where each belongs

This is the decision that quietly determines whether you sleep through the night. The rule is shaped by *who reads the file*.

- **Version ranges (`>=2.31`, `~=3.0`) belong in `pyproject.toml`.** This file states your true constraints: the minimum versions your code needs, the Python versions you support. Ranges here let the resolver find a compatible set and let you accept upgrades deliberately.
- **Exact pins (`==2.31.0`, plus hashes) belong in the lockfile.** This is what actually gets installed. Exact pins are what make an install *reproducible* - the same bytes today and in six months.

```text
pyproject.toml:  requests>=2.31      ← constraint: "at least this, newer is OK if I re-resolve"
uv.lock:         requests==2.31.0    ← reality: "this exact version, every install, everywhere"
```

*What just happened:* the two files answer two different questions. The range says what's *allowed*; the pin says what's *frozen*. The mistake to avoid is pinning exact versions directly in `pyproject.toml` - that makes future upgrades a manual chore for every package and defeats the resolver. Let ranges live in intent, pins live in the lock.

When you *do* want newer versions, you re-resolve on purpose:

```console
$ uv lock --upgrade            # re-resolve within your ranges, write new pins to the lock
$ uv lock --upgrade-package requests   # upgrade only one package, leave the rest pinned
```

*What just happened:* upgrading is now a deliberate, reviewable event that changes the lockfile in a commit - not something that happens by accident because someone installed on a Tuesday. You see the version bumps in the diff, run your tests, and decide.

## The gotchas that actually bite

**Committing the wrong things.** Commit `pyproject.toml` and the lockfile (`uv.lock` or `poetry.lock`). Never commit the environment folder (`.venv`). Add it to `.gitignore` on day one. Committing `.venv` bloats the repo and, because it contains platform-specific compiled files, breaks on other machines.

**The lockfile drifting from the manifest.** If someone hand-edits `pyproject.toml` and forgets to re-lock, your lock and your intent disagree. In CI, install in a mode that *fails* on drift rather than silently re-resolving:

```console
$ uv sync --frozen        # error out if uv.lock is missing or out of date - do not re-resolve
$ uv sync --locked        # assert the lock is up to date with pyproject.toml, then install
```

*What just happened:* in CI you want the build to *break loudly* if the lockfile doesn't match the manifest, because a silent re-resolution means production could get versions nobody reviewed. The frozen/locked modes turn drift into a failed check instead of a surprise in production.

**Forgetting that the lock is platform-aware.** A good lockfile resolves dependencies across the platforms you target (Linux, macOS, Windows, different Python versions), so the same lock works in CI and on every developer's machine. If you generated a lock that's somehow tied to one platform, an install on another can fail or pull different versions - which is exactly the "works on my machine" failure you adopted lockfiles to kill.

**Assuming "installed" means "imported correctly."** A package can install fine yet fail at runtime because of a conflict the resolver didn't catch, or because two of your dependencies need incompatible versions of a third. A modern resolver (uv, Poetry) tries to find one consistent set and *errors* if it can't - that error is a gift. Don't suppress it by mixing pip and the project tool in the same environment; pick one tool per project so a single resolver owns the whole shelf.

## In the wild

The production-grade setup is boring on purpose: `pyproject.toml` with ranges, a committed cross-platform lockfile, `.venv` git-ignored, and CI that runs `uv sync --frozen` (or the Poetry equivalent) so any drift fails the build before it reaches users. Upgrades happen through an explicit `uv lock --upgrade` commit that someone reviews. Get those four things right and "works on my machine" stops being a phrase anyone says.

```quiz
[
  {
    "q": "Where should exact version pins (e.g. requests==2.31.0) live?",
    "choices": [
      "In pyproject.toml, replacing the ranges",
      "In the lockfile (uv.lock / poetry.lock), generated by the tool",
      "In a separate pins.txt you hand-maintain",
      "Nowhere - pinning is an anti-pattern"
    ],
    "answer": 1,
    "explain": "Ranges express intent in pyproject.toml; exact pins for reproducibility belong in the tool-generated lockfile."
  },
  {
    "q": "Why run `uv sync --frozen` (or `--locked`) in CI?",
    "choices": [
      "It installs packages faster by skipping resolution",
      "It fails the build if the lockfile is missing or out of date instead of silently re-resolving",
      "It upgrades all dependencies to the newest versions",
      "It deletes the lockfile after installing"
    ],
    "answer": 1,
    "explain": "You want drift between manifest and lock to break loudly in CI, not slip unreviewed versions into production."
  },
  {
    "q": "What's the relationship between uv's project commands and Poetry's?",
    "choices": [
      "They use completely different models and can't be compared",
      "uv add/sync/run map to poetry add/install/run - same model, faster implementation",
      "uv only works with requirements.txt, never pyproject.toml",
      "Poetry is for libraries, uv is only for scripts"
    ],
    "answer": 1,
    "explain": "Same underlying model - per-project env, intent in pyproject, pins in a lockfile. uv is a faster, more all-in-one take."
  }
]
```
