Python Foundations for Backend Engineers
Python syntax is the easy part of a backend service. The harder part is knowing which objects live for one request, which live for the process, where failures cross a boundary, and which work can safely run concurrently. FastAPI makes those choices visible because type annotations, callables, context managers, and async functions are part of its public programming model.
This chapter establishes the Python knowledge used throughout the handbook. Examples target Python 3.11 or newer.
1. Start with the execution model
A deployed Python API normally has several layers of concurrency:
- A process manager or container starts one or more worker processes.
- Each worker imports the application once and owns a separate Python heap.
- An ASGI server runs an event loop in each worker.
- The event loop interleaves asynchronous requests.
- Synchronous route functions and dependencies may run in a thread pool.
This has immediate consequences:
- A module-level dictionary is shared by requests in one process, but not by other workers or hosts.
- Mutating global state can cause races even if a single test client looks correct.
- Restarting a worker loses in-memory state.
- Adding workers does not make a local cache or lock distributed.
- Import-time work is repeated per process and can make startup slow or fragile.
Treat process memory as an optimization or process-local coordination mechanism, never as the system of record.
2. Names, objects, and mutability
Python variables are names bound to objects. Assignment does not copy an object.
filters = {"status": ["open"]}
other_filters = filters
other_filters["status"].append("pending")
assert filters == {"status": ["open", "pending"]}
This matters when defaults, cached values, test fixtures, and request-scoped data contain mutable objects. Pydantic handles model defaults deliberately, but ordinary Python functions do not:
# Wrong: every call shares the same list.
def collect_error(message: str, errors: list[str] = []) -> list[str]:
errors.append(message)
return errors
# Correct: create state per call.
def collect_error(
message: str,
errors: list[str] | None = None,
) -> list[str]:
current = [] if errors is None else errors
current.append(message)
return current
Prefer immutable values for configuration and value objects. A frozen dataclass communicates intent and prevents accidental assignment:
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True, slots=True)
class Money:
amount: Decimal
currency: str
frozen=True is not a security boundary and does not recursively freeze nested objects. It is a design constraint for application code.
3. Type hints are contracts for tools
Python type annotations are not runtime validation. They support editors, static analyzers, framework introspection, and human review. FastAPI and Pydantic choose to inspect them at runtime, but a regular annotated function still accepts the wrong type unless its code rejects it.
def calculate_total(unit_price: int, quantity: int) -> int:
return unit_price * quantity
# Python does not reject this call at the boundary.
unexpected = calculate_total("10", 3)
assert unexpected == "101010"
Validate untrusted input at system boundaries, then pass well-typed values inward. Static checking and runtime validation solve different problems.
Model behavior, not container details
Use abstract collection types for inputs when callers do not need a specific implementation, and concrete types for return values when the implementation guarantees them.
from collections.abc import Iterable, Sequence
from uuid import UUID
def unique_ids(values: Iterable[UUID]) -> list[UUID]:
return list(dict.fromkeys(values))
def first_page(values: Sequence[str], size: int) -> list[str]:
return list(values[:size])
Use Protocol for structural interfaces that application services depend on:
from typing import Protocol
from uuid import UUID
class User(Protocol):
id: UUID
email: str
class UserReader(Protocol):
async def get(self, user_id: UUID) -> User | None: ...
async def require_user(reader: UserReader, user_id: UUID) -> User:
user = await reader.get(user_id)
if user is None:
raise LookupError(f"user {user_id} not found")
return user
The service depends on the capability it needs, not on a SQLAlchemy class. A fake that satisfies the protocol can be used in unit tests without inheritance.
Use Annotated for framework metadata
typing.Annotated keeps the underlying type usable by static tools while attaching metadata for FastAPI or Pydantic:
from typing import Annotated
from fastapi import Query
PageSize = Annotated[int, Query(ge=1, le=100)]
Named aliases reduce repeated constraints, but avoid hiding business meaning inside a large web of aliases.
4. Functions, callables, and dependency boundaries
Functions are objects. They can be passed to constructors, returned from factories, wrapped by decorators, and inspected by FastAPI. This is the basis of dependency injection.
Use keyword-only arguments when positional calls would be ambiguous:
from datetime import datetime
def schedule_report(
report_id: str,
*,
run_at: datetime,
notify: bool = True,
) -> None:
scheduler.enqueue(report_id=report_id, run_at=run_at, notify=notify)
Keep pure computation separate from I/O. Pure functions are deterministic, cheap to test, and safe to run without application infrastructure:
from decimal import Decimal, ROUND_HALF_UP
def calculate_tax(subtotal: Decimal, rate: Decimal) -> Decimal:
return (subtotal * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
The route can parse input, a service can coordinate database and payment work, and this function can own the calculation. A route that contains all three concerns becomes difficult to reuse and test.
Decorators need restraint
Decorators are useful for cross-cutting behavior, but they can hide control flow and break FastAPI's signature inspection. Preserve metadata with functools.wraps:
from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def audited(function: Callable[P, R]) -> Callable[P, R]:
@wraps(function)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
# Emit a structured audit event here.
return function(*args, **kwargs)
return wrapper
For authentication, transactions, and request-scoped resources, explicit dependencies or middleware are usually clearer than custom route decorators.
The example wrapper is synchronous. A decorator for an async callable needs an async wrapper that awaits the wrapped function; otherwise it observes coroutine creation rather than completion.
5. Exceptions define failure boundaries
Raise exceptions when a function cannot honor its contract. Catch them where the caller can add context, compensate, retry safely, or translate them into another boundary's error model.
class DomainError(Exception):
"""Base class for expected business failures."""
class InsufficientStock(DomainError):
def __init__(self, *, sku: str, available: int) -> None:
super().__init__(f"insufficient stock for {sku}")
self.sku = sku
self.available = available
A domain service can raise InsufficientStock; the HTTP layer can map it to 409 Conflict. The domain should not raise HTTPException, because that couples business code to one transport.
Avoid these patterns:
async def hidden_failure() -> object | None:
try:
return await provider.call()
except Exception:
return None # Hides programming errors, cancellation, and provider failures.
Catch the narrowest useful exception. Preserve the cause when translating:
try:
record = await gateway.fetch(reference)
except GatewayTimeout as exc:
raise ProviderUnavailable(reference=reference) from exc
Log an exception once at the boundary that owns reporting. Logging and re-raising at every layer produces duplicate events without new information.
6. Context managers own resource lifetime
Files, database sessions, locks, and network clients need deterministic cleanup. Context managers express acquisition and release as one unit.
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from httpx import AsyncClient
@asynccontextmanager
async def provider_client() -> AsyncIterator[AsyncClient]:
async with AsyncClient(
base_url="https://provider.example",
timeout=5.0,
) as client:
yield client
An application-wide connection pool should usually be created during lifespan startup and closed during shutdown. A database session or transaction normally belongs to one request or use case. Distinguish the lifetime of the pool from the lifetime of a checked-out connection.
Cleanup must run on errors and cancellation. Do not rely on garbage collection to return scarce resources promptly.
7. Iterators, generators, and streaming
An iterable can produce values lazily instead of holding all of them in memory:
from collections.abc import Iterator
def batches(values: list[str], size: int) -> Iterator[list[str]]:
if size < 1:
raise ValueError("size must be positive")
for start in range(0, len(values), size):
yield values[start : start + size]
Lazy production does not automatically make an HTTP response memory-safe. A serializer might materialize the iterable, a database result may retain a connection, and a slow client can hold resources for a long time. Streaming endpoints need explicit limits, cancellation handling, timeouts, and observability.
An async generator can model a resource dependency with teardown:
from collections.abc import AsyncIterator
async def transaction_scope() -> AsyncIterator[Transaction]:
transaction = await begin_transaction()
try:
yield transaction
await transaction.commit()
except BaseException:
await transaction.rollback()
raise
finally:
await transaction.close()
The exact commit policy is an architectural choice. Some systems commit in the service so transaction boundaries remain visible; others use a unit-of-work dependency. Never let an implicit commit make a multi-step business operation partially durable.
8. Data structures and value choices
Backend bugs often come from choosing a convenient primitive that does not represent the domain:
- Use
Decimalfor exact decimal business values such as money. Define rounding rules explicitly. - Use timezone-aware
datetimevalues for instants. Store and transmit a documented timezone, commonly UTC. - Use
UUID, integer IDs, or validated opaque strings consistently. Do not expose a storage key merely because it exists. - Use
EnumorLiteralfor genuinely closed sets. Plan migrations when a database enum is involved. - Use
bytesfor binary data andstrfor decoded text. Decode at a boundary with an explicit encoding. - Use
setfor membership only when ordering and duplicates have no meaning.
Do not use floating-point equality for financial values:
Parse Decimal from a string or JSON decimal representation according to the API contract. Converting an already imprecise binary float does not restore lost precision.
9. Modules, packages, and imports
Imports execute module top-level code once per interpreter and cache the module object. Keep top-level work predictable:
# Good: declarations are cheap and deterministic.
DEFAULT_PAGE_SIZE = 50
# Risky: network access can fail during import and is repeated by each worker.
catalog = requests.get("https://provider.example/catalog").json()
Use application lifespan for network clients, pools, and model loading that require cleanup or failure handling.
Prevent circular imports
A circular import usually signals confused ownership. Common remedies are:
- Move shared protocols or value types into a lower-level module.
- Pass collaborators into services instead of importing global instances.
- Keep the application composition root in one high-level module.
- Import routers in the application factory, not application objects from routers.
- Use
TYPE_CHECKINGonly for annotations when runtime imports are unnecessary.
Do not scatter local imports inside functions merely to silence the cycle. That can be a tactical escape hatch, but the dependency direction remains wrong.
10. Configuration and secrets
Configuration should be explicit, validated at startup, and immutable during a process lifetime unless dynamic configuration is a designed feature.
from functools import lru_cache
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
environment: str = "development"
database_url: SecretStr
request_timeout_seconds: float = 5.0
@lru_cache
def get_settings() -> Settings:
return Settings()
Call get_settings() from application composition or lifespan before readiness so missing production configuration fails during startup, not on the first user request. An .env file is a development convenience, not a production secret store. Do not log SecretStr values, return settings objects from endpoints, or bake environment-specific credentials into container images. Validate cross-field invariants, such as disallowing debug mode in production, before accepting traffic.
11. Packaging and reproducible environments
Define dependencies and tool configuration in pyproject.toml. Pin direct and transitive versions through a lock file produced by the chosen package manager. A version range describes compatibility intent; a lock file records a reproducible resolution.
Separate, at minimum:
- Runtime dependencies required to serve requests.
- Development dependencies for tests, formatting, linting, typing, and documentation.
- Optional integration dependencies that not every deployment needs.
Use a supported Python minor version consistently in local development, CI, and production. Compile native dependencies for the target platform in a controlled build. Run vulnerability review and dependency updates as routine maintenance, not as an emergency-only activity.
12. Concurrency vocabulary
- Synchronous code completes one operation before continuing in that execution path.
- Asynchronous code can suspend at explicit await points so an event loop can run other work.
- Concurrency means multiple tasks make progress during overlapping time.
- Parallelism means work executes at the same instant on multiple cores or machines.
- I/O-bound work mainly waits on databases, files, or networks.
- CPU-bound work mainly computes and consumes processor time.
async def does not make blocking code asynchronous. A blocking client called inside an async route can stop that worker's event loop. Conversely, a normal def route is a valid choice for blocking libraries because FastAPI can run it in a thread pool. Thread pools are finite, and moving a bad call to a thread does not remove its latency or downstream load.
The detailed decision guide is in Async, Concurrency, and Work Placement.
13. A boundary-oriented service example
The following application service does not know about HTTP or a particular database library:
from dataclasses import dataclass
from typing import Protocol
from uuid import UUID, uuid4
@dataclass(frozen=True, slots=True)
class RegisterUser:
email: str
@dataclass(frozen=True, slots=True)
class RegisteredUser:
id: UUID
email: str
class UserRepository(Protocol):
async def email_exists(self, email: str) -> bool: ...
async def add(self, user: RegisteredUser) -> None: ...
class DuplicateEmail(Exception):
"""Raised when registration conflicts with the unique email invariant."""
class RegistrationService:
def __init__(self, users: UserRepository) -> None:
self._users = users
async def register(self, command: RegisterUser) -> RegisteredUser:
normalized = command.email.strip().lower()
if await self._users.email_exists(normalized):
raise DuplicateEmail(normalized)
user = RegisteredUser(id=uuid4(), email=normalized)
await self._users.add(user)
return user
The existence check improves the error message but cannot enforce uniqueness under concurrency. The database still needs a unique constraint, and the repository must translate the resulting constraint violation. This distinction between a friendly pre-check and an authoritative invariant is a recurring backend design theme.
14. Common mistakes and better choices
| Mistake | Why it fails | Better choice |
|---|---|---|
| Mutable function defaults | State leaks between calls | Use None, then allocate per call |
| Module globals as durable storage | Workers and hosts do not share memory | Use a database or distributed store |
except Exception: pass |
Hides failures and corrupts control flow | Catch narrow exceptions and preserve causes |
| HTTP exceptions in domain services | Couples business logic to FastAPI | Translate domain errors at the API boundary |
| Network calls during import | Startup becomes slow and brittle | Acquire resources in lifespan |
| Type hints treated as validation | Untrusted data reaches business logic | Validate at boundaries with Pydantic or explicit parsers |
| Async function with blocking I/O | Stops the event loop | Use an async client, sync route, or bounded thread offload |
| One shared database session | Transactions and identity state leak across requests | Create a request or use-case scoped session |
| Blind retries | Duplicates side effects and amplifies incidents | Retry only classified, idempotent operations with limits |
15. Review checklist
Before accepting a Python backend change, ask:
- Are public functions and boundaries typed?
- Is untrusted input validated before domain use?
- Is mutable state scoped correctly?
- Does every acquired resource have deterministic cleanup?
- Are transaction and retry boundaries explicit?
- Does an exception retain enough context without leaking secrets?
- Can business logic run without FastAPI in a unit test?
- Could import-time behavior contact the network or mutate external state?
- Does concurrency create a race that only a database constraint or distributed primitive can resolve?
- Is blocking or CPU-heavy work placed deliberately?
Interview prompts
- Why are type hints not enough for API input? They are metadata unless code inspects and enforces them. FastAPI uses Pydantic for runtime validation, while a static checker finds different classes of errors before execution.
- Why is a module-level cache inconsistent with four workers? Each worker has a different address space and therefore a different cache. Invalidation and capacity are also per process.
- Where should a database transaction begin and end? Around one atomic business operation, usually in an application service or explicit unit of work. A request is not automatically the correct transaction boundary.
- What does dependency inversion buy a backend service? Business code depends on required behavior rather than infrastructure details, which makes replacements and focused tests possible. It is valuable when the abstraction represents a real boundary, not when it only renames one ORM call.
- Why can an existence check not guarantee uniqueness? Another transaction can insert between the check and write. An authoritative database constraint resolves that race.
- When is a generator dangerous in an API? When it holds a connection, transaction, file, or large producer open while a slow or disconnected client consumes the stream.