FastAPI & Python 3.12: Building Async Microservices with Pydantic v2
What You Will Master in This Tutorial
- Understand ASGI asynchronous concurrency with async def handlers.
- Validate complex nested payloads with Pydantic v2 models.
1. Designing Async Endpoints with Pydantic
FastAPI combines the speed of Starlette with the ergonomic validation of Pydantic.
from fastapi import FastAPI, status
from pydantic import BaseModel, Field, EmailStr
app = FastAPI(title="User Service")
class UserSignup(BaseModel):
username: str = Field(..., min_length=3)
email: EmailStr
@app.post("/users/register", status_code=status.HTTP_201_CREATED)
async def register_user(payload: UserSignup):
return {"status": "registered", "user": payload.username}
Knowledge Check: Test Your Understanding
Frequently Asked Questions
2. Step by step: call another microservice with httpx and a lifespan client
A single FastAPI service is a demo; microservices begin at the first outbound call. The production pattern has three rules: one shared async HTTP client for the whole app, created and closed with the application lifespan; a timeout on every call; and Pydantic models on both sides of the wire. Here is the pattern, using httpx — the async-native HTTP library that matches FastAPI’s ASGI model.
Step 1: create the shared client in the lifespan
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, Request
@asynccontextmanager
async def lifespan(app: FastAPI):
# One client, one connection pool, for the life of the process.
app.state.inventory = httpx.AsyncClient(
base_url="http://inventory-service:8000",
timeout=5.0,
)
yield
await app.state.inventory.aclose()
app = FastAPI(title="Order Service", lifespan=lifespan)
async def get_inventory(request: Request) -> httpx.AsyncClient:
return request.app.state.inventory
Creating an AsyncClient per request — the mistake every first draft makes — throws away connection pooling and adds a TLS handshake to every call. The lifespan client is created once, injected as a dependency, and closed cleanly on shutdown.
Step 2: validate, call, respond — all typed
from fastapi import Depends, HTTPException, status
from pydantic import BaseModel, Field
class OrderCreate(BaseModel):
sku: str = Field(..., min_length=1)
quantity: int = Field(..., gt=0)
class OrderOut(BaseModel):
sku: str
quantity: int
status: str
@app.post("/orders", response_model=OrderOut,
status_code=status.HTTP_201_CREATED)
async def create_order(
payload: OrderCreate,
client: httpx.AsyncClient = Depends(get_inventory),
):
try:
resp = await client.get(f"/stock/{payload.sku}")
resp.raise_for_status()
except httpx.HTTPError:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Inventory service unavailable",
)
if resp.json()["available"] < payload.quantity:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Insufficient stock",
)
return OrderOut(sku=payload.sku, quantity=payload.quantity,
status="reserved")
3. A complete worked example
Run the pair locally and watch the contract hold. Start the inventory stub and the order service, then place an order:
# Terminal 1 - the inventory service (a peer FastAPI app)
uvicorn inventory_service:app --port 8001
# Terminal 2 - the order service from this tutorial
uvicorn order_service:app --port 8000
# Terminal 3 - place an order
curl -s -X POST http://localhost:8000/orders \
-H "Content-Type: application/json" \
-d '{"sku": "WIDGET-1", "quantity": 2}'
Three outcomes demonstrate the design. A valid order returns 201 with the OrderOut body — nothing extra leaks, because the response model filters the payload (the discipline the deep-dive insists on). An order for more than the available stock returns 409 with a readable detail, decided by this service from the peer’s data. And if you kill the inventory service and retry, the order service answers 502 within the client’s five-second budget instead of hanging — the failure is contained at the boundary where it belongs, and /docs shows all three responses because FastAPI generates them from the same models that enforce them.
Step 3: test the boundary without the network
The dependency-injection design pays off in tests: FastAPI’s dependency_overrides lets you swap the real inventory client for one backed by httpx.MockTransport, so the 201, 409, and error paths are testable in milliseconds with no services running:
import httpx
from fastapi.testclient import TestClient
from order_service import app, get_inventory
async def fake_inventory_handler(request: httpx.Request) -> httpx.Response:
# The peer service, simulated: 10 widgets always in stock
return httpx.Response(200, json={"available": 10})
def override_inventory() -> httpx.AsyncClient:
transport = httpx.MockTransport(fake_inventory_handler)
return httpx.AsyncClient(transport=transport, base_url="http://inventory")
app.dependency_overrides[get_inventory] = override_inventory
client = TestClient(app)
def test_order_reserved_when_stock_covers_it():
resp = client.post("/orders", json={"sku": "WIDGET-1", "quantity": 2})
assert resp.status_code == 201
assert resp.json()["status"] == "reserved"
def test_order_rejected_when_quantity_is_invalid():
resp = client.post("/orders", json={"sku": "WIDGET-1", "quantity": 0})
assert resp.status_code == 422 # Pydantic rejects it before any call
Note what the second test proves: validation happens at this service’s edge, before a single byte reaches the peer. That ordering — validate, then call, then map errors — is the whole microservice contract in miniature.
Step 4: freeze the contract for consumers
The last habit costs one command. Because the Pydantic models are the contract, export the generated OpenAPI schema and commit it next to the code. Consumer teams generate their clients from this file, and your CI can diff it on every pull request — a breaking change to a response model then fails the build loudly instead of surprising a peer service in production:
# Snapshot the contract the models currently generate
curl -s http://localhost:8000/openapi.json -o openapi.json
# In CI: fail the build if the contract changed without a version bump
git diff --exit-code openapi.json
That diff is the cheapest contract test ever written, and it closes the loop this tutorial opened with: the models validate at runtime, document at /docs, and now also guard themselves in version control.
4. Troubleshooting
- "RuntimeError: This event loop is already running" or a coroutine was never awaited. Somewhere an async call lost its await — classically, calling the httpx client but returning the coroutine object, which Pydantic then fails to serialize with a confusing error. Search the traceback for the endpoint, and make every client call await resp = await client.get(...).
- ConnectError on the first request after deployment. The peer service is not up yet, or the base_url host only resolves inside your compose/Kubernetes network. Verify name resolution from inside the order service’s environment first; then add startup resilience — a retry with backoff on ConnectError — rather than longer timeouts.
- 422 errors on payloads that look correct. Print the actual body FastAPI received before blaming the model: the usual culprits are a JSON string sent instead of an object (double-encoded by the caller), camelCase keys against snake_case fields, or an integer arriving as a string from a form post. The /docs "Try it out" payload is the ground truth for what the model expects.
- Latency grows linearly with traffic. Check for per-request AsyncClient creation (step 1 exists to prevent it) and for a synchronous call hiding in the path — one requests.get or time.sleep inside an async def stalls the event loop for every concurrent request, exactly the failure this tutorial opened with.
Going deeper: FastAPI microservices in practice
FastAPI’s headline speed comes from two places this tutorial combines: ASGI async handlers that never block the event loop, and Pydantic v2 models that validate every payload before your business logic sees it. Production services live or die on the details the hello-world skips — async discipline around database and HTTP calls, a router structure that keeps service boundaries visible in the code, and contract discipline so the OpenAPI schema consumers generate clients from is the truth, not a rumor. Get those right and the framework’s ergonomics survive contact with real traffic.
Common mistakes
- Calling blocking code inside async handlers. A synchronous database driver, a requests call, or a stray time.sleep inside an async def endpoint stalls the event loop for every concurrent request. Either use async libraries end to end or let FastAPI run the blocking work in a plain def endpoint.
- Returning raw rows and dicts without response models. Skipping Pydantic response models leaks internal fields and lets the API contract drift silently. The models are the contract — the tutorial’s Field constraints, like min_length on the username, exist to reject bad payloads at the door.
- Letting main.py become the whole service. A microservice whose routes, models, and logic all live in one file has the coupling of a monolith with the deployment cost of a service. Split routers by domain early, while the split is still cheap.
- Validating only the happy path. Nested payloads, optional fields, and boundary values are where production incidents breed. Pydantic v2 makes deep validation cheap — the mistake is declaring models that accept almost anything and validating by hand later.
Frequently asked questions
When should an endpoint be async def instead of def?
Use async def when the handler awaits genuinely async I/O — async database drivers, httpx calls, websockets. For blocking work, a plain def endpoint is the safer choice because FastAPI runs it in a threadpool instead of on the event loop.
Where do the automatic API docs come from?
FastAPI generates an OpenAPI schema from your type hints and Pydantic models, and serves interactive Swagger UI at /docs with no extra code. Every model you tighten improves the documentation and the client SDKs generated from it.
Why does the tutorial insist on Pydantic v2?
FastAPI’s validation and serialization layer is built on Pydantic, and v2 is the current line: it is substantially faster than v1 and is what the Python 3.12 stack in this tutorial targets. Models written for v1 mostly migrate, but new services should start on v2.