Debugging Poetry Virtualenv Inside a DevContainer

Autocomplete shows packages your code can't import, or the debugger runs a different Python than your tests — both are interpreter-routing bugs. This page diagnoses why the editor picks the wrong interpreter in a Poetry devcontainer and routes it at the in-project .venv so the editor and runtime finally agree.

This matters because a devcontainer collapses the usual gap between "my machine" and "the project," and Poetry adds a second layer of indirection on top of it. Poetry does not install into the container's system interpreter; it builds an isolated virtualenv and installs your dependencies there. The VS Code Python extension, meanwhile, discovers interpreters on its own and caches whichever one it saw first. When those two discovery mechanisms disagree — Poetry pointing at .venv, the extension pointing at /usr/local/bin/python — you get an editor that lints, completes, and type-checks against a package set that the code never actually runs against. The fix is not to install anything new; it is to make one authoritative path and force every tool to resolve through it.

Reach for this workflow the moment the editor and the terminal disagree about what is installed: a green import in the editor that raises ModuleNotFoundError when you run the file, a debugger that steps into stdlib instead of your dependency, or a test runner that finds a package the language server insists is missing. The mental model to hold is that there is exactly one correct interpreter — the python executable inside .venv/bin — and every other tool is a client that must be routed to it. Once you internalize that the bug is always "a client resolved to the wrong server," the diagnosis below becomes mechanical rather than mysterious.

Prerequisites

You need a Poetry project with an in-project virtualenv.

  • Poetry configured with virtualenvs.in-project = true.
  • The Python extension installed in the container.
  • The .venv present in the workspace.

Debug prerequisitesConfirm the mismatch, locate the venv, route the editor, and verify both agree.Symptomeditor != runtimeFind venvpoetry env infoRoute editordefaultInterpreterPathVerifysame executable

The three prerequisites reinforce a single guarantee: the virtualenv must live at a path the editor can name literally. virtualenvs.in-project = true pins it to .venv beside your pyproject.toml rather than under Poetry's cache in ~/.cache/pypoetry, where the directory name is a content hash that changes with the project path and Python version. The Python extension has to be installed inside the container — not just on the host — because in a devcontainer the extension host runs remotely, and a host-only installation cannot see the container's filesystem to route anything. And .venv must actually be present, meaning poetry install has already run inside the container rather than the volume having been mounted before the install completed.

The detail people get wrong is assuming the setting alone is enough. Flipping virtualenvs.in-project to true does not move an existing out-of-project virtualenv; Poetry only honors the setting when it creates the environment. If a cached environment already exists from an earlier poetry install, the config change is silently ignored and .venv never appears, so you have to remove the stale environment and reinstall before the in-project path is real.

Step-by-Step Implementation

  1. Confirm the mismatch between editor and runtime interpreters.
poetry env info --path                 # the venv the runtime uses
# compare with the interpreter the editor reports (Python: Select Interpreter)

poetry env info --path is authoritative: it prints the exact directory Poetry will use when it runs your code, so it is the ground truth you are trying to make the editor match. Comparing it against the interpreter shown in the Python: Select Interpreter quick-pick turns a vague feeling that "something is off" into a concrete diff between two absolute paths. Do this comparison first, before changing any setting, because if the two paths already agree then your problem is not interpreter routing at all and the rest of this procedure would be wasted motion. The failure mode this step prevents is fixing the wrong thing — reinstalling packages or rebuilding the container to chase a bug that was only ever a stale interpreter selection.

  1. Ensure the venv is in-project so the path is predictable.
poetry config virtualenvs.in-project true
poetry install

Setting virtualenvs.in-project true is what makes ${containerWorkspaceFolder}/.venv/bin/python a path you can hard-code with confidence: the environment is created inside the workspace instead of in a cache directory whose name encodes a hash that differs across machines and container rebuilds. The poetry install that follows is not optional decoration — it is the step that materializes .venv under the new policy, because the config flag only takes effect at creation time. If a differently-located environment already exists, run poetry env remove --all first; otherwise poetry install reuses the old out-of-project environment and the predictable .venv you are about to route the editor at never comes into being.

  1. Route the editor at the in-project venv.
{
  "customizations": {
    "vscode": { "settings": { "python.defaultInterpreterPath": "${containerWorkspaceFolder}/.venv/bin/python" } }
  },
  "remoteUser": "vscode"
}

python.defaultInterpreterPath lives under customizations.vscode.settings so the routing travels with the devcontainer definition rather than with one person's workspace state. The value uses ${containerWorkspaceFolder} instead of a literal /workspaces/project path because that variable expands to wherever the workspace is actually mounted inside the container, keeping the setting portable across differently-named checkouts. It points at .venv/bin/python — the executable, not the directory — because the Python extension routes its language server, linters, and test discovery through that specific binary. Note that defaultInterpreterPath only seeds the choice when no interpreter has been selected yet; committing it in the devcontainer means a freshly built container starts already routed, so a teammate who opens the project never has to run Select Interpreter manually and never lands on the container's system Python by accident.

  1. Verify the editor and runtime resolve the same executable.
${containerWorkspaceFolder:-.}/.venv/bin/python -c "import sys; print(sys.executable)"

This verification closes the loop by printing sys.executable — the absolute path of the interpreter that is actually running — from the exact binary you routed the editor at. If the output matches the path from poetry env info --path in step one, the editor, debugger, and runtime are now all resolving through the same .venv, and the ghost-autocomplete class of bug is gone. The ${containerWorkspaceFolder:-.} fallback lets the same line run whether the devcontainer variable is expanded by the tooling or you paste the command straight into a plain shell where it defaults to the current directory. Making this check a habit is worth it because it converts "I think it's fixed" into a one-line proof, and it is the same assertion you can later fold into a postCreateCommand to fail the build loudly if the venv ever goes missing.

Interpreter routing decisionConfirm executables match; if not, make the venv in-project and route the editor.Do editor and runtime report the sameexecutable?NORoute editor at .venvIs the venv in-project?NOSet virtualenvs.in-project + reinstallEditor matches runtime

Common Pitfalls

Routing bugs come from an out-of-project venv path or an unset interpreter setting.

A subtler failure appears when .venv sits on a mounted volume and the ownership does not match remoteUser. If the workspace was bind-mounted from a host where the files belong to a different UID, or if poetry install ran as root during image build and the container then runs as vscode, the interpreter is present but the extension may fail to read or re-index it, and Poetry may refuse to write into the environment on the next install. The symptom looks like a routing bug — the editor cannot resolve packages — but the cause is permissions on the .venv tree. Align the two by installing as the same user the container runs as, or by chown-ing the workspace to remoteUser after the mount, so the executable at .venv/bin/python is both nameable and readable.

The other pitfall worth naming is the debugger drifting even after autocomplete is fixed. The Python extension's interpreter setting steers the language server, but a launch.json with an explicit python field or a stale debugpy configuration can still spawn a different binary, so tests pass in the terminal while breakpoints run against the wrong packages. Remove any hard-coded interpreter from the debug configuration and let it inherit defaultInterpreterPath; that keeps the debugger routed at the same .venv as everything else instead of quietly falling back to the container's system Python.

Poetry venv triageA triage path from interpreter mismatch to a consistent, routed venv.Does the editor import resolve like theruntime?NOPoint defaultInterpreterPath at .venvDoes the debugger use the venv?YESInterpreter is consistentNo editor/runtime drift

SymptomRoot CauseRemediation
Autocomplete sees packages runtime can't importEditor on the wrong interpreterSet defaultInterpreterPath to .venv
Debugger runs a different PythonDebug config uses system PythonPoint the debug config at the venv
venv path varies per machineOut-of-project virtualenvSet virtualenvs.in-project true
Interpreter resets after rebuildSetting not in devcontainer.jsonPin the path in customizations.vscode

Conclusion

The bug is always the same shape: the editor's language server and the Python runtime are pointed at different interpreters. Make the virtualenv in-project so its path is predictable, then set python.defaultInterpreterPath to that .venv in the devcontainer, and the editor, debugger, and runtime all resolve the identical packages.

The strategic payoff is that you have replaced an interactive, per-session choice with a declared, version-controlled fact. An interpreter someone picks from a quick-pick menu is invisible to code review and evaporates on the next rebuild; a defaultInterpreterPath committed under customizations.vscode is a line in the devcontainer that every teammate and every CI runner inherits identically. That is the same reproducibility discipline that pinning a base image or locking poetry.lock buys you, applied to the tooling layer rather than the dependency layer — the environment is not just the packages that are installed, but which interpreter every tool resolves through.

Framed against the broader pin-and-cache theme, routing the venv is the editor-facing half of a guarantee whose runtime half is the lockfile. poetry.lock fixes what is installed; the in-project .venv plus a pinned interpreter path fixes where it is installed and who reads it. Together they close the loop so that a green import in the editor is a promise about what the code will do at runtime, not a hopeful guess. Once both halves are declared in the repository, "works on my machine" stops being a caveat and becomes a property the container reproduces on demand.

Symptoms and fixInterpreter mismatch causes ghost autocomplete and drift; routing the venv fixes it.SymptomsGhost autocompleteDebugger driftImport errorsFixIn-project venvdefaultInterpreterPathPinned in config

FAQ

Why does autocomplete show packages my code can't import? Because the editor's language server is resolving against a different interpreter than the one running your code — usually a system Python instead of your Poetry .venv. The language server builds its symbol index from whatever interpreter's site-packages it was pointed at, so if that interpreter happens to have some libraries installed globally, it will happily complete and type-check against them even though your code runs elsewhere. Set python.defaultInterpreterPath to the in-project .venv, reload the window so the extension re-indexes, and the language server and runtime resolve the identical package set.

Where is my Poetry virtualenv, exactly? Run poetry env info --path to print it. With virtualenvs.in-project = true, it lives at ${containerWorkspaceFolder}/.venv, a predictable path you can point the editor at. An out-of-project venv lives under Poetry's cache and its path varies, which is why routing it reliably is harder — the cache directory name embeds a hash of the project path and interpreter version, so it changes if either changes. Making the venv in-project trades a small amount of workspace clutter for a path you can hard-code once and never revisit.

Why does the interpreter reset after a rebuild? Because the selection was made interactively rather than declared. An interpreter chosen from the Select Interpreter quick-pick is stored in per-workspace state that a fresh container rebuild discards, so the extension falls back to auto-discovery and lands on whatever it finds first. Pin python.defaultInterpreterPath in customizations.vscode.settings in the devcontainer, so every rebuild routes the editor at the in-project venv automatically instead of relying on a per-session pick.

Do I still need to activate the venv in the integrated terminal? Not for the editor's routing, but it matters for what you type. defaultInterpreterPath steers the language server, debugger, and test discovery, yet a raw python in the integrated terminal still resolves through $PATH, which may be the system interpreter. Either call .venv/bin/python explicitly, prefix commands with poetry run, or set python.terminal.activateEnvironment so the extension activates the routed venv when a terminal opens.

Should the .venv directory be committed or ignored? Ignore it. The in-project .venv is a build artifact of poetry install, not source, and it contains platform-specific binaries that will not transfer between the host and the container. Add .venv to .gitignore and let the devcontainer rebuild it, so the reproducible inputs — pyproject.toml, poetry.lock, and the pinned interpreter path — stay in version control while the derived environment is regenerated on each container create.

Why does poetry install recreate the environment on every rebuild? Because the virtualenv lives inside the workspace and the workspace is often a fresh mount or a rebuilt image layer, so the .venv from the previous container is gone. This is expected, not a fault; to make it fast, cache the Poetry download cache on a named volume or run poetry install in a build layer so the wheels are already present and only the venv linking repeats. The interpreter path you pinned stays valid regardless, because it names a location rather than a specific build.