Skip to content

Dev Environment & Tooling

You write TypeScript or Go backends. You know your toolchain cold. Now you need Python — and the version of Python you may remember (global python, pip install, hand-rolled virtualenv, a requirements.txt that drifts from reality) is gone. In 2026 there is essentially one tool: uv. It installs Python itself, manages projects, resolves and locks dependencies, runs your code, and runs throwaway tools — all in a single static Rust binary that’s 10–100× faster than the things it replaces.

This module gets you from nothing to a working, lockfile-backed Python project with linting, formatting, type-checking, and the shared Docker stack — mapping every step to its npm/go equivalent.

uv is a single binary with no Python prerequisite — it bootstraps Python for you. The mental model:

TypeScriptGoPython (2026)
nvm manages Node versionsGo toolchain manages itselfuv manages Python versions
npm / pnpm installs depsgo moduv installs deps
node_modules/$GOPATH/pkg/mod.venv/ (managed for you)
npx (run a one-off tool)go run pkg@latestuvx (run a one-off tool)
V8 runtimecompiled inCPython interpreter (uv-installed)
  1. Install the standalone binary (no Python needed first):

    Terminal window
    # macOS / Linux
    curl -LsSf https://astral.sh/uv/install.sh | sh
    # Windows (PowerShell)
    powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
    # or via Homebrew
    brew install uv
  2. Verify, and let uv install a Python for you:

    Terminal window
    uv --version # uv 0.7.x or later
    uv python install 3.14 # download & manage CPython 3.14
    uv python list # show installed + available versions
  3. Keep uv itself current:

    Terminal window
    uv self update

This is .nvmrc / the go directive in go.mod, but managed by uv:

Terminal window
uv python pin 3.14 # writes ".python-version" with "3.14"

The resulting .python-version file is committed to git. Anyone who runs a uv command in the project gets that exact interpreter auto-installed if missing — no “works on my machine.”

Terminal window
uv init example-app
cd example-app

This scaffolds a project. For a backend service, use the application layout with a src/ directory (uv supports both flat and src layouts; src is the convention we use throughout this guide):

Terminal window
uv init --package --app example-app # src/ layout, packaged app
  • Directoryexample-app/
    • pyproject.toml deps + metadata (like package.json / go.mod)
    • uv.lock resolved lockfile (like package-lock.json / go.sum)
    • .python-version pinned interpreter (like .nvmrc)
    • README.md
    • .gitignore
    • Directorysrc/
      • Directoryexample_app/
        • __init__.py marks the package
  • Directory(.venv/ created on first uv run — gitignored)

Now add a dependency and run something:

Terminal window
uv add httpx # adds to dependencies + updates uv.lock + installs
uv add --dev pytest ty # dev-only dependency group
uv run python -c "import httpx; print(httpx.__version__)"

Notice what you did not do: no python -m venv .venv, no source .venv/bin/activate, no pip install, no pip freeze > requirements.txt. uv run creates and syncs the virtualenv on demand, every time, automatically. The .venv exists, but you treat it like node_modules — generated, gitignored, never hand-managed.

This is the table to bookmark. Almost everything you do daily is here:

What you wantnpmgouv
Install the language runtimenvm install 22(bundled)uv python install 3.14
Pin the runtime version.nvmrcgo line in go.moduv python pin 3.14
Create a new projectnpm init -ygo mod inituv init
Add a dependencynpm install axiosgo get pkguv add httpx
Add a dev dependencynpm install -D vitestgo get (test)uv add --dev pytest
Remove a dependencynpm uninstall axiosgo get pkg@noneuv remove httpx
Install everything (CI/fresh clone)npm cigo mod downloaduv sync
Update the lockfilenpm updatego get -u ./...uv lock --upgrade
Run your appnpm startgo run .uv run python -m app.main
Run an arbitrary command in-envnpm execuv run <cmd>
Run a tool without installing itnpx prettiergo run pkg@latestuvx ruff check
Install a global CLI toolnpm i -g eslintgo install pkg@latestuv tool install ruff
Show the dependency treenpm lsgo mod graphuv tree
Lockfilepackage-lock.jsongo.sumuv.lock
Manifestpackage.jsongo.modpyproject.toml

uv run is the workhorse. Before running anything, it checks that .venv matches uv.lock and re-syncs if not — so the environment is always correct, the way go run always compiles against the resolved module graph.

Terminal window
uv run main.py # run a script
uv run python # REPL inside the project env
uv run pytest # run a dev tool
uv run -- uvicorn app.main:app --reload # run with args after --

The lock/sync split mirrors npm exactly:

Actionnpmuv
Resolve & write the lockfilenpm install (implicitly)uv lock
Materialize the env from the locknpm ciuv sync
Both, on demandnpm installuv run (auto)

uv.lock is cross-platform and hashed — commit it. It’s the source of truth for reproducible installs in CI and Docker.

uvx is npx: run a tool in an ephemeral, cached environment without adding it to your project.

Terminal window
uvx ruff check . # lint without installing ruff into the project
uvx --from 'httpie' http GET example.com
uvx cowsay@latest -t "modern python"

Use uvx for tools you run occasionally; uv add --dev for tools your project’s workflow depends on (so they’re locked and reproducible for the whole team).

Sometimes you want one file with dependencies — no project, no pyproject.toml. Python’s PEP 723 inline metadata lets you embed deps in a comment block, and uv run reads it, builds a throwaway env, and runs it. This is the killer replacement for “I need a quick script but it imports httpx.”

fetch.py
# /// script
# requires-python = ">=3.13"
# dependencies = ["httpx"]
# ///
import httpx
resp = httpx.get("https://api.github.com/zen")
print(resp.text)
Terminal window
uv run fetch.py # uv reads the header, installs httpx in a cache, runs it
Terminal window
# uv can also add inline deps to a script for you:
uv add --script fetch.py rich

There’s no Go or npm equivalent that’s this clean — the closest is a Go file with go:generate hacks or an npm “scriptlet.” For Python ops glue, reach for this instead of a bare .py you have to remember to set up an env for.

pyproject.toml is your package.json and go.mod and tool config, all in one standardized file. Here’s an annotated one for a backend service:

pyproject.toml
# ===== PROJECT METADATA (PEP 621) — like the top of package.json =====
[project]
name = "example-app" # distribution name (like "name" in package.json)
version = "0.1.0" # like package.json "version"
description = "An example backend service"
readme = "README.md"
requires-python = ">=3.13" # like "engines.node" / the go directive
authors = [{ name = "You", email = "you@example.com" }]
# Runtime dependencies — like package.json "dependencies" / go.mod "require"
dependencies = [
"fastapi>=0.115",
"httpx>=0.27",
]
# ===== DEPENDENCY GROUPS (PEP 735) — like "devDependencies", but plural =====
# Not installed for consumers; `uv sync` installs the "dev" group by default.
[dependency-groups]
dev = [
"pytest>=8",
"ruff>=0.6",
"ty",
]
# ===== ENTRY POINTS — like package.json "bin" =====
[project.scripts]
example-app = "example_app.main:main" # creates a CLI named example-app
# ===== TOOL CONFIG — ruff, ty, etc. live here (no extra config files) =====
[tool.ruff]
line-length = 100
target-version = "py314"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] # pycodestyle, pyflakes, isort, pyupgrade, bugbear
[tool.ty]
# ty configuration goes here (sane defaults; see the type-checking section)
# ===== BUILD BACKEND — how the package is built (uv's, by default) =====
[build-system]
requires = ["uv_build>=0.7"]
build-backend = "uv_build"

Mapped to what you know:

Conceptpackage.jsongo.modpyproject.toml
Package namenamemodule path[project] name
Versionversion(tags)[project] version
Runtime version constraintengines.nodego 1.23requires-python
Runtime depsdependenciesrequire[project] dependencies
Dev depsdevDependencies(test imports)[dependency-groups] dev
Optional/extra depspeerDependenciesbuild tags[project.optional-dependencies]
CLI entry pointsbin[project.scripts]
Tool config.eslintrc, .prettierrc[tool.*] (one file)
Build configtsconfig.json build[build-system]

ruff replaces black (formatter), flake8 (linter), isort (import sorter), and most of pylint — in one Rust binary that’s effectively instant. Think eslint + prettier collapsed into a tool that runs faster than you can blink.

ConcernTypeScriptGoPython (2026)
Formatprettier --write .gofmt -w .ruff format
Linteslint .go vet / golangci-lintruff check
Autofix linteslint --fixruff check --fix
Sort importseslint-plugin-importgoimportsruff check --fix (the I rules)
Terminal window
uv run ruff check . # lint
uv run ruff check --fix . # lint + autofix what's safe
uv run ruff format . # format (the black-compatible formatter)
uv run ruff format --check . # CI: fail if unformatted (like prettier --check)

A minimal, opinionated config (lives in pyproject.toml — no separate file):

pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py314"
[tool.ruff.lint]
# A solid starter rule set. E/F = pycodestyle+pyflakes (the flake8 core),
# I = import sorting (isort), UP = pyupgrade (modernize syntax), B = bugbear.
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["E501"] # line length is handled by the formatter

Python’s type annotations (x: int, -> str) are checked by a separate tool, the way tsc checks TypeScript. The modern, fast checker is ty, from Astral (the uv/ruff team) — again a Rust binary, designed to be fast enough to run on every keystroke.

Terminal window
uv add --dev ty
uv run ty check # type-check the project (like `tsc --noEmit`)
uv run ty check src/ # check a path

The mental model:

TypeScriptGoPython
tsc --noEmitcompiler (built-in)ty check (or mypy)
Types are erased at runtimestatic, enforcedTypes are erased at runtime (hints only)
strict: true in tsconfigalways strictconfigured in [tool.ty] / [tool.mypy]

Python organizes code into modules (a .py file) and packages (a directory with an __init__.py). Imports are by dotted module path relative to your src/ root — closer to Go’s package-path model than TS’s file-path model.

  • Directorymy-app/
    • package.json
    • package-lock.json
    • tsconfig.json
    • Directorysrc/
      • index.ts
      • models/user.ts
    • test/user.test.ts
    • Directorynode_modules/
src/main.ts
import { User } from "./models/user";
const u: User = { id: 1, name: "Ada", email: "ada@example.com" };
console.log(u.name);

What trips up TS/Go devs:

  • __init__.py marks a directory as a package. It’s usually empty — its job is “this folder is importable.” (Namespace packages can skip it, but be explicit while learning.)
  • Absolute imports (from app.models.user import User) are the norm — the src/ layout plus the package install makes app importable from anywhere. Prefer them over relative imports (from ..models.user import User).
  • Indentation is syntax. No braces. A block is defined by its indentation (4 spaces, always — ruff format enforces it). A stray space is a SyntaxError, not a style nit. This is the single biggest adjustment from C-family languages.

There’s no func main() that the runtime calls automatically. A Python file runs top to bottom when executed. The idiom:

src/app/main.py
def main() -> None:
print("Hello from modern Python!")
# True only when this file is *run directly*, not when it's *imported*.
# It's the guard that lets a module be both a library and a script.
if __name__ == "__main__":
main()
Terminal window
uv run python -m app.main # run as a module (recommended)
uv run example-app # if you defined [project.scripts]

Install the official extensions:

Terminal window
code --install-extension ms-python.python # Python core (debugger, env)
code --install-extension charliermarsh.ruff # Ruff lint + format

Point VS Code at the uv-managed interpreter and turn on format-on-save:

.vscode/settings.json
{
"python.defaultInterpreterPath": ".venv/bin/python",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": { "source.organizeImports": "explicit" }
}
}

Run Python: Select Interpreter from the command palette and pick the one in .venv if it isn’t auto-detected. That’s the whole setup — the same Prettier+ESLint-on-save flow you have in TS.

This guide uses one shared Docker Compose stack (shared-infra/) for PostgreSQL, Redis, and Kafka. Every module that needs a database or cache points at it — same ports, same credentials.

  1. Start all services:

    Terminal window
    cd shared-infra
    docker compose up -d
  2. Check status:

    Terminal window
    docker compose ps
    # postgres running 0.0.0.0:5432->5432/tcp
    # redis running 0.0.0.0:6379->6379/tcp
    # kafka running 0.0.0.0:29092->29092/tcp
    # kafka-ui running 0.0.0.0:8090->8080/tcp
  3. Verify each service:

    Terminal window
    docker exec -it shared-infra-postgres-1 pg_isready -U dev # accepting connections
    docker exec -it shared-infra-redis-1 redis-cli ping # PONG
    # Kafka UI is at http://localhost:8090
ServiceHost:PortCredentials
PostgreSQL 17localhost:5432user dev, password dev, db app
Redis 8localhost:6379no auth
Kafka 4 (KRaft)localhost:29092no auth
Kafka UIlocalhost:8090web UI

The connection strings you’ll reuse across the guide:

postgresql+asyncpg://dev:dev@localhost:5432/app # SQLAlchemy async
postgresql://dev:dev@localhost:5432/app # asyncpg raw
redis://localhost:6379/0 # Redis
localhost:29092 # Kafka bootstrap server

Managing the stack:

Terminal window
docker compose stop # stop, keep data
docker compose down # remove containers, keep volumes
docker compose down -v # remove EVERYTHING, including data
docker compose logs -f postgres # tail a service's logs
docker compose restart redis # restart a single service

You already use Docker. Here’s the Python-specific mental model, and how a uv multi-stage build differs from a Node or Go one:

ConceptNode.jsGoPython (uv)
Base imagenode:22-slimgolang:1.23scratchpython:3.14-slim
Install depsnpm cigo mod downloaduv sync --frozen
Build stepnpm run buildgo build(none — Python isn’t compiled)
Runtime size~200MB~10MB (static)~120MB (slim) / ~60MB (distroless)
Hot reloadnodemonairuvicorn --reload / uv run --watch

A first-pass uv Dockerfile (we cover the optimized multi-stage build in the deployment module):

Dockerfile
FROM python:3.14-slim
# Copy the uv binary from Astral's published image — no install step needed
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
# Install deps first (layer-cached) from the lockfile, before copying source
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
# Now the source, then install the project itself
COPY . .
RUN uv sync --frozen --no-dev
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

For local development, “hot reload” is your web server’s job — uvicorn --reload watches your files and restarts on change (covered in the FastAPI module). For a non-server script, uv run --watch main.py reruns on change.

Put the toolchain to work — build a real, small project from scratch with uv, ruff, and ty.

Terminal window
# Toolchain setup
curl -LsSf https://astral.sh/uv/install.sh | sh # install uv
uv python install 3.14 # install a Python
uv python pin 3.14 # pin it (.python-version)
# Project lifecycle
uv init --package --app myapp # new src-layout project
uv add httpx # add a runtime dependency
uv add --dev pytest ruff ty # add dev dependencies
uv sync # install from the lockfile (fresh clone / CI)
uv lock --upgrade # bump the lockfile
uv run python -m app.main # run your app
uv run script.py # run a PEP 723 single-file script
uvx ruff check # run a one-off tool (like npx)
# Quality gates
uv run ruff format . # format (replaces black)
uv run ruff check --fix . # lint + autofix (replaces flake8/isort)
uv run ty check # type-check (or: uv run mypy src/)
# Infrastructure
cd shared-infra
docker compose up -d # start Postgres, Redis, Kafka
docker compose ps # check status
docker compose down -v # clean shutdown