DEV Community

Cover image for # 8 FastAPI Techniques That Make Your Python APIs Faster and Easier to Maintain
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

# 8 FastAPI Techniques That Make Your Python APIs Faster and Easier to Maintain

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]}
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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)
    )
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)