From Python basics to shipping APIs.

The shortest path from knowing Python to building and running a URL shortener with FastAPI, Postgres and Redis on your own. Each stop is one idea with one working example. Mark stops as done to track your progress on this device.

    Prerequisites: Python essentials

    The Python-specific behaviour and idioms the rest of this guide assumes. Skim what you know, stop on what surprises you.

    Names, mutability and truthiness

    Variables are names pointing at objects, not boxes holding values. Assignment never copies. Lists, dicts and sets are mutable; ints, strings and tuples are not.

    a = [1, 2]
    b = a                  # same list, two names
    b.append(3)
    print(a)               # [1, 2, 3]
    c = a.copy()           # shallow copy; copy.deepcopy() for nested structures
    
    x == y                 # equal values
    x is None              # same object; use `is` only for None, True, False
    
    # falsy: None, False, 0, 0.0, "", [], {}, set(), ()   everything else is truthy
    if not items:
        print("empty")
    name = user_input or "anonymous"     # first truthy value

    Strings and f-strings

    code, clicks, rate = "abc", 1234567, 0.4213
    
    f"{code!r} has {clicks:,} clicks"   # "'abc' has 1,234,567 clicks"
    f"{rate:.1%}"                       # '42.1%'
    f"{rate:.2f}"                       # '0.42'
    f"{clicks=}"                        # 'clicks=1234567'  handy for debugging
    
    "a,b,,c".split(",")                 # ['a', 'b', '', 'c']
    ", ".join(["a", "b"])               # 'a, b'
    "  hi \n".strip()                   # 'hi'
    "https://x.dev".removeprefix("https://")   # 'x.dev'
    "abc".startswith(("a", "z"))        # True, accepts a tuple of options

    Collections, slicing and unpacking

    xs = [10, 20, 30, 40]
    xs[-1], xs[1:3], xs[::-1]           # 40, [20, 30], [40, 30, 20, 10]
    
    first, *rest = xs                   # first=10, rest=[20, 30, 40]
    a, b = b, a                         # swap without a temp variable
    point = (3, 4)                      # tuple: fixed, immutable record
    x, y = point
    
    d = {"abc": "https://example.com"}
    d.get("zzz")                        # None instead of KeyError
    d.get("zzz", "fallback")
    for key, value in d.items(): ...
    merged = {**defaults, **overrides}  # later keys win; also: defaults | overrides
    
    seen = {"abc", "xyz"}               # set: O(1) membership
    "abc" in seen                       # True; `in` also works on lists, dicts (keys), strings

    Loops and control flow idioms

    for i, url in enumerate(urls, start=1): ...        # index + value
    for code, url in zip(codes, urls, strict=True): ... # parallel; strict errors on length mismatch
    
    ranked = sorted(links, key=lambda l: l.clicks, reverse=True)
    any(l.clicks > 0 for l in links)                    # True if at least one
    all(u.startswith("https") for u in urls)
    
    status = "active" if clicks else "unused"            # conditional expression
    if 0 < n <= 100: ...                                # chained comparison
    
    for url in urls:
        if url.endswith(".exe"):
            break
    else:                                                # runs only if the loop didn't break
        print("all clean")
    
    match event:                                         # structural pattern matching (3.10+)
        case {"type": "click", "code": code}:
            record(code)
        case {"type": "delete", "code": code}:
            remove(code)
        case _:
            raise ValueError(event)

    Function arguments: *args, **kwargs and friends

    Parameters can be positional-only (before /), keyword-only (after * or *args), or collected into a tuple (*args) and a dict (**kwargs).

    def f(a, /, b, *args, c, d=0, **kwargs):
        print(a, b, args, c, d, kwargs)
    
    f(1, 2, 3, 4, c=5, extra=6)
    # 1 2 (3, 4) 5 0 {'extra': 6}
    
    # the same stars unpack when calling
    nums = [1, 2]
    opts = {"c": 5, "d": 9}
    f(*nums, **opts)                     # 1 2 () 5 9 {}
    
    square = lambda n: n * n             # one-expression anonymous function
    
    def add_tag(tag, tags=[]):           # BUG: the default list is created once
        tags.append(tag)                 # and shared by every call
        return tags

    Passing a list or dict into a function passes the same object, so mutating it inside changes the caller's copy too.

    Scope and closures

    Name lookup goes Local, Enclosing function, Global, Built-in. An inner function that remembers variables from its enclosing function is a closure; decorators are built on this.

    def make_counter():
        count = 0
        def increment():
            nonlocal count              # without this, count += 1 raises UnboundLocalError
            count += 1
            return count
        return increment
    
    next_id = make_counter()
    next_id(), next_id()                # (1, 2)  count lives on between calls

    Python for applications

    The pieces that turn single-file scripts into structured, maintainable programs.

    Project setup with uv

    uv replaces pip, venv and pyenv with one fast tool. It records dependencies in pyproject.toml and pins exact versions in uv.lock. Use Python 3.12 or newer.

    # install uv once: https://docs.astral.sh/uv/
    uv init shortener && cd shortener
    uv python pin 3.13
    uv add "fastapi[standard]"    # updates pyproject.toml + uv.lock, creates .venv
    uv add --dev pytest           # dev-only dependency
    uv run python main.py         # runs inside .venv, no activation needed

    Without uv: python -m venv .venv, then source .venv/bin/activate (Windows: .venv\Scripts\activate), then pip install "fastapi[standard]". Never install project packages into your global Python.

    Modules, packages and imports

    A module is a .py file; a package is a folder of modules with an __init__.py. A module's top-level code runs once, on first import, so a module-level object behaves like a singleton. That's how a shared settings object works later.

    shortener/
    ├── pyproject.toml
    ├── app/
    │   ├── __init__.py        # marks app/ as a package (can be empty)
    │   ├── main.py
    │   └── codes.py
    └── tests/
        └── test_codes.py
    # app/main.py
    from app.codes import make_code      # absolute import (preferred)
    from . import codes                  # relative import, only inside a package
    
    def main() -> None:
        print(make_code())
    
    if __name__ == "__main__":           # true only when run directly, not on import
        main()

    Run it as a module from the project root: uv run python -m app.main. For pytest to find app, add this to pyproject.toml:

    [tool.pytest.ini_options]
    pythonpath = ["."]

    Watch out: circular imports (a imports b, b imports a) fail at startup. Move the shared piece into a third module both can import.

    Function design

    Three habits for functions other code depends on: make optional arguments keyword-only, use None as the default for anything mutable, and pass functions around as values when behaviour varies.

    def shorten(url: str, *, code: str | None = None, tags: list[str] | None = None) -> str:
        tags = tags or []            # never write tags: list = [] as a default
        ...
    
    shorten("https://x.dev", code="xdev")   # ok
    shorten("https://x.dev", "xdev")        # TypeError: code is keyword-only (after *)
    
    transforms = {"upper": str.upper, "strip": str.strip}   # functions are values
    transforms["upper"]("hi")               # 'HI'

    Why the default rule: a default value is evaluated once, when the function is defined, so a mutable default list is shared across every call.

    Classes and dataclasses

    For plain internal data, @dataclass writes __init__, __repr__ and __eq__ for you.

    from dataclasses import dataclass, field
    from datetime import datetime, timezone
    
    @dataclass
    class Link:
        code: str
        target: str
        clicks: int = 0
        created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    
        def hit(self) -> None:
            self.clicks += 1
    
        @property                          # accessed like an attribute: link.is_popular
        def is_popular(self) -> bool:
            return self.clicks > 100
    
        @classmethod                       # alternative constructor
        def from_dict(cls, data: dict) -> "Link":
            return cls(**data)
    
    link = Link("abc", "https://example.com")
    link.hit()
    print(link)    # Link(code='abc', target='https://example.com', clicks=1, created_at=...)

    Which to use: a dataclass for internal data, a Pydantic model for data crossing a boundary (API input/output, config), an ORM model for database rows. You'll meet all three.

    Exceptions

    Define domain errors once, raise them deep in your logic, and translate them to HTTP responses in one place (see Responses and errors in the FastAPI part).

    class LinkError(Exception):
        """Base class for all link errors."""
    
    class LinkNotFound(LinkError): ...
    class CodeTaken(LinkError): ...
    
    def resolve(store: dict[str, str], code: str) -> str:
        try:
            return store[code]
        except KeyError as e:
            raise LinkNotFound(code) from e     # keeps the original cause in the traceback
    
    try:
        resolve({}, "nope")
    except LinkNotFound as e:
        print(f"missing: {e}")
    else:
        print("found")          # runs only if nothing was raised
    finally:
        print("always runs")    # cleanup

    Watch out: catch specific exceptions. A bare except: or except Exception: pass hides real bugs.

    Context managers

    with guarantees cleanup even when an error happens. You'll use it for files, database sessions, HTTP clients and FastAPI's startup/shutdown.

    import time
    from contextlib import contextmanager
    
    with open("links.txt", encoding="utf-8") as f:    # file closed even on error
        lines = f.read().splitlines()
    
    @contextmanager
    def timer(label: str):
        start = time.perf_counter()
        try:
            yield                       # the body of the with-block runs here
        finally:
            print(f"{label}: {time.perf_counter() - start:.3f}s")
    
    with timer("lookup"):
        sum(range(1_000_000))

    The async version is identical in shape: async with and @asynccontextmanager.

    Decorators

    A decorator is a function that takes a function and returns a replacement. @timed above def slow() means slow = timed(slow).

    import functools
    import time
    
    def timed(fn):
        @functools.wraps(fn)                 # keep fn's name and docstring
        def wrapper(*args, **kwargs):
            start = time.perf_counter()
            try:
                return fn(*args, **kwargs)
            finally:
                print(f"{fn.__name__} took {time.perf_counter() - start:.4f}s")
        return wrapper
    
    @timed
    def slow() -> None:
        time.sleep(0.2)
    
    slow()     # slow took 0.2001s

    A decorator with arguments, like @app.get("/health"), is a function that returns a decorator. FastAPI's version registers your function as a route and returns it unchanged.

    Generators and yield

    A function containing yield produces values lazily, one at a time. Code after the yield runs when the generator is resumed or closed: exactly how FastAPI dependencies do setup and teardown.

    def read_urls(path: str):
        with open(path, encoding="utf-8") as f:
            for line in f:
                if url := line.strip():      # walrus: assign and test in one step
                    yield url                # constant memory, even for huge files
    
    for url in read_urls("urls.txt"):
        ...
    
    def session_scope():                     # the shape of a FastAPI yield dependency
        print("setup")
        try:
            yield "session"                  # the value handed to the caller
        finally:
            print("cleanup")                 # runs when the generator is closed
    
    gen = session_scope()
    next(gen)      # prints "setup", returns 'session'
    gen.close()    # prints "cleanup"

    Files, JSON, env vars and logging

    import json
    import logging
    import os
    from pathlib import Path
    
    DATA = Path(__file__).parent / "data" / "links.json"
    DATA.parent.mkdir(parents=True, exist_ok=True)
    DATA.write_text(json.dumps({"abc": "https://example.com"}, indent=2), encoding="utf-8")
    links = json.loads(DATA.read_text(encoding="utf-8"))
    
    port = int(os.environ.get("PORT", "8000"))        # env values are always strings
    
    logging.basicConfig(level=logging.INFO,
                        format="%(asctime)s %(levelname)s %(name)s: %(message)s")
    log = logging.getLogger(__name__)                 # one logger per module
    log.info("loaded %d links", len(links))           # args formatted only if emitted

    Habit to build now: use logging, not print, for anything that will run as a server. Levels and module names make production logs searchable.

    Testing with pytest

    Tests are plain functions named test_* using plain assert. Fixtures provide reusable setup, injected by parameter name.

    # tests/test_codes.py
    import pytest
    from app.codes import make_code
    
    def test_code_length():
        assert len(make_code()) == 7
    
    @pytest.mark.parametrize("n", [1, 10, 1000])
    def test_codes_are_unique(n):
        assert len({make_code() for _ in range(n)}) == n
    
    @pytest.fixture
    def store() -> dict[str, str]:
        return {"abc": "https://example.com"}
    
    def test_lookup(store):                    # fixture injected by name
        assert store["abc"].startswith("https://")

    Run with uv run pytest -q. Write tests from the first milestone of your project, not after.

    The FastAPI toolkit

    FastAPI is mostly type hints, Pydantic and async put together. Learn these three on their own and FastAPI stops feeling like magic.

    Type hints

    Python does not enforce hints at runtime. Pydantic and FastAPI read them to validate input, convert types and generate API docs. A type checker (Pyright, mypy, or your editor's built-in one) catches mistakes before you run anything, so turn it on.

    from collections.abc import Callable
    from typing import Annotated, Literal
    
    def top(counts: dict[str, int], n: int = 5) -> list[str]: ...   # built-in generics
    def find(code: str) -> str | None: ...                          # Optional[str] in older code
    
    Method = Literal["GET", "POST", "DELETE"]          # only these exact values
    Handler = Callable[[str], str]                     # takes a str, returns a str
    Code = Annotated[str, "any metadata"]              # a str, plus metadata FastAPI can read

    Why Annotated matters: FastAPI uses it to attach extra meaning to a type: Annotated[int, Query(ge=1)] is "an int, read from the query string, at least 1".

    Pydantic v2

    A Pydantic model validates and converts data on the way in, and serializes it on the way out. In FastAPI, request bodies and responses are Pydantic models.

    from pydantic import BaseModel, ConfigDict, Field, HttpUrl, ValidationError, field_validator
    
    RESERVED = {"docs", "redoc", "links", "health"}
    
    class LinkCreate(BaseModel):                       # what the client sends
        url: HttpUrl
        code: str | None = Field(default=None, min_length=4, max_length=16,
                                 pattern=r"^[A-Za-z0-9_-]+$")
    
        @field_validator("code")
        @classmethod
        def not_reserved(cls, v: str | None) -> str | None:
            if v in RESERVED:
                raise ValueError("this code is reserved")
            return v
    
    class LinkOut(BaseModel):                          # what the API returns
        model_config = ConfigDict(from_attributes=True)    # allow building from ORM objects
        code: str
        target_url: str
        clicks: int = 0
    
    data = LinkCreate.model_validate({"url": "https://example.com"})
    data.model_dump()          # -> dict
    data.model_dump_json()     # -> JSON string
    
    try:
        LinkCreate(url="not a url")
    except ValidationError as e:
        print(e.errors())      # FastAPI turns this into a 422 response automatically

    Watch out: HttpUrl is a URL object, not a str. Convert with str(data.url) before storing it. Also note that Pydantic's default (lax) mode converts compatible values, such as "5" into 5 for an int field.

    Configuration with pydantic-settings

    Typed config read from environment variables and a .env file. It fails fast at startup if something required is missing. Install it separately: uv add pydantic-settings.

    # app/core/config.py
    from pydantic_settings import BaseSettings, SettingsConfigDict
    
    class Settings(BaseSettings):
        model_config = SettingsConfigDict(env_file=".env")
    
        database_url: str                                # required: DATABASE_URL
        redis_url: str = "redis://localhost:6379/0"
        base_url: str = "http://localhost:8000"
        api_key: str = "change-me"                       # API_KEY in .env
        debug: bool = False                              # "true"/"1" become True
    
    settings = Settings()                                # import this everywhere
    # .env  (add to .gitignore)
    DATABASE_URL=postgresql+asyncpg://app:app@localhost:5432/shortener
    REDIS_URL=redis://localhost:6379/0

    async and await

    Async lets one thread juggle many tasks that are waiting on I/O (network, database, Redis). While one request waits for Postgres, the event loop serves others. It is concurrency, not parallelism.

    import asyncio
    import time
    
    async def fake_io(n: int) -> int:          # calling it returns a coroutine; nothing runs yet
        await asyncio.sleep(1)                 # yields control to the event loop while waiting
        return n
    
    async def main() -> None:
        start = time.perf_counter()
        results = await asyncio.gather(fake_io(1), fake_io(2), fake_io(3))
        print(results, f"{time.perf_counter() - start:.1f}s")    # [1, 2, 3] 1.0s, not 3.0s
    
    asyncio.run(main())                        # starts the loop (FastAPI does this for you)

    A real example with an async HTTP client (httpx ships with fastapi[standard]):

    import httpx
    
    async def check_alive(urls: list[str]) -> dict[str, int]:
        async with httpx.AsyncClient(timeout=5, follow_redirects=True) as client:
            responses = await asyncio.gather(*(client.head(u) for u in urls))
        return {u: r.status_code for u, r in zip(urls, responses)}

    The rules that matter in FastAPI:

    • Inside async def, never call blocking code: time.sleep, requests, synchronous database drivers. It stalls every request on the server. Use async libraries, or wrap unavoidable blocking calls with await asyncio.to_thread(fn, arg).
    • FastAPI runs plain def endpoints in a thread pool, so synchronous libraries are safe there. Choose per endpoint.
    • Async does not speed up CPU-heavy work. Offload that to a separate process or a job queue.

    HTTP in two minutes

    An API is a set of methods on resources, answered with status codes. Pick codes deliberately; clients depend on them.

    MethodMeaningIdempotent
    GETRead a resourceYes
    POSTCreate, or trigger an actionNo
    PUT / PATCHReplace / partially updatePUT yes, PATCH not necessarily
    DELETERemoveYes
    CodeUse it when
    200 / 201 / 204OK / created / success with no body
    301 / 302 / 307Permanent redirect (browsers cache it) / temporary / temporary, keeps method and body
    400 / 422Bad request / valid JSON that fails validation (FastAPI's default)
    401 / 403Not authenticated / authenticated but not allowed
    404 / 409Not found / conflicts with current state, such as a taken short code
    429 / 500Rate limited / your bug

    FastAPI

    Routes, validation, dependency injection, structure and tests. Enough to build a real service and know where each piece belongs.

    Your first app

    # main.py
    from fastapi import FastAPI
    
    app = FastAPI(title="Shortener")
    
    @app.get("/health")
    async def health() -> dict[str, str]:
        return {"status": "ok"}
    uv run fastapi dev main.py     # dev server with auto-reload on http://127.0.0.1:8000
    uv run fastapi run main.py     # production mode: no reload, listens on 0.0.0.0

    Open /docs for interactive Swagger UI, generated from your type hints. You'll test most endpoints there while building.

    Path, query and body

    FastAPI decides where each parameter comes from by looking at it: named in the path means path parameter; a simple type (str, int, bool) means query string; a Pydantic model means JSON body.

    from typing import Annotated
    from fastapi import FastAPI, Path, Query
    
    @app.get("/links/{code}")
    async def get_link(
        code: Annotated[str, Path(min_length=4, max_length=16)],   # from the URL path
        include_stats: bool = False,                               # ?include_stats=true
    ): ...
    
    @app.get("/links")
    async def list_links(
        limit: Annotated[int, Query(ge=1, le=100)] = 20,           # validated query param
        q: str | None = None,                                      # optional
    ): ...
    
    @app.post("/links", status_code=201)
    async def create_link(payload: LinkCreate) -> LinkOut:        # JSON body in, LinkOut out
        ...

    Responses and errors

    The return annotation validates, filters and documents what leaves your API. Fields not in the output model are never sent, which keeps internal columns private. If you return a dict or an ORM object instead of the model itself, declare the model with response_model=LinkOut on the decorator.

    from fastapi import HTTPException, Request, status
    from fastapi.responses import JSONResponse, RedirectResponse
    
    store: dict[str, str] = {"abc": "https://example.com"}    # code -> target URL
    
    @app.get("/links/{code}")
    async def get_link(code: str) -> LinkOut:
        target = store.get(code)
        if target is None:
            raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="link not found")
        return LinkOut(code=code, target_url=target)
    
    @app.get("/{code}")
    async def follow(code: str) -> RedirectResponse:
        target = resolve(store, code)                         # raises LinkNotFound
        return RedirectResponse(target, status_code=302)      # default is 307
    
    @app.exception_handler(LinkNotFound)                       # translate domain errors once
    async def on_not_found(request: Request, exc: LinkNotFound) -> JSONResponse:
        return JSONResponse(status_code=404, content={"detail": f"no link '{exc}'"})

    resolve and LinkNotFound are from the Exceptions stop. Raising HTTPException directly is fine in routes; domain exceptions plus a handler keep HTTP details out of your business logic.

    Free validation errors: invalid input never reaches your function. FastAPI returns a 422 with a list of which fields failed and why.

    Dependency injection with Depends

    A dependency is a function FastAPI calls before your endpoint, passing in its result. Use it for database sessions, the current user, API keys, pagination and settings. Dependencies can depend on other dependencies, and each one is called at most once per request.

    from typing import Annotated
    from fastapi import Depends, Header, HTTPException
    
    def pagination(skip: int = 0, limit: int = 20) -> tuple[int, int]:
        return skip, min(limit, 100)
    
    async def require_api_key(x_api_key: Annotated[str, Header()]) -> None:
        # reads the "x-api-key" header (underscores become hyphens)
        if x_api_key != settings.api_key:
            raise HTTPException(status_code=401, detail="invalid API key")
    
    Paging = Annotated[tuple[int, int], Depends(pagination)]      # reusable alias
    
    @app.get("/links", dependencies=[Depends(require_api_key)])   # run it, ignore its value
    async def list_links(paging: Paging):
        skip, limit = paging
        ...

    A dependency with yield does setup before the yield and cleanup after it. That's the pattern for database sessions in the persistence part. In tests, swap any dependency with app.dependency_overrides[real] = fake.

    Project structure with APIRouter

    Split by responsibility: routers handle HTTP, services hold business logic, schemas define API shapes, models define tables. Routers stay thin.

    app/
    ├── main.py            # create app, lifespan, include routers, exception handlers
    ├── core/config.py     # Settings
    ├── db.py              # engine, session dependency
    ├── cache.py           # Redis client dependency
    ├── errors.py          # domain exceptions: LinkNotFound, CodeTaken
    ├── models.py          # SQLAlchemy tables
    ├── schemas.py         # Pydantic request/response models
    ├── services/links.py  # business logic: no FastAPI imports here
    └── routers/
        ├── links.py       # /links endpoints
        └── redirect.py    # /{code}  (include this router last)
    tests/
    # app/routers/links.py
    from fastapi import APIRouter
    
    router = APIRouter(prefix="/links", tags=["links"])
    
    @router.get("/{code}")
    async def get_link(code: str): ...
    
    # app/main.py
    from app.routers import links, redirect
    app.include_router(links.router)
    app.include_router(redirect.router)    # catch-all /{code} goes last

    Route order matters: FastAPI matches routes in the order they were added. A catch-all like /{code} registered early would swallow /health.

    Lifespan, middleware and background tasks

    lifespan runs code once at startup (before yield) and once at shutdown (after). Create long-lived clients here, not per request. Middleware wraps every request.

    import time
    from contextlib import asynccontextmanager
    import redis.asyncio as redis
    from fastapi import FastAPI, Request
    from app.core.config import settings
    from app.db import engine
    
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        app.state.redis = redis.from_url(settings.redis_url, decode_responses=True)
        yield
        await app.state.redis.aclose()
        await engine.dispose()
    
    app = FastAPI(lifespan=lifespan)
    
    @app.middleware("http")                                 # runs around every request
    async def add_timing(request: Request, call_next):
        start = time.perf_counter()
        response = await call_next(request)
        response.headers["X-Process-Time"] = f"{time.perf_counter() - start:.4f}"
        return response
    from fastapi import BackgroundTasks
    
    @router.get("/{code}")
    async def follow(code: str, background: BackgroundTasks):
        background.add_task(log_click, code)        # runs after the response is sent
        ...

    Background tasks suit small fire-and-forget work. For anything that must not be lost, use a real queue later. When a browser frontend on another origin starts calling your API, add FastAPI's CORSMiddleware.

    Testing endpoints

    # tests/conftest.py  (fixtures here are shared by all test files)
    import pytest
    from fastapi.testclient import TestClient
    from app.main import app
    
    @pytest.fixture
    def client():
        with TestClient(app) as c:        # the with-block runs lifespan startup/shutdown
            yield c
        app.dependency_overrides.clear()
    
    # tests/test_links.py
    def test_health(client):
        r = client.get("/health")
        assert r.status_code == 200
        assert r.json() == {"status": "ok"}
    
    def test_rejects_bad_url(client):
        r = client.post("/links", json={"url": "not a url"})
        assert r.status_code == 422

    Watch out: TestClient(app) used without with does not run lifespan, so anything created at startup (like the Redis client) won't exist.

    Persistence and caching

    Postgres as the source of truth through SQLAlchemy 2.0 (async), schema changes with Alembic, and Redis for speed.

    Run Postgres and Redis locally

    # compose.yaml
    services:
      db:
        image: postgres:17
        environment:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: shortener
        ports: ["5432:5432"]
        volumes: [pgdata:/var/lib/postgresql/data]
      cache:
        image: redis:7
        ports: ["6379:6379"]
    volumes:
      pgdata:
    docker compose up -d
    uv add "sqlalchemy[asyncio]" asyncpg alembic redis pydantic-settings

    No Docker yet? Start with SQLite: uv add aiosqlite and DATABASE_URL=sqlite+aiosqlite:///./app.db. The SQLAlchemy code below stays the same.

    SQLAlchemy 2.0: engine, session, models

    The engine holds a connection pool (one per app). A session is a unit of work (one per request). Models map classes to tables using typed Mapped[...] columns.

    # app/db.py
    from collections.abc import AsyncIterator
    from typing import Annotated
    from fastapi import Depends
    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
    from sqlalchemy.orm import DeclarativeBase
    from app.core.config import settings
    
    engine = create_async_engine(settings.database_url)
    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
    
    class Base(DeclarativeBase):
        pass
    
    async def get_session() -> AsyncIterator[AsyncSession]:
        async with SessionLocal() as session:     # closed after the request
            yield session
    
    SessionDep = Annotated[AsyncSession, Depends(get_session)]

    Why expire_on_commit=False: by default a commit marks loaded objects as stale, so the next attribute access silently reloads them from the database. In async code that implicit reload fails with MissingGreenlet. Turn it off, and call await session.refresh(obj) when you need values the database generated.

    # app/models.py
    from datetime import datetime
    from sqlalchemy import DateTime, String, func
    from sqlalchemy.orm import Mapped, mapped_column
    from app.db import Base
    
    class Link(Base):
        __tablename__ = "links"
    
        id: Mapped[int] = mapped_column(primary_key=True)
        code: Mapped[str] = mapped_column(String(16), unique=True, index=True)
        target_url: Mapped[str] = mapped_column(String(2048))
        clicks: Mapped[int] = mapped_column(default=0)
        created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
        expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))   # "| None": nullable

    Always store UTC, timezone-aware: plain Mapped[datetime] becomes a column without a timezone and returns naive datetimes. Comparing those to datetime.now(timezone.utc) raises TypeError. DateTime(timezone=True) maps to Postgres timestamptz and returns aware values. SQLite does not store timezones at all, so if you prototype there, treat every value as UTC.

    Services own the transaction

    One rule for where database work lives: service functions query, commit and roll back, and raise domain exceptions. Routes only translate HTTP to a service call and back. Exception handlers in main.py map LinkNotFound to 404 and CodeTaken to 409.

    # app/services/links.py   (no FastAPI imports)
    from sqlalchemy import select, update
    from sqlalchemy.exc import IntegrityError
    from sqlalchemy.ext.asyncio import AsyncSession
    from app.codes import make_code
    from app.errors import CodeTaken, LinkNotFound
    from app.models import Link
    
    async def create_link(session: AsyncSession, target_url: str, code: str | None = None) -> Link:
        link = Link(code=code or make_code(), target_url=target_url)
        session.add(link)
        try:
            await session.commit()
        except IntegrityError as e:                 # unique constraint on code
            await session.rollback()
            raise CodeTaken(link.code) from e
        await session.refresh(link)                 # load server defaults (created_at)
        return link
    
    async def get_link(session: AsyncSession, code: str) -> Link:
        link = await session.scalar(select(Link).where(Link.code == code))
        if link is None:
            raise LinkNotFound(code)
        return link
    
    async def record_click(session: AsyncSession, code: str) -> None:
        # atomic increment in the database: no read-modify-write race
        await session.execute(update(Link).where(Link.code == code).values(clicks=Link.clicks + 1))
        await session.commit()
    # app/routers/links.py   (HTTP in, HTTP out)
    from app.services import links as svc
    
    @router.post("", status_code=201)               # POST /links
    async def create(payload: LinkCreate, session: SessionDep) -> LinkOut:
        link = await svc.create_link(session, str(payload.url), payload.code)
        return LinkOut.model_validate(link)
    
    @router.get("/{code}")
    async def read(code: str, session: SessionDep) -> LinkOut:
        return LinkOut.model_validate(await svc.get_link(session, code))

    Watch out: in async SQLAlchemy, touching a relationship that wasn't loaded raises MissingGreenlet instead of lazily querying. Load it up front with select(...).options(selectinload(Model.rel)).

    Migrations with Alembic

    Never edit a live schema by hand. Alembic compares your models to the database and writes versioned migration scripts you commit to git.

    uv run alembic init -t async migrations     # async-ready template
    # migrations/env.py  (edit near the top)
    from app.core.config import settings
    from app.db import Base
    import app.models  # noqa: F401   registers the tables on Base.metadata
    
    config.set_main_option("sqlalchemy.url", settings.database_url)
    target_metadata = Base.metadata
    uv run alembic revision --autogenerate -m "create links"   # review the generated file
    uv run alembic upgrade head                                 # apply
    uv run alembic downgrade -1                                 # undo the last one

    Prototype shortcut: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) in lifespan creates tables instantly, but can't alter existing ones. Switch to Alembic before your data matters.

    Redis basics

    An in-memory key-value store: microsecond reads, keys that expire on their own, and atomic counters. Create one client in lifespan (shown in the FastAPI part) and hand it out through a dependency.

    # app/cache.py
    from typing import Annotated
    import redis.asyncio as redis
    from fastapi import Depends, Request
    
    def get_redis(request: Request) -> redis.Redis:
        return request.app.state.redis
    
    RedisDep = Annotated[redis.Redis, Depends(get_redis)]
    
    # the commands you'll actually use
    await r.set("link:abc", "https://example.com", ex=86_400)   # value with a 24h TTL
    await r.get("link:abc")                                      # str, or None if missing/expired
    await r.delete("link:abc")
    await r.incr("clicks:abc")                                   # atomic +1, returns new value
    await r.expire("clicks:abc", 3600)                           # set a TTL on an existing key

    Conventions: namespace keys like link:{code}, give cached values a TTL, and treat Redis as disposable. The database stays the source of truth.

    Cache-aside

    Read from the cache; on a miss, read the database and fill the cache. When the data changes, delete the key so the next read refills it with fresh data.

    # app/services/links.py (continued)
    import redis.asyncio as redis
    
    async def resolve(session: AsyncSession, cache: redis.Redis, code: str) -> str:
        if cached := await cache.get(f"link:{code}"):
            return cached                                            # hit
        link = await get_link(session, code)                         # miss: raises LinkNotFound
        await cache.set(f"link:{code}", link.target_url, ex=86_400)  # fill
        return link.target_url
    
    async def delete_link(session: AsyncSession, cache: redis.Redis, code: str) -> None:
        link = await get_link(session, code)
        await session.delete(link)
        await session.commit()
        await cache.delete(f"link:{code}")                           # invalidate after the commit

    Capstone: build the URL shortener

    A spec, then seven steps. Each step lists what to do and shows the code as it stands at the end of it, so you can compare against your own.

    The spec

    EndpointDoesResponses
    POST /linksCreate a short link; optional custom code201 with code and short URL, 409 code taken, 422 invalid input
    GET /{code}Redirect to the target and count the click302, 404, 410 if expired
    GET /links/{code}Link details and click count200, 404
    DELETE /links/{code}Remove the link and its cache entry204, 404
    GET /healthLiveness check200

    How to work through it

    Build in seven steps, in order. Each step ends with a check you can run, and each one reuses a stop from above. Try it yourself first, then compare with the code shown. The code is one possible solution, not the only one. Finish a step, commit, and only then move on.

    1. In-memory version
    2. Validation and errors
    3. Structure and tests
    4. Persistence
    5. Caching
    6. Hardening
    7. Ship it

    Step 1: in-memory version

    Get all five endpoints answering before you think about structure. One file, one dict[str, str] mapping code to URL.

    1. Set up the project as in Project setup with uv, add fastapi[standard], and start the server as in Your first app.
    2. Write the five routes from the spec. Use path and body parameters as in Path, query and body.
    3. For now generate codes with secrets (see Design hints) and return the short URL built from the request's base URL.
    4. Open /docs and call every endpoint by hand, including the failure cases.

    The whole app at this point:

    # main.py
    import secrets
    import string
    
    from fastapi import FastAPI, HTTPException, Request
    from fastapi.responses import RedirectResponse
    from pydantic import BaseModel, HttpUrl
    
    app = FastAPI(title="Shortener")
    
    store: dict[str, str] = {}      # code -> target URL
    clicks: dict[str, int] = {}     # code -> click count
    ALPHABET = string.ascii_letters + string.digits
    
    
    class LinkCreate(BaseModel):
        url: HttpUrl
        code: str | None = None
    
    
    def make_code(length: int = 6) -> str:
        return "".join(secrets.choice(ALPHABET) for _ in range(length))
    
    
    @app.get("/health")
    async def health() -> dict[str, str]:
        return {"status": "ok"}
    
    
    @app.post("/links", status_code=201)
    async def create_link(payload: LinkCreate, request: Request) -> dict:
        code = payload.code or make_code()
        if code in store:
            raise HTTPException(status_code=409, detail="code already taken")
        store[code] = str(payload.url)
        clicks[code] = 0
        return {"code": code, "short_url": f"{request.base_url}{code}"}
    
    
    @app.get("/links/{code}")
    async def get_link(code: str) -> dict:
        if code not in store:
            raise HTTPException(status_code=404, detail="link not found")
        return {"code": code, "target_url": store[code], "clicks": clicks[code]}
    
    
    @app.delete("/links/{code}", status_code=204)
    async def delete_link(code: str) -> None:
        if code not in store:
            raise HTTPException(status_code=404, detail="link not found")
        del store[code]
        del clicks[code]
    
    
    @app.get("/{code}")                      # catch-all: keep it last
    async def follow(code: str) -> RedirectResponse:
        if code not in store:
            raise HTTPException(status_code=404, detail="link not found")
        clicks[code] += 1
        return RedirectResponse(store[code], status_code=302)

    Done when: every row of the spec table returns the right status code from /docs, even though the data vanishes on restart.

    Step 2: validation and errors

    Replace loose inputs and ad hoc HTTPException calls with schemas and domain exceptions.

    1. Write LinkCreate and LinkOut with Pydantic, as in Pydantic v2: HttpUrl for the target, an optional custom code with a length and pattern constraint.
    2. Add a validator that rejects reserved codes (see Design hints).
    3. Define exceptions such as CodeTaken and LinkNotFound, following Exceptions, and map them to 409 and 404 with exception handlers, as in Responses and errors.
    4. Set response_model and status_code on each route so the docs match the spec.

    Still one file. The new parts are the schemas, the two exceptions with their handlers, and get_target, which every route shares:

    # main.py
    import secrets
    import string
    from typing import Annotated
    
    from fastapi import FastAPI, Path, Request
    from fastapi.responses import JSONResponse, RedirectResponse
    from pydantic import BaseModel, Field, HttpUrl, field_validator
    
    app = FastAPI(title="Shortener")
    
    store: dict[str, str] = {}      # code -> target URL
    clicks: dict[str, int] = {}     # code -> click count
    ALPHABET = string.ascii_letters + string.digits
    RESERVED = {"docs", "redoc", "openapi.json", "links", "health"}
    
    Code = Annotated[str, Path(min_length=4, max_length=16)]
    
    
    # --- errors ---------------------------------------------------------------
    class LinkNotFound(Exception): ...
    class CodeTaken(Exception): ...
    
    
    @app.exception_handler(LinkNotFound)
    async def on_not_found(request: Request, exc: LinkNotFound) -> JSONResponse:
        return JSONResponse(status_code=404, content={"detail": f"no link '{exc}'"})
    
    
    @app.exception_handler(CodeTaken)
    async def on_taken(request: Request, exc: CodeTaken) -> JSONResponse:
        return JSONResponse(status_code=409, content={"detail": f"code '{exc}' is taken"})
    
    
    # --- schemas --------------------------------------------------------------
    class LinkCreate(BaseModel):
        url: HttpUrl
        code: str | None = Field(default=None, min_length=4, max_length=16,
                                 pattern=r"^[A-Za-z0-9_-]+$")
    
        @field_validator("code")
        @classmethod
        def not_reserved(cls, v: str | None) -> str | None:
            if v in RESERVED:
                raise ValueError("this code is reserved")
            return v
    
    
    class LinkOut(BaseModel):
        code: str
        short_url: str
        target_url: str
        clicks: int = 0
    
    
    # --- logic ----------------------------------------------------------------
    def make_code(length: int = 6) -> str:
        return "".join(secrets.choice(ALPHABET) for _ in range(length))
    
    
    def get_target(code: str) -> str:
        if code not in store:
            raise LinkNotFound(code)
        return store[code]
    
    
    # --- routes ---------------------------------------------------------------
    @app.get("/health")
    async def health() -> dict[str, str]:
        return {"status": "ok"}
    
    
    @app.post("/links", status_code=201)
    async def create_link(payload: LinkCreate, request: Request) -> LinkOut:
        code = payload.code or make_code()
        if code in store:
            raise CodeTaken(code)
        store[code] = str(payload.url)
        clicks[code] = 0
        return LinkOut(code=code, short_url=f"{request.base_url}{code}",
                       target_url=store[code])
    
    
    @app.get("/links/{code}")
    async def read_link(code: Code, request: Request) -> LinkOut:
        target = get_target(code)
        return LinkOut(code=code, short_url=f"{request.base_url}{code}",
                       target_url=target, clicks=clicks[code])
    
    
    @app.delete("/links/{code}", status_code=204)
    async def delete_link(code: Code) -> None:
        get_target(code)
        del store[code]
        del clicks[code]
    
    
    @app.get("/{code}")                      # catch-all: keep it last
    async def follow(code: Code) -> RedirectResponse:
        target = get_target(code)
        clicks[code] += 1
        return RedirectResponse(target, status_code=302)

    Done when: a bad URL gives 422, a duplicate custom code gives 409, and routes contain no raise HTTPException.

    Step 3: structure and tests

    Split the file by responsibility and lock the behavior in with tests before touching the database.

    1. Follow Project structure with APIRouter: routers/ for HTTP, services/ for logic, schemas/, and a config.py using pydantic-settings (see Configuration with pydantic-settings).
    2. Keep routes thin. They parse input, call a service and return the result.
    3. Inject the store through Depends (see Dependency injection with Depends) so tests can swap it.
    4. Write tests with TestClient as in Testing endpoints and Testing with pytest: one happy path and one failure per endpoint, using fixtures and dependency overrides.

    The layout:

    app/
    ├── __init__.py          # empty; one in every folder under app/
    ├── main.py
    ├── codes.py             # make_code
    ├── errors.py            # LinkNotFound, CodeTaken
    ├── schemas.py           # LinkCreate, LinkOut
    ├── store.py             # the in-memory store and its dependency
    ├── core/config.py
    ├── services/links.py
    └── routers/
        ├── links.py
        └── redirect.py
    tests/
    ├── conftest.py
    └── test_links.py

    The code moves out of main.py mostly unchanged. The new idea is that services take the store as an argument and are async already, so the persistence step swaps the argument and keeps every signature.

    # app/core/config.py
    from pydantic_settings import BaseSettings, SettingsConfigDict
    
    
    class Settings(BaseSettings):
        model_config = SettingsConfigDict(env_file=".env")
    
        base_url: str = "http://localhost:8000"
    
    
    settings = Settings()
    # app/errors.py
    class LinkNotFound(Exception): ...
    class CodeTaken(Exception): ...
    # app/codes.py
    import secrets
    import string
    
    ALPHABET = string.ascii_letters + string.digits
    
    
    def make_code(length: int = 6) -> str:
        return "".join(secrets.choice(ALPHABET) for _ in range(length))
    # app/schemas.py
    from pydantic import BaseModel, Field, HttpUrl, field_validator
    
    RESERVED = {"docs", "redoc", "openapi.json", "links", "health"}
    
    
    class LinkCreate(BaseModel):
        url: HttpUrl
        code: str | None = Field(default=None, min_length=4, max_length=16,
                                 pattern=r"^[A-Za-z0-9_-]+$")
    
        @field_validator("code")
        @classmethod
        def not_reserved(cls, v: str | None) -> str | None:
            if v in RESERVED:
                raise ValueError("this code is reserved")
            return v
    
    
    class LinkOut(BaseModel):
        code: str
        short_url: str
        target_url: str
        clicks: int = 0
    # app/store.py
    from dataclasses import dataclass
    from typing import Annotated
    
    from fastapi import Depends
    
    
    @dataclass
    class LinkRecord:
        code: str
        target_url: str
        clicks: int = 0
    
    
    Store = dict[str, LinkRecord]
    _store: Store = {}
    
    
    def get_store() -> Store:
        return _store
    
    
    StoreDep = Annotated[Store, Depends(get_store)]
    # app/services/links.py
    # No FastAPI imports here.
    from app.codes import make_code
    from app.errors import CodeTaken, LinkNotFound
    from app.store import LinkRecord, Store
    
    
    async def create_link(store: Store, target_url: str, code: str | None = None) -> LinkRecord:
        code = code or make_code()
        if code in store:
            raise CodeTaken(code)
        store[code] = LinkRecord(code=code, target_url=target_url)
        return store[code]
    
    
    async def get_link(store: Store, code: str) -> LinkRecord:
        if code not in store:
            raise LinkNotFound(code)
        return store[code]
    
    
    async def resolve(store: Store, code: str) -> str:
        return (await get_link(store, code)).target_url
    
    
    async def record_click(store: Store, code: str) -> None:
        store[code].clicks += 1
    
    
    async def delete_link(store: Store, code: str) -> None:
        await get_link(store, code)
        del store[code]
    # app/routers/links.py
    from typing import Annotated
    
    from fastapi import APIRouter, Path
    
    from app.core.config import settings
    from app.schemas import LinkCreate, LinkOut
    from app.services import links as svc
    from app.store import StoreDep
    
    router = APIRouter(prefix="/links", tags=["links"])
    
    Code = Annotated[str, Path(min_length=4, max_length=16)]
    
    
    def to_out(link) -> LinkOut:
        return LinkOut(code=link.code, short_url=f"{settings.base_url}/{link.code}",
                       target_url=link.target_url, clicks=link.clicks)
    
    
    @router.post("", status_code=201)
    async def create(payload: LinkCreate, store: StoreDep) -> LinkOut:
        return to_out(await svc.create_link(store, str(payload.url), payload.code))
    
    
    @router.get("/{code}")
    async def read(code: Code, store: StoreDep) -> LinkOut:
        return to_out(await svc.get_link(store, code))
    
    
    @router.delete("/{code}", status_code=204)
    async def remove(code: Code, store: StoreDep) -> None:
        await svc.delete_link(store, code)
    # app/routers/redirect.py
    from fastapi import APIRouter
    from fastapi.responses import RedirectResponse
    
    from app.routers.links import Code
    from app.services import links as svc
    from app.store import StoreDep
    
    router = APIRouter()
    
    
    @router.get("/{code}")
    async def follow(code: Code, store: StoreDep) -> RedirectResponse:
        target = await svc.resolve(store, code)
        await svc.record_click(store, code)
        return RedirectResponse(target, status_code=302)
    # app/main.py
    from fastapi import FastAPI, Request
    from fastapi.responses import JSONResponse
    
    from app.errors import CodeTaken, LinkNotFound
    from app.routers import links, redirect
    
    app = FastAPI(title="Shortener")
    
    
    @app.exception_handler(LinkNotFound)
    async def on_not_found(request: Request, exc: LinkNotFound) -> JSONResponse:
        return JSONResponse(status_code=404, content={"detail": f"no link '{exc}'"})
    
    
    @app.exception_handler(CodeTaken)
    async def on_taken(request: Request, exc: CodeTaken) -> JSONResponse:
        return JSONResponse(status_code=409, content={"detail": f"code '{exc}' is taken"})
    
    
    @app.get("/health")
    async def health() -> dict[str, str]:
        return {"status": "ok"}
    
    
    app.include_router(links.router)
    app.include_router(redirect.router)      # catch-all /{code} goes last
    # tests/conftest.py
    import pytest
    from fastapi.testclient import TestClient
    
    from app.main import app
    from app.store import get_store
    
    
    @pytest.fixture
    def client():
        fresh: dict = {}
        app.dependency_overrides[get_store] = lambda: fresh    # same dict for the whole test
        with TestClient(app) as c:
            yield c
        app.dependency_overrides.clear()
    # tests/test_links.py
    BODY = {"url": "https://example.com", "code": "abcd"}
    
    
    def test_health(client):
        assert client.get("/health").json() == {"status": "ok"}
    
    
    def test_create_and_read(client):
        r = client.post("/links", json=BODY)
        assert r.status_code == 201
        assert r.json()["short_url"].endswith("/abcd")
        assert client.get("/links/abcd").json()["clicks"] == 0
    
    
    def test_duplicate_code_is_409(client):
        client.post("/links", json=BODY)
        assert client.post("/links", json=BODY).status_code == 409
    
    
    def test_bad_input_is_422(client):
        assert client.post("/links", json={"url": "not a url"}).status_code == 422
        assert client.post("/links", json={**BODY, "code": "docs"}).status_code == 422
    
    
    def test_redirect_counts_clicks(client):
        client.post("/links", json=BODY)
        r = client.get("/abcd", follow_redirects=False)
        assert r.status_code == 302
        assert r.headers["location"] == "https://example.com/"
        assert client.get("/links/abcd").json()["clicks"] == 1
    
    
    def test_unknown_code_is_404(client):
        assert client.get("/nope1").status_code == 404
        assert client.get("/links/nope1").status_code == 404
    
    
    def test_delete(client):
        client.post("/links", json=BODY)
        assert client.delete("/links/abcd").status_code == 204
        assert client.delete("/links/abcd").status_code == 404

    Run them with uv run pytest.

    Done when: pytest passes, and you can change the internals of a service without editing a test.

    Step 4: persistence

    Swap the dictionary for a database without changing the routes or the HTTP-level tests.

    1. Start Postgres and Redis as in Run Postgres and Redis locally.
    2. Define a Link model with a unique indexed code, url, clicks, created_at and a nullable expires_at (see SQLAlchemy 2.0: engine, session, models).
    3. Rewrite the service functions following Services own the transaction: the service commits, routes never touch the session. Turn a unique-constraint failure into CodeTaken.
    4. Create the table with an Alembic migration (see Migrations with Alembic) instead of create_all.
    5. Point the tests at a separate test database and make each test start clean.
    docker compose up -d
    docker compose exec db createdb -U app shortener_test      # a separate database for tests
    uv run alembic init -t async migrations
    # edit migrations/env.py as in the Alembic stop, then:
    uv run alembic revision --autogenerate -m "create links"
    uv run alembic upgrade head
    # .env
    DATABASE_URL=postgresql+asyncpg://app:app@localhost:5432/shortener

    Delete app/store.py. schemas.py, errors.py, codes.py and tests/test_links.py do not change, which is the point of the previous step. These files change:

    # app/core/config.py
    from pydantic_settings import BaseSettings, SettingsConfigDict
    
    
    class Settings(BaseSettings):
        model_config = SettingsConfigDict(env_file=".env")
    
        database_url: str
        test_database_url: str = "postgresql+asyncpg://app:app@localhost:5432/shortener_test"
        base_url: str = "http://localhost:8000"
    
    
    settings = Settings()
    # app/db.py
    from collections.abc import AsyncIterator
    from typing import Annotated
    
    from fastapi import Depends
    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
    from sqlalchemy.orm import DeclarativeBase
    
    from app.core.config import settings
    
    engine = create_async_engine(settings.database_url)
    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
    
    
    class Base(DeclarativeBase):
        pass
    
    
    async def get_session() -> AsyncIterator[AsyncSession]:
        async with SessionLocal() as session:
            yield session
    
    
    SessionDep = Annotated[AsyncSession, Depends(get_session)]
    # app/models.py
    from datetime import datetime
    
    from sqlalchemy import DateTime, String, func
    from sqlalchemy.orm import Mapped, mapped_column
    
    from app.db import Base
    
    
    class Link(Base):
        __tablename__ = "links"
    
        id: Mapped[int] = mapped_column(primary_key=True)
        code: Mapped[str] = mapped_column(String(16), unique=True, index=True)
        target_url: Mapped[str] = mapped_column(String(2048))
        clicks: Mapped[int] = mapped_column(default=0)
        created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
        expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    # app/services/links.py
    # No FastAPI imports here. Services own the transaction.
    from sqlalchemy import select, update
    from sqlalchemy.exc import IntegrityError
    from sqlalchemy.ext.asyncio import AsyncSession
    
    from app.codes import make_code
    from app.errors import CodeTaken, LinkNotFound
    from app.models import Link
    
    
    async def create_link(session: AsyncSession, target_url: str, code: str | None = None) -> Link:
        link = Link(code=code or make_code(), target_url=target_url)
        session.add(link)
        try:
            await session.commit()
        except IntegrityError as e:              # unique constraint on code
            await session.rollback()
            raise CodeTaken(link.code) from e
        await session.refresh(link)              # load server defaults (created_at)
        return link
    
    
    async def get_link(session: AsyncSession, code: str) -> Link:
        link = await session.scalar(select(Link).where(Link.code == code))
        if link is None:
            raise LinkNotFound(code)
        return link
    
    
    async def resolve(session: AsyncSession, code: str) -> str:
        return (await get_link(session, code)).target_url
    
    
    async def record_click(session: AsyncSession, code: str) -> None:
        # atomic increment in the database: no read-modify-write race
        await session.execute(update(Link).where(Link.code == code).values(clicks=Link.clicks + 1))
        await session.commit()
    
    
    async def delete_link(session: AsyncSession, code: str) -> None:
        link = await get_link(session, code)
        await session.delete(link)
        await session.commit()
    # app/routers/links.py
    from typing import Annotated
    
    from fastapi import APIRouter, Path
    
    from app.core.config import settings
    from app.db import SessionDep
    from app.models import Link
    from app.schemas import LinkCreate, LinkOut
    from app.services import links as svc
    
    router = APIRouter(prefix="/links", tags=["links"])
    
    Code = Annotated[str, Path(min_length=4, max_length=16)]
    
    
    def to_out(link: Link) -> LinkOut:
        return LinkOut(code=link.code, short_url=f"{settings.base_url}/{link.code}",
                       target_url=link.target_url, clicks=link.clicks)
    
    
    @router.post("", status_code=201)
    async def create(payload: LinkCreate, session: SessionDep) -> LinkOut:
        return to_out(await svc.create_link(session, str(payload.url), payload.code))
    
    
    @router.get("/{code}")
    async def read(code: Code, session: SessionDep) -> LinkOut:
        return to_out(await svc.get_link(session, code))
    
    
    @router.delete("/{code}", status_code=204)
    async def remove(code: Code, session: SessionDep) -> None:
        await svc.delete_link(session, code)
    # app/routers/redirect.py
    from fastapi import APIRouter
    from fastapi.responses import RedirectResponse
    
    from app.db import SessionDep
    from app.routers.links import Code
    from app.services import links as svc
    
    router = APIRouter()
    
    
    @router.get("/{code}")
    async def follow(code: Code, session: SessionDep) -> RedirectResponse:
        target = await svc.resolve(session, code)
        await svc.record_click(session, code)
        return RedirectResponse(target, status_code=302)
    # app/main.py
    from contextlib import asynccontextmanager
    
    from fastapi import FastAPI, Request
    from fastapi.responses import JSONResponse
    
    from app.db import engine
    from app.errors import CodeTaken, LinkNotFound
    from app.routers import links, redirect
    
    
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        yield
        await engine.dispose()
    
    
    app = FastAPI(title="Shortener", lifespan=lifespan)
    
    
    @app.exception_handler(LinkNotFound)
    async def on_not_found(request: Request, exc: LinkNotFound) -> JSONResponse:
        return JSONResponse(status_code=404, content={"detail": f"no link '{exc}'"})
    
    
    @app.exception_handler(CodeTaken)
    async def on_taken(request: Request, exc: CodeTaken) -> JSONResponse:
        return JSONResponse(status_code=409, content={"detail": f"code '{exc}' is taken"})
    
    
    @app.get("/health")
    async def health() -> dict[str, str]:
        return {"status": "ok"}
    
    
    app.include_router(links.router)
    app.include_router(redirect.router)      # catch-all /{code} goes last
    # tests/conftest.py
    import asyncio
    
    import pytest
    from fastapi.testclient import TestClient
    from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
    from sqlalchemy.pool import NullPool
    
    from app.core.config import settings
    from app.db import Base, get_session
    from app.main import app
    
    # NullPool: no connection is reused across event loops (pytest's and TestClient's).
    test_engine = create_async_engine(settings.test_database_url, poolclass=NullPool)
    TestSession = async_sessionmaker(test_engine, expire_on_commit=False)
    
    
    async def reset_tables() -> None:
        async with test_engine.begin() as conn:
            await conn.run_sync(Base.metadata.drop_all)
            await conn.run_sync(Base.metadata.create_all)
    
    
    async def override_session():
        async with TestSession() as session:
            yield session
    
    
    @pytest.fixture
    def client():
        asyncio.run(reset_tables())               # empty tables for every test
        app.dependency_overrides[get_session] = override_session
        with TestClient(app) as c:
            yield c
        app.dependency_overrides.clear()

    Why NullPool in tests: TestClient runs the app on its own event loop, and a pooled async connection created on a different loop fails when reused. The table reset uses create_all because tests need a clean schema, not migration history.

    Done when: the same tests from the previous step pass against Postgres, and links survive a restart.

    Step 5: caching

    Make the hot path, the redirect, avoid the database.

    1. Connect Redis in the lifespan handler (see Lifespan, middleware and background tasks) and inject the client.
    2. Apply Cache-aside to GET /{code}: look in Redis, fall back to the database, store the result with a TTL.
    3. Delete the cache key in the delete service. Decide what a cache entry must never outlive, an expired link for example, and set its TTL accordingly.
    4. Still count the click on a cache hit. Decide where that write happens, then see the click-counting hint.

    Redis was already listed in compose.yaml. schemas.py, errors.py, codes.py and db.py are unchanged; cache.py is new:

    # app/cache.py
    from typing import Annotated
    
    import redis.asyncio as redis
    from fastapi import Depends, Request
    
    
    def get_redis(request: Request) -> redis.Redis:
        return request.app.state.redis
    
    
    RedisDep = Annotated[redis.Redis, Depends(get_redis)]
    # app/core/config.py
    from pydantic_settings import BaseSettings, SettingsConfigDict
    
    
    class Settings(BaseSettings):
        model_config = SettingsConfigDict(env_file=".env")
    
        database_url: str
        test_database_url: str = "postgresql+asyncpg://app:app@localhost:5432/shortener_test"
        redis_url: str = "redis://localhost:6379/0"
        test_redis_url: str = "redis://localhost:6379/15"
        base_url: str = "http://localhost:8000"
    
    
    settings = Settings()
    # app/main.py
    from contextlib import asynccontextmanager
    
    import redis.asyncio as redis
    from fastapi import FastAPI, Request
    from fastapi.responses import JSONResponse
    
    from app.core.config import settings
    from app.db import engine
    from app.errors import CodeTaken, LinkNotFound
    from app.routers import links, redirect
    
    
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        app.state.redis = redis.from_url(settings.redis_url, decode_responses=True)
        yield
        await app.state.redis.aclose()
        await engine.dispose()
    
    
    app = FastAPI(title="Shortener", lifespan=lifespan)
    
    
    @app.exception_handler(LinkNotFound)
    async def on_not_found(request: Request, exc: LinkNotFound) -> JSONResponse:
        return JSONResponse(status_code=404, content={"detail": f"no link '{exc}'"})
    
    
    @app.exception_handler(CodeTaken)
    async def on_taken(request: Request, exc: CodeTaken) -> JSONResponse:
        return JSONResponse(status_code=409, content={"detail": f"code '{exc}' is taken"})
    
    
    @app.get("/health")
    async def health() -> dict[str, str]:
        return {"status": "ok"}
    
    
    app.include_router(links.router)
    app.include_router(redirect.router)      # catch-all /{code} goes last
    # app/services/links.py
    # No FastAPI imports here. Services own the transaction.
    import redis.asyncio as redis
    from sqlalchemy import select, update
    from sqlalchemy.exc import IntegrityError
    from sqlalchemy.ext.asyncio import AsyncSession
    
    from app.codes import make_code
    from app.errors import CodeTaken, LinkNotFound
    from app.models import Link
    
    
    async def create_link(session: AsyncSession, target_url: str, code: str | None = None) -> Link:
        link = Link(code=code or make_code(), target_url=target_url)
        session.add(link)
        try:
            await session.commit()
        except IntegrityError as e:              # unique constraint on code
            await session.rollback()
            raise CodeTaken(link.code) from e
        await session.refresh(link)              # load server defaults (created_at)
        return link
    
    
    async def get_link(session: AsyncSession, code: str) -> Link:
        link = await session.scalar(select(Link).where(Link.code == code))
        if link is None:
            raise LinkNotFound(code)
        return link
    
    
    async def resolve(session: AsyncSession, cache: redis.Redis, code: str) -> str:
        if cached := await cache.get(f"link:{code}"):
            return cached                                            # hit
        link = await get_link(session, code)                         # miss: raises LinkNotFound
        await cache.set(f"link:{code}", link.target_url, ex=86_400)  # fill
        return link.target_url
    
    
    async def record_click(session: AsyncSession, code: str) -> None:
        # atomic increment in the database: no read-modify-write race
        await session.execute(update(Link).where(Link.code == code).values(clicks=Link.clicks + 1))
        await session.commit()
    
    
    async def delete_link(session: AsyncSession, cache: redis.Redis, code: str) -> None:
        link = await get_link(session, code)
        await session.delete(link)
        await session.commit()
        await cache.delete(f"link:{code}")                           # invalidate after the commit
    # app/routers/links.py
    from typing import Annotated
    
    from fastapi import APIRouter, Path
    
    from app.core.config import settings
    from app.cache import RedisDep
    from app.db import SessionDep
    from app.models import Link
    from app.schemas import LinkCreate, LinkOut
    from app.services import links as svc
    
    router = APIRouter(prefix="/links", tags=["links"])
    
    Code = Annotated[str, Path(min_length=4, max_length=16)]
    
    
    def to_out(link: Link) -> LinkOut:
        return LinkOut(code=link.code, short_url=f"{settings.base_url}/{link.code}",
                       target_url=link.target_url, clicks=link.clicks)
    
    
    @router.post("", status_code=201)
    async def create(payload: LinkCreate, session: SessionDep) -> LinkOut:
        return to_out(await svc.create_link(session, str(payload.url), payload.code))
    
    
    @router.get("/{code}")
    async def read(code: Code, session: SessionDep) -> LinkOut:
        return to_out(await svc.get_link(session, code))
    
    
    @router.delete("/{code}", status_code=204)
    async def remove(code: Code, session: SessionDep, cache: RedisDep) -> None:
        await svc.delete_link(session, cache, code)
    # app/routers/redirect.py
    from fastapi import APIRouter
    from fastapi.responses import RedirectResponse
    
    from app.cache import RedisDep
    from app.db import SessionDep
    from app.routers.links import Code
    from app.services import links as svc
    
    router = APIRouter()
    
    
    @router.get("/{code}")
    async def follow(code: Code, session: SessionDep, cache: RedisDep) -> RedirectResponse:
        target = await svc.resolve(session, cache, code)
        await svc.record_click(session, code)
        return RedirectResponse(target, status_code=302)
    # tests/conftest.py
    import asyncio
    
    import pytest
    import redis as redis_sync
    import redis.asyncio as redis
    from fastapi.testclient import TestClient
    from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
    from sqlalchemy.pool import NullPool
    
    from app.cache import get_redis
    from app.core.config import settings
    from app.db import Base, get_session
    from app.main import app
    
    # NullPool: no connection is reused across event loops (pytest's and TestClient's).
    test_engine = create_async_engine(settings.test_database_url, poolclass=NullPool)
    TestSession = async_sessionmaker(test_engine, expire_on_commit=False)
    
    
    async def reset_tables() -> None:
        async with test_engine.begin() as conn:
            await conn.run_sync(Base.metadata.drop_all)
            await conn.run_sync(Base.metadata.create_all)
    
    
    async def override_session():
        async with TestSession() as session:
            yield session
    
    
    async def override_redis():
        r = redis.from_url(settings.test_redis_url, decode_responses=True)
        try:
            yield r
        finally:
            await r.aclose()
    
    
    @pytest.fixture
    def cache():                                  # sync client for assertions inside tests
        r = redis_sync.Redis.from_url(settings.test_redis_url, decode_responses=True)
        r.flushdb()
        yield r
        r.close()
    
    
    @pytest.fixture
    def client(cache):
        asyncio.run(reset_tables())               # empty tables for every test
        app.dependency_overrides[get_session] = override_session
        app.dependency_overrides[get_redis] = override_redis
        with TestClient(app) as c:
            yield c
        app.dependency_overrides.clear()

    Add this test to tests/test_links.py:

    def test_redirect_fills_cache_and_delete_clears_it(client, cache):
        client.post("/links", json=BODY)
        assert cache.get("link:abcd") is None
        client.get("/abcd", follow_redirects=False)
        assert cache.get("link:abcd") == "https://example.com/"
        client.delete("/links/abcd")
        assert cache.get("link:abcd") is None
        assert client.get("/abcd").status_code == 404

    What the cache saves: a hit skips the SELECT, but record_click still writes on every redirect. Moving that write to a Redis counter is the stretch goal in Design hints.

    Done when: a second redirect to the same code does not query Postgres (check with SQL logging or a counter in a test), and a deleted link returns 404 immediately.

    Step 6: hardening

    Add the three protections a public service needs.

    1. Expiry: return 410 when expires_at has passed, using timezone-aware datetimes.
    2. API key: put the check in a dependency and attach it to POST and DELETE only. Read the key from settings and compare with secrets.compare_digest.
    3. Rate limiting: a fixed-window counter in Redis as described in Design hints, written as a dependency that returns 429.
    4. Add a test for each protection.

    New files are security.py and ratelimit.py. The rest add an exception, a field and two dependencies:

    # app/security.py
    import secrets
    from typing import Annotated
    
    from fastapi import Header, HTTPException
    
    from app.core.config import settings
    
    
    async def require_api_key(x_api_key: Annotated[str | None, Header()] = None) -> None:
        expected = settings.api_key.encode()
        if x_api_key is None or not secrets.compare_digest(x_api_key.encode(), expected):
            raise HTTPException(status_code=401, detail="invalid API key")
    # app/ratelimit.py
    import time
    
    from fastapi import HTTPException, Request
    
    from app.cache import RedisDep
    from app.core.config import settings
    
    
    async def rate_limit(request: Request, cache: RedisDep) -> None:
        client = request.client.host if request.client else "unknown"
        key = f"rl:{client}:{int(time.time() // 60)}"          # one counter per client per minute
        async with cache.pipeline(transaction=True) as pipe:   # INCR and EXPIRE run together
            pipe.incr(key)
            pipe.expire(key, 60)
            count, _ = await pipe.execute()
        if count > settings.rate_limit_per_minute:
            raise HTTPException(status_code=429, detail="too many requests")
    # app/core/config.py
    from pydantic_settings import BaseSettings, SettingsConfigDict
    
    
    class Settings(BaseSettings):
        model_config = SettingsConfigDict(env_file=".env")
    
        database_url: str
        test_database_url: str = "postgresql+asyncpg://app:app@localhost:5432/shortener_test"
        redis_url: str = "redis://localhost:6379/0"
        test_redis_url: str = "redis://localhost:6379/15"
        api_key: str = "change-me"
        rate_limit_per_minute: int = 30
        base_url: str = "http://localhost:8000"
    
    
    settings = Settings()
    # app/errors.py
    class LinkNotFound(Exception): ...
    class CodeTaken(Exception): ...
    class LinkExpired(Exception): ...
    # app/schemas.py
    from datetime import datetime, timezone
    
    from pydantic import AwareDatetime, BaseModel, Field, HttpUrl, field_validator
    
    RESERVED = {"docs", "redoc", "openapi.json", "links", "health"}
    
    
    class LinkCreate(BaseModel):
        url: HttpUrl
        code: str | None = Field(default=None, min_length=4, max_length=16,
                                 pattern=r"^[A-Za-z0-9_-]+$")
        expires_at: AwareDatetime | None = None        # must carry a timezone
    
        @field_validator("code")
        @classmethod
        def not_reserved(cls, v: str | None) -> str | None:
            if v in RESERVED:
                raise ValueError("this code is reserved")
            return v
    
        @field_validator("expires_at")
        @classmethod
        def in_the_future(cls, v: datetime | None) -> datetime | None:
            if v is not None and v <= datetime.now(timezone.utc):
                raise ValueError("expires_at must be in the future")
            return v
    
    
    class LinkOut(BaseModel):
        code: str
        short_url: str
        target_url: str
        clicks: int = 0
        expires_at: datetime | None = None
    # app/services/links.py
    # No FastAPI imports here. Services own the transaction.
    from datetime import datetime, timezone
    
    import redis.asyncio as redis
    from sqlalchemy import select, update
    from sqlalchemy.exc import IntegrityError
    from sqlalchemy.ext.asyncio import AsyncSession
    
    from app.codes import make_code
    from app.errors import CodeTaken, LinkExpired, LinkNotFound
    from app.models import Link
    
    
    async def create_link(session: AsyncSession, target_url: str, code: str | None = None,
                          expires_at: datetime | None = None) -> Link:
        link = Link(code=code or make_code(), target_url=target_url, expires_at=expires_at)
        session.add(link)
        try:
            await session.commit()
        except IntegrityError as e:              # unique constraint on code
            await session.rollback()
            raise CodeTaken(link.code) from e
        await session.refresh(link)              # load server defaults (created_at)
        return link
    
    
    async def get_link(session: AsyncSession, code: str) -> Link:
        link = await session.scalar(select(Link).where(Link.code == code))
        if link is None:
            raise LinkNotFound(code)
        return link
    
    
    async def resolve(session: AsyncSession, cache: redis.Redis, code: str) -> str:
        if cached := await cache.get(f"link:{code}"):
            return cached                                            # hit
        link = await get_link(session, code)                         # miss: raises LinkNotFound
        ttl = 86_400
        if link.expires_at is not None:
            remaining = int((link.expires_at - datetime.now(timezone.utc)).total_seconds())
            if remaining <= 0:
                raise LinkExpired(code)
            ttl = min(ttl, remaining)                                # never outlive the link
        await cache.set(f"link:{code}", link.target_url, ex=ttl)     # fill
        return link.target_url
    
    
    async def record_click(session: AsyncSession, code: str) -> None:
        # atomic increment in the database: no read-modify-write race
        await session.execute(update(Link).where(Link.code == code).values(clicks=Link.clicks + 1))
        await session.commit()
    
    
    async def delete_link(session: AsyncSession, cache: redis.Redis, code: str) -> None:
        link = await get_link(session, code)
        await session.delete(link)
        await session.commit()
        await cache.delete(f"link:{code}")                           # invalidate after the commit
    # app/routers/links.py
    from typing import Annotated
    
    from fastapi import APIRouter, Depends, Path
    
    from app.core.config import settings
    from app.cache import RedisDep
    from app.db import SessionDep
    from app.models import Link
    from app.ratelimit import rate_limit
    from app.schemas import LinkCreate, LinkOut
    from app.security import require_api_key
    from app.services import links as svc
    
    router = APIRouter(prefix="/links", tags=["links"])
    
    Code = Annotated[str, Path(min_length=4, max_length=16)]
    
    
    def to_out(link: Link) -> LinkOut:
        return LinkOut(code=link.code, short_url=f"{settings.base_url}/{link.code}",
                       target_url=link.target_url, clicks=link.clicks, expires_at=link.expires_at)
    
    
    @router.post("", status_code=201,
                 dependencies=[Depends(require_api_key), Depends(rate_limit)])
    async def create(payload: LinkCreate, session: SessionDep) -> LinkOut:
        return to_out(await svc.create_link(session, str(payload.url), payload.code,
                                                payload.expires_at))
    
    
    @router.get("/{code}")
    async def read(code: Code, session: SessionDep) -> LinkOut:
        return to_out(await svc.get_link(session, code))
    
    
    @router.delete("/{code}", status_code=204, dependencies=[Depends(require_api_key)])
    async def remove(code: Code, session: SessionDep, cache: RedisDep) -> None:
        await svc.delete_link(session, cache, code)

    In app/main.py, import LinkExpired and add one more handler:

    # app/main.py (addition)
    @app.exception_handler(LinkExpired)
    async def on_expired(request: Request, exc: LinkExpired) -> JSONResponse:
        return JSONResponse(status_code=410, content={"detail": f"link '{exc}' has expired"})

    In tests/conftest.py, send the key by default: TestClient(app, headers={"x-api-key": settings.api_key}). Then extend tests/test_links.py:

    # tests/test_links.py (new imports at the top)
    import asyncio
    from datetime import datetime, timedelta, timezone
    
    from sqlalchemy import update
    
    from app.core.config import settings
    from app.models import Link
    from tests.conftest import TestSession
    
    
    # ...existing tests...
    
    
    def test_write_endpoints_need_the_api_key(client):
        bad = {"x-api-key": "wrong"}
        assert client.post("/links", json=BODY, headers=bad).status_code == 401
        assert client.delete("/links/abcd", headers=bad).status_code == 401
    
    
    def test_rate_limit(client, monkeypatch):
        monkeypatch.setattr(settings, "rate_limit_per_minute", 2)
        codes = ["aaaa", "bbbb", "cccc"]
        statuses = [client.post("/links", json={**BODY, "code": c}).status_code for c in codes]
        assert statuses == [201, 201, 429]
    
    
    def test_expiry(client):
        assert client.post("/links", json={**BODY, "expires_at": "2020-01-01T00:00:00Z"}).status_code == 422
        assert client.post("/links", json={**BODY, "expires_at": "2099-01-01T00:00:00"}).status_code == 422
        client.post("/links", json={**BODY, "expires_at": "2099-01-01T00:00:00Z"})
        asyncio.run(expire("abcd"))
        assert client.get("/abcd", follow_redirects=False).status_code == 410
    
    
    async def expire(code: str) -> None:
        async with TestSession() as session:
            past = datetime.now(timezone.utc) - timedelta(hours=1)
            await session.execute(update(Link).where(Link.code == code).values(expires_at=past))
            await session.commit()

    Why expires_at is capped by the cache TTL: a cached entry is served without looking at the database, so it must disappear no later than the link does. Every dependency of POST /links runs in list order, so the key check comes before the rate limit and unauthenticated callers don't use up the quota.

    Done when: missing or wrong key gives 401, the limit trips with 429, and an expired link gives 410.

    Step 7: ship it

    Run the whole system with one command.

    1. Write a Dockerfile: a Python base image, install dependencies with uv, copy the code, run fastapi run.
    2. Add the app to compose.yaml next to Postgres and Redis. Pass settings through environment variables, and use the service names as hosts.
    3. Run migrations before the app starts, either as a separate compose service or a startup command.
    4. Walk the full flow against the containers: create, redirect, details, delete.

    Inside the compose network the hosts are the service names, so the URLs differ from your local .env. Environment variables beat .env in pydantic-settings, so nothing in the image needs editing.

    # Dockerfile
    FROM python:3.13-slim
    COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
    WORKDIR /srv
    COPY pyproject.toml uv.lock ./
    RUN uv sync --frozen --no-dev --no-install-project
    COPY . .
    ENV PATH="/srv/.venv/bin:$PATH"
    EXPOSE 8000
    CMD ["sh", "-c", "alembic upgrade head && fastapi run app/main.py --port 8000"]
    # .dockerignore
    .venv
    .env
    __pycache__
    .pytest_cache
    .git
    # compose.yaml
    services:
      api:
        build: .
        ports: ["8000:8000"]
        environment:
          DATABASE_URL: postgresql+asyncpg://app:app@db:5432/shortener
          REDIS_URL: redis://cache:6379/0
          BASE_URL: http://localhost:8000
          API_KEY: ${API_KEY:?set API_KEY in your shell or a .env file}
        depends_on:
          db:
            condition: service_healthy
          cache:
            condition: service_started
      db:
        image: postgres:17
        environment:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: shortener
        ports: ["5432:5432"]
        volumes: [pgdata:/var/lib/postgresql/data]
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U app -d shortener"]
          interval: 2s
          retries: 15
      cache:
        image: redis:7
        ports: ["6379:6379"]
    volumes:
      pgdata:
    API_KEY=pick-a-secret docker compose up --build
    curl -s -X POST localhost:8000/links -H "x-api-key: pick-a-secret" \
         -H "content-type: application/json" -d '{"url": "https://example.com"}'
    curl -si localhost:8000/health

    Watch out: the CLI needs every folder under app/ to be a package (an __init__.py in each), or from app... imports fail inside the container. Running migrations in the start command is fine for one container; with several replicas, run them as a separate one-off step instead.

    Done when: docker compose up on a clean checkout gives a working API at /docs with nothing installed but Docker.

    Design hints

    • Generating codes: either encode the row's integer id in base62 (short, unique, but guessable and sequential) or draw random characters with the secrets module and retry on collision. Try both and compare.
    • Redirect status: 301 is cached by browsers, so repeat visits never reach your server and click counts undercount. Use 302 or 307.
    • Reserved codes: any code that matches one of your own paths (docs, links, health) must be rejected at validation.
    • Expiry: compare expires_at with datetime.now(timezone.utc). Both sides must be timezone-aware.
    • Rate limiting: a fixed window is one Redis counter per client per minute. INCR a key like rl:{client}:{minute}, set a 60-second EXPIRE when the count is 1, and return 429 above your limit. Two traps: INCR and EXPIRE are separate calls, so run them in a pipeline with transaction=True; and behind a proxy, request.client.host is the proxy's address (and request.client can be None), so configure the server to trust the proxy's forwarded header (uvicorn: --forwarded-allow-ips).
    • Counting clicks at scale: writing to Postgres on every redirect gets expensive. Stretch goal: INCR in Redis and flush totals to the database periodically.
    • Normalize before storing: str(payload.url) from HttpUrl gives a normalized URL string; decide whether identical URLs should share one code.