Migrating codeceibe from pyenv + pip + venv to uv



codeceibe ran on the usual stack for a long time: pyenv to pin 3.13.x, a per-checkout venv, and a requirements.txt compiled by hand whenever a dependency changed. It worked, but every fresh clone cost ten minutes of "activate the venv, remember which pyenv shim is active, reinstall because someone forgot to pin a transitive dependency." We moved the whole thing to uv over a single afternoon.

Pinning the interpreter

uv python pin 3.13
cat .python-version
# 3.13

uv python pin writes a .python-version file and, if that exact 3.13 build isn't already on the machine, uv downloads a prebuilt standalone interpreter -- no pyenv install, no waiting on a from-source build the first time someone sets up the repo.

From requirements.txt to a lockfile

uv init --app --no-readme
uv add $(cat requirements.txt | grep -v '^#' | cut -d= -f1)
uv add --dev pytest pytest-django ruff
uv lock

This produces a pyproject.toml with real dependency constraints and a uv.lock that pins every transitive package, hash included. uv sync from then on reproduces the exact environment -- no more "works on my machine" from someone whose pip install happened to resolve a different minor version of django-filter.

How much faster it actually is

Bar chart comparing install time: pip+venv 41.8s, pip-tools 33.2s, uv pip install 4.1s, uv sync warm cache 0.6s

That's the same requirements.txt, same machine, Docker build cache cleared between runs so it's measuring the actual resolve-and-download work. uv sync against a warm cache and an existing lockfile is close to instant because uv hardlinks packages out of its global cache instead of re-downloading and re-extracting them per project.

The gotchas

  • Editable installs need a real pyproject.toml. Our internal ccpost bundle-format package was still a bare setup.py with no build backend declared. uv pip install -e . refused it outright until we added a minimal [build-system] block with hatchling.
  • Private index auth isn't picked up from pip.conf. uv reads UV_INDEX_URL / UV_EXTRA_INDEX_URL and, for credentials, either the URL itself (https://user:token@host/simple) or keyring if you set UV_KEYRING_PROVIDER=subprocess. The old ~/.pip/pip.conf with a netrc entry is silently ignored, which cost us one confused CI run before we moved the token into a repo secret and an env var.
  • The Docker image needs the uv binary, not a pip install of it. We copy it in directly:
    COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
    RUN --mount=type=cache,target=/root/.cache/uv     uv sync --frozen --no-dev
    That cache mount is most of the win in CI -- the layer above it changes every build, but the uv cache persists across builds on the same runner.
  • uv.lock is not requirements.txt. Anything outside the uv/Docker world that still expects a plain requirements file (a couple of old deploy scripts, mainly) needs uv export --no-hashes -o requirements.txt run explicitly; it doesn't happen automatically on uv lock.

Was it worth it

Yes, mostly for CI time and onboarding, not raw developer-machine speed -- on a warm machine the difference between pip install and uv sync is a few seconds either way. Where it paid for itself was Docker builds (down from roughly 90 seconds of dependency installation to under 10 with a warm cache mount) and new-contributor setup, which went from "install pyenv, install the right Python, create a venv, hope requirements.txt resolves" to just uv sync.

Leave a comment

Anonymous comments are published after review.