Why Your PostgreSQL Queries Are Slow (And How to Fix Them)
An in-depth look at debugging a 3-second API endpoint, fixing an N+1 query, and implementing the correct PostgreSQL index.
I spent the better part of last week debugging an API endpoint that was taking over 3 seconds to respond. The culprit? A missing index on a heavily queried foreign key, combined with an N+1 query issue in Django.
Here's a breakdown of what I found, how I diagnosed it, and the exact steps to prevent this in your applications.
The Symptoms
The endpoint was a standard GET /api/v1/orders/ that returned a list of user orders, along with the items in each order. In local development with 10 orders, it was instantaneous. In production with 500,000 orders? A completely different story.
I ran EXPLAIN ANALYZE on the underlying query:
EXPLAIN ANALYZE
SELECT * FROM orders_orderitem
WHERE order_id IN (SELECT id FROM orders_order WHERE user_id = 4521);
The output showed a Sequential Scan on orders_orderitem. Postgres was reading every single row in a 5-million row table.
Tip: Always run
EXPLAIN ANALYZEon slow queries.EXPLAINjust gives you the query plan, but addingANALYZEactually runs the query and gives you the real execution time.
The Solution
1. Add the Database Index
In Django, foreign keys get indexes automatically, but if you're writing raw SQL or managing migrations manually, it's easy to miss. I explicitly verified the index in Django:
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, db_index=True)
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.IntegerField()
2. Fix the N+1 Query in Django
Even with the index, Django was still executing a separate query for every single order to fetch the items.
The fix is using prefetch_related:
# Before (N+1 Problem)
orders = Order.objects.filter(user=request.user)
data = [{
'id': order.id,
'items': [item.product.name for item in order.orderitem_set.all()]
} for order in orders]
# After (Optimized)
orders = Order.objects.filter(user=request.user).prefetch_related('orderitem_set__product')
data = [{
'id': order.id,
'items': [item.product.name for item in order.orderitem_set.all()]
} for order in orders]
The Results
By adding the index and fixing the ORM query, the response time dropped from 3.2 seconds to 45 milliseconds.
Takeaways
- Always load test your endpoints with production-like data volumes.
- Use
select_relatedfor foreign keys (1-to-1 or many-to-1). - Use
prefetch_relatedfor reverse foreign keys and many-to-many relationships.