Engineering in practice

PostgreSQL Indexing and Query Optimization for Python APIs

14 October 2026 · 9 min read

When the API Grinds to a Halt: It’s Rarely Python

When asynchronous Python and FastAPI applications experience crippling latency under load, developers instinctively blame the runtime: “Python’s GIL is bottlenecking our concurrency” or “Asyncio isn’t fast enough.”

In reality, database profilers reveal that in over 90% of real-world bottlenecks, the culprit is unoptimized relational queries: sequential table scans reading millions of rows, missing composite indexes, and the notorious N+1 query multiplication introduced by modern ORMs.

Here is how to systematically diagnose, index, and optimize PostgreSQL queries for high-throughput Python backends.


1. Diagnostics First: Mastering EXPLAIN ANALYZE

Never add indexes blindly based on assumptions. Always interrogate PostgreSQL’s query planner directly:

EXPLAIN (ANALYZE, BUFFERS, COSTS)
SELECT id, title, created_at
FROM orders
WHERE tenant_id = 'tenant_42' AND status = 'completed'
ORDER BY created_at DESC
LIMIT 20;

Key Metrics to Analyze in Planner Output:

  • Seq Scan on orders: The database engine is reading every single block from disk from start to finish. If the table holds hundreds of thousands of records, this saturates storage I/O and stalls the connection pool.
  • Index Scan / Index Only Scan: The optimal execution path. The engine navigates a balanced B-Tree index and resolves matching records within fractions of a millisecond.
  • Buffers: shared hit=... read=...: Indicates memory efficiency. High hit counts demonstrate that data pages were served directly from RAM (PostgreSQL shared buffers), whereas high read counts reflect expensive disk reads.

2. Composite Indexes and Column Ordering Rules

When queries routinely filter on multiple criteria simultaneously:

WHERE tenant_id = 'tenant_42' AND status = 'completed'

Creating two separate single-column indexes on tenant_id and status forces the engine to perform a Bitmap Index Scan and merge results in memory. A single composite index is dramatically faster:

CREATE INDEX idx_orders_tenant_status_created 
ON orders (tenant_id, status, created_at DESC);

The Rule for Composite Column Ordering:

  1. Equality columns first: Place exact-match criteria (tenant_id, status) at the leading positions of the index.
  2. Range and sorting columns last: Place inequality, range, or ordering criteria (created_at) at the tail.

By adhering to this order, PostgreSQL locates the matching tenant partition instantly and yields rows pre-sorted, eliminating expensive in-memory sort operations (Sort Method: top-N heapsort).


3. Partial Indexes: Saving 80% of Index Overhead

If your application has 5 million archived completed orders, but background workers spend 99% of their time polling for unprocessed records (status = 'pending'), indexing the entire table wastes valuable memory.

Use a partial index:

CREATE INDEX idx_orders_pending 
ON orders (created_at) 
WHERE status = 'pending';

A partial index contains only the tiny fraction of rows matching the WHERE condition. It consumes virtually zero storage, minimizes write amplification, and fits entirely in L3 CPU cache.


4. Eliminating the N+1 Query Anti-Pattern in Async SQLAlchemy

In asynchronous Python backends using SQLAlchemy, lazy-loading relationships inside loops triggers severe query cascades:

# The N+1 Catastrophe: 1 query for users, followed by 50 individual queries for invoices!
stmt = select(User).limit(50)
users = (await session.scalars(stmt)).all()

for user in users:
    print(len(user.invoices))  # Fires a separate SQL query per iteration!

Eliminate this by explicitly declaring eager loading via selectinload or joinedload:

from sqlalchemy.orm import selectinload

# Executes exactly 2 optimized SQL queries regardless of record count
stmt = select(User).options(selectinload(User.invoices)).limit(50)
users = (await session.scalars(stmt)).all()

Summary Checklist

  • Inspect sluggish queries with EXPLAIN (ANALYZE, BUFFERS).
  • Group frequent multi-column filters into composite indexes following the Equality-First rule.
  • Use partial indexes to isolate active working datasets from cold historical records.
  • Explicitly configure eager loading in SQLAlchemy to prevent N+1 request waterfalls.