Python virtual environments with venv and uv
If you’ve come to Python from R, you might wonder why these templates make such a fuss about virtual environments and a tool called uv. R users typically install packages globally with install.packages() and call it done. Python is messier — global installs cause real problems in research projects, and the standard tools (venv, pip, pipx, pyenv, conda, poetry, …) all do overlapping pieces of the same job. This chapter explains what a virtual environment is, why a research project needs one, and why the templates standardize on uv instead of any of the older tools.
On this page
What’s a virtual environment?
A Python virtual environment is an isolated folder containing its own Python interpreter and its own copy of any packages you install. Two projects with different virtual environments can use completely different package versions without interfering with each other. The same project on two different machines can recreate the same environment from a lockfile, so coauthors and replicators see identical behavior.
The R analog is renv — same job for R projects, captures the exact set of installed package versions per project.
The contrast that makes virtual environments necessary: by default pip install pandas installs into the system-wide Python, where every project on the machine sees it. Two projects that need different pandas versions can’t coexist without one environment per project.
Why a research project needs one
Three concrete failure modes that an environment per project prevents:
- A new project breaks an old one. Project A used
pandas==1.5for two years. You start project B andpip install pandasupgrades to 3.0 globally. Running project A’s pipeline now produces subtly different numbers because the pandas API changed. With per-project environments, A still sees 1.5 while B sees 3.0. - A reviewer can’t reproduce your results. You ran your analysis with
numpy==1.24.3. A reviewer installs numpy fresh in 2027 and getsnumpy==2.5. Their replication of your numbers fails for reasons neither of you understands. A committed lockfile makes the exact versions explicit and reproducible. - Cross-machine drift between coauthors. You and your coauthor both “have pandas installed” but actually have different versions, and the same script produces different output. Per-project environments populated from the lockfile make this impossible.
The Python tool landscape (and why uv)
For most of Python’s history, virtual environments and packages have required juggling several separate tools:
| Tool | What it does | Replaces |
|---|---|---|
venv (stdlib) |
Creates a virtual environment | — |
pip |
Installs packages into the active environment | — |
pip-tools |
Adds lockfile support on top of pip (pip-compile → pinned requirements.txt) |
— |
pyenv |
Manages multiple Python interpreter versions on one machine | — |
pipx |
Installs Python command-line tools globally without polluting the system environment | — |
poetry |
Combines environments, dependency resolution, and locking with its own pyproject.toml |
venv + pip + pip-tools |
conda / mamba |
Anaconda’s environment + package manager, with its own non-PyPI package set | venv + pip (but separate ecosystem) |
uv |
All of the above | venv + pip + pip-tools + pyenv + pipx |
The templates standardize on uv because it replaces nearly all of the older tools with one fast binary:
- Creates and manages virtual environments (replaces
venv/virtualenv). - Installs packages and resolves dependencies (replaces
pip/pip-tools). - Manages Python interpreter versions (replaces
pyenv). - Installs CLI tools (replaces
pipx). - Reads and writes the standard
pyproject.tomlplus auv.locklockfile (no proprietary format). - ~10–100× faster than the older tools — Rust-based, parallel installs.
Astral (the company behind uv and the makers of the Ruff linter) released uv in early 2024 and it’s quickly become the modern standard. If you’re starting a Python project now, uv is the cleanest pick.
Installing uv
One-line install per the official instructions:
- macOS / Linux:
curl -LsSf https://astral.sh/uv/install.sh | sh - Windows (PowerShell):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
After install, uv --version should print a version number. uv lives in your user directory; no admin rights needed.
You don’t need to install Python separately — uv will download and manage Python interpreters for you on demand. uv run against a pyproject.toml that requires Python 3.12 will fetch a 3.12 interpreter automatically if you don’t already have one.
The pyproject.toml + uv.lock model
A uv-managed Python project lives around two files at the repo root:
pyproject.toml— the project’s declared dependencies, Python version constraints, and package metadata. You edit this directly or viauv add. Committed to git.uv.lock— the exact resolved package versions (including transitive dependencies) for everything inpyproject.toml. Generated byuv. Committed to git so coauthors and replicators get the same versions byte-for-byte.
A minimal pyproject.toml for a research project:
[project]
name = "your-project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"polars>=1.0",
"duckdb>=1.0",
"pyfixest",
"great-tables",
"keyring",
"python-dotenv",
]
The companion uv.lock is generated automatically the first time you run uv sync or uv run. Don’t edit it by hand — let uv regenerate it.
Commands you’ll actually use
Five commands cover ~95% of day-to-day work:
uv sync— install everything inpyproject.tomlinto the project’s venv (creating the venv if it doesn’t exist), respectinguv.lock. Run this once after cloning the repo and any timepyproject.tomlchanges.uv run <command>— run a command inside the project’s venv.uv run python script.py,uv run src/001-download-data.py,uv run pytest.uvauto-syncs first if needed, so this is usually all you have to type.uv add <package>— add a package topyproject.toml, install it, and updateuv.lockin one step. e.g.uv add polars.uv remove <package>— opposite ofadd.uv lock --upgrade— re-resolve everything to current versions and update the lockfile. Use sparingly; review the lockfile diff before committing.
You generally don’t need to “activate” the environment the way you would with source venv/bin/activate. uv run handles activation transparently for that one command.
How the templates use uv
The polyglot project-template ships with a pyproject.toml and uv.lock at the repo root. The first time you run a Python pipeline script — e.g. uv run src/001-download-data.py — the template’s project_setup() helper auto-runs, walks you through configuring .env, storing WRDS credentials in keyring, installing any missing dependencies, and verifying everything is wired up.
After that, every Python pipeline step is invoked through uv run:
uv run src/001-download-data.py
uv run src/002-transform-data.py
# ... etc
Or via the orchestrator:
uv run src/run-all.py
The batch_run() helper in utils.py internally invokes Python through a subprocess that respects the uv-managed venv, so per-script logs work the same way as R’s R CMD BATCH flow does. See JAR data policy for what those logs are for.
IDE integration
VS Code-family editors and PyCharm need to be told which Python interpreter to use for a project. Point them at the venv uv created (typically .venv/bin/python on macOS / Linux or .venv/Scripts/python.exe on Windows):
- VS Code / Cursor / Positron: click the Python interpreter indicator in the bottom-right status bar → “Enter interpreter path” → pick
.venv/bin/python(or its Windows equivalent). The settingpython.defaultInterpreterPathin.vscode/settings.jsonmakes this stick across collaborators when committed. - PyCharm: Settings → Project → Python Interpreter → Add Local Interpreter → Existing → point at
.venv/bin/python.
Once the interpreter is set, the editor’s IntelliSense / autocomplete, debugger, and integrated terminal all use the project’s venv automatically.
See Setting up your IDE for the broader editor setup story this fits into.
Common gotchas
- Running plain
pythoninstead ofuv run python. Plainpythonuses your system interpreter (or whatever venv happens to be activated in that shell), not the project’s venv. Symptoms: missing packages, wrong package versions, scripts that work for you but fail for a coauthor. Always prefix research-project commands withuv run. - Forgetting to commit
uv.lock. Without it, a coauthor doinguv syncgets the latest versions of everything matching yourpyproject.tomlconstraints — which may differ from yours. The lockfile is the reproducibility artifact; commit it. uvinstalled but not onPATH. After install, you may need to restart your shell (or run the printedexport PATH=...line) for theuvcommand to be found.- Editor still using the wrong interpreter. If your editor’s IntelliSense or test runner is hitting the global Python instead of the project venv, the interpreter setting is wrong. Reset it via the bottom-right status bar (VS Code-family) or PyCharm’s interpreter settings, then restart the editor.
- Mixing
uvandpipin the same project. Don’tpip installinto auv-managed venv — the lockfile andpyproject.tomlwon’t know about it, and the install vanishes the next time someone runsuv sync. Always useuv addfor new dependencies; if you really needpip’s interface, useuv pip installwhich routes throughuv. .venv/accidentally committed. Gitignore it. The lockfile +pyproject.tomlare all someone needs to recreate the venv from scratch; the venv itself is large, machine-specific, and regenerable.
See also
uvdocumentation — the canonical reference.- Astral’s announcement post for uv — the original motivation and tour of features.
- PEP 621 — the Python standard that defines
[project]inpyproject.toml. - Setting up your IDE — broader editor setup, including the Python interpreter selection step referenced above.
- Project structure for research — where the venv and
pyproject.tomlsit in the broader repo layout. - Environment variables and the
.envfile — the.envmechanism the templates’ Python code reads alongside the venv.