As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
I started building web APIs with Python years ago, and for a long time I relied on Flask and Django REST Framework. They worked, but every new project felt like I was fighting the same battles: manual validation, slow response times when too many users hit the same endpoint, and documentation that always fell out of date. Then I discovered FastAPI. It changed how I think about APIs. Instead of bolting on features after the fact, FastAPI forces clean design from the start. After building a dozen production APIs with it, I want to share eight techniques that made the biggest difference in performance and maintainability. I'll write this as if we're sitting together, explaining each idea with code you can copy and adapt.
Async handlers changed my life
When I first heard about async Python, I thought it was just for niche cases. Then I watched my synchronous API grind to a halt during a Black Friday simulation. The problem wasn't the database; it was that every request blocked the event loop while waiting for a response. FastAPI makes it trivial to write async handlers that handle thousands of concurrent requests without breaking a sweat. Instead of using time.sleep, you use asyncio.sleep. Instead of requests.get, you use httpx.AsyncClient. And instead of synchronous database drivers, you use libraries like asyncpg or databases. Here is what my typical async endpoint looks like now:
from fastapi import FastAPI, HTTPException
import asyncpg
import asyncio
app = FastAPI()
pool = None
@app.on_event("startup")
async def startup():
global pool
pool = await asyncpg.create_pool(
user="user", password="pass", database="db",
host="localhost", min_size=5, max_size=20
)
@app.on_event("shutdown")
async def shutdown():
await pool.close()
@app.get("/users/{user_id}")
async def get_user(user_id: int):
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, name, email FROM users WHERE id = $1", user_id
)
if not row:
raise HTTPException(status_code=404, detail="User not found")
return dict(row)
See how the async with block acquires a connection from the pool? That means twenty different users can hit this endpoint simultaneously, and each one gets a connection from the pool without waiting for the previous one to finish. Before async, I used thread pools and connection queues. Now I just write async def. The event loop handles the rest.
Validation without the headache
I used to write validation logic by hand – checking if an email looked like an email, making sure ages were positive, rejecting empty strings. That code was boring, error‑prone, and I always forgot one edge case. Pydantic models solve this cleanly. FastAPI uses Pydantic to automatically validate incoming JSON and convert it into Python objects. If the data is wrong, the client gets a clear error message. No manual if name is None blocks. Here is how I define a user creation schema:
from pydantic import BaseModel, Field, EmailStr, validator
from typing import List, Optional
class UserCreate(BaseModel):
name: str = Field(..., min_length=2, max_length=50)
email: EmailStr
age: Optional[int] = Field(None, ge=0, le=150)
tags: List[str] = []
@validator('name')
def name_must_not_be_reserved(cls, v):
if v.lower() in ('admin', 'root'):
raise ValueError('Reserved names not allowed')
return v
Now when someone sends a POST request to /users, FastAPI automatically runs these validations. If the email is invalid, the client gets a 422 status with details. I don't have to think about it. The EmailStr type even checks the format for me. And because Pydantic models are just Python objects, I can reuse them for responses, too. UserCreate becomes the contract between my API and the frontend.
Dependency injection: the silent winner
Before FastAPI, I put authentication checks at the beginning of every endpoint by copy‑pasting the same token‑verification code. When I needed to change the logic – say, switch from API keys to JWTs – I had to touch every route. Dependency injection fixes that. You define a function that returns something (like a database session or the current user), and FastAPI calls it automatically when a route needs it. I use dependencies for authentication, database connections, permission checks, and even configuration values. Here is my standard auth dependency:
from fastapi import Header, HTTPException, Depends
async def verify_token(authorization: str = Header(...)):
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer":
raise HTTPException(status_code=401, detail="Invalid scheme")
# In real life, decode JWT or call an auth service
return {"user_id": 123, "role": "admin"}
@app.get("/admin/items")
async def admin_items(user=Depends(verify_token)):
# Now user is a dict with user_id and role
return {"items": ["secret1", "secret2"]}
See how the endpoint doesn't even know about tokens? It just asks for a user dependency. If the token is invalid, the dependency raises an HTTP exception before the endpoint code runs. I can chain dependencies too – one for auth, another for admin role, another for database session. Each one is a small, testable function.
Background tasks that don't slow down users
Sometimes you need to do something after sending a response – send an email, update a cache, log analytics. Doing it inside the endpoint blocks the client until it finishes. FastAPI has a BackgroundTasks class that lets you schedule work to run after the response is sent. The client doesn't wait. I use this for welcome emails and non‑critical cache updates. Here is an example:
from fastapi import BackgroundTasks
import asyncio
def send_email_sync(email: str, name: str):
# Simulate slow SMTP call
import time
time.sleep(2)
print(f"Sent email to {email}")
async def update_cache_async(user_id: int):
await asyncio.sleep(5)
print(f"Cache updated for user {user_id}")
@app.post("/register")
async def register(username: str, email: str, tasks: BackgroundTasks):
# Save to database (omitted for brevity)
user_id = 42
tasks.add_task(send_email_sync, email, username)
tasks.add_task(update_cache_async, user_id)
return {"id": user_id, "status": "registered"}
The response comes back instantly, while the email and cache update happen in the background. Note that I used a synchronous email function – that's fine because FastAPI runs background tasks in a thread pool executor. For truly async tasks, you can use asyncio.create_task inside the endpoint, but BackgroundTasks is simpler.
Pagination without pain
Early in my career, I built an API that returned all 50,000 records of a table in one response. The frontend crashed, the database locked, and my boss was not happy. Pagination is not optional. FastAPI makes it easy to implement both offset‑based and cursor‑based pagination. I use offset pagination for most admin UIs because it's simple, and cursor‑based for real‑time feeds where new rows keep appearing. Here is my offset pagination pattern:
from fastapi import Query
from math import ceil
class PaginatedResponse(BaseModel):
items: list
total: int
page: int
size: int
pages: int
@app.get("/items", response_model=PaginatedResponse)
async def get_items(page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)):
offset = (page - 1) * size
total = await pool.fetchval("SELECT COUNT(*) FROM items")
rows = await pool.fetch("SELECT * FROM items LIMIT $1 OFFSET $2", size, offset)
return PaginatedResponse(
items=[dict(r) for r in rows],
total=total,
page=page,
size=size,
pages=ceil(total / size)
)
The LIMIT and OFFSET prevent loading all rows into memory. I also return metadata like pages so the frontend can build pagination controls. For cursor pagination (used when you want stable pagination despite new rows), I use WHERE id < $1 and return a next_cursor field. Both patterns are straightforward in FastAPI.
Middleware: the invisible layer
When you have dozens of endpoints, adding the same logic to each one – like CORS headers, request timing, or rate limiting – becomes a maintenance nightmare. Middleware runs before your endpoint code and can modify the request or response. FastAPI supports ASGI middleware natively. I always add a timing middleware to detect slow endpoints in production:
import time
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
response.headers["X-Processing-Time"] = str(duration)
if duration > 1.0:
print(f"SLOW: {request.method} {request.url} took {duration:.2f}s")
return response
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
app.add_middleware(TimingMiddleware)
Rate limiting is another middleware I deploy – it tracks client IPs and rejects requests if they exceed a threshold. The middleware runs before the endpoint, so you never waste resources on a request that will be returned instantly.
Error handling that doesn't lie
A good API gives clear error messages, not cryptic tracebacks. FastAPI comes with built‑in validation error responses, but I also want custom business‑logic errors to have the same structure. I define a custom exception class and a handler for it:
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
class APIException(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
@app.exception_handler(APIException)
async def api_exception_handler(request, exc):
return JSONResponse(status_code=exc.status_code, content={"error": exc.detail})
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
return JSONResponse(status_code=422, content={"errors": exc.errors()})
@app.get("/items/{item_id}")
async def get_item(item_id: int):
if item_id <= 0:
raise APIException(400, "Item ID must be positive")
row = await pool.fetchrow("SELECT * FROM items WHERE id = $1", item_id)
if not row:
raise APIException(404, f"Item {item_id} not found")
return dict(row)
Now every error response has the same structure: a status_code and a human‑readable error field. The frontend can parse this consistently.
Performance tricks that save seconds
The last technique is about raw speed. You can have clean code and fast databases, but if you're serializing with json.dumps on every request, you're leaving milliseconds on the table. FastAPI supports ORJSONResponse which uses the fast orjson library. I also add GZip compression for larger responses and Redis caching for frequently accessed data. Here is a setup that reduced my response times by 40%:
from fastapi.responses import ORJSONResponse
from fastapi.middleware.gzip import GZipMiddleware
import aioredis
import json
app = FastAPI(default_response_class=ORJSONResponse)
app.add_middleware(GZipMiddleware, minimum_size=1000)
redis = aioredis.from_url("redis://localhost")
@app.get("/cached-items")
async def cached_items(category: str = "all"):
cache_key = f"items:{category}"
cached = await redis.get(cache_key)
if cached:
return ORJSONResponse(content=json.loads(cached))
rows = await pool.fetch("SELECT * FROM items WHERE category = $1", category)
data = [dict(r) for r in rows]
await redis.setex(cache_key, 300, json.dumps(data)) # 5 min cache
return ORJSONResponse(content=data)
Cache misses still hit the database, but popular categories are served from Redis in microseconds. I also reuse database connections through a pool (as shown earlier) – opening a new connection for every request kills performance.
These eight techniques don't exist in isolation. In my current project, every endpoint uses async handlers, Pydantic validation, dependency injection, and often background tasks. Middleware wraps the entire application, custom error handlers catch exceptions, and the performance layer sits on top. The result is an API that handles thousands of requests per second on a modest server, and my team can add new features without breaking existing ones. If you start with these patterns from the beginning of your FastAPI project, you avoid the painful refactoring that comes later. I learned that the hard way – now I teach it to everyone who asks.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)