HinterBuild logoHinterBuild
Backend Systems · 9 min read

PostgreSQL Performance Secrets Developers Miss (Guide)

PostgreSQL Performance Secrets Developers Miss (Guide) guidance for engineers: compare architecture choices, avoid failure modes, and ship a.

Muhammad Abdul Sami, author

Muhammad Abdul Sami

· Updated · 9 min read

  • PostgreSQL
  • Architecture
  • Performance
  • Data Pipelines

Table of Contents:

Why PostgreSQL Slowdowns Are Usually Fixable

Short answer: Most PostgreSQL performance problems come from missing indexes, connection pool exhaustion, and queries that bypass the planner — not from PostgreSQL being "too slow" for your workload.

If you searched "PostgreSQL performance secrets", you probably have queries that worked at 10K rows and choke at 10M, connection errors under moderate load, or dashboards that timeout. At HinterBuild, our backend API engineering team resolves 90% of PostgreSQL incidents without hardware upgrades — the fixes are in schema design, indexes, and configuration.

Key Takeaways:

  • B-tree isn't always right — GIN, GiST, and BRIN indexes solve specific query patterns
  • Partial indexes can be 10× smaller and faster than full-table indexes
  • EXPLAIN (ANALYZE, BUFFERS) reveals sequential scans hiding in ORM-generated SQL
  • PgBouncer in transaction mode prevents connection exhaustion at scale
  • Autovacuum tuning prevents bloat that degrades performance silently over weeks

This guide covers PostgreSQL performance secrets developers miss, with production patterns we apply across client deployments on AWS RDS, Aurora, and self-managed clusters.


Secret 1: The Right Index Type for the Query

Developers default to B-tree indexes for everything. PostgreSQL offers specialized index types that dramatically outperform B-tree on the wrong access pattern.

Index Type Selection Guide

Index TypeBest ForExample QueryAvoid When
B-tree (default)Equality, range, sortingWHERE created_at > '2026-01-01'Full-text search
GINArrays, JSONB, full-textWHERE tags @> '{python}'Low-cardinality columns
GiSTGeometric, range types, nearest-neighborWHERE location <-> point < 1000Simple equality
BRINLarge tables with natural orderingWHERE logged_at > now() - interval '1 day'Random insert patterns
HashEquality only (rare)WHERE status = 'active'Range queries

Full-Text Search: GIN vs Sequential Scan

sql
-- Slow: sequential scan on 5M rows
SELECT * FROM articles WHERE to_tsvector('english', body) @@ to_tsquery('postgresql & performance');

-- Fast: GIN index on tsvector column
ALTER TABLE articles ADD COLUMN body_tsv tsvector
    GENERATED ALWAYS AS (to_tsvector('english', body)) STORED;

CREATE INDEX idx_articles_body_tsv ON articles USING GIN (body_tsv);

SELECT * FROM articles WHERE body_tsv @@ to_tsquery('postgresql & performance');
-- Index Scan: 12ms vs Seq Scan: 4,200ms

Array Containment with GIN

sql
CREATE INDEX idx_users_tags ON users USING GIN (tags);

-- Uses GIN index
SELECT id, name FROM users WHERE tags @> ARRAY['enterprise', 'active'];

Choosing the wrong index type is the most common PostgreSQL performance mistake we see in code reviews. Match index to query operator, not column name.


Secret 2: Partial and Covering Indexes

A full index on orders(status) indexes every row — including millions of completed orders you'll never query.

Partial Indexes

Index only the rows you actually query:

sql
-- Only index active orders (typically 2-5% of table)
CREATE INDEX idx_orders_active
    ON orders (created_at DESC)
    WHERE status IN ('pending', 'processing');

-- Query uses partial index automatically
SELECT * FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 50;
Index TypeSize (10M orders)Query Time
Full B-tree on status890 MB45ms
Partial (active only)42 MB3ms

10× smaller, 15× faster — because the index fits in memory.

Covering Indexes (Index-Only Scans)

Include columns in the index to avoid heap lookups:

sql
-- Covering index: query answered entirely from index
CREATE INDEX idx_orders_user_covering
    ON orders (user_id, created_at DESC)
    INCLUDE (total, status);

-- Index Only Scan — no heap access
SELECT total, status, created_at
FROM orders
WHERE user_id = 'abc-123'
ORDER BY created_at DESC
LIMIT 20;

Verify with EXPLAIN:

Index Only Scan using idx_orders_user_covering on orders
  Heap Fetches: 0    ← ideal

When Heap Fetches is high, run VACUUM on the table — visibility map may be stale.

These patterns complement API design best practices — fast queries enable fast APIs.


Secret 3: EXPLAIN ANALYZE — Read It Correctly

EXPLAIN ANALYZE is the single most powerful PostgreSQL performance tool. Most developers run it but misread the output.

Essential EXPLAIN Commands

sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.total, u.name
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'pending'
ORDER BY o.created_at DESC
LIMIT 50;

What to Look For

Red FlagMeaningFix
Seq Scan on large tableFull table scanAdd index
Rows Removed by Filter: 999950Scanning rows then filteringIndex the filter column
Nested Loop with high row countInefficient join orderUpdate statistics, add index on join key
Sort with high costSorting without index supportAdd index matching ORDER BY
Buffers: shared hit=50 read=5000Cache misses — disk readsIncrease shared_buffers, add index
Planning Time: 45msComplex query or stale statsANALYZE table, simplify query

Real Example: ORM-Generated N+1

Django/SQLAlchemy often produce:

sql
-- 50 separate queries instead of 1 JOIN
SELECT * FROM order_items WHERE order_id = 1;
SELECT * FROM order_items WHERE order_id = 2;
-- ... repeated 50 times

Fix in application code with eager loading — but detect it first with query logging:

sql
-- Enable pg_stat_statements
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

Track slow queries in observability & monitoring dashboards. Alert on queries exceeding 100ms mean execution time.


Secret 4: Connection Pooling with PgBouncer

PostgreSQL connections are expensive (~5–10 MB RAM each). At 100 API pods × 50 connections = 5,000 connections — PostgreSQL will reject new ones or thrash on memory.

PgBouncer Configuration

ini
[databases]
app_db = host=postgres-primary port=5432 dbname=app_db

[pgbouncer]
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 50
reserve_pool_size = 10
reserve_pool_timeout = 3
server_idle_timeout = 600
Pool ModeBehaviorUse Case
SessionConnection held for entire client sessionPrepared statements, temp tables
TransactionConnection returned after each transactionWeb APIs (recommended)
StatementConnection returned after each statementVery short queries only

Application-Side Pool Sizing

python
engine = create_async_engine(
    "postgresql+asyncpg://user:pass@pgbouncer:6432/app_db",
    pool_size=10,        # per pod — keep low, PgBouncer pools globally
    max_overflow=5,
    pool_pre_ping=True,
    pool_recycle=300,
)
go
config, _ := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
config.MaxConns = 15          // per pod
config.MinConns = 3
config.MaxConnLifetime = 5 * time.Minute
config.MaxConnIdleTime = 1 * time.Minute
pool, _ := pgxpool.NewWithConfig(ctx, config)

Rule of thumb: Total app connections across all pods should be 2–3× PostgreSQL's max_connections, with PgBouncer multiplexing down to 100–200 actual server connections.

For system design at 10M users, PgBouncer is non-negotiable.


Secret 5: Vacuum, Bloat, and Autovacuum Tuning

PostgreSQL's MVCC model leaves dead tuples after UPDATE/DELETE. Without vacuum, tables bloat and indexes degrade — performance drops silently over weeks.

Detect Bloat

sql
SELECT
    schemaname, tablename,
    pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS total_size,
    n_dead_tup,
    last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

Autovacuum Tuning for High-Write Tables

Default autovacuum is too conservative for busy tables:

sql
-- Per-table autovacuum settings for high-write events table
ALTER TABLE events SET (
    autovacuum_vacuum_scale_factor = 0.01,     -- vacuum at 1% dead tuples (default 20%)
    autovacuum_analyze_scale_factor = 0.005,
    autovacuum_vacuum_cost_delay = 2           -- faster vacuum (default 20ms)
);
SymptomCauseFix
Queries getting slower over weeksTable bloatVACUUM FULL or pg_repack
Index scans slower despite index existingIndex bloatREINDEX CONCURRENTLY
Planner choosing bad plansStale statisticsANALYZE table or lower analyze scale factor
Autovacuum not keeping upWrite-heavy tablePer-table autovacuum tuning
Long-running transactions block vacuumOpen idle transactionsSet idle_in_transaction_session_timeout
sql
-- Kill idle transactions blocking vacuum
ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';
SELECT pg_reload_conf();

Schedule bloat monitoring in your cloud infrastructure runbooks — not just CPU and memory alerts.


Secret 6: JSONB Indexing Done Right

JSONB columns are convenient and frequently unindexed — causing sequential scans on production's hottest tables.

GIN vs Expression Indexes

sql
-- GIN on entire JSONB column (good for containment queries)
CREATE INDEX idx_events_payload ON events USING GIN (payload jsonb_path_ops);

-- Containment query uses index
SELECT * FROM events WHERE payload @> '{"type": "purchase"}';

-- Expression index for specific key (good for equality on one field)
CREATE INDEX idx_events_user_id ON events ((payload->>'user_id'));

SELECT * FROM events WHERE payload->>'user_id' = 'abc-123';
Query PatternIndex TypeExample
Key existenceGIN (jsonb_ops)payload ? 'email'
ContainmentGIN (jsonb_path_ops)payload @> '{"status":"active"}'
Specific key valueExpression B-tree(payload->>'user_id')
Nested pathExpression(payload #>> '{address,city}')

Avoid: Indexing every JSONB key. Index only keys that appear in WHERE clauses on high-traffic queries.


Secret 7: Lock Contention and Transaction Scope

Long transactions and unnecessary locks are a hidden PostgreSQL performance killer — especially under concurrent write load.

Keep Transactions Short

python
# BAD: external API call inside transaction
async with session.begin():
    order = await session.get(Order, order_id)
    order.status = "processing"
    await payment_gateway.charge(order.total)  # 2-5 seconds holding row lock
    order.status = "paid"

# GOOD: charge outside transaction
async with session.begin():
    order = await session.get(Order, order_id)
    order.status = "processing"
    await session.commit()  # release lock

result = await payment_gateway.charge(order.total)

async with session.begin():
    order = await session.get(Order, order_id)
    order.status = "paid" if result.success else "failed"
    await session.commit()

Detect Lock Contention

sql
SELECT
    blocked.pid AS blocked_pid,
    blocked.query AS blocked_query,
    blocking.pid AS blocking_pid,
    blocking.query AS blocking_query,
    now() - blocked.query_start AS blocked_duration
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
    ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';
Lock TypeCausePrevention
Row-levelLong UPDATE transactionsShort transactions, charge outside TX
Table-levelALTER TABLE, VACUUM FULLUse CONCURRENTLY variants
AdvisoryApplication-level lockingSet timeouts, use Redis locks instead

For high-write counters, avoid row-level lock contention:

sql
-- Instead of UPDATE accounts SET balance = balance - 100 (row lock)
INSERT INTO ledger (account_id, amount, type) VALUES ($1, -100, 'debit');
-- Compute balance from ledger sum periodically or via materialized view

Secret 8: Partitioning Before Sharding

Before reaching for Citus or application-level sharding, PostgreSQL native partitioning handles most time-series and archival patterns.

Range Partitioning by Time

sql
CREATE TABLE events (
    id          BIGSERIAL,
    user_id     UUID NOT NULL,
    event_type  TEXT NOT NULL,
    payload     JSONB,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_09 PARTITION OF events
    FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

CREATE TABLE events_2026_10 PARTITION OF events
    FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
BenefitImpact
Partition pruningQueries with date filter scan one partition, not entire table
Fast archivalDROP TABLE events_2025_01 instead of DELETE millions of rows
Per-partition indexesSmaller, faster indexes
Parallel maintenanceVacuum/reindex one partition at a time

Automate partition creation with pg_partman or a scheduled job in your data pipelines.

Compare with full sharding strategies in system design for 10M users.


PostgreSQL Tuning Checklist

Query Level

  • EXPLAIN ANALYZE run on top 20 queries by total time
  • No sequential scans on tables > 100K rows
  • Partial indexes on filtered queries
  • Covering indexes for frequent SELECT patterns
  • JSONB queries have appropriate GIN/expression indexes

Connection Level

  • PgBouncer deployed in transaction mode
  • App pool size ≤ 20 per pod
  • idle_in_transaction_session_timeout set

Maintenance Level

  • Autovacuum tuned for high-write tables
  • Bloat monitoring scheduled weekly
  • pg_stat_statements enabled
  • Statistics refreshed after bulk loads (ANALYZE)

Server Level (RDS/Aurora/self-managed)

  • shared_buffers = 25% of RAM
  • effective_cache_size = 75% of RAM
  • work_mem tuned per concurrent query count
  • random_page_cost = 1.1 for SSD storage
  • Read replicas for read-heavy workloads

Benchmark API impact after database tuning with Go vs Python benchmarks — database fixes often matter more than runtime choice.


Secret 9: Prepared Statements and ORM Pitfalls

ORMs generate parameterized queries — but prepared statement caching behaves differently across drivers and connection poolers, causing subtle PostgreSQL performance regressions.

The PgBouncer + Prepared Statement Problem

In PgBouncer transaction mode, prepared statements don't persist across transactions. Drivers that auto-prepare every query pay re-planning cost on each execution:

python
# asyncpg: disable automatic prepared statement cache with PgBouncer
engine = create_async_engine(
    DATABASE_URL,
    connect_args={"statement_cache_size": 0},  # required for PgBouncer transaction mode
)
go
// pgx: use Query without Prepare when behind PgBouncer transaction pool
// pgxpool handles this correctly by default in pgx v5
rows, err := pool.Query(ctx, "SELECT * FROM users WHERE id = $1", userID)
SetupPrepared statementsRecommendation
Direct to PostgreSQLSafe, beneficialEnable caching
PgBouncer transaction modeBreaks across TXDisable statement cache
PgBouncer session modeSafeEnable caching (fewer pooled connections)

Query Plan Stability

Prepared statements can also lock in stale plans after data distribution shifts dramatically (e.g., table grows from 10K to 10M rows). If a previously fast prepared query degrades:

sql
-- Force plan refresh
DEALLOCATE ALL;  -- session-level
-- Or per-query:
DISCARD PLANS;
ANALYZE affected_table;

Monitor plan regression via pg_stat_statements — sudden 10× mean time increase on a stable query often indicates stale plans, not missing indexes. Pair with API latency dashboards to correlate database plan changes with endpoint p99 spikes.


Primary references: official documentation, official documentation, official documentation, official documentation.

Frequently Asked Questions

What is the most common PostgreSQL performance mistake?

Missing indexes on WHERE and JOIN columns, especially those generated by ORMs. Run pg_stat_statements to find your slowest queries, then EXPLAIN them.

How do I know if I need more RAM or better indexes?

If EXPLAIN shows Index Scan with low Buffers: shared read but queries are still slow, you may need more RAM. If you see Seq Scan on large tables, you need indexes — not hardware.

Is PostgreSQL slower than MySQL for read-heavy workloads?

Not inherently. PostgreSQL with proper indexes, read replicas, and connection pooling matches MySQL on reads. PostgreSQL excels on complex queries, JSONB, and full-text search.

When should I use read replicas?

When read queries exceed 70% of database load and the primary shows CPU saturation. Route eventually-consistent reads to replicas; keep writes and critical reads on primary.

How often should I run VACUUM FULL?

Rarely. VACUUM FULL locks the table. Prefer regular autovacuum tuning and pg_repack for online bloat removal. Schedule during maintenance windows only.

Does ORM use affect PostgreSQL performance?

Yes — ORMs generate N+1 queries, SELECT *, and unoptimized JOINs by default. Use eager loading, raw SQL for hot paths, and always EXPLAIN ORM-generated queries.

What is a good query latency target?

< 10ms for simple indexed lookups. < 50ms for JOIN queries returning paginated results. < 200ms for analytical aggregations. Alert on p95 exceeding 100ms for OLTP queries.

Should I migrate to a NoSQL database for performance?

Not for relational data. PostgreSQL with JSONB, partitioning, and read replicas handles most "NoSQL" use cases. Migrate only when you need horizontal write scaling beyond single-primary limits.


Conclusion

PostgreSQL performance secrets aren't secret — they're just skipped in the rush to ship features. The highest-impact fixes:

  • Match index type to query pattern (GIN, partial, covering)
  • Read EXPLAIN (ANALYZE, BUFFERS) on every slow query
  • Deploy PgBouncer before scaling API pods
  • Tune autovacuum on high-write tables
  • Partition time-series data before considering sharding

Database tuning delivers larger performance gains than switching from Python to Go — fix PostgreSQL first.

At HinterBuild, we optimize PostgreSQL for production workloads:

Schedule a consultation for a PostgreSQL performance audit.

Free consultation

Book a free consultation call on PostgreSQL optimization & database tuning

30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.

Book a meeting

Keep reading