Python DevContainer Setup with Poetry & venv

A reproducible Python environment in a devcontainer comes down to three pins and one routing decision: pin the interpreter, pin the lockfile, cache the wheels, and point the editor at the project's virtualenv. This guide, under the language configurations guide, focuses on the failure that plagues Python most — the editor analyzing your code against a different interpreter than the one it runs on, so autocomplete and type-checking quietly lie.

Poetry with an in-project .venv is the setup that makes this deterministic: the virtualenv lives at a known path inside the workspace, the editor is pointed at it explicitly, and poetry.lock fixes the dependency graph. Everything else is caching to keep rebuilds fast.

Prerequisites

You need a pinned Python via the Python Feature, Poetry installed at image time, Poetry configured to create the virtualenv in the project, and a cache volume for wheels and the venv.

  • ghcr.io/devcontainers/features/python pinned to an exact version.
  • Poetry installed (Feature or pipx), with virtualenvs.in-project = true.
  • poetry.lock committed.
  • A named volume for the Poetry cache (and optionally the .venv).

Python prerequisitesYou need a pinned Python, Poetry, an in-project venv path, and a cache volume.Python Featurepinned versionPoetryinstalled at imagetimeIn-project venv.venv committed pathCache volumewheels + venv

Python's reproducibility problem is sharper than most languages', which is why the interpreter-routing prerequisite matters so much. Python has historically had many ways to install packages and many places an interpreter can live — the system Python, a pyenv shim, a Poetry-managed virtualenv, a conda environment — and the editor's language server and the runtime can each independently pick a different one. When they diverge, the symptom is uniquely misleading: Pylance shows clean, green autocomplete because it resolved imports against one interpreter's packages, while python app.py throws ModuleNotFoundError because the runtime used a different interpreter that lacks them. The prerequisite that prevents this is a single, predictable interpreter that both the editor and the runtime are explicitly pointed at — which is exactly what an in-project .venv plus a pinned defaultInterpreterPath provides.

The in-project-venv prerequisite is the key enabling decision, and its virtue is predictability of path. By default, Poetry (and virtualenv tools generally) can place the environment in a global, hashed location that varies by project and machine, making it hard to point the editor at reliably. Setting virtualenvs.in-project = true puts the venv at a fixed, known path — ${containerWorkspaceFolder}/.venv — every time, so the editor's defaultInterpreterPath can reference it deterministically and the same setting works for every developer. This predictability is what makes the routing reliable: you cannot dependably point the editor at a path that varies, so fixing the venv's location is the prerequisite for fixing the interpreter routing.

The lockfile prerequisite is what makes the dependency graph reproducible, and Poetry's poetry.lock does this thoroughly. It records the exact resolved version and hash of every dependency, direct and transitive, so poetry install reproduces the identical package set on every machine — and --sync additionally removes anything not in the lock, so the environment matches the lockfile exactly rather than merely containing it. Committing poetry.lock and installing with --sync is what turns "roughly the same dependencies" into "byte-for-byte the same dependencies." This is the Python analogue of Rust's Cargo.lock and Go's go.sum: a committed lockfile that fully determines the dependency graph, making the wheel cache safe because it can only ever hold versions the lockfile already specifies.

Architecture & Configuration Deep Dive

The environment is four layers that must agree. The Python runtime is Feature-pinned so every rebuild has the same interpreter. Poetry resolves poetry.lock to a fixed dependency set. The in-project .venv (enabled with virtualenvs.in-project) puts the virtualenv at ${containerWorkspaceFolder}/.venv, a predictable path. And interpreter routing via python.defaultInterpreterPath points the editor's language server at that exact venv.

Python environment layersA pinned interpreter, Poetry-resolved lockfile, in-project venv, and editor routing form the stack.Python runtimeFeature-pinned interpreterPoetryresolves poetry.lock.venv in projectvirtualenvs.in-project = trueInterpreter routingpython.defaultInterpreterPath

The routing layer is where reproducibility is usually lost. If python.defaultInterpreterPath is unset or points at a system Python, the language server resolves imports against different packages than the runtime uses — so Pylance shows green while python app.py throws ModuleNotFoundError. Pinning the interpreter path to the in-project venv makes the editor and the runtime resolve identically. Debugging this routing when it breaks is covered in debugging Poetry virtualenv inside a devcontainer.

The four layers are best understood as a chain where a break at any link reintroduces non-determinism. A pinned interpreter with an unpinned lockfile gives you the same Python but different packages; a pinned lockfile with an unrouted editor gives you the right packages at runtime but a lying editor; an in-project venv with no defaultInterpreterPath pointing at it leaves the editor guessing which interpreter to use. Only when all four agree — the same interpreter, the same locked packages, at a predictable path the editor is explicitly pointed at — does the environment become fully deterministic, with the editor's analysis a faithful mirror of what will run. The layers are not four independent conveniences but four halves of one guarantee, and the routing layer is the one most often forgotten precisely because everything appears to work without it until an import fails at runtime.

The routing failure deserves dwelling on because it is the single most common and most confusing Python-in-a-container problem. When defaultInterpreterPath is unset or points at a system Python, Pylance analyzes your code against whatever packages that interpreter has, which may include globally-installed ones your project does not declare — so it shows autocomplete and type-checks as green for imports that will fail at runtime, and misses errors for packages the runtime does have but the editor's interpreter lacks. The editor is confidently wrong in both directions. Pinning defaultInterpreterPath to the in-project .venv makes Pylance resolve exactly the packages poetry install placed there, so its analysis becomes a true preview of the runtime. This is the Python expression of the interpreter-routing discipline that the Go, Rust, and Java guides all describe: the editor must see the same environment the code runs in.

Poetry's role in the chain is worth clarifying, because it does two distinct jobs. It is a resolver — it reads pyproject.toml, resolves a consistent dependency graph, and writes the exact result to poetry.lock. And it is an installer — it reads poetry.lock and installs precisely those versions into the venv. The reproducibility comes from separating these: resolution (which can change versions) happens deliberately when you run poetry lock or add a dependency, while installation (which reproduces the locked set) happens on every poetry install. In a devcontainer you almost always want the installer behavior — reproduce the committed lock — not re-resolution, which is why poetry install (not poetry update) belongs in postCreateCommand. This keeps container creation reproducing the exact locked environment rather than potentially resolving newer versions.

Step-by-Step Implementation

Pin Python, install from the lockfile in postCreateCommand, route the editor at the venv, and verify an import resolves.

Setup flowPin Python, install from the lockfile, route the editor at the venv, then verify imports.Pin PythonFeature versionpoetry installfrom poetry.lockRoute editordefaultInterpreterPathVerifypython -c import

{
  "name": "Python + Poetry",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED",
  "features": { "ghcr.io/devcontainers/features/python:1": { "version": "3.12" } },
  "containerEnv": { "POETRY_VIRTUALENVS_IN_PROJECT": "true" },
  "customizations": {
    "vscode": {
      "extensions": ["ms-python.python", "ms-python.vscode-pylance"],
      "settings": { "python.defaultInterpreterPath": "${containerWorkspaceFolder}/.venv/bin/python" }
    }
  },
  "mounts": [
    "source=devcontainer-poetry-cache,target=/home/vscode/.cache/pypoetry,type=volume"
  ],
  "postCreateCommand": "pipx install poetry && poetry install --no-root",
  "remoteUser": "vscode"
}

Data-science projects add heavy binary wheels (NumPy, PyTorch), where caching matters even more — see optimizing a Python devcontainer for data science.

The containerEnv and settings in the config work together to make the in-project venv both created and used, and each half is necessary. POETRY_VIRTUALENVS_IN_PROJECT: true tells Poetry to create the venv at .venv in the workspace, and python.defaultInterpreterPath: ${containerWorkspaceFolder}/.venv/bin/python tells the editor to use that venv. Setting only the first creates the venv but leaves the editor guessing; setting only the second points the editor at a path Poetry may never populate. Together they close the loop: Poetry puts the environment at the predictable path, and the editor is pointed at exactly that path, so the venv the packages install into is the venv the editor analyzes. This pairing is the concrete mechanism behind "the editor and runtime resolve the same interpreter."

The postCreateCommand: pipx install poetry && poetry install --no-root warms the environment at create time, and its choices are deliberate. Installing Poetry via pipx isolates it in its own environment so it does not pollute the project's venv, and poetry install reproduces the locked dependency set into .venv. The --no-root flag skips installing the project package itself, which is appropriate when you only need the dependencies (common for applications and during setup). Running this at create time means the venv is fully populated before you start working, and paired with the Poetry cache volume, the wheel downloads and builds are reused on rebuilds so the install is fast after the first. Adding --sync makes the install additionally remove anything not in the lock, keeping the venv exactly matched to poetry.lock.

The ownership consideration common to every cache volume applies here too. The Poetry cache volume (and the .venv if you cache it) must be writable by the vscode user; a root-owned volume under a non-root session produces permission errors during install that look like Poetry or network failures. Ensuring the cache directory is owned by the remoteUser — via a chown in postCreateCommand or the mount configuration — is what lets the cache function under the non-root discipline. This is the same ownership issue seen with Rust's target, Go's GOMODCACHE, and the JVM caches, and anticipating it alongside adding the volume avoids a confusing "the cache is mounted but install still fails" state.

Performance & Resource Optimization

Poetry install dominates rebuild time, especially with compiled wheels. Caching Poetry's cache directory on a named volume means a rebuild reuses downloaded and built wheels instead of refetching and recompiling them.

Dependency install timeCaching wheels and the venv drastically cuts Poetry install time on rebuilds.Cold poetry install71sWarm wheel cache11sPrebuilt venv3sillustrative install time

Key the value of the cache on poetry.lock: because the lockfile fixes the resolved set, a warm cache is only reused when the dependencies are genuinely unchanged, so it never smuggles in a stale package. For the heaviest data-science stacks, consider a prebuilt image that bakes the venv, turning install into a near-no-op on attach.

Python's install cost is dominated by wheels, and the nature of the wheel determines how much caching helps. Pure-Python wheels download quickly; the expensive ones are compiled wheels for packages with C or Rust extensions — NumPy, SciPy, PyTorch, database drivers — which are large and, when a prebuilt wheel is not available for your platform, must be compiled from source. Caching Poetry's cache directory means these downloaded and built wheels persist across rebuilds, so the multi-minute cost of fetching and compiling a heavy scientific stack is paid once rather than every rebuild. For data-science projects this is transformative, which is why the linked data-science how-to treats wheel caching (and often a prebuilt venv) as the central optimization rather than an afterthought.

The lockfile is what makes this caching safe, and the mechanism is worth stating precisely. Because poetry.lock pins every dependency to an exact version and hash, a cached wheel can only ever be one the lockfile already specifies — a wheel with a different version or hash would not match the lock and would not be used. So the warm cache is reused only when the resolved dependency set is genuinely unchanged, and it can never smuggle in a stale or wrong package. This is the same guarantee Rust's Cargo.lock and Go's go.sum provide, applied to Python wheels: a fully-pinned lockfile makes the cache pure speed with no reproducibility cost. The performance win and the reproducibility guarantee are not in tension; the lockfile is what reconciles them.

For the very heaviest stacks, a prebuilt image that bakes the populated .venv into a layer takes this one step further, turning poetry install into a near-no-op on attach. The trade is maintaining and periodically rebuilding that image when dependencies change, so it is worth it mainly for large data-science environments where even a warm-cache install is slow, or for Codespaces where every create is a fresh machine. Most projects are well served by the wheel-cache volume alone; the prebuilt venv is the escalation for when the install remains a bottleneck despite the cache, and it composes naturally with the Codespaces prebuild model described elsewhere on the site.

Validation & Testing

Confirm the editor uses the project's .venv interpreter and that poetry.lock resolves unchanged. The interpreter check is the one that catches the most insidious bug.

Python validationConfirm the editor uses the project venv and the lockfile resolves unchanged.Does the editor use the .venvinterpreter?NOdefaultInterpreterPath wrongDoes poetry.lock resolve unchanged?YESWheels cached on a volumeEditor and runtime agree

# Editor and runtime must resolve the same interpreter
poetry env info --path                       # -> .../.venv
${containerWorkspaceFolder:-.}/.venv/bin/python -c "import sys; print(sys.executable)"
poetry install --no-root --sync              # lockfile resolves cleanly

The interpreter-agreement check is the most valuable validation because it directly tests the property that most often breaks. Confirm that poetry env info --path reports the in-project .venv, then run the same import from both the editor's chosen interpreter and the .venv Python to confirm they resolve identically. The definitive test is to import a package your project depends on and one it does not: the project dependency should resolve in both the editor and the runtime, and the non-dependency should fail in both. If the editor and runtime disagree — the editor resolving an import the runtime cannot, or vice versa — they are pointed at different interpreters, and the fix is to align defaultInterpreterPath with the .venv. Because this mismatch is silent (the editor looks fine until runtime fails), a deliberate cross-check is the only reliable way to catch it.

The lockfile-resolution check validates reproducibility rather than routing, and it belongs in CI. Running poetry install --no-root --sync should reproduce the exact locked environment cleanly; if it needs to change poetry.lock to succeed, the lockfile has drifted out of sync with pyproject.toml and the environment is not fully pinned. The --sync flag additionally proves the venv matches the lock exactly by removing anything extraneous, so a passing --sync install confirms the environment is neither missing nor carrying packages relative to the lockfile. Running this in CI catches a drifted lockfile before it produces a "works with my cached packages" divergence, and because it uses the committed lock, it validates the same dependency set developers get.

Common Pitfalls

The failures below are interpreter mismatch or uncached installs. The triage below sorts them.

Python pitfall triageA triage path from interpreter mismatch or slow installs to a deterministic Python env.Does import resolve differently ineditor vs run?YESPoint editor at .venvDo wheels reinstall every rebuild?YESCache the wheel dirDeterministic Python env

SymptomRoot CauseRemediation
Autocomplete sees packages the runtime doesn'tEditor points at the wrong interpreterSet python.defaultInterpreterPath to .venv
Wheels rebuild on every rebuildNo cache volume for PoetryMount ~/.cache/pypoetry on a named volume
.venv not created in the projectvirtualenvs.in-project not setSet POETRY_VIRTUALENVS_IN_PROJECT=true
CI installs different versionspoetry.lock not committed or ignoredCommit the lockfile; install with --sync
Slow data-science rebuildsLarge binary wheels recompiledCache wheels; consider a prebuilt venv image

The interpreter-mismatch pitfall is worth expanding because it is Python's signature failure and it fails in the most misleading way possible. The editor points at one interpreter and the runtime at another, so Pylance shows confident green diagnostics against packages the runtime does not have, and the developer trusts an editor that is quietly wrong until an import throws ModuleNotFoundError at runtime. Because nothing errors in the editor, the developer looks everywhere except the interpreter setting. The fix is always to set python.defaultInterpreterPath to the in-project .venv, after which the editor resolves exactly the runtime's packages. The tell is autocomplete or type-checking that disagrees with what actually imports at runtime — an impossibility when both use the same interpreter, so its existence points straight at a routing mismatch rather than a code problem.

The venv-location pitfall is the enabling failure behind many routing problems. If virtualenvs.in-project is not set, Poetry places the venv in a global, hashed location that varies by project and machine, so there is no stable path to point the editor at — and any defaultInterpreterPath you hardcode will be wrong on another machine or after a rebuild. The symptom is an editor that intermittently loses the interpreter or a defaultInterpreterPath that works for one developer but not another. Setting POETRY_VIRTUALENVS_IN_PROJECT=true fixes the venv at the predictable .venv path, which is what makes deterministic routing possible in the first place. Both pitfalls reflect this section's lesson: reproducibility requires that the tool that runs the code and the tool that analyzes it point at the same, predictable environment — and in Python that means a fixed-path venv both are explicitly aimed at.

Conclusion

Three pins and one route. Pin the interpreter on the Feature, pin the dependency graph with poetry.lock, cache the wheels on a named volume, and route the editor at the in-project .venv. The invariant to hold onto is that the editor's language server and the Python runtime must resolve the same interpreter — when they do, autocomplete tells the truth and rebuilds stay fast.

Pin and cache PythonPin the interpreter, lockfile and path; cache the wheels and venv for fast rebuilds.PinPython versionpoetry.lockInterpreter pathCacheWheel cacheIn-project .venvPoetry cache

Standing back, the Python environment concentrates this section's themes with one added twist: Python's ecosystem makes interpreter routing uniquely error-prone, so the "one route" is as important as the three pins. poetry.lock handles dependency reproducibility, the Feature handles interpreter reproducibility, and the wheel cache handles speed — but none of that helps if the editor analyzes against a different interpreter than the runtime uses, because then the tool developers trust most (autocomplete and type-checking) lies to them. The in-project venv at a predictable path, with the editor explicitly pointed at it, is what closes that gap. Get the three pins and the one route right and Python becomes as deterministic as any compiled language here, with the bonus that the editor's diagnostics are a true preview of runtime behavior.

The robustness of the setup comes from every defining input being explicit and pointed at the same place. The interpreter is a Feature-pinned version, not "whatever python is on PATH"; the dependencies are a committed poetry.lock, not "whatever pip resolves"; the venv is at a fixed .venv path, not a machine-specific hashed location; and the editor is aimed at that exact venv, not left to guess. Because both the runtime and the editor resolve the identical, explicitly-specified environment, the class of Python bug where "it works in the editor but not at runtime" simply cannot occur. That alignment — the same interpreter, the same locked packages, at a known path both tools target — is the whole game, and it is what turns Python's famously fiddly environment story into something a devcontainer makes reproducible by default.

FAQ

Why does Pylance show no errors but the code fails to import at runtime? Because the editor's language server is analyzing against a different interpreter than the one that runs your code — usually a system Python instead of the project's virtualenv. Set python.defaultInterpreterPath to the in-project .venv, so the language server and the runtime resolve the identical package set.

Should the virtualenv live inside the project or outside? Inside, at ${containerWorkspaceFolder}/.venv, via POETRY_VIRTUALENVS_IN_PROJECT=true. An in-project venv has a predictable path you can point the editor at deterministically, and it is trivial to cache. An out-of-project venv path varies and is harder to route the editor to reliably. The predictability is the whole reason for the choice: you cannot dependably aim defaultInterpreterPath at a path that changes per project and machine, so fixing the venv at .venv in the workspace is the prerequisite that makes deterministic editor routing possible. It also makes the environment trivial to reason about — the venv is right there in the project, at a path every developer's config references identically.

How do I speed up Poetry installs on rebuild? Mount Poetry's cache directory on a named volume so downloaded and compiled wheels persist across rebuilds. Because poetry.lock fixes the resolved set, the warm cache is reused only when dependencies are unchanged. For very heavy stacks, bake the venv into a prebuilt image so install becomes nearly instant. The biggest wins come from caching compiled wheels — packages with C or Rust extensions like NumPy or PyTorch — which are expensive to build from source when a prebuilt wheel is unavailable; the cache lets that compilation happen once rather than every rebuild.

Should I use Poetry, or would uv or pip-tools be simpler? All three can give you a reproducible, pinned environment; the differences are ergonomics and speed. Poetry integrates resolution, locking, and virtualenv management in one tool with a poetry.lock. uv is a much faster resolver and installer that can dramatically cut install time and is worth considering for large dependency sets — the linked uv how-to covers using it in a devcontainer. pip-tools with a compiled requirements.txt is the most minimal. Whichever you choose, the devcontainer principles are identical: pin the interpreter, commit a lockfile, cache the wheels, and route the editor at the environment — only the specific commands and cache paths change.

Do I commit the .venv directory, or just cache it? Neither commit it nor rely on committing it — the .venv is a build artifact, so it belongs in .gitignore, and you reproduce it from poetry.lock on each machine. What you cache (on a named volume) is optional and separate from version control: caching the venv or the wheel store speeds up reconstruction, but the source of truth is always the committed lockfile, not the venv itself. Committing a .venv would bloat the repo with platform-specific binaries that do not transfer between machines anyway, which is exactly why the lockfile-plus-cache approach exists.