Using uv for Fast Python Installs in a DevContainer

uv installs Python dependencies dramatically faster than pip while staying reproducible via a lockfile. This page wires uv into a devcontainer with a cached store and a committed lock, so Python environments build in a fraction of the time.

The reason this matters in a devcontainer specifically is that dependency installation is not a one-time cost. Every time you rebuild the container — after editing the Dockerfile, after a Feature bump, after a teammate reclones the repo, or every time CI spins up a fresh workspace — the postCreateCommand runs the install again from scratch. With pip that means re-resolving the dependency graph and re-downloading wheels on each rebuild, and those minutes accumulate across a team into real friction. uv collapses that cost by resolving in parallel, writing to a content-addressed store it can reuse, and reading from a committed uv.lock so resolution is deterministic rather than recomputed. The net effect is that a rebuild that once took over a minute becomes a few seconds once the cache is warm.

Reach for this setup when your Python devcontainer's install step has become the slow part of the inner loop, or when you want the exact same reproducibility contract Poetry gives you — a pinned interpreter plus a committed lock — without paying Poetry's resolver time on every rebuild. The mental model is simple: uv is the installer, uv.lock is the source of truth for what gets installed, and the cache volume is what turns a cold download into a warm copy. Everything below exists to keep those three pieces aligned so that installs stay both fast and identical between machines.

Prerequisites

You need a Python devcontainer and a uv-managed project.

  • A Python Feature pinned to a version.
  • uv installed in the image.
  • A committed uv.lock (or requirements) and a cache volume.

These prerequisites line up in a chain, and each one guards the next. The Python Feature must be pinned to an explicit version rather than left floating, because uv resolves and locks against whatever interpreter is on the path; if that interpreter changes between rebuilds, the wheels uv selects can change with it and your "reproducible" install quietly drifts. Installing uv in the image (not fetching it in postCreateCommand) keeps the tool itself out of the per-rebuild critical path and available before any sync runs. The committed uv.lock is what makes the install deterministic — without it, uv sync --frozen has nothing to freeze against and will refuse to proceed.

The detail people most often get wrong is the cache volume target. The mount has to point at the exact directory uv uses for its store — under the vscode user that is /home/vscode/.cache/uv — and the running user has to own that path. If you mount the volume but the container runs installs as root while the editor runs as vscode, or the volume was first created under a different UID, uv writes into a directory the effective user cannot read on the next rebuild and silently falls back to re-downloading everything. The cache then looks configured but never actually warms.

uv prerequisitesYou need uv, a lockfile-driven sync, a cache volume, and a check.uv installedin the imageuv syncfrom the lockCacheuv store volumeVerifyfast + reproducible

Step-by-Step Implementation

  1. Install uv in the image.
RUN curl -LsSf https://astral.sh/uv/install.sh | sh

Running the installer at build time bakes uv into the image layer, so it is present the moment the container starts and never has to be fetched again during postCreateCommand. The -LsSf flags matter here: -L follows the redirect Astral serves, -s keeps the download quiet so build logs stay readable, -S still surfaces genuine errors even in silent mode, and -f makes curl exit non-zero on an HTTP failure instead of piping an error page into sh. Without -f, a transient outage would hand the shell a stray HTML body and the build could appear to succeed while installing nothing. Baking uv into the image rather than a Feature or a postCreate step also means the tool version is captured in the image itself, so a rebuilt image installs with the same uv you tested against.

  1. Sync from the lockfile in postCreate.
{ "postCreateCommand": "uv sync --frozen", "remoteUser": "vscode" }

uv sync --frozen is the load-bearing command of the whole setup. Plain uv sync is allowed to update uv.lock when it thinks the resolution has moved — which is exactly what you do not want happening silently on a colleague's rebuild. The --frozen flag forbids that: uv installs strictly what the committed lock records and errors out if the lock and pyproject.toml have diverged, turning a would-be drift into a loud, fixable failure. Setting remoteUser to vscode in the same block is what keeps the install, the cache, and the editor all running as one non-root user, so the files uv writes are owned by the account that later reads them. Running this in postCreateCommand rather than at image-build time is deliberate: it lets the sync read the mounted cache volume and the live project source, neither of which exists yet during the image build.

  1. Cache uv's store on a volume.
{ "mounts": ["source=devcontainer-uv-cache,target=/home/vscode/.cache/uv,type=volume"] }

This mount is what converts uv's cold-download time into warm-copy time. The source=devcontainer-uv-cache is a named Docker volume that survives container rebuilds, so the wheels and unpacked distributions uv stored on the last build are still there on the next one. The target=/home/vscode/.cache/uv path must match uv's actual cache location for the vscode user — pointing it at any other directory leaves the real store on the ephemeral container layer, where it is discarded every rebuild. Because uv's store is content-addressed, a warm cache means an unchanged dependency is a hard-link or copy rather than a network fetch, which is precisely why the "warm cache" bar in the timing chart collapses to a few seconds. Use a named volume rather than a bind mount so the cache is not entangled with a host path that may differ across machines or CI runners.

  1. Verify the environment resolves fast and unchanged.
uv sync --frozen && python -c "import sys; print(sys.executable)"

The verification does double duty. Re-running uv sync --frozen on an already-synced environment should complete almost instantly and, crucially, without touching uv.lock — if it errors, the lock and pyproject.toml are out of agreement and you have caught the drift before it reaches CI. The chained python -c "import sys; print(sys.executable)" then confirms the interpreter you actually get resolves to uv's managed venv rather than the system Python; if the printed path is /usr/bin/python instead of the project's .venv, the editor and shell are pointed at the wrong environment and imports will not match what uv installed. Running both as a single && chain means the check only reports success when the sync stayed frozen and the interpreter routed correctly, so a green result is a genuine end-to-end signal rather than two hopeful half-checks.

Install time: pip vs uvuv installs far faster than pip, and a warm cache makes it near-instant.pip install (cold)71suv sync (cold)20suv sync (warm cache)5sillustrative install time

Common Pitfalls

uv issues are a non-frozen sync or an uncached store.

The most common failure is an ownership mismatch on the cache volume. When the devcontainer-uv-cache volume is first populated by a process running as one user and later read by another — the classic case being a build step that runs as root and a postCreateCommand that runs as vscode — uv cannot read its own store and treats every dependency as a fresh download. The install still "works," so nothing errors, but the warm-cache speedup never materialises and rebuilds stay slow. The fix is to keep every stage on the same non-root user: set remoteUser to vscode, make sure /home/vscode/.cache/uv is owned by vscode, and if a volume was created under the wrong UID, remove it once so it is recreated with correct ownership on the next up.

The second recurring pitfall is treating uv sync and uv sync --frozen as interchangeable. A plain sync that quietly rewrites uv.lock produces a working environment locally, so the developer who ran it never notices — but the lock now differs from what is committed, and the next teammate to rebuild either resolves a different dependency set or hits a confusing --frozen failure in CI. Standardise on --frozen everywhere installs are automated, and reserve the unfrozen uv sync or uv lock for the deliberate, reviewed act of updating dependencies, committing the regenerated lock as its own change. The table below maps each visible symptom back to the specific misconfiguration behind it.

uv triageA triage path from slow or drifting installs to fast, frozen uv syncs.Does uv sync use --frozen?NOAdd --frozen for reproducibilityIs the uv cache on a volume?YESInterpreter routed to the venvFast, reproducible Python

SymptomRoot CauseRemediation
Install changes the lockfileNot using --frozenSync with uv sync --frozen
Store re-downloads each builduv cache not on a volumeMount ~/.cache/uv on a volume
Editor uses wrong interpretervenv not routedPoint defaultInterpreterPath at the uv venv
uv not foundNot installed in the imageInstall uv in the Dockerfile

Conclusion

uv brings pip-compatible, lockfile-reproducible Python installs at a fraction of the time. Sync with --frozen from a committed lock, cache uv's store on a volume, and route the editor at the resulting venv — fast and deterministic, the same contract as Poetry but quicker.

The strategic payoff is that speed and reproducibility stop being a trade-off. The usual instinct is to cache aggressively to go fast and then worry that caching has made the environment non-deterministic; uv sidesteps that tension because its store is content-addressed and its lock is authoritative. A warm cache changes only how quickly a dependency arrives, never which dependency you get — that is fixed entirely by uv.lock. So you can pursue the fastest possible rebuild without loosening the guarantee that every machine, and every CI run, installs byte-identical wheels.

This is the same pin-and-cache pattern that runs through the rest of a well-built Python devcontainer, just applied to the installer. Pin the interpreter with a versioned Feature, pin the dependency set with a committed lock, and cache the expensive artefacts — here uv's wheel store — on a named volume so the cost is paid once and amortised across every rebuild. Route the editor's interpreter path at the venv uv produces and the whole environment resolves consistently for the language server, the terminal, and CI alike. Adopt it and the install step stops being the slow, flaky part of the inner loop and becomes a few reproducible seconds you rarely think about again.

Pin and cache uvPin the interpreter and lock; cache uv's store for fast rebuilds.PinPython versionuv.lockInterpreter pathCacheuv storeIn-project venvWheels

FAQ

Is uv reproducible like Poetry? Yes — uv resolves from a committed lockfile, and uv sync --frozen fails rather than changing it, so the dependency set is fixed. You get the same reproducibility contract as Poetry (pinned runtime + committed lock), with substantially faster installs. The important nuance is that reproducibility comes from uv.lock being committed and --frozen being enforced, not from uv itself — a workflow that runs plain uv sync and lets the lock drift is no more reproducible than unpinned pip. Treat the lock as source you review and commit, exactly as you would a Poetry lock, and the guarantee holds across every rebuild and CI run.

How much faster is uv than pip? Substantially — uv is written for speed and parallel downloads, so cold installs are typically several times faster than pip, and a warm cache makes them near-instant. In a devcontainer where installs run on every rebuild, that compounds. The gap widens most on projects with many dependencies or heavy wheels, where pip's serial download-and-build dominates the rebuild while uv fans the work out in parallel. The timing chart above is illustrative rather than a benchmark of your project, but the shape holds: cold uv beats cold pip by a wide margin, and a warm cache turns the install into a near-instant copy from the store.

How do I route the editor at uv's environment? Point python.defaultInterpreterPath at the venv uv creates (an in-project .venv if configured), exactly as you would for a Poetry venv, so the language server and runtime resolve the same packages. If the editor keeps selecting the system interpreter, it usually means the venv did not exist when the window opened or the path is wrong; reload the window after postCreateCommand completes and confirm the path resolves inside the workspace. The same interpreter-routing fix that applies to a Poetry virtualenv applies here unchanged, since uv produces an ordinary venv.

Do I still need pip inside the container? Not for the managed workflow — uv sync handles installation from the lock, and uv add / uv remove manage the dependency set without invoking pip directly. uv also ships a pip-compatible interface (uv pip install) for the occasional one-off or for tooling that shells out to pip, so nothing breaks if a script expects it, but your reproducible install path should run through uv sync --frozen rather than ad-hoc pip commands that bypass the lock.

Should I commit the venv or only the lock? Commit only uv.lock (and pyproject.toml); never commit the .venv directory. The venv is a build artefact uv recreates deterministically from the lock, and it contains platform-specific binaries that would be wrong on any machine but the one that produced them. Keeping the venv out of version control and rebuilding it from the committed lock on each container is what makes the environment portable — the lock travels, the venv is regenerated locally from the cached store.

Why is my cache not speeding up rebuilds? Almost always the volume target or its ownership is off. Confirm the mount points at the exact directory uv uses (/home/vscode/.cache/uv for the vscode user) and that the running user owns it; a store written under one UID and read under another looks present but is unreadable, so uv re-downloads every wheel. Keep the install, the cache, and the editor on the same non-root user, and if a volume was created under the wrong user, delete it once to let it be recreated with correct ownership.