Skip to main content
All posts

PostgreSQL Query Optimization: Performance Tuning for Full-Stack Applications

16 June 2026

PostgreSQL Query Optimization: Performance Tuning for Full-Stack Applications

PostgreSQL is the backbone of CitizenApp. We handle millions of queries monthly across multi-tenant workloads, and I’ve learned the hard way that a poorly optimized query doesn’t just slow your app—it tanks your entire infrastructure. This post covers what actually works in production.

Why PostgreSQL Optimization Matters for Full-Stack Teams

As a full-stack developer, you own the entire chain: React frontend, FastAPI backend, and the database. When your app is slow, the blame gets passed around. Usually, it’s the database.

Here’s the truth: most performance problems aren’t architectural failures. They’re indexing oversights, N+1 queries, or missing EXPLAIN analysis. I prefer PostgreSQL because its query planner is transparent—you can see exactly what’s happening and fix it.

Start with EXPLAIN ANALYZE

Before you optimize anything, you need to see what PostgreSQL is actually doing.

EXPLAIN ANALYZE
SELECT u.id, u.email, COUNT(t.id) as task_count
FROM users u
LEFT JOIN tasks t ON u.id = t.user_id
WHERE u.tenant_id = 'acme-corp'
GROUP BY u.id, u.email;

This returns a plan showing:

I always run EXPLAIN first. It takes 10 seconds and saves hours of guessing.

In CitizenApp, I discovered a query hitting 2.3 seconds because PostgreSQL was estimating 100 rows but actually finding 500,000. The culprit? Outdated table statistics. Running ANALYZE users dropped it to 80ms.

Strategic Indexing: The Most Overlooked Lever

Indexes are your first optimization target. I’m opinionated here: you need B-tree indexes on foreign keys, filter columns, and sort columns—period.

Single-Column Indexes

-- Foreign keys MUST be indexed
CREATE INDEX idx_tasks_user_id ON tasks(user_id);

-- Filter columns that appear in WHERE clauses
CREATE INDEX idx_tasks_status ON tasks(status);

-- Sort columns for ORDER BY
CREATE INDEX idx_tasks_created_at ON tasks(created_at DESC);

These are cheap insurance. In SQLAlchemy, I’m explicit about relationships:

from sqlalchemy import Column, Integer, String, ForeignKey, Index
from sqlalchemy.orm import relationship

class Task(Base):
    __tablename__ = "tasks"
    
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
    status = Column(String, index=True)
    created_at = Column(DateTime, index=True)
    
    user = relationship("User")

That index=True is non-negotiable for me.

Composite Indexes for Common Queries

This is where indexing gets strategic. If you always filter by tenant + status, a composite index beats two separate ones:

-- Query pattern: WHERE tenant_id = X AND status = 'active'
CREATE INDEX idx_tasks_tenant_status ON tasks(tenant_id, status);

Order matters. Postgres uses indexes left-to-right. Put the most selective column first (the one that filters the most rows).

# In SQLAlchemy models, define composite indexes in __table_args__:
class Task(Base):
    __tablename__ = "tasks"
    __table_args__ = (
        Index('idx_tasks_tenant_status', 'tenant_id', 'status'),
    )
    
    tenant_id = Column(String, nullable=False)
    status = Column(String, default='pending')

Eliminating N+1 Queries in Your ORM

This burned me hard early in CitizenApp. Your React component loads a list of users. Your FastAPI endpoint does:

# ❌ BAD: N+1 query disaster
@app.get("/api/users")
def get_users(tenant_id: str):
    users = db.session.query(User).filter_by(tenant_id=tenant_id).all()
    return [
        {
            "id": u.id,
            "email": u.email,
            "task_count": len(u.tasks)  # <- THIS triggers a query per user!
        }
        for u in users
    ]

If you have 100 users, that’s 101 queries. Your 80ms query becomes 8+ seconds.

Solution: use SQLAlchemy’s joinedload or aggregate in SQL.

from sqlalchemy.orm import joinedload

# ✅ GOOD: Single query with joined data
@app.get("/api/users")
def get_users(tenant_id: str):
    users = (
        db.session.query(User)
        .filter_by(tenant_id=tenant_id)
        .options(joinedload(User.tasks))
        .all()
    )
    return [
        {
            "id": u.id,
            "email": u.email,
            "task_count": len(u.tasks)
        }
        for u in users
    ]

Or better yet, compute it in SQL:

from sqlalchemy import func

# ✅ BEST: Aggregate at the database
@app.get("/api/users")
def get_users(tenant_id: str):
    results = (
        db.session.query(
            User.id,
            User.email,
            func.count(Task.id).label('task_count')
        )
        .outerjoin(Task)
        .filter(User.tenant_id == tenant_id)
        .group_by(User.id, User.email)
        .all()
    )
    return [
        {"id": r.id, "email": r.email, "task_count": r.task_count}
        for r in results
    ]

One query. Always.

Connection Pooling and Render/Vercel Reality

Here’s what I learned deploying to Render and Vercel: serverless functions kill database connections.

Each Vercel function invocation creates a new connection. Without pooling, you exceed PostgreSQL’s connection limit and everything fails.

# FastAPI with SQLAlchemy
from sqlalchemy import create_engine
from sqlalchemy.pool import NullPool

# For serverless: use NullPool (don't keep connections)
engine = create_engine(
    DATABASE_URL,
    poolclass=NullPool  # Important for Render, Vercel
)

# For traditional servers (Render web services): use default QueuePool
engine = create_engine(
    DATABASE_URL,
    pool_size=5,
    max_overflow=10
)

I use PgBouncer on Render for additional connection pooling—it’s a game-changer when you have variable traffic.

Gotcha: VACUUM and Bloat

Here’s what I missed in my first year: PostgreSQL databases accumulate dead tuples (deleted/updated rows). Over time, this bloat degrades performance.

-- Check table bloat
SELECT schemaname, tablename, 
       pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;

If your tasks table is 15GB but stores only 5GB of actual data, it’s bloated.

-- Run during low-traffic windows
VACUUM ANALYZE tasks;

I now schedule this weekly via GitHub Actions on Render:

name: Vacuum PostgreSQL
on:
  schedule:
    - cron: '0 2 * * 0'  # 2 AM Sunday

jobs:
  vacuum:
    runs-on: ubuntu-latest
    steps:
      - run: |
          psql ${{ secrets.DATABASE_URL }} -c "VACUUM ANALYZE;"

The Real Win: Metrics Matter

You can’t optimize blind. I monitor query performance in production:

import time
from fastapi import Request

@app.middleware("http")
async def log_query_time(request: Request, call_next):
    start = time.time()
    response = await call_next(request)
    duration = time.time() - start
    
    if duration > 0.5:  # Log slow requests
        print(f"SLOW: {request.url.path} took {duration:.2f}s")
    
    return response

Then EXPLAIN those slow queries.

Wrapping Up

PostgreSQL performance isn’t magic. It’s indexing + query analysis + connection pooling. Start with EXPLAIN, build the right indexes, eliminate N+1, and monitor production.

Your full-stack app will thank you.

You might also like

Comments

All comments are moderated before appearing.

Loading comments…

Leave a comment

0/2000

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch