Building a REST API with FastAPI: A Practical Starter
If you're building the backend for a mobile or web app in Python, FastAPI is one of the best places to start — and it's what I reach for whenever a project needs a Python API. It's modern, fast, and gives you request validation and interactive documentation almost for free, which means the boring 40% of API work — checking inputs, documenting endpoints, keeping the two in sync — mostly evaporates. This post walks through the core ideas and, more importantly, the practices that keep a FastAPI project healthy after the honeymoon, when it's grown from three endpoints to thirty.
Why FastAPI
Three things make FastAPI stand out from the older Python frameworks:
- Type-driven validation. You declare what your data looks like with ordinary Python type hints, and FastAPI validates every incoming request against them automatically. Bad requests get clear, structured errors before your code ever runs — a whole class of defensive boilerplate you never write.
- Automatic documentation. It generates interactive OpenAPI docs from your code at
/docs— every endpoint, every field, every type, testable in the browser. Because the docs are derived from the code, they can't drift out of date, which anyone who has maintained a hand-written API document knows is the only kind of API documentation that stays true. - Async support. It's built on modern async Python (the ASGI ecosystem), so it handles many simultaneous connections efficiently — a real advantage for an API backing a busy app, where most request time is spent waiting on a database or another service.
A minimal API
Here's a complete, working endpoint:
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
Run it with an ASGI server (uvicorn main:app --reload) and you have a live API with auto-generated docs at /docs. That's the whole "hello world" — and that /health endpoint isn't a throwaway, either; it's what your uptime monitor and deployment checks will hit forever. Every real API should have one.
Validation with models
The real power shows up when you define the shape of your data using Pydantic models. FastAPI uses them to validate input, serialize output, and document both:
from pydantic import BaseModel
class Expense(BaseModel):
title: str
amount: float
category: str | None = None
@app.post("/expenses")
def create_expense(expense: Expense):
# `expense` is already validated and typed here
saved = save_to_db(expense)
return {"id": saved.id}
If a client sends a missing field, the wrong type, or malformed JSON, FastAPI rejects the request with a helpful, machine-readable error automatically — your handler only ever sees valid data. You spend your time on logic, not on hand-writing if "amount" not in body checks.
Two habits worth adopting early. First, constrain more than types: Pydantic's Field lets you say amount: float = Field(gt=0) — an expense can't be negative, so encode that instead of discovering it in production data. Second, use separate input and output models where they differ (ExpenseCreate vs ExpenseOut): what a client may send and what your API returns are rarely identical, and separating them prevents fields like id or owner from being client-settable by accident. FastAPI's response_model parameter also filters the response — a database object with a secret field, returned through the right output model, sheds the secret automatically.
Async when it helps
For endpoints that wait on I/O — a database, another API, an LLM call — declare them async and use await, so the server can handle other requests while yours waits:
@app.get("/summary")
async def summary():
data = await fetch_from_db() # non-blocking
return {"summary": data}
The rules that keep this from biting you: use async def with awaitable libraries for I/O-bound work; plain def is perfectly fine for simple handlers (FastAPI runs those in a thread pool, so they don't block anything). The one genuine trap: a slow, blocking call inside an async def — a synchronous database driver, time.sleep, heavy computation — stalls the entire event loop, freezing every request in the process, not just this one. It's the classic FastAPI performance mystery: "the whole API gets slow sometimes" traces back to one blocking call in one async handler. If it blocks, keep it in a plain def or hand it to a thread.
Structure it before it sprawls
A single main.py is great for a demo and painful for a real app. As soon as you have more than a handful of endpoints, organize:
- Routers — group related endpoints (
expenses,users) into separate modules viaAPIRouter, included into the app with prefixes and tags. - Schemas — keep Pydantic models together, separate from route logic.
- Services — put business logic in its own layer, so routes stay thin (parse → call service → shape response) and the logic is testable without HTTP.
- Dependencies — use FastAPI's dependency injection for shared concerns: database sessions, pagination parameters, and especially the "current authenticated user," which becomes one
Depends(get_current_user)on every protected route instead of copy-pasted token checks.
The dependency system is the feature that most repays learning properly — it's how FastAPI projects stay clean at scale, and it makes testing dramatically easier because any dependency can be overridden with a fake.
Don't skip these
A few things that separate a toy from a shippable API:
- Authentication. Protect endpoints that need it, verify tokens server-side, and inject the user via a dependency. (The token design itself is a topic I've covered in API Authentication with JWT.)
- Error handling. Return meaningful HTTP status codes and one consistent error shape your client can rely on — mobile developers consuming your API will thank you for never having to guess whether errors come as
detail,message, orerror. - Environment configuration. Secrets (database URLs, API keys) live in environment variables, never in code — pydantic-settings reads and validates them at startup, so a missing variable fails loudly at boot instead of mysteriously at 2 a.m.
- CORS. If a browser app calls your API, configure CORS middleware deliberately — specific origins, not a reflexive
*. - Tests. FastAPI's test client runs your app in-process — no server, no network — so endpoint tests are fast and easy to write from day one. The dependency-override mechanism means even auth and database access are swappable in tests. There is no excuse quite as weak as an untested FastAPI app.
Summary
FastAPI lets you build a modern Python backend quickly without sacrificing rigor: type hints give you automatic validation and always-true documentation, Pydantic models encode your data rules once for input, output, and docs, and async support keeps it efficient under I/O-heavy load — as long as nothing blocking sneaks into an async handler. Start with simple typed endpoints, split into routers, schemas, and services before the code sprawls, lean on dependency injection for auth and shared resources, and write tests against the built-in client from the start. It's a stack that's pleasant on day one and — the rarer thing — still pleasant in month twelve.
Further reading
- FastAPI documentation — genuinely among the best docs of any framework; the tutorial is worth reading end to end.
- Pydantic — the validation engine underneath; its
Fieldconstraints solve most input problems. - Uvicorn — the ASGI server you'll run it with.
- API authentication with JWT, explained — designing the auth layer this post told you not to skip.