uv for Rails Developers: Bundler, rbenv and pipx in One Tool
uv for Rails Developers: Bundler, rbenv and pipx in One Tool
In the last piece we built a small Flask app and mapped it onto Rails ideas: routes, views, blueprints and the request cycle. Most Rubyists hit a problem before they write any Flask, though, and it isn't the framework. It's the tooling around it.
In Ruby the setup is settled. rbenv (or asdf, or chruby) picks the interpreter, Bundler resolves gems from a Gemfile and pins them in Gemfile.lock, and bundle exec runs commands against exactly those versions. Very few people argue about it anymore.
Python spent years without a settled answer. pyenv, virtualenv, venv, pip, pip-tools, pipenv, Poetry, PDM, pipx and conda each fixed part of the problem, and a typical tutorial uses three of them without saying why. If you've ever typed pip install and got an error about an "externally managed environment", you've met this mess.
uv is the tool that brings it together. It's written in Rust by Astral, the company behind the Ruff linter, which OpenAI announced in March 2026 that it was acquiring; OpenAI has said it will keep supporting uv as an open-source project. One fast binary handles interpreter management, virtual environments, dependency resolution, lockfiles, running commands and installing global CLI tools. For a Rails developer, the simplest way to think of it is rbenv, Bundler and gem install for command-line tools rolled into a single tool. It's also noticeably faster than any of them.
The Rosetta Stone
Here's the short version before the details:
| Ruby world | uv equivalent |
|---|---|
rbenv install 3.3.5 |
uv python install 3.12 |
.ruby-version |
.python-version (uv python pin 3.12) |
bundle init |
uv init |
Gemfile |
pyproject.toml |
Gemfile.lock |
uv.lock |
bundle add rack |
uv add flask |
bundle remove rack |
uv remove flask |
bundle install |
uv sync |
bundle exec rspec |
uv run pytest |
bundle update rack |
uv lock --upgrade-package flask |
group :development, :test |
uv add --dev pytest |
gem install rubocop (global CLI) |
uv tool install ruff |
gem exec rubocop / one-off run |
uvx ruff |
bundler/inline scripts |
uv add --script (PEP 723 inline metadata) |
gem build / gem push |
uv build / uv publish |
If you only remember one row, make it this one: uv run is bundle exec. Most of the rest follows from it.
Installing uv
uv ships as a standalone binary and doesn't depend on any Python installation:
curl -LsSf https://astral.sh/uv/install.sh | sh
# or
brew install uv
This matters more than it seems. Python's classic bootstrap problem was that you needed Python to install the tools that manage Python. Since uv doesn't need Python, a fresh laptop goes from nothing to a working project with one install.
Interpreters: Goodbye rbenv (and pyenv)
uv downloads and manages Python versions for you:
uv python install 3.12
uv python list
uv python pin 3.12 # writes .python-version
.python-version does the same job as .ruby-version: commit it, and uv uses that interpreter in that directory. Unlike rbenv, there are no shims and no shell hook to add to your .zshrc. uv reads the file whenever you run a uv command, and if the version is missing it downloads it automatically by default. You often won't need to run uv python install at all.
These are prebuilt standalone Python builds, so nothing compiles. If you remember waiting through rbenv install while it compiled Ruby (and sometimes OpenSSL) from source, this will feel very fast.
Projects: Gemfile Becomes pyproject.toml
Start a project the same way you'd start a gem or app:
uv init blog
cd blog
uv add flask
uv init creates a pyproject.toml, a .python-version, a README.md, a .gitignore and a starter main.py, and initialises a Git repository. uv add flask does three things at once:
- Adds
flaskto thedependencieslist inpyproject.toml. - Resolves the whole dependency graph and writes
uv.lock. - Creates
.venv/in the project and installs everything into it.
The resulting manifest is plain TOML:
[project]
name = "blog"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"flask>=3.1.0",
]
pyproject.toml is a Python packaging standard, not a uv-specific format, so other tools can read it. uv.lock is uv's own file and plays the role of Gemfile.lock. Commit it, never edit it by hand, and let uv sync reproduce it exactly. Like a Bundler lockfile with several platforms added, it's cross-platform: one lockfile resolves correctly on a teammate's Mac and on your Linux CI runner.
Where Did the Virtualenv Go?
This is the part that confuses Rubyists most. Bundler installs gems into a shared location and uses the lockfile to activate the right versions at runtime. Python instead gives each project its own isolated folder of installed packages, called a virtual environment.
uv puts that environment in .venv/ at the project root and manages it for you. You should never need source .venv/bin/activate, though it still works if you want it. Keep .venv out of Git (the .gitignore from uv init already excludes it) and treat it like node_modules: disposable and rebuildable at any time with uv sync.
Running Things: uv run Is bundle exec
In Rails you wouldn't run rspec without bundle exec (or a binstub). With uv the equivalent is:
uv run flask --app main run --debug
uv run pytest
uv run python -m scripts.seed
Before each command, uv run checks that the lockfile matches pyproject.toml and that .venv matches the lockfile, and syncs anything that has drifted. In practice you can git pull, see that a teammate added a package, and uv run will install it before starting your server. There's no separate "remember to bundle" step.
Dev and Test Groups
Bundler's group :development, :test do blocks map to dependency groups:
uv add --dev pytest ruff
uv add --group lint mypy
These land under [dependency-groups] in pyproject.toml. uv sync installs the dev group by default. In production, skip it with:
uv sync --locked --no-dev
--locked tells uv to install from the lockfile and fail if uv.lock is out of date with pyproject.toml, which is the same guarantee you get from bundle config set deployment true. (--frozen also skips re-resolving, but uses the lockfile without checking it against pyproject.toml.) Either is what you want in a Dockerfile.
Updating Dependencies
uv lock --upgrade-package flask # like `bundle update flask`
uv lock --upgrade # like `bundle update` (everything)
uv tree # like `bundle list` / dependency graph
uv tree --outdated # roughly `bundle outdated`
Because resolution is fast, upgrading the whole lockfile is usually quick, often a matter of seconds even on large projects. That changes how often people are willing to do it.
Global Tools: The pipx Replacement
Rubyists usually gem install CLIs like rubocop or foreman globally. In Python, installing CLIs into the system interpreter is how environments get broken, so the community used pipx to give each tool its own isolated environment. uv now handles this too:
uv tool install ruff
uv tool install httpie
uv tool list
uv tool upgrade --all
Each tool gets a private environment, and its executable goes on your PATH. To try something once without installing it, use uvx, which is shorthand for uv tool run:
uvx ruff check .
uvx cookiecutter gh:some/template
If you've used npx, it works the same way: the tool is downloaded into a cache and run, and your global environment stays untouched.
Single-File Scripts: bundler/inline, Python Edition
Ruby has require "bundler/inline" for scripts that declare their own gems. Python has a standard for this (PEP 723), and uv supports it:
uv init --script fetch_feed.py
uv add --script fetch_feed.py requests feedparser
uv run fetch_feed.py
uv writes a commented metadata block at the top of the file listing dependencies and Python version. uv run builds a temporary environment for it on the fly. This is very handy for the throwaway scripts that would otherwise end up in a Rake task.
Putting It Together: The Flask App, Properly
Here's the full setup for the app from the previous article, from a clean machine:
uv init blog && cd blog
uv python pin 3.12
uv add flask flask-sqlalchemy gunicorn
uv add --dev pytest ruff
uv run flask --app app run --debug
A teammate who clones the repo needs exactly one command:
uv run flask --app app run --debug
uv sees .python-version, installs Python 3.12 if needed, creates .venv, installs from uv.lock, and starts the server. That's the "clone, bin/setup, go" experience Rails developers expect, and it's finally available in Python.
A minimal production Dockerfile looks like this:
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev
COPY . .
ENV PATH="/app/.venv/bin:$PATH"
CMD ["gunicorn", "app:app", "-b", "0.0.0.0:8000"]
Running gunicorn straight from the virtualenv avoids uv run at container start, which would otherwise re-sync and pull the dev group back in. Add .venv to a .dockerignore so COPY . . doesn't overwrite the image's environment with your local one, and pin the uv image to a specific version rather than latest for reproducible builds.
When You'll Still See pip
Plenty of tutorials, READMEs and Stack Overflow answers still say pip install -r requirements.txt. uv has a compatible interface for those cases, uv pip install -r requirements.txt, and uv add -r requirements.txt imports an old requirements file into a modern project. You can also export a lockfile back to that format for tools that expect it with uv export --format requirements-txt.
The overall rule: in your own projects, use uv add, uv sync and uv run, and treat uv pip as the compatibility layer for older setups.
The Takeaway
Python's packaging reputation came from years of good tools that each covered only part of the job. uv covers the whole job, and it maps closely onto habits Rails developers already have: a manifest, a lockfile, an exec wrapper, a version file and isolated global tools. Learn those five ideas and Python's environment tooling stops being the hard part of switching languages. That leaves your attention for the actual differences, which, as the Flask piece showed, mostly come down to how much the framework decides for you.