3 FastAPI Mistakes That Were Slowing Down My Backend
How I optimized CareerPulse's FastAPI backend by fixing async DB drivers, reducing Pydantic serialization overhead, and solving the N+1 query problem.
When I started building CareerPulse, my career-tracking platform, I expected blistering speed. FastAPI is renowned for its high performance, regularly benchmarking alongside Go and Node.js. Yet, as my database grew, my endpoints started feeling sluggish—even though raw database queries were executing in milliseconds.
After digging into APM logs and profiling my code, I realized the framework wasn’t the problem; it was how I was using it.
Here are the three critical FastAPI mistakes that were slowing down my backend, and how fixing them slashed my API response times.
1. Blocking the Event Loop with Synchronous DB Drivers
One of the most common pitfalls in FastAPI is misunderstanding how async def behaves. When you declare an endpoint with async def, FastAPI expects you to await asynchronous operations. If you execute a blocking synchronous operation (like using a standard sync database driver) inside an async route, you block the entire single-threaded event loop.
This means while one user is waiting for a database query to finish, no other requests can be processed.
The Mistake:
# A blocking DB call in an async endpoint blocks the whole server!
@app.get("/jobs/{job_id}")
async def get_job(job_id: int, db: Session = Depends(get_db)):
# Standard sync query blocks the main thread
job = db.query(Job).filter(Job.id == job_id).first()
return jobThe Fix:
You have two choices:
- Switch to an async database driver: Use tools like
asyncpgorasyncmywith SQLAlchemy’s async extension, and properlyawaityour queries. - Use standard
def: If you must use sync database drivers, declare your endpoint with plaindefinstead ofasync def. FastAPI will automatically run these endpoints in an external thread pool, preventing them from blocking the main event loop.
# Option A: True Async with SQLAlchemy AsyncSession
@app.get("/jobs/{job_id}")
async def get_job(job_id: int, db: AsyncSession = Depends(get_async_db)):
result = await db.execute(select(Job).filter(Job.id == job_id))
return result.scalars().first()
# Option B: Sync DB driver with plain def
@app.get("/jobs/{job_id}")
def get_job(job_id: int, db: Session = Depends(get_db)):
return db.query(Job).filter(Job.id == job_id).first()2. Bloated Payloads and Pydantic Serialization Overhead
Pydantic is fantastic for data validation, but parsing and serializing large schemas can be surprisingly CPU-intensive. When returning database models with nested relationships, I was sending entire objects back to the client—including null values, default fields, and unused attributes. This bloated the payload size and wasted precious serialization cycles.
The Mistake:
Returning a massive Pydantic schema with nested fields containing defaults or unneeded empty lists, forcing Pydantic to serialize every single field.
The Fix:
Use response_model_exclude_unset or response_model_exclude_none in your route decorators. This ensures that only fields explicitly set in your database or model are serialized and sent over the wire, drastically trimming payload size and speed.
@app.get(
"/users/{user_id}/profile",
response_model=UserProfileOut,
response_model_exclude_unset=True, # Excludes fields that weren't explicitly set
response_model_exclude_none=True # Excludes fields that evaluate to None
)
async def get_profile(user_id: int, db: AsyncSession = Depends(get_async_db)):
...By implementing this in CareerPulse, I managed to reduce response payload sizes by over 40% for complex nested user objects, resulting in a noticeable speedup on the frontend.
3. The Dreaded N+1 Query Problem in Loop Operations
As CareerPulse grew, I added a feature to list user applications along with the associated company profile and job details. Without realizing it, I introduced the classic N+1 query problem.
My main query fetched $N$ applications, and then for each application, my code made a separate database call to fetch the company details inside a loop or during Pydantic's lazy-loading serialization.
The Mistake:
# Doing database lookups inside a loop, or letting Pydantic lazy-load relations
@app.get("/applications")
async def get_applications(db: Session = Depends(get_db)):
apps = db.query(Application).all()
# This triggers an implicit SQL query for *each* application under the hood!
return [
{
"id": app.id,
"company_name": app.company.name, # Lazy-loading triggered here
}
for app in apps
]The Fix:
Batch your queries or use eager loading. In SQLAlchemy, you can use joinedload or selectinload to fetch related models in a single, highly optimized SQL join.
from sqlalchemy.orm import joinedload
@app.get("/applications")
async def get_applications(db: AsyncSession = Depends(get_async_db)):
# Eagerly load the company relation in a single database round-trip
stmt = select(Application).options(joinedload(Application.company))
result = await db.execute(stmt)
return result.scalars().all()Switching to eager loading collapsed 50+ individual database round-trips into a single query, instantly shaving hundreds of milliseconds off my list endpoints.
Conclusion
FastAPI is incredibly fast out of the box, but framework benchmarks don't save you from architectural bottlenecks. By:
- Keeping the event loop non-blocking,
- Trimming Pydantic serialization overhead, and
- Resolving N+1 query patterns with eager loading,
I was able to restore CareerPulse's API response times to the blazing-fast speeds I expected. If your FastAPI backend is feeling sluggish, start by profiling your database interactions and serialization patterns—the culprit is usually hiding right there!