DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

PostgreSQL Query Optimization: Performance Tuning for Full-Stack Applications

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

This returns a plan showing:

  • Seq Scan (full table scan) vs Index Scan
  • Rows estimated vs actual (mismatches indicate stale stats)
  • Execution time breakdown

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

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

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

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

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

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

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

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

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

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

-- Run during low-traffic windows
VACUUM ANALYZE tasks;
Enter fullscreen mode Exit fullscreen mode

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

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

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.

Top comments (0)