A virtual environment is an isolated, self-contained workspace that decouples a project’s dependencies, runtime, and configuration from the host system and from other projects. The core tension it resolves is:

Project A and Project B may require different versions of the same dependency, but the host OS allows only one version to be installed globally.

Different tools address this at different layers of the stack:

  • Docker at the OS/kernel level,
  • Conda at the userland binary level,
  • uv venv at the Python interpreter level.

This article will introduce each tool with their design philosophy, logical structure, implementation mechanism, and practical usage examples. It will also provide a comparative summary, debugging strategies for version conflicts and VSCode integration tips.

1. Docker: OS-Level Isolation

Docker answers the question:

What if we ship the entire runtime — down to the filesystem root — as a unit?

It draws on two long-standing Linux kernel features:

  • Namespaces — isolate what a process sees (PID, network, mount, UTS, IPC, user, cgroup).
  • Control Groups (cgroups) — limit what a process uses (CPU, memory, I/O).

Together they provide the illusion of a dedicated machine without the overhead of hardware virtualisation — this is OS-level virtualisation, sometimes called containerisation. The key insight is that a Docker image is not a running system; it is a union of read-only layers, each representing a filesystem delta (e.g., “install Python 3.11”, “copy app code”). A container is simply a process launched inside a set of namespaces, with a thin read-write layer on top of that union.

For example, a Python web app built from this Dockerfile:

1
2
3
4
5
FROM python:3.11-slim
RUN apt-get update && apt-get install -y curl
RUN pip install flask
COPY ./app /app
CMD ["python", "/app/main.py"]

produces the following layer stack at build time:

                    ┌────────────────────────────┐
CONTAINER LAYER     │  (empty, writable)         │  ← created at docker run
                    ├────────────────────────────┤
LAYER 3 (COPY)      │  /app/main.py              │  ← application code
                    ├────────────────────────────┤
LAYER 2 (RUN)       │  /usr/local/lib/python3.11 │  ← pip install flask
                    │    /site-packages/flask/   │
                    ├────────────────────────────┤
LAYER 1 (RUN)       │  /usr/bin/curl             │  ← apt install curl
                    ├────────────────────────────┤
LAYER 0 (FROM)      │  /usr/bin/python3.11       │  ← Debian slim + Python
  python:3.11-slim  │  /usr/lib/...              │
                    └────────────────────────────┘

Each RUN / COPY / ADD instruction creates exactly one layer. These layers are content-addressed by SHA256 and cached: if LAYER 0–2 are unchanged, docker build reuses them and only executes LAYER 3. The container’s read-write layer is ephemeral — all writes (logs, temp files, apt install inside a running container) go here and are discarded when the container is removed, leaving the image layers untouched.

1.2 Logical Structure: The Layer Stack

$$ \text{Image} = \bigcup\_{i=1}^{n} \text{Layer}\_i, \quad \text{Container} = \text{Image} \cup \text{Container Layer (R/W)}. $$
┌─────────────────────────┐
│ Container R/W Layer     │  ← ephemeral, discarded on `docker rm`
├─────────────────────────┤
│ Layer 4: COPY ./app     │
├─────────────────────────┤
│ Layer 3: RUN pip install│
├─────────────────────────┤
│ Layer 2: RUN apt-get    │
├─────────────────────────┤
│ Layer 1: FROM ubuntu    │  ← base image tarball
└─────────────────────────┘

Each layer is an archive (tar) identified by a SHA256 digest. Layers are content-addressed and cached: if two images share Layer 1–3, Docker stores them once and shares them. This is the copy-on-write filesystem model, typically implemented by overlay2:

/var/lib/docker/overlay2/
├── <layer-id>/diff/       ← layer contents
├── <layer-id>/merged/     ← unified view for the container
└── <layer-id>/work/       ← overlayfs working directory

1.3 Implementation Mechanism

When you run docker run -it ubuntu bash:

  1. Image resolution — the Docker daemon (dockerd) checks locally for the ubuntu image; if absent, pulls manifest + layer blobs from a registry (registry-1.docker.io).
  2. Layer assembly — overlay2 mounts the union of read-only layers with a fresh R/W directory into the container’s root filesystem.
  3. Namespace creation — the daemon calls clone(2) with CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWCGROUP (simplified; libcontainer orchestration is more nuanced).
  4. cgroup attachment — the process PID is added to a cgroup under /sys/fs/cgroup/…, enforcing resource limits.
  5. Entrypoint execution — bash becomes PID 1 inside the PID namespace.

The container is not a VM: it shares the host kernel. This is why you cannot run a Windows container on a Linux host natively — the kernel ABIs differ.

1.4 Dockerfile & Build Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Stage 1 — build
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt

# Stage 2 — runtime (smaller)
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "main.py"]

Build and run:

1
2
docker build -t myapp:latest .
docker run --rm -p 8000:8000 myapp:latest

Multi-stage builds exploit the layer model: the final image discards the compiler toolchain from the build stage, keeping only the runtime artifacts. This is a direct consequence of the union-filesystem design — each FROM is a new root filesystem, and COPY --from selectively pulls artifacts across roots.

1.5 Applications

Use CaseWhy Docker
Microservice deploymentConsistent runtime across dev/staging/prod
CI/CD pipelinesEphemeral, reproducible build environments
Legacy system encapsulationRun Python 2.7 + old Debian without polluting host
Multi-service orchestrationdocker-compose defines service graphs (DB + API + Cache)
Cross-platform developmentlinux/arm64 on M-series Mac via QEMU binfmt

2. Conda: Userland Binary Isolation

Conda attacks the problem at the userland level rather than the kernel level. Its central insight is

Most dependency conflicts involve compiled C/C++/Fortran libraries, not just Python packages.

Pip delegates to the OS for external libraries; Conda ships precompiled binaries for everything, including non-Python dependencies. There is a concrete example about installing numpy by pip vs Conda: when installing numpy, pip only places numpy’s Python code into site-packages. The underlying libopenblas (a C linear-algebra library that numpy depends on) is not pip’s concern — you must apt install libopenblas-dev yourself. Whatever version the system provides is what numpy gets; if the version mismatches, things break silently. However, Conda bundles numpy’s Python code, libopenblas.so, and all required headers together into ~/miniconda3/envs/<env_name>/lib/.

The result is identical whether you are on Ubuntu, macOS, or Windows, and nothing outside the environment directory is touched. In fact, Conda’s packaging model is language-agnostic: a Conda package is a compressed tarball containing binaries, libraries, headers, and metadata, decoupled from the language ecosystem. An environment is simply a directory with a bin/, lib/, and include/ that shadows (or replaces) the system equivalents when activated.

Thus Conda’s implied motto is

Whatever the system has, I bring everything this environment needs myself.

Structure: Prefix Environment

In Conda, an environment is a directory tree called a prefix. The default location is

1
~/miniconda3/envs/<env_name>/

Each environment has its own bin/, lib/, include/,etc/(optional), and it is organized as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
~/miniconda3/
├── envs/
│   ├── dl/              ← PyTorch + CUDA 12.1
│   │   ├── bin/python
│   │   ├── lib/libpython3.11.so
│   │   └── lib/libtorch.so
│   └── web/             ← Django + PostgreSQL client
│       ├── bin/python
│       └── lib/libpq.so
├── pkgs/                ← shared cache of extracted packages (hard-linked)
│   ├── python-3.11.8-h955c04a/
│   └── ...
└── bin/conda            ← manager itself

As above, the environment of deep learning (dl/) and the web project (web/) can share the same python-3.11.8 package in pkgs/, but each environment has its own isolated bin/ and lib/. This allows multiple projects to coexist without interfering with each other.

The critical optimisation is hard-linking: when the same package version is installed in two environments, Conda creates hard links from pkgs/ into both envs/ prefixes. The physical data exists once on disk; the directory entries in each environment point to the same inode. This is why creating a new Conda environment with overlapping dependencies (e.g., numpy used in both dl/ and web/) takes seconds, not minutes. New common packages but new unique ones are detected automaticallywhen installed and updated, so the scale of pkgs/ is non-decreasing unless cleaned manually.

Usage: Common Commands

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# isolate environment
conda create -n [EnvName] [PackageName]    # create a new environment with a package
conda install [PackageName]
conda activate [EnvName]
conda deactivate            
conda list
conda env remove -n [EnvName]

# common package management
conda clean --packages                     # remove unused packages from pkgs/
conda clean --all                          # remove all caches, logs, and unused packages

# conda itself
conda --version        
conda update conda      
conda init                                 # setup shell integration, hooks into .bashrc/.zshrc

The design principle: pkgs/ is a warehouse — one physical copy per unique package version, hard-linked into as many envs/ as need it. Each envs/<name>/ is a workshop — it sees its own isolated bin/ and lib/ but consumes zero extra disk for packages already cached in pkgs/. bin/conda is the foreman that manages both.

Implementation: Dependency and Activation

Conda’s solver (libmamba, formerly conda-libmamba-solver) models dependency resolution as a Boolean satisfiability (SAT) problem:

  • Each package version is a Boolean variable $p_{v}$ (package $p$ at version $v$, optionally with a hash build).
  • Version constraints become clauses $$\neg\ ( p_{v_i} \land p_{v_j})=(\neg \ p_{v_i}) \lor(\neg \ p_{v_j})$$ to be false if there are conflicting versions, and $$ p_v\to \bigvee q_w= (\neg \ p_v) \lor q_{w_1} \lor q_{w_2} \lor \ldots$$ to be false if a required dependency $q_{w_i}$ is not installed when $p_v$ is.
  • The solver searches for a satisfying assignment; if none exists, it reports the minimal unsatisfiable core.

This is fundamentally harder than pip’s backtracking resolver because Conda packages can carry arbitrary cross-language dependencies (e.g., pytorch depends on libtorch which depends on cudatoolkit). libmamba uses CDCL (Conflict-Driven Clause Learning), a SAT-solving algorithm that significantly outperforms naive backtracking. It works in four phases:

  1. Decide — pick an unassigned Boolean variable $p_v$ and guess its value (e.g., “try installing pytorch 2.1”).
  2. Propagate — apply unit propagation: if all but one literal in a clause are false, the remaining literal must be true. This cascades constraints forward ("pytorch 2.1 requires libtorch 1.13 → requires cudatoolkit 12.1").
  3. Conflict — if propagation forces a variable to both true and false simultaneously, a conflict is detected.
  4. Learn & Backjump — the solver analyses the implication graph to find the subset of decisions that caused the conflict, derives a new clause (“never combine these assignments again”), and jumps back to the earliest relevant decision point — not just one step.

The key advantage over pip’s backtracking resolver: backtracking without learning unwinds one decision at a time and repeats the same mistakes down different branches. CDCL learns the reason for failure as a new clause, pruning the search space exponentially. This is why Conda can handle cross-language constraint graphs that would cause naive backtracking to time out.

Then we turn to activation of the environment. When you run

1
conda activate dl

as activation is shell-level, not a subprocess boundary, modifying the current shell’s environment variables, shell hooks in ~/.bashrc or ~/.zshrc are executed. The activation script does the following:

  1. Prepends ~/miniconda3/envs/dl/bin to $PATH.
  2. Sets $CONDA_PREFIX, $CONDA_DEFAULT_ENV, $CONDA_PROMPT_MODIFIER.
  3. Optionally sets environment-specific variables (e.g., $LD_LIBRARY_PATH, $CUDA_HOME) from envs/dl/etc/conda/activate.d/.

So the memory of the shell is modified to see the dl environment’s binaries and libraries first, until you deactivate, then the reverse happens, restoring the previous $PATH.

Example: Creating a Conda Environment

At last, a practical example of creating a Conda environment for deep learning with PyTorch and CUDA, in two ways: explicitly via conda create or declaratively via environment.yml are given below

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# From explicit packages
conda create -n dl python=3.11 pytorch torchvision cudatoolkit=12.1 -c pytorch -c nvidia

# From environment.yml
cat > environment.yml <<EOF
name: dl
channels:
  - pytorch
  - nvidia
  - conda-forge
dependencies:
  - python=3.11
  - pytorch=2.1.0
  - cudatoolkit=12.1
  - pip:
      - transformers==4.35.0
EOF
conda env create -f environment.yml  

Note the pip: key inside an environment.yml — this instructs Conda to install those packages via pip inside the Conda prefix. This hybrid approach is common but subtle: Conda cannot resolve pip dependencies against Conda packages, so version conflicts between the two are discovered only at install time. So in practice, it is recommended to use Conda packages whenever possible, and only fall back to pip for packages not available in Conda channels, with the order of installation being Conda first, pip second.


3. uv venv: Python-Interpreter Isolation

uv is a fast Python package and environment manager written in Rust by Astral (the team behind Ruff). It targets the Python ecosystem exclusively, with a laser focus on speed, achieved through:

  • A bespoke resolver and installer implemented in Rust (no subprocess calls to pip).
  • Aggressive caching with a global package store (content-addressed, like pnpm for Node).
  • Parallel downloads via an async HTTP client.

uv venv creates PEP 405 virtual environments — lightweight, interpreter-level isolation. Unlike Conda or Docker, it does not ship a separate Python binary; it copies (or symlinks) the host Python interpreter and isolates only the site-packages directory and activation scripts.

3.2 Logical Structure: The Virtual Environment Directory

PEP 405 defines a virtual environment as a directory tree that, when the contained interpreter is invoked, causes sys.path to prioritise the venv’s site-packages over the host’s:

myproject/
├── .venv/                ← virtual environment root
│   ├── pyvenv.cfg        ← metadata (Python version, home key)
│   ├── bin/              ← symlinks/copies of python, pip, activate
│   │   ├── python  -> /usr/local/bin/python3.11
│   │   ├── pip
│   │   └── activate
│   ├── lib/
│   │   └── python3.11/
│   │       └── site-packages/  ← isolated package directory
│   └── include/
├── pyproject.toml
└── src/

The key file is pyvenv.cfg:

1
2
3
home = /usr/local/bin
include-system-site-packages = false
version = 3.11.8

When the venv’s bin/python starts, it reads pyvenv.cfg, sets sys.prefix and sys.exec_prefix to the venv root, and constructs sys.path so that lib/python3.11/site-packages/ is searched before the system paths. If include-system-site-packages = false, system site-packages are excluded entirely.

3.3 Implementation Mechanism

3.3.1 Global Cache (Content-Addressed Store)

uv maintains a global cache at ~/.cache/uv/:

~/.cache/uv/
├── archive-v0/           ← raw downloaded wheels/sdists
│   └── <package>/
│       └── <file>.whl
├── wheels-v4/            ← pre-built/extracted wheels
│   └── <package>/
│       └── <contents>/
└── builds-v0/            ← sdists compiled to wheels

When you uv pip install numpy inside a venv, uv:

  1. Resolves numpy and its dependency graph.
  2. Downloads the wheel to archive-v0/ (if not already cached).
  3. Extracts/links the wheel contents into wheels-v4/.
  4. Symlinks or copies the package from the cache into .venv/lib/python3.11/site-packages/.

This means multiple venvs sharing the same packages share disk space, similar to Conda’s hard-linking but using symlinks and a unified cache.

3.3.2 Resolver

uv’s resolver is a PubGrub-derived algorithm that treats dependency resolution as a constraint-satisfaction problem with unit-propagation, similar in spirit to Conda’s SAT solver but specialised for Python packaging heuristics (PEP 440, 508, 517, 660). The resolver is eager — it commits to package versions early and backtracks on conflict rather than maintaining the full search tree.

3.4 Workflow Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Install uv (standalone binary)
curl -LsSf https://astral.sh/uv/install.sh | sh

# Create venv + install in one step
uv venv .venv
source .venv/bin/activate
uv pip install fastapi uvicorn httpx

# Or via pyproject.toml
cat > pyproject.toml <<EOF
[project]
name = "myproject"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["fastapi>=0.100", "uvicorn[standard]", "httpx"]
EOF
uv pip install -e .

For full project management (lockfile, deterministic installs):

1
2
uv lock          # generate uv.lock
uv sync          # install all deps from uv.lock into .venv, creating .venv if needed

uv sync replaces the traditional pip install -r requirements.txt + virtualenv workflow with a single command that reads the lockfile and ensures exact reproducibility.


4. Comparative Summary

DimensionDockerCondauv venv
Isolation LevelKernel (namespaces + cgroups)Userland (prefix directory)Python interpreter (site-packages)
ScopeEntire OS runtimeAny binary (Python, R, C libs)Python packages only
Overhead~MB for image, ~kB for container~GB for pkgs/ cache~MB for venv dir
Speed (create)Seconds–minutes (build)Seconds (hard-link)Milliseconds–seconds
ReproducibilityFull (Dockerfile + image digest)Partial (environment.yml, no cross-OS)Full (uv.lock, cross-platform)
ComplexityHigh (Docker daemon, networking)Medium (SAT solver, channel management)Low (single binary)
Best ForProduction, microservices, CIML/data science, CUDA, bioinformaticsPython app development

The choice is not mutually exclusive — a common pattern is uv venv for development, Docker for deployment, with Conda filling the gap when the project needs non-Python compiled libraries that are awkward to install via apt inside a Docker image.


5. Version-Mismatch Debugging

Version conflicts are the most common failure mode across all three tools. Below is a systematic debugging approach.

5.1 General Strategy

$$ \text{Debugging} = \text{Identify} + \text{Trace Graph} + \text{Relax} + \text{Verify}. $$
  1. Identify — what package, and what version range, is the source of conflict?
  2. Trace the dependency graph — which transitive dependency introduces the conflicting constraint?
  3. Relax constraints — can you loosen the version pin? Can you upgrade/downgrade the root package?
  4. Verify — does the resolution succeed now? Do tests pass?

5.2 Docker: Build-Time Dependency Failures

Symptom: docker build fails with pip/apt errors.

1
ERROR: Cannot install pydantic==2.0.0 and pydantic==1.10.0

Debugging workflow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 1. Enter the build context at the failing layer
docker build --target builder -t debug-stage .
docker run -it debug-stage bash

# 2. Inside the container, manually reproduce the failing command
pip install --dry-run -r requirements.txt
pipdeptree                    # view dependency tree

# 3. Check what version is actually installed
pip show pydantic
python -c "import pydantic; print(pydantic.__version__)"

Common Docker-specific issues:

  • Stale cache layers: RUN pip install -r requirements.txt caches forever if requirements.txt doesn’t change. When you bump a version pin, Docker still uses the cached layer. Fix: add --no-cache or reorder COPY statements so the requirements file is copied first and the cache is invalidated intentionally.
  • Architecture mismatch: FROM python:3.11 on an ARM Mac pulls linux/arm64; deploying to linux/amd64 cloud may produce different wheels. Fix: docker build --platform linux/amd64.

5.3 Conda: SAT Solver Conflicts

Symptom: conda create spins for minutes then reports an unsatisfiable set.

1
2
3
LibMambaUnsatisfiableError: Encountered problems while solving:
  - package pytorch-2.1.0-py3.11_cuda12.1 requires
    python >=3.11,<3.12, but none of the providers can be installed

Debugging workflow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 1. Inspect the conflicting package's dependencies
conda search --info pytorch=2.1.0

# 2. Use mamba (or libmamba-solver) with verbose logging
conda create -n test python=3.11 pytorch=2.1.0 \
  --solver=libmamba --verbose 2>&1 | tee conflict.log

# 3. Check which channels supply conflicting versions
conda search -c pytorch -c conda-forge pytorch

# 4. Export explicit versions from a working environment
conda list --explicit > pinned.txt
# Then recreate: conda create -n test2 --file pinned.txt

Common Conda-specific issues:

  • Channel mixing: pytorch from pytorch channel and numpy from conda-forge may carry different build variants of libopenblas. Fix: set channel_priority: strict in .condarc, or pin the channel per-package: conda-forge::numpy.

  • pip-inside-Conda incompatibility: pip install in a Conda env can overwrite Conda-managed files. Fix: always install pip packages last, and pin them in environment.yml. After installing both Conda and pip packages, run conda list --revisions to confirm no files were clobbered.

5.4 uv venv: Resolver Failures & Lockfile Drift

Symptom: uv lock or uv pip install fails with an unresolvable dependency.

1
2
3
error: Because only pydantic<=1.10.0 is available and your project
depends on fastapi>=0.100 which depends on pydantic>=2.0.0,
version solving failed.

Debugging workflow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 1. See the resolution tree (uv outputs a PubGrub-style explanation)
uv lock --verbose                    # detailed step-by-step reasoning

# 2. List what's currently installed
uv pip list
uv pip tree                         # dependency tree (if available)

# 3. Check why a specific version was chosen
uv pip show pydantic

# 4. Force a fresh resolution
uv lock --refresh

Common uv-specific issues:

  • requires-python mismatch: The pyproject.toml says requires-python = ">=3.11" but the venv was created with Python 3.10. uv respects the venv interpreter. Fix: uv venv --python 3.12 to create the venv with the correct interpreter.

  • Lockfile drift: uv.lock pins exact versions. If a dependency yanks a release or an index moves, uv sync fails. Fix: uv lock --upgrade to regenerate against current indices, or commit your own package mirror.


6. VSCode Integration

VSCode discovers virtual environments automatically and asks which to use for a workspace. Below are setup recipes for each tool.

6.1 Docker: Dev Containers

The Dev Containers extension runs VSCode inside a container, so the editor itself operates in the isolated filesystem.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// .devcontainer/devcontainer.json
{
    "name": "Python 3.11",
    "image": "mcr.microsoft.com/devcontainers/python:3.11",
    "features": {
        "ghcr.io/devcontainers/features/docker-in-docker:2": {}
    },
    "customizations": {
        "vscode": {
            "extensions": [
                "ms-python.python",
                "ms-python.vscode-pylance",
                "charliermarsh.ruff"
            ],
            "settings": {
                "python.defaultInterpreterPath": "/usr/local/bin/python"
            }
        }
    },
    "postCreateCommand": "pip install -r requirements.txt"
}

After F1 → Dev Containers: Reopen in Container, VSCode attaches to the container and the Python extension auto-detects /usr/local/bin/python. All terminal sessions, linters, and debuggers run inside the container.

Key benefit: the .devcontainer.json is checked into the repo — every contributor gets the exact same toolchain when they open the project.

6.2 Conda: Interpreter Selection

VSCode’s Python extension scans standard Conda paths:

1
2
3
4
5
6
// .vscode/settings.json (workspace)
{
    "python.defaultInterpreterPath": "~/miniconda3/envs/dl/bin/python",
    "python.terminal.activateEnvironment": true,
    "python.condaPath": "~/miniconda3/bin/conda"
}

Steps:

  1. Ctrl+Shift+P → Python: Select Interpreter — VSCode lists all detected environments, including Conda envs.
  2. Select Python 3.11.x ('dl': conda).
  3. New integrated terminals will automatically conda activate dl.

If auto-detection fails, add the path explicitly in settings.json. For Jupyter notebooks inside Conda envs, additionally install ipykernel:

1
2
conda activate dl
conda install ipykernel

Then in VSCode, the kernel dropdown (Notebook: Select Notebook Kernel) will show the Conda environment.

6.3 uv venv: Autodetection & Task Workflows

uv creates a .venv/ directory, which VSCode’s Python extension detects automatically (it searches for pyvenv.cfg). No manual configuration is needed in most cases.

However, you can integrate uv into VSCode tasks for a smoother workflow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// .vscode/tasks.json
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "uv: sync",
            "type": "shell",
            "command": "uv sync",
            "group": "build",
            "presentation": { "reveal": "always" }
        },
        {
            "label": "uv: lock",
            "type": "shell",
            "command": "uv lock",
            "group": "build"
        },
        {
            "label": "uv: run tests",
            "type": "shell",
            "command": "uv run pytest",
            "group": { "kind": "test", "isDefault": true }
        }
    ]
}

And configure launch.json to use uv run for debugging:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// .vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Python: FastAPI",
            "type": "debugpy",
            "request": "launch",
            "module": "uvicorn",
            "args": ["main:app", "--reload"],
            "cwd": "${workspaceFolder}",
            "env": { "UV_PROJECT_ENVIRONMENT": "${workspaceFolder}/.venv" }
        }
    ]
}

Tip: Set "python.terminal.activateEnvironment": true so that every new terminal in VSCode sources .venv/bin/activate automatically. With uv, the venv is at the project root (./.venv) by convention, and VSCode’s Python extension finds it instantly.

6.4 General VSCode Settings for Python Environments

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// .vscode/settings.json (recommended defaults)
{
    // Interpreter
    "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",

    // Linting & Formatting
    "[python]": {
        "editor.defaultFormatter": "charliermarsh.ruff",
        "editor.formatOnSave": true,
        "editor.codeActionsOnSave": {
            "source.organizeImports": "explicit"
        }
    },

    // Type checking
    "python.analysis.typeCheckingMode": "basic",

    // Terminal
    "python.terminal.activateEnvironment": true,

    // Testing
    "python.testing.pytestEnabled": true,
    "python.testing.pytestPath": "${workspaceFolder}/.venv/bin/pytest"
}

7. Choosing Your Stack: A Decision Flow

Do you need non-Python system packages?
├── Yes → Do you need exact OS-level reproducibility?
│         ├── Yes → Docker (with .devcontainer)
│         └── No  → Conda (+ pip inside Conda)
└── No  → Do you need CUDA or compiled libraries?
          ├── Yes → Conda (or Docker with nvidia-container-toolkit)
          └── No  → uv venv

For most pure-Python projects in 2025–2026, uv venv is the simplest, fastest choice. For machine learning projects that depend on PyTorch + CUDA, Conda remains the path of least resistance because it handles the CUDA toolkit as a package dependency. For production services and CI/CD, Docker is the gold standard — the Dockerfile serves as both documentation and executable specification of the complete runtime.


References

  1. Docker documentation — Overlay filesystem
  2. PEP 405 — Python Virtual Environments
  3. Conda documentation — Deep dive into the solver
  4. uv documentation — Resolver design
  5. VSCode Python extension — Environment management
  6. Dev Containers specification — devcontainer.json reference