Python & FastAPI6 min read·By Liyabona Saki··

Building REST APIs with FastAPI — A Complete Guide

A complete, production-focused walkthrough of building REST APIs with FastAPI — Pydantic models, dependency injection, async endpoints, SQLAlchemy and Docker.

What changes vs Flask, Django, or Spring (a diff-style walkthrough)

If you are coming to FastAPI from another HTTP framework — Flask, Django REST, Express, or Spring Boot — the parts that *look* familiar are familiar, and the parts that look different are different for specific reasons. This guide is organised as a diff against those expectations, not as a from-scratch tutorial. Each section names the assumption you bring with you and shows what FastAPI does instead and why.

Assumption 1: "Routing is decorated handlers." Mostly true, but with type hints doing real work.

The shape will look familiar:

```python
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Order(BaseModel): id: str quantity: int

@app.post("/orders", status_code=201, response_model=Order) def create_order(order: Order) -> Order: return order ```

The difference: order: Order is not just type documentation. FastAPI inspects the annotation at startup, generates a JSON Schema, validates incoming requests against it, and feeds the schema into the auto-generated OpenAPI doc at /docs. Validation and documentation are the same thing. In Flask + marshmallow you wire those up by hand and they drift; in FastAPI they cannot drift because they share a source.

The cost: at startup, FastAPI walks every route to build the schema. A service with 800 endpoints starts noticeably slower than one with 8.

Assumption 2: "I'll add async wherever it speeds things up." Half-true; the trap is mixing.

def vs async def is a real choice, not cosmetic:

```python
@app.get("/sync")
def sync_handler():
    time.sleep(1)        # blocks a thread from the threadpool
    return {"ok": True}

@app.get("/async") async def async_handler(): await asyncio.sleep(1) # yields the event loop return {"ok": True} ```

FastAPI runs def handlers in an external threadpool and async def handlers on the event loop. Both work. The trap is calling synchronous I/O (requests.get, blocking SQLAlchemy session, psycopg2 query) inside an async def handler. That blocks the event loop and every concurrent request on the same worker stalls.

Rule of thumb: if any I/O in the handler is synchronous, declare the handler def. If everything is awaitable, declare it async def. Never half-and-half.

Assumption 3: "Dependency injection is a Spring thing." FastAPI's is lighter and pure-function.

Spring's DI is a runtime container with scopes, profiles and bean post-processors. FastAPI's is a function-composition system — no container, no lifecycle, just functions calling functions:

```python
from fastapi import Depends

def get_db(): db = SessionLocal() try: yield db finally: db.close()

@app.get("/orders/{id}") def read(id: str, db = Depends(get_db)): return db.query(Order).filter_by(id=id).one() ```

The handler doesn't know how db is built. The yield form lets the dependency clean up after the handler returns. Dependencies can depend on other dependencies. Testing replaces dependencies with app.dependency_overrides[get_db] = fake_db — one line, no monkeypatching.

This is the feature most ex-Spring users underrate at first and use everywhere by week two.

Assumption 4: "Authentication is middleware." FastAPI prefers a dependency.

You *can* write middleware. You usually shouldn't, because dependencies compose better with the per-route metadata FastAPI already collects:

```python
from fastapi.security import OAuth2PasswordBearer
from jose import jwt

oauth2 = OAuth2PasswordBearer(tokenUrl="auth/login")

def current_user(token: str = Depends(oauth2)) -> User: try: payload = jwt.decode(token, SECRET, algorithms=["HS256"]) except JWTError: raise HTTPException(401, "invalid token") return User(**payload)

@app.get("/me", response_model=User) def me(user: User = Depends(current_user)): return user ```

OAuth2PasswordBearer is also responsible for the "Authorize" button in /docs — middleware can't contribute to OpenAPI. That alone is the reason to prefer dependencies for auth.

Assumption 5: "Database access is SQLAlchemy, just like Flask." Yes, but with one critical async caveat.

Sync SQLAlchemy + def handler is the simplest, dullest, and most production-ready combination. It scales horizontally; pick it by default.

Async SQLAlchemy (AsyncSession over asyncpg) plus async def handler is faster on heavy-I/O workloads, but uses a *different* driver, *different* session class, and *different* query API. Do not mix sync and async sessions in the same app. Pick one driver for the whole codebase. The most common production failure for new async FastAPI services is psycopg2 being silently imported by an ORM helper and blocking the event loop for 80 ms per query.

Assumption 6: "Background tasks are like Celery." There are two distinct mechanisms; don't confuse them.

BackgroundTasks runs after the response is sent, in the same worker process:

python
@app.post("/signup")
def signup(body: SignupBody, bg: BackgroundTasks):
    user = create_user(body)
    bg.add_task(send_welcome_email, user.email)
    return {"id": user.id}

Good for: sending an email, emitting an analytics event, cache warm-up. Bad for: anything that must survive a pod restart. The task lives in process memory; kubectl rollout restart loses it.

Celery / RQ / Arq run tasks in a separate worker fleet, with a broker for durability. That is what you want for "must complete eventually" jobs. The two are complementary, not interchangeable.

Assumption 7: "I'll deploy it with the development server." Uvicorn's `--reload` is not a production server.

Gunicorn with Uvicorn workers is the conventional production setup:

bash
gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 --bind 0.0.0.0:8000

-w 4 is the worker count — start at 2 * CPU + 1 and tune. One Python process per worker means no shared memory between requests handled by different workers; in-process caches don't help across workers.

For high-concurrency async-only workloads, plain Uvicorn (uvicorn app.main:app --workers 4) is fine and slightly faster. Don't reach for Gunicorn unless you need its process supervision.

Assumption 8: "Testing is pytest." Yes — and `TestClient` is synchronous on purpose.

```python
from fastapi.testclient import TestClient
client = TestClient(app)

def test_create_order(): r = client.post("/orders", json={"id": "1", "quantity": 3}) assert r.status_code == 201 ```

TestClient works for both def and async def handlers. For tests that need to drive the event loop directly (concurrency tests), use httpx.AsyncClient(app=app) with pytest-asyncio. Reach for it only when you need to.

Assumption 9: "Error handling is try/except in handlers." You'll write less code with exception handlers.

One handler per exception type, applied to every route:

```python
from fastapi import Request
from fastapi.responses import JSONResponse

class OrderNotFound(Exception): ...

@app.exception_handler(OrderNotFound) def handle_order_not_found(request: Request, exc: OrderNotFound): return JSONResponse(status_code=404, content={"detail": "order not found"}) ```

Combined with Pydantic's built-in validation errors and FastAPI's default 422 response, your handlers usually shrink to "do the thing, raise on failure."

What FastAPI deliberately doesn't give you

  • An ORM. Bring your own (SQLAlchemy, SQLModel, Tortoise). FastAPI doesn't care.
  • A template engine. Jinja2Templates is opt-in; the framework is API-first.
  • A migration system. Use Alembic.
  • A built-in auth backend. fastapi-users is an excellent third-party.

That smaller core is why FastAPI services tend to feel more like "Python that happens to do HTTP" than like a framework you live inside.

The summary, diff-style

| Coming from | Reframe | |--------------------|------------------------------------------------------------| | Flask | Pydantic replaces marshmallow; Depends replaces extensions | | Django REST | Schemas via Pydantic, not Serializer classes; no app config | | Spring Boot | DI is functions, not beans; OpenAPI is automatic | | Express / Node | Validation is up-front, not a middleware layer |

Each of those swaps is small, but together they change how a typical handler reads — usually for the better, and almost always with fewer lines of code.

Decision matrix: FastAPI vs Django REST vs Flask

Three Python frameworks, three different sweet spots. FastAPI wins for greenfield APIs where you want async support, automatic OpenAPI docs and Pydantic validation for free. It is the right default for new microservices and for any service that fronts a machine-learning model. Django REST Framework wins when the app is more than an API — an admin interface, an ORM you already know, an auth stack that just works — and when the team values 'batteries included' over performance headroom. It is still the right choice for content-heavy sites, dashboards and back-office tools. Flask wins for tiny services (a webhook receiver, a health endpoint, a glue script) where FastAPI's ceremony is disproportionate. It is also the right choice for teams already fluent in Flask who do not need the specific features FastAPI adds. Team fluency beats framework benchmarks nine times out of ten.

Go deeper

Further reading

#FastAPI#Python#REST API#Pydantic#SQLAlchemy#Docker

Stay in the Loop

Get the next tutorial in your inbox

Continue reading in Python & FastAPI

FastAPI Microservices Architecture Explained Step by Step

How to design and build a Python microservices architecture with FastAPI — services, API gateway, async messaging, Redis, Postgres and Docker Compose.

Related tutorials