uv and Ruff: The Rust-Powered Python Toolchain in Production
Two years ago my Python projects needed pip, pip-tools, pyenv, virtualenv, Black, isort, and flake8 just to get from a clean checkout to a passing CI run. Today they need two binaries: uv and ruff. Both are written in Rust by Astral, both are stupidly fast, and both have quietly become the default choice for new Python projects.
This post is for anyone who writes Python day to day and wants a straight answer to “should I actually switch, and what does it look like once I have?” I will walk through setting up a real project with uv, linting and formatting it with ruff, wiring both into CI, and the one configuration mistake that causes almost every “it works locally but fails in CI” report I have seen.
Which Python Tools uv and Ruff Replace
uv is a single Rust binary that manages Python interpreters, virtual environments, dependencies, lockfiles, and command-line tools — standing in for pip, pip-tools, pyenv, virtualenv, and pipx. Ruff is a single Rust binary that lints and formats Python, standing in for flake8, Black, isort, and pyupgrade, implementing over 800 lint rules and running one to two orders of magnitude faster than the tools it replaces.
| Old tool | Replaced by | What the single binary now owns |
|---|---|---|
| pip, pip-tools | uv add, uv lock, uv sync |
Resolution and a cross-platform uv.lock |
| pyenv | uv python pin, uv python install |
Interpreter download and per-project pinning |
| virtualenv, venv | uv venv (implicit in uv run) |
Environment creation |
| pipx | uv tool install, uvx |
Isolated CLI tools |
| flake8 | ruff check |
Lint rules, including the plugin families |
| Black | ruff format |
Formatting |
| isort | ruff check rule family I |
Import sorting |
| pyupgrade | ruff check rule family UP |
Syntax modernisation |
Both come from the same company, Astral. On 19 March 2026, OpenAI announced an agreement to acquire Astral, with the team joining the Codex group and closing subject to the usual regulatory conditions. OpenAI’s announcement states its intention to keep supporting Astral’s open source products — uv, ruff, and the newer ty type checker — after the deal closes. OpenAI’s own figures put uv at more than 126 million downloads in the month before the announcement, which tells you why a model company bought a package manager. It was not for fun.
Worth being honest about what that guarantee is: a stated intention, not a licence change. uv and ruff are MIT-licensed and the history is public, so the downside scenario is a fork, not a lockout. I would still keep the version pinned in uv.lock rather than tracking latest blindly, but that was already true before March.
Project Setup: Initialising and Locking Dependencies with uv
Starting a new project looks like this:
uv init --name demo-app
uv add requests
uv add resolves the dependency, writes it into pyproject.toml, and updates (or creates) uv.lock — a cross-platform lockfile that pins the exact resolved versions, not just the ranges you asked for:
[project]
name = "demo-app"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"requests>=2.34.2",
]
uv.lock is the part that matters for production. It records every package, version, hash, and source, so the same install happens on your laptop, in CI, and on the server:
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/.../certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742..." },
]
You never hand-edit uv.lock. You run uv add, uv remove, or uv lock --upgrade, and let the tool keep it consistent. To install from a lockfile exactly as recorded — which is what you want in CI and in Docker builds — use:
uv sync --locked
--locked fails the build instead of silently re-resolving if pyproject.toml and uv.lock have drifted apart. That flag alone has saved me from a “works on my machine” argument more than once.
Linting and Formatting Python with Ruff: check, format and fix safety
Ruff needs no separate config to start being useful. Point it at a file with a few obvious problems:
import os,sys
import requests
def main( ):
x=1
unused_var = "oops"
l = [1,2,3]
print( "hello" )
ruff check --output-format=concise .
Running that against Ruff 0.16.8 gives:
demo.py:1:1: I001 [*] Import block is un-sorted or un-formatted
demo.py:1:8: F401 [*] `os` imported but unused
demo.py:1:11: F401 [*] `sys` imported but unused
demo.py:2:8: F401 [*] `requests` imported but unused
demo.py:5:5: F841 Local variable `x` is assigned to but never used
demo.py:6:5: F841 Local variable `unused_var` is assigned to but never used
demo.py:7:5: F841 Local variable `l` is assigned to but never used
Found 7 errors.
[*] 4 fixable with the `--fix` option (3 hidden fixes can be enabled with the `--unsafe-fixes` option).
Two things in that last line repay attention. The [*] marker means Ruff has a fix it considers safe — one that preserves behaviour and does not delete a comment. The three hidden fixes are the F841 unused-variable ones, classified as unsafe because removing l = [1, 2, 3] discards an expression that might have had side effects. Ruff will not apply those unless you ask with --unsafe-fixes, which is exactly the right default for a tool you let loose on a whole repository. The Ruff fix-safety documentation explains the classification.
I have left --output-format=concise in that command deliberately. Ruff’s default output since 0.14 is the full renderer, which prints a source excerpt and a suggested diff per diagnostic — excellent when you are reading it, noisy when you are scanning a CI log.
ruff format handles the whitespace and style side separately, and shows you a diff before touching anything:
ruff format --diff .
--- demo.py
+++ demo.py
@@ -1,8 +1,9 @@
-import os,sys
+import os, sys
import requests
-def main( ):
- x=1
+
+def main():
+ x = 1
unused_var = "oops"
- l = [1,2,3]
- print( "hello" )
+ l = [1, 2, 3]
+ print("hello")
Notice that ruff format left import os, sys on one line while ruff check flagged I001 and wanted to split it. That is not a contradiction: the formatter deliberately does not reorganise imports, because import sorting is a lint rule (I001, the isort family) rather than a formatting decision. Run both, or run ruff check --fix before ruff format.
Once you are past the defaults, configuration lives in pyproject.toml alongside everything else:
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.ruff.format]
quote-style = "double"
target-version is worth getting right early — it tells Ruff which syntax modernisation rules apply (the UP rules), and it should agree with the requires-python value in the same file and with whatever Python versions your CI matrix actually tests. Mismatch those two, and Ruff will happily suggest syntax your oldest supported Python cannot run.
Running uv and Ruff in GitHub Actions CI
A minimal GitHub Actions job, using the official setup-uv action with caching turned on:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- run: uv sync --locked --all-extras --dev
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run pytest
enable-cache: true hashes uv.lock and reuses the downloaded package cache between runs when it has not changed, which is most of the time. Put the lint and format checks before the test suite — they run in seconds and give you the fastest possible “no” on a broken pull request.
The gotcha, and the one I keep seeing in bug reports: if you also run Ruff through a pre-commit hook, .pre-commit-config.yaml pins its own Ruff version, entirely separate from the one uv.lock resolves. Let those two drift, and you get a developer whose pre-commit hook passes locally while CI fails on a rule that only exists in the newer version — which reads exactly like a flaky test, and wastes an afternoon before anyone thinks to check the version numbers. Pin both to the same release and bump them together.
Mental Model: uv as Dependency Transport, Ruff as Code Inspection
Think of uv as the removal van and ruff as the house inspector. The removal van does not care what is inside the boxes — it just moves everything (interpreters, packages, virtual environments) reliably and fast, and writes down exactly what it moved in uv.lock so it can do the same move again identically next time. The inspector walks through afterwards and tells you which rooms are untidy. Different jobs, same house, and neither one needs to know how the other works.
Migration Checklist: Moving a Python Project to uv and Ruff
- Replace
pip install/pip-tools/pyenvwithuv add,uv sync, anduv python pin— one binary, one lockfile. - Commit
uv.lock. Useuv sync --lockedin CI and Docker builds so drift fails loudly instead of silently re-resolving. - Start Ruff with the defaults, then add
select/target-versioninpyproject.tomlonce you know which rule families you actually want. - Run
ruff checkandruff format --checkbefore your test suite in CI — they are nearly free and catch the easy stuff first. - If you use both
uv.lockand a pre-commit Ruff hook, pin them to the same version and bump both together.
Final Thoughts: Is the uv and Ruff Migration Worth It?
None of this is exotic any more. uv and ruff are close to the default choice for new Python projects, and OpenAI buying Astral in March 2026 — while committing to keep both open source — only confirms how load-bearing they have become. If you are still juggling pip, pyenv, virtualenv, Black, and flake8 separately, the migration is smaller than it looks: one lockfile, one linter, and a CI job that runs in seconds instead of minutes.
If you are pairing this toolchain with an AI coding agent day to day, I wrote about my own Codex CLI workflow in Codex CLI Part 3: Practical Workflows for Blogging and Python Development, which covers a lot of the same “fast feedback loop” thinking.
References
- uv documentation — Astral
- Using uv in GitHub Actions — Astral
- Ruff documentation — Astral
- The Ruff Formatter — Astral
- Ruff rules reference — Astral
- Ruff fix safety: safe versus unsafe fixes — Astral
- uv: locking and syncing environments — Astral
- OpenAI to acquire Astral — OpenAI, 19 March 2026
- Thoughts on OpenAI acquiring Astral and uv/ruff/ty — Simon Willison
- astral-sh/setup-uv — GitHub
Did you like this post? Please let me know if you have any comments or suggestions.
Python posts that might be interesting for youStay Ahead in AI, Machine Learning & Python
No hype. Weekly notes on AI tools, Python, and what I'm actually building — plus six free gifts, including the 15-page Fantastic AI: The 2026 Toolkit and a Git Commands & Contribution Workflow Cheatsheet.
You're in
Check your inbox for Set a password to unlock articles if you want gated tutorials. Log in with the same email.