Researchers: ditch conda, embrace uv

On the merits of declarative design and good coding practices for research.

Originally published on Substack on October 5, 2025.

If you’re a graduate student like me who writes a lot of code and you haven’t read Patrick Mineault’s Good Research Code Handbook, I highly recommend it. It’s an approachable guide built on a simple premise: organizing your code saves you from frustration, makes your research more reproducible, and, most importantly, frees up your limited mental energy to focus on actual research problems. How you structure your code is how you think, and skipping a 30-minute project setup can cost you hours of refactoring later.

Over the years my own practices have deviated slightly from Patrick’s, but the core principles remain the same. I’m writing this post because I recently adopted a new tool that has improved my approach to Python environment management and made it much easier to adhere to the tenets of the handbook: uv.

This might come as a surprise to some. In the handbook, Patrick recommends conda, which is what just about every researcher I know uses. While conda was a step in the right direction for managing the complex dependencies common in data science, uv introduces a philosophy that perfectly aligns with the handbook’s goals of reducing friction and guaranteeing reproducibility.

The problem that good coding practices solve

Mineault’s handbook correctly identifies the “monolithic Python environment” as a primary source of pain. This is the single, undocumented environment where every package you’ve ever needed lives. It eventually becomes impossible to recreate, leading to hours of frustration when you move to a new machine or return to a project months later.

The handbook perfectly illustrates this with the classic XKCD comic on Python environments.

XKCD 1987: Python environment

We’ve all been here. Thankfully, we don’t have to be. Source: xkcd.com/1987.

The standard conda workflow described below, while an improvement, still has its pitfalls.

conda create -n my_env
conda activate my_env
conda install numpy pandas ...
# Work for a while, installing more packages as needed...
# Remember to run:
conda env export > environment.yml

The problem is that it’s easy to forget the last step, leading to a mismatch between your environment and your documentation. This workflow encourages documenting your environment after the fact, not before.

Furthermore, two bad habits are easy to fall into:

  • Reusing environments. There’s nothing tying a conda environment to a specific project folder. Often the fastest way to start a new project is to reuse an existing environment. (Raise your hand if your base environment has more packages than an Amazon warehouse — I know mine does.)
  • Accumulating cruft. While exploring, you might install a package, decide not to use it, and forget to uninstall it. Over time, your environment becomes bloated with unnecessary dependencies.

A better way: uv and declarative thinking

Enter uv, a fast, all-in-one project and environment manager written in Rust. The key shift uv introduces is moving from an imperative (“do this, then this”) to a declarative (“this is what I need”) workflow.

The traditional conda workflow is imperative. Think of it like cooking a familiar dish from memory — a dash of soy sauce here, a splash of mirin there, tasting and adjusting as you go. You might jot down notes afterward, but the process is fluid. This approach is great for building intuition, but if you wanted to recreate that dish exactly months later, it would be nearly impossible.

In contrast, the uv workflow is declarative. This is like following a precise recipe that lists every ingredient with exact measurements before you start. The recipe is the definition of the dish. The beauty of uv is its speed. It’s so fast that you can experiment with new “recipes” — adding or changing dependencies — almost instantly. You get the creative freedom of the imperative style with the rigor and safety of a declarative one.

With uv, you declare your project’s direct dependencies in one central file, pyproject.toml. This file acts as the blueprint for your environment, where you define the packages you know you’ll need. uv then does all the hard work to resolve the full dependency tree and records the exact versions of every package in a uv.lock file. The final environment is a direct consequence of the lock file, making it trivial to reproduce the environment anytime, on any machine.

This massively increases confidence. My Python environments used to feel like an unknowable tangle of packages that might break when I added a new one. With uv, I haven’t worried about unexpected package conflicts or struggled moving between computers for months. It just works, every time.

Getting started: your first uv project

Excited to start? Here’s a quick guide to creating a new project.

Step 1: Install uv

No need to explain this one. Check out the installation page.

Step 2: Create your pyproject.toml

This single file replaces the need for setup.py, environment.yml, and requirements.txt. Here’s a minimal example.

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "my-research-project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "torch>=2.7.0",
    "numpy",
    "matplotlib",
    "pandas",
    "ipykernel>=6.29.5",
]

[tool.setuptools]
packages = ["src"]

Key parts to notice:

  • Project metadata ([project]): the project’s name, version, and the Python version it requires.
  • Direct dependencies (dependencies): the list of packages your project needs. Notice the mix of pinned versions ("torch>=2.7.0") for critical packages and unpinned versions ("numpy") for flexibility.
  • Build system ([build-system], [tool.setuptools]): this standard configuration makes your helper code installable. The packages = ["src"] line tells Python to treat the src folder as a package, so you can import your own functions with import src.my_function, just as the handbook recommends.

Step 3: Create the virtual environment

Navigate to your project directory in your terminal and run:

uv venv

This creates a local virtual environment inside a .venv folder. Be sure to add .venv to your .gitignore.

Step 4: Sync your environment

Now, tell uv to make your environment match your pyproject.toml:

uv sync

That’s it. uv reads your pyproject.toml, resolves the complete dependency tree, creates a uv.lock file with the exact package versions, and installs everything into your .venv folder.

Want to update all your packages to their most recent versions?

uv lock --upgrade
uv sync

The first command updates the versions of all packages in the lock file, and the second propagates those changes to the Python virtual environment.

An aside on the magic of uv

When you run uv sync the first time or uv lock --upgrade, something pretty impressive is happening behind the scenes. Before uv can assemble the environment you requested, it needs to do something called dependency resolution. In this step, uv must find a compatible set of versions for all your project’s dependencies, their dependencies, and so on. This is a notoriously hard problem (NP-hard, in fact).

Sometimes a solution is impossible. For instance, if package A requires numpy<=1.26 but package B needs numpy>=2.0, no single version of numpy can satisfy both. While this wouldn’t be a problem for some other languages, Python can’t install two different versions of the same package in one environment. That means sometimes an environment cannot be built given a list of dependencies. If this is the case, uv will tell you immediately.

A more common challenge, however, is ambiguity. For example, what happens if your analysis library requires scipy>=1.10 but your plotting library needs scipy<1.14? There are several versions of scipy that could work. uv must find a single version that satisfies both constraints. Its resolver will analyze the possibilities and, by default, select the newest compatible version that works for both — perhaps 1.13.3.

Now, imagine scaling this puzzle to the hundreds or even thousands of dependencies (and dependencies of dependencies) in a modern machine learning project. This is where uv shines. It solves these dependency graphs orders of magnitude faster than conda or pip. Once it finds a solution, it records the exact version of every single package in the uv.lock file. The lock file is the key to perfect reproducibility and should be committed to version control alongside your code.

The declarative workflow with uv

Using uv, the iterative cycle of research coding becomes simple and robust.

  1. Need a new package? Add it to the dependencies list in pyproject.toml.
  2. Update your environment by running uv sync.
  3. Use uv run python path/to/my-script.py to execute a script within the context of your virtual environment.
  4. Repeat.

This workflow makes collaboration trivial. The instructions in your README.md to replicate your environment are incredibly simple:

git clone <your-repo-url>
cd <your-repo>
uv venv
uv sync
uv run python path/to/my-script.py

The environment is guaranteed to be identical every time. Pretty nifty.

Less time fighting tools, more time for science

If you’ve made it this far, I hope I’ve convinced you of some of the benefits of switching to uv. It’s faster, simpler, and promotes a declarative-first workflow that is inherently reproducible. I spend a lot of time thinking about the sources of friction in my research, and getting Python environment management to feel smooth and secure has been a big win for me. Reproducible results need reproducible environments, and uv makes this a core part of the process, not an afterthought.

Give uv a try for your next project. You can use this template GitHub repository to get started.

On a personal note, thanks for reading my first Substack post. I wanted to start with a practical topic like this because, as any grad student knows, our time and mental energy are precious. Finding tools and workflows that help us reduce friction and focus on actual research is something I’m passionate about. Time permitting, I hope to write more on other topics that interest me, from neuroscience to technology to public policy.

Appendix: practical tips I’ve found along the way

A few useful tips I’ve picked up while using uv.

Activating the environment. If you want to activate the environment to work in an interactive shell (like in conda), run source .venv/bin/activate on macOS/Linux or .venv\Scripts\activate on Windows.

Install an IPython kernel for Jupyter. To use your uv environment in Jupyter, first add ipykernel to your pyproject.toml and run uv sync. Then, run this from your project’s root:

uv run ipython kernel install --user \
  --env VIRTUAL_ENV $(pwd)/.venv \
  --name="my-project-name"

This tells IPython to create a new kernel named my-project-name and points it to the Python executable inside your project’s local .venv folder. Now, when you open Jupyter, you’ll see my-project-name as an available kernel. For more on using Jupyter with uv, see this article.

Updating a single package. To update a specific package to the latest version allowed by your pyproject.toml constraints:

uv pip install --upgrade <package-name>

This updates your uv.lock file automatically.

Adding a public GitHub repo as a dependency:

dependencies = [
    "sphinx @ git+https://github.com/sphinx-doc/sphinx#egg=sphinx",
]

Adding a private GitHub repo as a dependency (requires SSH keys set up with GitHub):

dependencies = [
    "my_pkg_name @ git+ssh://[email protected]/my-github-name/my_repo.git",
]

To update a GitHub dependency to the latest commit, run uv pip install --upgrade <package-name>.

Installing a local package in editable mode. For active development on a local dependency, use uv pip install -e /path/to/local/dependency. Note: this is an imperative step. Running uv sync will remove this package unless it’s also in your pyproject.toml. Use this for temporary development, but strive to keep pyproject.toml as the source of truth.

Note on pyproject.toml. It’s worth knowing that pyproject.toml isn’t a uv-specific file. It’s the modern, standardized way to configure Python projects, as defined in PEP 518. Tools like pip, setuptools, and hatch all use it. By using uv, you’re adopting best practices from the broader Python ecosystem. You can learn more in the official Python Packaging User Guide.