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
· Updated · 9 min read
- PostgreSQL
- Architecture
- Performance
- Data Pipelines
Table of Contents:
- Why PostgreSQL Slowdowns Are Usually Fixable
- Secret 1: The Right Index Type for the Query
- Secret 2: Partial and Covering Indexes
- Secret 3: EXPLAIN ANALYZE — Read It Correctly
- Secret 4: Connection Pooling with PgBouncer
- Secret 5: Vacuum, Bloat, and Autovacuum Tuning
- Secret 6: JSONB Indexing Done Right
- Secret 7: Lock Contention and Transaction Scope
- Secret 8: Partitioning Before Sharding
- PostgreSQL Tuning Checklist
- Frequently Asked Questions
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 Type | Best For | Example Query | Avoid When |
|---|---|---|---|
| B-tree (default) | Equality, range, sorting | WHERE created_at > '2026-01-01' | Full-text search |
| GIN | Arrays, JSONB, full-text | WHERE tags @> '{python}' | Low-cardinality columns |
| GiST | Geometric, range types, nearest-neighbor | WHERE location <-> point < 1000 | Simple equality |
| BRIN | Large tables with natural ordering | WHERE logged_at > now() - interval '1 day' | Random insert patterns |
| Hash | Equality only (rare) | WHERE status = 'active' | Range queries |
Full-Text Search: GIN vs Sequential Scan
-- 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
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:
-- 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 Type | Size (10M orders) | Query Time |
|---|---|---|
| Full B-tree on status | 890 MB | 45ms |
| Partial (active only) | 42 MB | 3ms |
10× smaller, 15× faster — because the index fits in memory.
Covering Indexes (Index-Only Scans)
Include columns in the index to avoid heap lookups:
-- 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
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 Flag | Meaning | Fix |
|---|---|---|
Seq Scan on large table | Full table scan | Add index |
Rows Removed by Filter: 999950 | Scanning rows then filtering | Index the filter column |
Nested Loop with high row count | Inefficient join order | Update statistics, add index on join key |
Sort with high cost | Sorting without index support | Add index matching ORDER BY |
Buffers: shared hit=50 read=5000 | Cache misses — disk reads | Increase shared_buffers, add index |
Planning Time: 45ms | Complex query or stale stats | ANALYZE table, simplify query |
Real Example: ORM-Generated N+1
Django/SQLAlchemy often produce:
-- 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:
-- 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
[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 Mode | Behavior | Use Case |
|---|---|---|
| Session | Connection held for entire client session | Prepared statements, temp tables |
| Transaction | Connection returned after each transaction | Web APIs (recommended) |
| Statement | Connection returned after each statement | Very short queries only |
Application-Side Pool Sizing
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,
)
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
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:
-- 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)
);
| Symptom | Cause | Fix |
|---|---|---|
| Queries getting slower over weeks | Table bloat | VACUUM FULL or pg_repack |
| Index scans slower despite index existing | Index bloat | REINDEX CONCURRENTLY |
| Planner choosing bad plans | Stale statistics | ANALYZE table or lower analyze scale factor |
| Autovacuum not keeping up | Write-heavy table | Per-table autovacuum tuning |
| Long-running transactions block vacuum | Open idle transactions | Set idle_in_transaction_session_timeout |
-- 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
-- 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 Pattern | Index Type | Example |
|---|---|---|
| Key existence | GIN (jsonb_ops) | payload ? 'email' |
| Containment | GIN (jsonb_path_ops) | payload @> '{"status":"active"}' |
| Specific key value | Expression B-tree | (payload->>'user_id') |
| Nested path | Expression | (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
# 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
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 Type | Cause | Prevention |
|---|---|---|
| Row-level | Long UPDATE transactions | Short transactions, charge outside TX |
| Table-level | ALTER TABLE, VACUUM FULL | Use CONCURRENTLY variants |
| Advisory | Application-level locking | Set timeouts, use Redis locks instead |
For high-write counters, avoid row-level lock contention:
-- 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
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');
| Benefit | Impact |
|---|---|
| Partition pruning | Queries with date filter scan one partition, not entire table |
| Fast archival | DROP TABLE events_2025_01 instead of DELETE millions of rows |
| Per-partition indexes | Smaller, faster indexes |
| Parallel maintenance | Vacuum/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_timeoutset
Maintenance Level
- Autovacuum tuned for high-write tables
- Bloat monitoring scheduled weekly
-
pg_stat_statementsenabled - Statistics refreshed after bulk loads (
ANALYZE)
Server Level (RDS/Aurora/self-managed)
-
shared_buffers= 25% of RAM -
effective_cache_size= 75% of RAM -
work_memtuned 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:
# 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
)
// 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)
| Setup | Prepared statements | Recommendation |
|---|---|---|
| Direct to PostgreSQL | Safe, beneficial | Enable caching |
| PgBouncer transaction mode | Breaks across TX | Disable statement cache |
| PgBouncer session mode | Safe | Enable 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:
-- 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:
- Backend API Engineering
- Cloud Infrastructure & DevOps
- Observability & Monitoring
- Data Pipelines & Integrations
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
Related articles
SQLite in Production: When It Beats PostgreSQL (Guide)
SQLite in Production guidance for engineers: compare architecture choices, avoid failure modes, and ship a measurable, reliable production implementation.
Read post
PostgreSQL FOR UPDATE SKIP LOCKED: Production Queue Pattern
Learn postgresql for update skip locked through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Read post
Redis vs Valkey What Changed After the Fork (Complete Guide)
Redis vs Valkey What Changed After the Fork (Complete Guide) guidance for engineers: compare architecture choices, avoid failure modes, and ship a.
Read post
Multi-Tenancy Patterns: Database Strategies for SaaS
Learn multi-tenancy patterns through concrete architecture trade-offs, failure modes, rollout controls, and production measurement practices.
Read post
