For years, my Python workflow relied on conda for environment and package management, pip for package management, black for auto-formatting, and isort for sorting imports. In places where I couldn’t use conda, such as Digital Research Alliance of Canada (FKA Compute Canada) clusters or own school’s own SLURM cluster, I would use virtualenv/venv to manage my environments and ocassionally, conda-pack when I absolutely needed to use the exact conda environment on a cluster.
Recently, I moved to entirely two new tools: uv and ruff. The move has been very easy and frictionless, so I wanted to write about it and recommend it to others who might be in the same boat as me.
ruff: An All-in-One Python Linter and Formatter
I used black for auto-formatting code for many years. It is opinionated by design, PEP-8 compliant, and has a cool little badge that I used to put on my READMEs. Most importantly though, it took off all the mental load out of deciding what the “right” way to format the code was.
I found that ruff is a great replacement. It offers the same opinionated formatting as black, but it also lints the code, thus replacing flake8 and isort. While ruff’s reported speed difference over black is massive, that raw speed isn’t entirely noticeable on small(er) codebases like mine. That said, I still appreciate that it is a single tool to do formatting, linting, and sorting imports.
What Does ruff Do?
To see why a formatter is essential, take a look at this code snippet. It has single/double quote inconsistencies, uneven spacing around =, line lengths exceeding PEP-8’s recommended 79 characters, uneven line breaks, etc.
# Unformatted, messy code
import sys
import os
import pandas as pd
from my_module import very_long_function_name, another_function, a_function_that_will_exceed_the_line_limit
import numpy as np
def do_something_mathy( x, y ):
result=np.array([x,y])*2
my_string = 'This is a string'
return result
When I run ruff’s formatter and import sorting on this, it instantly cleans up the code:
# Formatted with ruff
import os
import sys
import numpy as np
import pandas as pd
from my_module import (
very_long_function_name,
another_function,
a_function_that_will_exceed_the_line_limit,
)
def do_something_mathy(x, y):
result = np.array([x, y]) * 2
my_string = "This is a string"
return result
One of the first things you would notice is that it sorted the imports and fixed the line wrapping and spacing, among other things. Seemingly trivial things like these become really important if you are collaborating or submitting pull requests to an existing repository. Once you have these formatting standards set up, you don’t have to worry about formatting/fixing things manually.
You can also configure ruff to fix these issues automatically when you save the file in your IDE. Let’s talk about how to set it up in VS Cod* (VS Code or VSCodium or any of the VS Code forks).
VS Code Configuration
Assuming you already have ruff installed, we can automate auto-formatting + auto-import sorting on save in VS Code. Just add this to your VS Code’s settings.json:
{
"[python]": {
"editor.defaultFormatter": "astral-sh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
}
},
"ruff.format.args": ["--line-length=79"],
"ruff.lint.args": ["--select=I"]
}
The --select=I tells ruff to run its built-in isort functionality, which is equivalent to running isort.
uv: A Fast Python Package Manager
I had heard a lot of good things about uv for a while (October 30 2025 update: a new blog post praising uv is currently #1 on HN right now) but hadn’t gotten around to trying it till earlier this year. To me, it was appealing because:
- It replaces both
pipandvirtualenv/venv. But, if you, like me, don’t want to alter your workflow too much, you can simply use it withpiplike:uv pip install <package>. - It is orders of magnitude faster than
pip, which is noticeable if you are working with a lot of packages. Setting up a new environment with PyTorch + torchvision + cuDNN used to take so long. - It does not require
sudoto install. This may not be a huge deal for many, but for me, this allowed me to install it on my lab workstation where I don’t have sudo access.
Here is a very quick time difference demonstration with a simple pip upgrade:
$ time pip install --upgrade pip
Requirement already satisfied: pip in ./Installations/miniconda3/envs/monai/lib/python3.10/site-packages (25.2)
real 0m0.515s
user 0m0.445s
sys 0m0.044s
$ time uv pip install --upgrade pip
Using Python 3.10.19 environment at: /localhome/kabhishe/Installations/miniconda3/envs/monai
Resolved 1 package in 15ms
Audited 1 package in 0.16ms
real 0m0.063s
user 0m0.031s
sys 0m0.020s
The Pure uv Workflow
If you want to completely switch to uv and have uv manage an entire project from scratch, the workflow is quite neat and simple. First, initialize the project:
$ uv init
This will create a pyproject.toml file, an associated virual environment in a .venv directory, and pin your Python version. To install a package, instead of using pip install <package>, you can just “add” a package:
$ uv add pandas tqdm
This will download the packages to your .venv and automatically add them to your pyproject.toml file. To ensure that your environment is up to date and perfectly matches your pyproject.toml file, just run:
$ uv sync
This will generate a uv.lock file. Because this lockfile is both human-readable and cross-platform, it guarantees environment reproducibility wherever you share it.
You can also run a single script using this isolated environment without needing to explicitly “activate” it (like we need to with conda or virtualenv or venv). Just run:
$ uv run python myscript.py --myscript_args
What Does a pyproject.toml File Look Like?
Here is an example. Notice how we can configure ruff and import sorting directly in this file.
[project]
name = "my-project-name"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"numpy>=2.2.6",
"pandas>=2.3.3",
"torch>=2.6.0",
"tqdm>=4.68.2",
]
# Formatting: PEP-8 line length
[tool.ruff]
line-length = 79
# Flags "E" and "F" flag linting errors.
# Rule "I" tells Ruff's linter to sort imports.
[tool.ruff.lint]
select = ["E", "F", "I"]
# Point uv to the correct PyTorch CUDA wheels
[tool.uv.sources]
torch = { index = "pytorch-cu126" }
[[tool.uv.index]]
name = "pytorch-cu126"
url = "https://download.pytorch.org/whl/cu126"
explicit = true
Organizing uv Environments
By default, uv creates the .venv directory inside the project folder. However, if you, like me, prefer to have all your environments organized in a single, dedicated directory, you can override this behavior by setting the UV_PROJECT_ENV_DIR environment variable. Simply add this to your ~/.bashrc or ~/.zshrc:
export UV_PROJECT_ENVIRONMENT="/path/to/your/envs/project-one-env"
My Workflow: A Hybrid conda + uv Approach
Call it stubbornness or laziness, but I’m not ready to completely give up on years of using conda. So, in my day-to-day workflow, I use a hybrid approach: I use Miniconda to set up my base environment with conda, and then inside that environment (after activating it), I use uv to manage my project-specific environments with pip as:
$ conda activate <ENV_NAME>
$ uv pip install <package>
This way, I can rely on uv to speed up my PyPI package installation, while still being able to use conda install for any non-PyPI packages.