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.
Install uv
Section titled “Install uv”uv is a single binary with no Python prerequisite — it bootstraps Python for
you. The mental model:
| TypeScript | Go | Python (2026) |
|---|---|---|
nvm manages Node versions | Go toolchain manages itself | uv manages Python versions |
npm / pnpm installs deps | go mod | uv installs deps |
node_modules/ | $GOPATH/pkg/mod | .venv/ (managed for you) |
npx (run a one-off tool) | go run pkg@latest | uvx (run a one-off tool) |
| V8 runtime | compiled in | CPython interpreter (uv-installed) |
-
Install the standalone binary (no Python needed first):
Terminal window # macOS / Linuxcurl -LsSf https://astral.sh/uv/install.sh | sh# Windows (PowerShell)powershell -c "irm https://astral.sh/uv/install.ps1 | iex"# or via Homebrewbrew install uv -
Verify, and let uv install a Python for you:
Terminal window uv --version # uv 0.7.x or lateruv python install 3.14 # download & manage CPython 3.14uv python list # show installed + available versions -
Keep uv itself current:
Terminal window uv self update
Pinning a Python version
Section titled “Pinning a Python version”This is .nvmrc / the go directive in go.mod, but managed by uv:
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.”
Your first project: uv init
Section titled “Your first project: uv init”uv init example-appcd example-appThis 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):
uv init --package --app example-app # src/ layout, packaged appDirectoryexample-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:
uv add httpx # adds to dependencies + updates uv.lock + installsuv add --dev pytest ty # dev-only dependency groupuv 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.
The command map: uv ↔ npm ↔ go
Section titled “The command map: uv ↔ npm ↔ go”This is the table to bookmark. Almost everything you do daily is here:
| What you want | npm | go | uv |
|---|---|---|---|
| Install the language runtime | nvm install 22 | (bundled) | uv python install 3.14 |
| Pin the runtime version | .nvmrc | go line in go.mod | uv python pin 3.14 |
| Create a new project | npm init -y | go mod init | uv init |
| Add a dependency | npm install axios | go get pkg | uv add httpx |
| Add a dev dependency | npm install -D vitest | go get (test) | uv add --dev pytest |
| Remove a dependency | npm uninstall axios | go get pkg@none | uv remove httpx |
| Install everything (CI/fresh clone) | npm ci | go mod download | uv sync |
| Update the lockfile | npm update | go get -u ./... | uv lock --upgrade |
| Run your app | npm start | go run . | uv run python -m app.main |
| Run an arbitrary command in-env | npm exec | — | uv run <cmd> |
| Run a tool without installing it | npx prettier | go run pkg@latest | uvx ruff check |
| Install a global CLI tool | npm i -g eslint | go install pkg@latest | uv tool install ruff |
| Show the dependency tree | npm ls | go mod graph | uv tree |
| Lockfile | package-lock.json | go.sum | uv.lock |
| Manifest | package.json | go.mod | pyproject.toml |
uv run and the lockfile workflow
Section titled “uv run and the lockfile workflow”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.
uv run main.py # run a scriptuv run python # REPL inside the project envuv run pytest # run a dev tooluv run -- uvicorn app.main:app --reload # run with args after --The lock/sync split mirrors npm exactly:
| Action | npm | uv |
|---|---|---|
| Resolve & write the lockfile | npm install (implicitly) | uv lock |
| Materialize the env from the lock | npm ci | uv sync |
| Both, on demand | npm install | uv run (auto) |
uv.lock is cross-platform and hashed — commit it. It’s the source of truth for
reproducible installs in CI and Docker.
One-off tools with uvx
Section titled “One-off tools with uvx”uvx is npx: run a tool in an ephemeral, cached environment without adding it to
your project.
uvx ruff check . # lint without installing ruff into the projectuvx --from 'httpie' http GET example.comuvx 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).
Single-file scripts (PEP 723)
Section titled “Single-file scripts (PEP 723)”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.”
# /// script# requires-python = ">=3.13"# dependencies = ["httpx"]# ///import httpx
resp = httpx.get("https://api.github.com/zen")print(resp.text)uv run fetch.py # uv reads the header, installs httpx in a cache, runs it# uv can also add inline deps to a script for you:uv add --script fetch.py richThere’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 anatomy
Section titled “pyproject.toml anatomy”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:
# ===== 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 directiveauthors = [{ 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 = 100target-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:
| Concept | package.json | go.mod | pyproject.toml |
|---|---|---|---|
| Package name | name | module path | [project] name |
| Version | version | (tags) | [project] version |
| Runtime version constraint | engines.node | go 1.23 | requires-python |
| Runtime deps | dependencies | require | [project] dependencies |
| Dev deps | devDependencies | (test imports) | [dependency-groups] dev |
| Optional/extra deps | peerDependencies | build tags | [project.optional-dependencies] |
| CLI entry points | bin | — | [project.scripts] |
| Tool config | .eslintrc, .prettierrc | — | [tool.*] (one file) |
| Build config | tsconfig.json build | — | [build-system] |
Ruff: lint + format in one tool
Section titled “Ruff: lint + format in one tool”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.
| Concern | TypeScript | Go | Python (2026) |
|---|---|---|---|
| Format | prettier --write . | gofmt -w . | ruff format |
| Lint | eslint . | go vet / golangci-lint | ruff check |
| Autofix lint | eslint --fix | — | ruff check --fix |
| Sort imports | eslint-plugin-import | goimports | ruff check --fix (the I rules) |
uv run ruff check . # lintuv run ruff check --fix . # lint + autofix what's safeuv 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):
[tool.ruff]line-length = 100target-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 formatterty: fast type checking
Section titled “ty: fast type checking”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.
uv add --dev tyuv run ty check # type-check the project (like `tsc --noEmit`)uv run ty check src/ # check a pathThe mental model:
| TypeScript | Go | Python |
|---|---|---|
tsc --noEmit | compiler (built-in) | ty check (or mypy) |
| Types are erased at runtime | static, enforced | Types are erased at runtime (hints only) |
strict: true in tsconfig | always strict | configured in [tool.ty] / [tool.mypy] |
Project structure & imports
Section titled “Project structure & imports”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/
- …
Directorymy-app/
- go.mod
- go.sum
- main.go
Directoryinternal/
- user/user.go
- cmd/server/main.go
Directorymy-app/
- pyproject.toml deps + metadata
- uv.lock lockfile
- .python-version pinned interpreter
Directorysrc/
Directoryapp/
- __init__.py marks the package
- main.py entry point
Directorymodels/
- __init__.py
- user.py
Directorytests/
- test_user.py
Directory(.venv/ generated, gitignored)
- …
Imports, three ways
Section titled “Imports, three ways”import { User } from "./models/user";
const u: User = { id: 1, name: "Ada", email: "ada@example.com" };console.log(u.name);package main
import ( "fmt" "myapp/internal/user")
func main() { u := user.User{ID: 1, Name: "Ada", Email: "ada@example.com"} fmt.Println(u.Name)}from app.models.user import User
def main() -> None: u = User(id=1, name="Ada", email="ada@example.com") print(u.name)
if __name__ == "__main__": main()What trips up TS/Go devs:
__init__.pymarks 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 — thesrc/layout plus the package install makesappimportable 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 formatenforces it). A stray space is aSyntaxError, not a style nit. This is the single biggest adjustment from C-family languages.
Entry points & if __name__ == "__main__"
Section titled “Entry points & if __name__ == "__main__"”There’s no func main() that the runtime calls automatically. A Python file runs
top to bottom when executed. The idiom:
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()uv run python -m app.main # run as a module (recommended)uv run example-app # if you defined [project.scripts]Editor setup
Section titled “Editor setup”Install the official extensions:
code --install-extension ms-python.python # Python core (debugger, env)code --install-extension charliermarsh.ruff # Ruff lint + formatPoint VS Code at the uv-managed interpreter and turn on format-on-save:
{ "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.
PyCharm (JetBrains, same family as IntelliJ) has first-class Python support out of the box.
- Interpreter: Settings → Project → Python Interpreter → “Add” → select
uv (PyCharm detects
uv.lock) or point it at.venv/bin/python. - Ruff: install the Ruff plugin (Settings → Plugins), enable “Run ruff format on save” and “Use ruff for import optimization.”
- Type checking: enable the inspections, or run
uv run ty checkin the terminal.
The Community Edition is free and sufficient for backend work.
Docker infrastructure
Section titled “Docker infrastructure”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.
-
Start all services:
Terminal window cd shared-infradocker compose up -d -
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 -
Verify each service:
Terminal window docker exec -it shared-infra-postgres-1 pg_isready -U dev # accepting connectionsdocker exec -it shared-infra-redis-1 redis-cli ping # PONG# Kafka UI is at http://localhost:8090
| Service | Host:Port | Credentials |
|---|---|---|
| PostgreSQL 17 | localhost:5432 | user dev, password dev, db app |
| Redis 8 | localhost:6379 | no auth |
| Kafka 4 (KRaft) | localhost:29092 | no auth |
| Kafka UI | localhost:8090 | web UI |
The connection strings you’ll reuse across the guide:
postgresql+asyncpg://dev:dev@localhost:5432/app # SQLAlchemy asyncpostgresql://dev:dev@localhost:5432/app # asyncpg rawredis://localhost:6379/0 # Redislocalhost:29092 # Kafka bootstrap serverManaging the stack:
docker compose stop # stop, keep datadocker compose down # remove containers, keep volumesdocker compose down -v # remove EVERYTHING, including datadocker compose logs -f postgres # tail a service's logsdocker compose restart redis # restart a single serviceDocker for TS/Go devs — Python flavor
Section titled “Docker for TS/Go devs — Python flavor”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:
| Concept | Node.js | Go | Python (uv) |
|---|---|---|---|
| Base image | node:22-slim | golang:1.23 → scratch | python:3.14-slim |
| Install deps | npm ci | go mod download | uv sync --frozen |
| Build step | npm run build | go build | (none — Python isn’t compiled) |
| Runtime size | ~200MB | ~10MB (static) | ~120MB (slim) / ~60MB (distroless) |
| Hot reload | nodemon | air | uvicorn --reload / uv run --watch |
A first-pass uv Dockerfile (we cover the optimized multi-stage build in the deployment module):
FROM python:3.14-slim
# Copy the uv binary from Astral's published image — no install step neededCOPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
# Install deps first (layer-cached) from the lockfile, before copying sourceCOPY pyproject.toml uv.lock ./RUN uv sync --frozen --no-install-project --no-dev
# Now the source, then install the project itselfCOPY . .RUN uv sync --frozen --no-dev
EXPOSE 8000CMD ["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.
Practice
Section titled “Practice”Put the toolchain to work — build a real, small project from scratch with uv,
ruff, and ty.
Quick reference card
Section titled “Quick reference card”# Toolchain setupcurl -LsSf https://astral.sh/uv/install.sh | sh # install uvuv python install 3.14 # install a Pythonuv python pin 3.14 # pin it (.python-version)
# Project lifecycleuv init --package --app myapp # new src-layout projectuv add httpx # add a runtime dependencyuv add --dev pytest ruff ty # add dev dependenciesuv sync # install from the lockfile (fresh clone / CI)uv lock --upgrade # bump the lockfileuv run python -m app.main # run your appuv run script.py # run a PEP 723 single-file scriptuvx ruff check # run a one-off tool (like npx)
# Quality gatesuv 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/)
# Infrastructurecd shared-infradocker compose up -d # start Postgres, Redis, Kafkadocker compose ps # check statusdocker compose down -v # clean shutdown