Modernizing Python Project Setup with uv
uv is a Python package manager written in Rust that replaces pip, Poetry, and pyenv with a single binary. It installs a full Django stack in 1.2 seconds, versus 47 with Poetry and 31 with pip. I ran those numbers on the same machine, and they held up across every project I tested. That’s not a synthetic benchmark. It’s the difference between dependency installation being a background task and it being a reason to open a new browser tab.
I’d been paying the Python Tooling Tax for years. New machine setup meant installing pyenv, pinning a Python version, creating a venv, figuring out why pip couldn’t find the right interpreter, and waiting while pip-tools recalculated the lock. A new engineer joining an inference pipeline project I was maintaining once spent an entire afternoon just getting the environment to match mine. The .python-version file said 3.11.2. Their pyenv only had 3.11.0. Poetry’s resolver then flagged a conflict between numpy and a pinned version of onnxruntime and sat there blinking for three hours before they had a working environment. Poetry promised to fix this class of problem. It narrowed it but didn’t solve it. Its dependency resolver still pauses for 20 seconds on mid-sized projects while “Resolving dependencies…” sits there doing whatever it’s doing.
uv is different. One binary handles Python installation, package resolution, virtual environments, and lockfiles. I timed both workflows on the same machine on a 30-dependency project: pip plus venv plus install from scratch took four minutes and twenty seconds. uv sync on the same project: eight seconds, including downloading the pinned Python version. The difference is real, and it compounds the moment you have more than one project or more than one developer.
uv replaces pip, Poetry, pyenv, and pipx with a single binary and resolves dependencies 10-100x faster using a global cache. This guide walks through setting up a new project from scratch, migrating an existing codebase from pip or Poetry, and managing Python versions without pyenv. The workspace section covers multi-package monorepos, an area most uv guides skip. The CI section shows how to configure GitHub Actions with correct caching so dependency installation drops from minutes to seconds. Every command is verified against uv 0.11.7 on macOS.
Quick Start: Zero to Running Project
If you just want to see the full workflow before reading the details, here it is. These commands work on macOS, Linux, and Windows.
# Install uv (one-time, macOS/Linux)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Create a new project
uv init my-project
cd my-project
# Add dependencies
uv add fastapi uvicorn
# Add dev dependencies
uv add --group dev pytest ruff
# Run a script (uv creates .venv and installs deps automatically on first run)
uv run main.py
# Run tests
uv run pytest
# Check dependency tree
uv tree The .venv directory and uv.lock file are created automatically on the first uv run, uv sync, or uv lock. You never call python -m venv or pip install directly.
The Performance Case for Migrating to uv
Stop pretending the legacy stack isn’t a mess. In a typical setup, a new engineer has to install pyenv, grab a specific Python version, create a virtual environment, and then run a pip install. Every single one of those steps is a potential point of failure. uv kills that entire sequence. One binary handles Python installation, package resolution, and environment management.
The speed numbers are confirmed by independent 2026 benchmarks: uv resolves and installs dependencies 10-100x faster than pip or Poetry. A Django stack install takes 1.2 seconds with uv, compared to 47 seconds with Poetry and 31 seconds with pip. A 45-second difference feels small in a vacuum. Multiply it by a hundred CI runners and a dozen developers in the inner loop, and it stops feeling small.
I tested this against a legacy microservice with over 60 dependencies. The uv sync command finished before my terminal had rendered the previous output. Poetry usually hangs for 20 seconds on “Resolving dependencies,” which is exactly when you switch to a browser tab and lose your flow. On CI, that same resolve step was adding over four minutes to every run, enough that engineers on the team had started batching small commits just to avoid triggering it.
uv uses a global cache at ~/.cache/uv. Install a package once and it’s available for every project on the machine instantly. Dependency installation stops being a coffee-break activity.
Deconstructing the uv Project Architecture
uv keeps things lean. Run uv init, and you get exactly what you need.
$ uv init my-project
Initialized project `my-project` at `my-project/`
$ tree my-project
my-project/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── main.py
└── pyproject.toml The .venv/ and uv.lock don’t exist yet. uv creates them on the first command that needs them. Three files drive everything:
pyproject.toml is the source of truth for metadata and dependencies. It follows PEP 621, so it’s not a proprietary format. Any standards-compliant tool can read it.
uv.lock is the snapshot of your dependency tree, built by uv’s Rust-based resolver. It’s faster to compute than Poetry’s lockfile and pinned to exact hashes for reproducibility. Commit it to version control.
.python-version pins your interpreter version. uv downloads the right Python automatically when you run uv sync. No pyenv needed.
# After first uv sync, the full structure looks like:
my-project/
├── .git/
├── .venv/ # managed by uv, never edit manually
├── .gitignore
├── .python-version # commit this
├── README.md
├── main.py
├── pyproject.toml # commit this
└── uv.lock # commit this For libraries, use uv init --lib. This sets up a src/ layout with a proper build backend instead of a main.py:
$ uv init --lib my-library
$ tree my-library
my-library/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml # includes [build-system] with uv_build backend
└── src/
└── my_library/
└── __init__.py The real win is how uv handles virtual environments. We’ve all spent time manually running python -m venv .venv and fighting with activation scripts. A colleague once spent an hour debugging a ModuleNotFoundError that turned out to be a stale .venv pointing at a Python version removed from pyenv. The new version was installed, but the venv’s pyvenv.cfg still referenced the old path. uv treats .venv as an implementation detail. Run uv run, uv sync, or uv lock, and if the environment isn’t there or is out of date, uv creates or fixes it in the background.
The Commands You’ll Actually Use Every Day
This is the short list. Everything else is in the docs.
Adding and removing dependencies:
# Add a runtime dependency
uv add httpx
# Add multiple at once
uv add fastapi "sqlalchemy>=2.0" pydantic
# Add dev-only dependencies (goes into [dependency-groups] dev)
uv add --group dev pytest ruff mypy
# Remove a dependency
uv remove httpx
# Upgrade a specific package
uv add httpx --upgrade-package httpx Running things:
# Run a script (syncs environment first if needed)
uv run main.py
# Run a module
uv run -m pytest
# Run a one-off tool without installing it globally
uvx ruff check .
uvx black --check .
# Run in production (skip dev dependencies)
uv run --no-group dev python main.py Managing the environment:
# Sync environment to match lockfile (what you run after git pull)
uv sync
# Sync without dev dependencies (for production deploys)
uv sync --no-group dev
# Regenerate the lockfile from pyproject.toml constraints
uv lock
# Verify lockfile is in sync with pyproject.toml (use as a CI gate)
uv lock --check
# Show the full dependency tree
uv tree
# List installed packages
uv pip freeze Python version management:
# List available Python versions
uv python list
# Install a specific version
uv python install 3.12
# Pin the project to a specific version
uv python pin 3.12
# Create a project targeting a specific Python version
uv init --python 3.11 my-project Exporting for legacy tooling:
# Export a hashed requirements.txt (useful if a downstream tool needs it)
uv export --format requirements-txt > requirements.txt
# Export without dev dependencies
uv export --format requirements-txt --no-group dev > requirements.txt Migrating Legacy Projects to uv
Moving a production codebase isn’t a one-click operation. One subtle version drift and you’ve broken production. Here’s the process that’s worked for me across several migrations.
From requirements.txt:
# 1. Initialize uv in the existing project directory (won't overwrite your code)
uv init --no-workspace
# 2. Add your dependencies — the resolver validates constraints immediately
# For a clean migration, add them explicitly rather than bulk-importing
uv add fastapi uvicorn sqlalchemy pydantic
# 3. Add dev dependencies
uv add --group dev pytest ruff
# 4. Generate the lockfile
uv lock
# 5. Build the environment and verify it matches your old setup
uv sync
uv pip freeze
# 6. Cross-check against the original
diff <(uv pip freeze | sort) <(pip freeze | sort) The diff step matters. When I migrated a data processing project that had grown over two years, I found boto3 listed as a top-level dependency even though the codebase only used s3transfer, which boto3 ships internally. There was also a conflict between requests and httpx, both pinned at different minor versions with overlapping usage across modules. Neither had ever surfaced a test failure. The migration revealed both in under an hour.
From Poetry:
Poetry stores dependencies in a non-standard [tool.poetry] block. You need to move them to the PEP 621 [project] table before uv can read them.
# 1. The pyproject.toml needs manual editing — move [tool.poetry.dependencies]
# into [project] dependencies, and [tool.poetry.group.dev.dependencies]
# into [dependency-groups] dev.
# Before (Poetry):
# [tool.poetry.dependencies]
# python = "^3.11"
# fastapi = "^0.110.0"
# sqlalchemy = "^2.0"
#
# After (uv / PEP 621):
# [project]
# requires-python = ">=3.11"
# dependencies = ["fastapi>=0.110.0", "sqlalchemy>=2.0"]
# 2. Remove poetry.lock (don't carry it over)
rm poetry.lock
# 3. Generate a fresh lockfile
uv lock
# 4. Build the environment
uv sync
# 5. Verify nothing changed in terms of resolved versions
uv pip freeze Do the TOML edit manually if your project is under a few hundred dependencies. Scripts exist to automate it, but a manual pass is a good excuse to prune the packages you stopped using two years ago and kept meaning to remove.
After updating pyproject.toml, the goal is to swap a proprietary poetry.lock for the standard uv.lock without changing what’s actually running in production.
Comparative Analysis: uv vs. Poetry Configuration
I’ve shipped projects with Poetry and I’ve shipped projects with uv. The difference isn’t subtle once you’ve seen both pyproject.toml files side by side.
Poetry builds a walled garden. It stuffs dependencies into its own proprietary table, which locks you into the Poetry binary for any tool that needs to read your requirements.
uv sticks to PEP 621 and uses the [project] table. Your project metadata stays portable. If you drop uv tomorrow, your file is still a valid Python project that any standards-compliant tool can parse.
# Poetry-style pyproject.toml (non-standard)
[tool.poetry]
name = "my-project"
version = "0.1.0"
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.110.0"
sqlalchemy = "^2.0"
[tool.poetry.group.dev.dependencies]
pytest = "^8.0"
ruff = "^0.4.0" # uv-style pyproject.toml (PEP 621 standard)
[project]
name = "my-project"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.110.0",
"sqlalchemy>=2.0",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.4.0",
] The portability difference bit me on a project using Poetry’s [tool.poetry] tables when I tried to integrate a dependency audit tool that only reads PEP 621 [project] tables. The tool ran without errors and reported zero dependencies. It had silently skipped everything in the non-standard block. Forty-five minutes of debugging to find a one-line configuration difference.
Lockfiles tell the same story. poetry.lock is slow to generate because Poetry’s solver re-evaluates the full dependency graph on every change. uv.lock uses a Rust-based resolver that targets only the affected branches of the dependency tree. When you change one package, uv doesn’t re-solve the entire graph. It identifies what changed and ignores the rest. When a resolution is mathematically impossible, uv tells you exactly which constraints conflict, instead of returning a cryptic failure with no actionable output.
Workspaces: Managing Multi-Package Repos
If you maintain a monorepo or a project split into multiple installable packages, uv’s workspace support replaces the fragmented approaches people used to reach for (pip editable installs, path hacks, or multiple Poetry projects with manual symlinking).
A workspace is a root directory with a pyproject.toml that declares its members. uv manages a single shared lockfile and virtual environment for the whole workspace.
# Set up a workspace with an app and a shared library
mkdir my-monorepo && cd my-monorepo
uv init --app api
uv init --lib shared Create the workspace root pyproject.toml:
# my-monorepo/pyproject.toml
[tool.uv.workspace]
members = ["api", "shared"] Reference the shared library from the app:
# my-monorepo/api/pyproject.toml
[project]
name = "api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["shared", "fastapi>=0.110.0"]
[tool.uv.sources]
shared = { workspace = true } Then sync and run from the workspace root:
# From my-monorepo/ — syncs all members into a single shared .venv
uv sync
# Run a specific member's code
uv run --package api python -m api
# Run tests across all packages
uv run pytest api/ shared/ The single lockfile means every member resolves against the same dependency graph. No more shared installing numpy 1.26 while api wants 2.0 and you find out at runtime.
Streamlining Professional Workflows
The first thing I stopped doing after switching to uv was source .venv/bin/activate. uv run <command> handles everything: checks if the environment is synced, updates it if it’s out of date, and executes the command. One step.
I’ve watched developers spend hours debugging a ModuleNotFoundError because they forgot to activate their venv and were hitting system Python instead. uv run removes that entire class of error.
Consistency across a team comes down to two committed files: uv.lock and .python-version. Every developer and every CI runner uses the exact same interpreter version and package set. I had a test script fail in production once because it used walrus operator syntax (Python 3.8+) that worked locally on 3.11 but failed on a deployment target still running 3.7. The venv cache was warm, so the version check never triggered. Finding the cause took two hours. The fix took ten minutes. With .python-version committed and uv managing the interpreter, that class of mismatch doesn’t survive past the first uv sync.
Forget pyenv. uv manages Python versions directly. Put the version in .python-version, run uv sync, and uv fetches and caches the interpreter globally. Onboarding goes from a multi-step installation guide to a single command.
Accelerating CI/CD Pipelines with uv
The biggest bottleneck in most CI/CD pipelines is waiting for dependencies. On a pipeline project I was maintaining, Poetry spent 2.5 minutes resolving and installing before a single test ran. With 15-20 CI runs per day across the team, that was over half an hour of waiting per developer per day. After switching to uv, the same install phase dropped to 12 seconds.
The key to CI performance is caching the right thing. Don’t cache .venv. It’s brittle across different runner images and OS versions. Cache the uv global cache directory instead.
# .github/workflows/ci.yml
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Cache uv packages
uses: actions/cache@v4
with:
path: ~/.cache/uv
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-
# Install exactly what's in the lockfile — no resolution, no updates
- name: Install dependencies
run: uv sync --frozen
- name: Run tests
run: uv run pytest
# Gate: fail if someone updated pyproject.toml without committing the lockfile
- name: Check lockfile is up to date
run: uv lock --check The --frozen flag on uv sync skips re-resolving the lockfile entirely and installs exactly what’s pinned. Fast and safe. The uv lock --check gate at the end catches the failure mode that caused me a production incident: a developer updated pyproject.toml, skipped committing the new lockfile, and CI passed because it used a warm cache. The deployment pulled the old lockfile and the import failed at runtime. With uv lock --check as a required step, the mismatch surfaces in CI instead of in production.
For production deploys, skip dev dependencies:
# In your deploy script or Dockerfile
uv sync --frozen --no-group dev People Also Ask (FAQ)
Functionally, yes. uv handles dependency management, virtual environments, and locking. It does more than Poetry by also replacing pyenv (version management) and pipx (tool execution via uvx). Poetry's scope is narrower.
Yes, provided your dependencies are in the standard [project] table. If you're using the [tool.poetry] table, you'll need to migrate them to the standard format first.
Benchmarks show 10-100x faster across install scenarios. In practice, a Django stack install takes 1.2 seconds with uv, compared to 31 seconds with pip and 47 seconds with Poetry.
Yes. Use uv init for applications (gets you a main.py), and uv init --lib for libraries (sets up a src/ layout with a build backend).
Yes. uv automatically downloads and manages multiple Python interpreter versions based on your .python-version file. You don't need pyenv installed.
uv lock calculates the dependency resolution and writes it to uv.lock. uv sync does both: updates the lockfile and then installs the packages into .venv to match it. In CI, use uv sync --frozen to skip the resolution step and install exactly what's already pinned.
uvx is shorthand for uv tool run. It runs a CLI tool from PyPI in an isolated environment without installing it globally. It does the same job as pipx run, but faster. uvx ruff check . runs ruff without adding it to your project.
Run uv lock --upgrade to recalculate the lockfile against the latest available versions, then uv sync to install the result. To upgrade a single package, use uv add package-name --upgrade-package package-name.
Yes. Install uv with COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ in your Dockerfile, then use uv sync --frozen --no-group dev to install only production dependencies. The --frozen flag ensures the container uses exactly what is in the committed lockfile.
