PostgreSQL Performance Tuning for Application Developers (2026 Edition)¶
Slow queries are not a database problem. They are a design problem.
A query that takes 4 seconds on a table with 100 million rows almost always has a fixable root cause: missing index, wrong index type, table bloat, exhausted connection pool, or configuration defaults that were set for a 1GB dataset and never updated. Most performance problems are found — and solved — at the query and schema level before any hardware needs to be touched.
The Ground Rules¶
Before tuning anything, state what you are optimizing for. The two primary goals are usually in tension:
- Read throughput: Indexes help. More indexes = faster reads, slower writes.
- Write throughput: Indexes hurt. Every INSERT/UPDATE/DELETE must maintain all indexes.
For OLTP applications (web apps, APIs, microservices), reads dominate 80–95% of traffic. Index aggressively, monitor write latency separately. For data pipelines and batch jobs, consider bulk-loading with indexes dropped and rebuilt after.
Part 1: Profile First, Tune Second¶
The most common tuning mistake is guessing. Always measure before changing anything.
Enable pg_stat_statements¶
This extension tracks every query executed, how often, and how long. It is the single most useful tool for finding slow queries.
-- Add to postgresql.conf, then restart
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
pg_stat_statements.track = all
Find your top 10 slowest queries by total time:
SELECT
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
calls,
round(rows::numeric / calls, 1) AS avg_rows,
left(query, 120) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
Find high-frequency queries with mediocre latency (often the biggest real-world impact):
SELECT
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(stddev_exec_time::numeric, 2) AS stddev_ms,
left(query, 120) AS query
FROM pg_stat_statements
WHERE calls > 1000
ORDER BY mean_exec_time DESC
LIMIT 20;
Reset stats after tuning to measure improvements cleanly:
Find Unused Indexes¶
Indexes cost writes and storage. Unused ones are pure overhead:
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan AS scans
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE idx_scan = 0
AND indisunique IS FALSE
ORDER BY pg_relation_size(indexrelid) DESC;
Any index with zero scans since the last stats reset is a candidate for removal. Confirm with a longer observation window (one week minimum) before dropping.
Find Missing Indexes (Sequential Scans on Large Tables)¶
SELECT
schemaname,
relname AS table,
seq_scan,
seq_tup_read,
idx_scan,
n_live_tup AS live_rows,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
WHERE seq_scan > 0
AND n_live_tup > 100000
ORDER BY seq_tup_read DESC
LIMIT 20;
High seq_tup_read on a large table means a full table scan is happening repeatedly. That table needs an index on the columns in the WHERE clause.
Part 2: Reading EXPLAIN Like a Senior Engineer¶
EXPLAIN shows the query plan. EXPLAIN (ANALYZE, BUFFERS) shows what actually happened, including cache usage.
The Modern EXPLAIN Invocation¶
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM transactions
WHERE acc_from = 'ACC-001'
AND created_at > now() - interval '30 days';
What each output line means:
Bitmap Heap Scan on transactions (cost=297.23..8421.44 rows=10037 width=89)
^cost estimate ^rows estimate
Recheck Cond: (acc_from = 'ACC-001')
-> Bitmap Index Scan on idx_txn_acc_from (cost=0.00..294.72 rows=10037)
Index Cond: (acc_from = 'ACC-001')
Buffers: shared hit=4821 read=4153
^cache hits ^disk reads
Planning Time: 1.2 ms
Execution Time: 297.5 ms
Key things to look for:
| Signal | What it means |
|---|---|
Seq Scan on large table | Missing index — add one |
rows=10000 vs actual rows=1 | Stale statistics — run ANALYZE |
Buffers: read=10000 | High disk I/O — check shared_buffers or index selectivity |
Hash Join on small tables | Usually fine; Nested Loop preferred for indexed lookups |
Sort without index | Add index to support ORDER BY or merge joins |
cost=0..9999999 very high | Large sequential scan hidden deep in the plan |
Force Plans for Testing¶
-- Test without index to measure impact
SET enable_indexscan = off;
EXPLAIN ANALYZE SELECT ...;
SET enable_indexscan = on;
Part 3: Index Strategy¶
The Baseline: B-Tree Indexes¶
B-tree is the default and handles =, <, >, BETWEEN, LIKE 'prefix%', and ORDER BY.
-- Single column
CREATE INDEX CONCURRENTLY idx_txn_acc_from
ON transactions (acc_from);
-- With timing: sequential scan vs index scan
-- Before: 3,757 ms (seq scan, 100M rows)
-- After: 297 ms (index scan) — 12× faster
Always use CONCURRENTLY in production — it builds the index without locking writes.
Compound Indexes: Column Order Matters¶
A compound index (a, b) is useful for: - WHERE a = ? AND b = ? — uses both columns - WHERE a = ? — uses leading column only - ORDER BY a, b — supports sort without a separate sort step
It is not useful for WHERE b = ? alone — the leading column must be present.
-- For: WHERE acc_from = ? AND acc_to = ?
CREATE INDEX CONCURRENTLY idx_txn_from_to
ON transactions (acc_from, acc_to);
-- Timing comparison for acc_from + acc_to filter:
-- Separate indexes: 34.6 ms
-- Compound index: 1.2 ms — 28× faster
Rule: Put the highest-selectivity column (fewest duplicate values) first in a compound index when both columns appear in WHERE. Put equality columns before range columns.
Covering Indexes: INCLUDE Columns¶
A covering index contains all columns the query needs, so PostgreSQL never has to visit the heap table. This produces an Index Only Scan — the fastest possible read path.
-- Query: SELECT acc_from, acc_to, value FROM transactions WHERE acc_from = ?
CREATE INDEX CONCURRENTLY idx_txn_covering
ON transactions (acc_from)
INCLUDE (acc_to, value);
The INCLUDE columns are stored in the index leaf but not in the B-tree structure, so they don't affect sort order and don't increase index depth.
When to use: High-frequency read queries that always select the same small set of columns.
Partial Indexes: Index Only What You Query¶
If 95% of queries filter on status = 'active', there's no value indexing the 95% of rows that are 'archived'.
-- Index only active records
CREATE INDEX CONCURRENTLY idx_orders_active
ON orders (created_at)
WHERE status = 'active';
-- This query will use it:
SELECT * FROM orders WHERE status = 'active' AND created_at > now() - interval '7 days';
-- This query will NOT (different status value):
SELECT * FROM orders WHERE status = 'archived' AND created_at > ...;
Partial indexes are smaller, faster to build, and faster to scan.
Expression Indexes¶
Index the result of a function, not the raw column value:
-- Query uses lower() for case-insensitive match
SELECT * FROM users WHERE lower(email) = lower('User@Example.com');
-- Create expression index
CREATE INDEX CONCURRENTLY idx_users_email_lower
ON users (lower(email));
-- Index on JSONB field extraction
CREATE INDEX CONCURRENTLY idx_events_user_id
ON events ((payload->>'user_id'));
The query must use the exact same expression as the index for PostgreSQL to use it.
GIN Indexes: Full-Text Search and JSONB¶
B-tree cannot index array membership or JSONB key existence. GIN (Generalized Inverted Index) can.
-- Full-text search
ALTER TABLE articles ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX CONCURRENTLY idx_articles_search
ON articles USING GIN (search_vector);
-- Query
SELECT title FROM articles
WHERE search_vector @@ plainto_tsquery('english', 'cloud native kubernetes');
-- JSONB containment
CREATE INDEX CONCURRENTLY idx_events_payload
ON events USING GIN (payload jsonb_path_ops);
-- Query: find events where payload contains {"type": "login"}
SELECT * FROM events WHERE payload @> '{"type": "login"}';
BRIN Indexes: Massive Tables with Natural Order¶
BRIN (Block Range Index) stores min/max values per block range instead of individual row pointers. It is tiny (often 100× smaller than B-tree) but only works when data has physical ordering — like a created_at column on an insert-only table.
-- Effective for time-series / append-only tables
CREATE INDEX CONCURRENTLY idx_events_created_brin
ON events USING BRIN (created_at) WITH (pages_per_range = 128);
-- A 100M row table's BRIN index: ~500 KB vs ~3 GB for B-tree
Use BRIN when: The column has high cardinality, data is inserted roughly in order (like timestamps), and queries use range filters. BRIN is not useful for random lookup by arbitrary values.
Part 4: Server-Side Configuration¶
Defaults ship for compatibility, not performance. These three parameters have the highest impact.
shared_buffers¶
PostgreSQL's in-memory page cache. Default is 128MB — far too low for any production workload.
PostgreSQL also uses the OS page cache on top of this. Setting shared_buffers above 8GB rarely helps because the OS cache covers the gap.
effective_cache_size¶
Tells the query planner how much total cache (PostgreSQL + OS) is available. Does not allocate memory — it's a hint that affects plan choices. Set it high.
With a high effective_cache_size, the planner prefers index scans over sequential scans because it assumes more data is cached.
work_mem¶
Memory per sort or hash operation. Default is 4MB. If a sort can't fit in work_mem, it spills to disk (Sort Method: external merge Disk).
Warning: work_mem is per-operation, and a complex query can have many sorts and hashes simultaneously. With 200 connections and 5 operations each at 64MB, peak memory is 64GB. Set conservatively for OLTP; raise per session for analytics:
Full Recommended Configuration for 16GB OLTP Server¶
# postgresql.conf
shared_buffers = 4GB
effective_cache_size = 12GB
work_mem = 32MB
maintenance_work_mem = 1GB # For VACUUM, CREATE INDEX
max_connections = 100 # Use PgBouncer for more
wal_buffers = 64MB
checkpoint_completion_target = 0.9
random_page_cost = 1.1 # SSD: 1.1; spinning disk: 4.0
effective_io_concurrency = 200 # SSD: 200; spinning disk: 2
max_worker_processes = 8
max_parallel_workers = 4
max_parallel_workers_per_gather = 2
After changing postgresql.conf:
# Parameters marked as requiring restart
sudo systemctl restart postgresql-17
# Parameters that apply on reload
sudo systemctl reload postgresql-17
# Check which changed without restart
SELECT name, pending_restart FROM pg_settings WHERE pending_restart = true;
Part 5: Connection Pooling with PgBouncer¶
PostgreSQL creates a new OS process (not a thread) per connection. At 500 connections, you have 500 processes competing for memory and CPU even when most are idle. Connection overhead alone can consume gigabytes of RAM and add latency.
PgBouncer sits between your application and PostgreSQL, maintaining a small pool of real database connections and multiplexing thousands of application connections across them.
Install PgBouncer¶
Configure /etc/pgbouncer/pgbouncer.ini¶
[databases]
myapp = host=127.0.0.1 port=5432 dbname=myapp
[pgbouncer]
listen_port = 6432
listen_addr = 127.0.0.1
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
; Transaction pooling: connection returned to pool after each transaction
; Best for OLTP — works with most ORMs
pool_mode = transaction
server_pool_size = 25 ; Real DB connections per database/user pair
max_client_conn = 1000 ; Max application connections PgBouncer accepts
default_pool_size = 25
reserve_pool_size = 5
reserve_pool_timeout = 3
; Keep connections alive
server_idle_timeout = 600
client_idle_timeout = 0
log_connections = 0
log_disconnections = 0
Create the Auth File¶
# Generate scram-sha-256 hash from PostgreSQL
psql -c "SELECT concat('\"', usename, '\" \"', passwd, '\"') FROM pg_shadow WHERE usename = 'myappuser';" -t
Application connects to port 6432 instead of 5432. PostgreSQL max_connections can now be set to 100 instead of 1000 — PgBouncer absorbs the application concurrency.
Pool Mode Trade-offs¶
| Mode | When connection returns | Compatibility |
|---|---|---|
session | When client disconnects | Full — supports SET, LISTEN, temp tables |
transaction | After each transaction commit | Most ORMs — no session-level state |
statement | After each statement | Rare — incompatible with multi-statement transactions |
Use transaction mode for most OLTP applications. Use session mode if your application uses advisory locks, SET LOCAL, or prepared statements that persist across transactions.
Part 6: Table Partitioning¶
When a table grows beyond ~50–100M rows and queries always filter by a known dimension (time, tenant, region), partitioning lets PostgreSQL skip entire partitions without scanning them.
Range Partitioning by Date¶
-- Create partitioned parent table
CREATE TABLE transactions (
id bigserial,
acc_from varchar(20) NOT NULL,
acc_to varchar(20) NOT NULL,
value numeric(18,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);
-- Create monthly partitions
CREATE TABLE transactions_2026_01 PARTITION OF transactions
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE transactions_2026_02 PARTITION OF transactions
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- Indexes are created per partition
CREATE INDEX ON transactions_2026_01 (acc_from);
CREATE INDEX ON transactions_2026_02 (acc_from);
Partition pruning happens automatically when the WHERE clause includes the partition key:
-- PostgreSQL scans only transactions_2026_01 — all other partitions skipped
EXPLAIN SELECT * FROM transactions
WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31'
AND acc_from = 'ACC-001';
-- → Index Scan on transactions_2026_01 (not a full table scan)
Automate Partition Creation¶
Use pg_partman to create and drop partitions on a schedule:
CREATE EXTENSION pg_partman SCHEMA partman;
SELECT partman.create_parent(
p_parent_table := 'public.transactions',
p_control := 'created_at',
p_interval := 'monthly',
p_premake := 3 -- Create 3 future partitions ahead
);
-- Run via cron or pg_agent to maintain partitions
SELECT partman.run_maintenance(p_analyze := false);
When Not to Partition¶
- Table < 10M rows: partitioning adds overhead without benefit
- No clear partition key: hash partitioning rarely improves query performance
- Random access patterns: partition pruning only helps range/list queries
Part 7: Autovacuum Tuning¶
PostgreSQL uses MVCC (Multi-Version Concurrency Control): updates don't overwrite rows, they create new versions and mark old ones as "dead." Autovacuum reclaims dead tuple space. If it falls behind, tables bloat, queries slow down, and in severe cases the transaction ID wraparound emergency kicks in.
Check Current Bloat¶
SELECT
schemaname,
relname AS table,
n_live_tup,
n_dead_tup,
round(n_dead_tup * 100.0 / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 20;
A dead_pct above 20% on a frequently-queried table is a problem. High dead tuples mean queries scan rows they will then discard.
Tune Autovacuum Globally¶
Default autovacuum triggers when dead tuples exceed 50 + 0.2 × live_rows (20% of the table). On a 100M row table, that's 20 million dead tuples before vacuum runs — far too many.
# postgresql.conf
# Worker resources
autovacuum_max_workers = 5 # Default: 3
autovacuum_vacuum_cost_delay = 2ms # Default: 2ms (SSD); set 20ms for spinning disk
autovacuum_vacuum_cost_limit = 400 # Default: 200; raise for SSD
# Trigger thresholds (lower = vacuum runs sooner)
autovacuum_vacuum_scale_factor = 0.05 # Default: 0.20 (5% dead triggers vacuum)
autovacuum_analyze_scale_factor = 0.02 # Default: 0.10
autovacuum_vacuum_threshold = 500 # Default: 50
Per-Table Autovacuum Overrides¶
Hot tables need more aggressive settings than cold tables:
-- High-write OLTP table: vacuum aggressively
ALTER TABLE transactions SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 100,
autovacuum_analyze_scale_factor = 0.005,
autovacuum_vacuum_cost_delay = 1
);
-- Large archive table: vacuum rarely (minimal writes)
ALTER TABLE transactions_archive SET (
autovacuum_vacuum_scale_factor = 0.10,
autovacuum_vacuum_threshold = 10000
);
Emergency Manual Vacuum¶
When bloat is already severe and autovacuum can't keep up:
# Run VACUUM ANALYZE without blocking reads/writes (but blocks other VACUUMs)
psql -d myapp -c "VACUUM (ANALYZE, VERBOSE) transactions;"
# For extreme bloat: reclaims space to OS (requires brief exclusive lock at end)
psql -d myapp -c "VACUUM (FULL, ANALYZE) transactions_archive;"
VACUUM FULL rewrites the entire table and should only be used during maintenance windows. For zero-downtime space reclamation, use pg_squeeze or pg_repack extensions.
Part 8: Data Modeling for Performance¶
The schema design decisions made at the start are harder to change than any index or configuration. A few rules that compound over time:
Use the Right Data Types¶
-- Bad: storing integers as text
account_id varchar(20) -- requires text comparison, larger storage
-- Better
account_id bigint -- fixed 8 bytes, integer comparison
-- Bad: status as freeform text
status varchar(20) -- unbounded, no constraint
-- Better
status text CHECK (status IN ('active', 'inactive', 'suspended'))
-- Or use a native ENUM (faster comparison, less storage):
CREATE TYPE account_status AS ENUM ('active', 'inactive', 'suspended');
Use BIGSERIAL or UUIDs Intentionally¶
-- For internal IDs (contiguous, fast joins)
id bigserial PRIMARY KEY
-- For external/distributed IDs (random order — use UUIDv7 for monotonic inserts)
id uuid DEFAULT gen_random_uuid() PRIMARY KEY
UUIDv4 inserts into a B-tree primary key cause page splits. UUIDv7 (time-ordered) avoids this. PostgreSQL 17 includes gen_ulid() as a time-ordered alternative.
Separate Hot and Cold Data¶
Tables with a mix of frequently-updated and rarely-touched columns slow down vacuum and bloat the heap. Vertical partitioning keeps hot data small:
-- Hot table: core financial data, updated often
CREATE TABLE transactions (
id bigserial PRIMARY KEY,
acc_from varchar(20) NOT NULL,
acc_to varchar(20) NOT NULL,
value numeric(18,2) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL
);
-- Cold table: metadata appended once, never updated
CREATE TABLE transaction_details (
transaction_id bigint PRIMARY KEY REFERENCES transactions(id),
channel text,
ip_address inet,
user_agent text,
raw_payload jsonb
);
Part 9: Query-Level Optimization Patterns¶
Avoid Functions on Indexed Columns in WHERE¶
-- Bad: function prevents index use
SELECT * FROM users WHERE date_trunc('day', created_at) = '2026-05-01';
-- Good: range query uses index
SELECT * FROM users
WHERE created_at >= '2026-05-01'
AND created_at < '2026-05-02';
Use CTEs Carefully (PostgreSQL 12+)¶
Before PostgreSQL 12, CTEs were optimization fences — the planner treated them as black boxes. Since 12, the planner can inline CTEs. If you need the old fence behavior (rare), use WITH ... AS MATERIALIZED.
-- PostgreSQL 12+: this CTE is inlined and the planner optimizes it end-to-end
WITH recent_txn AS (
SELECT * FROM transactions WHERE created_at > now() - interval '1 day'
)
SELECT acc_from, sum(value) FROM recent_txn GROUP BY acc_from;
-- Force materialization (old behavior) — rarely needed
WITH recent_txn AS MATERIALIZED (
SELECT * FROM transactions WHERE created_at > now() - interval '1 day'
)
SELECT acc_from, sum(value) FROM recent_txn GROUP BY acc_from;
LIMIT + ORDER BY Requires the Right Index¶
-- Query: get latest 20 transactions for account ACC-001
SELECT * FROM transactions
WHERE acc_from = 'ACC-001'
ORDER BY created_at DESC
LIMIT 20;
-- Correct index: equality first, then ORDER BY column
CREATE INDEX CONCURRENTLY idx_txn_acc_created
ON transactions (acc_from, created_at DESC);
Without this index, PostgreSQL fetches all rows matching acc_from = 'ACC-001', sorts them, then takes the top 20 — regardless of LIMIT.
Batch Large Writes¶
-- Bad: 100,000 individual INSERTs (100,000 round trips)
for row in data:
execute("INSERT INTO events VALUES (?)", row)
-- Good: single multi-row INSERT
INSERT INTO events (user_id, event_type, created_at)
VALUES
(1, 'login', now()),
(2, 'purchase', now()),
...; -- Up to ~1000 rows per statement
For very large bulk loads, use COPY:
COPY bypasses row-by-row overhead and is 10–100× faster than INSERT for bulk data.
Part 10: Monitoring Queries to Run Regularly¶
Make these part of your weekly operational review:
Largest Tables and Indexes¶
SELECT
relname,
pg_size_pretty(pg_total_relation_size(oid)) AS total,
pg_size_pretty(pg_relation_size(oid)) AS table,
pg_size_pretty(pg_indexes_size(oid)) AS indexes
FROM pg_class
WHERE relkind = 'r'
ORDER BY pg_total_relation_size(oid) DESC
LIMIT 15;
Long-Running Queries¶
SELECT
pid,
now() - query_start AS duration,
state,
wait_event_type,
wait_event,
left(query, 100) AS query
FROM pg_stat_activity
WHERE state != 'idle'
AND query_start < now() - interval '30 seconds'
ORDER BY duration DESC;
Lock Waits¶
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.cardinality(pg_blocking_pids(blocked.pid)) > 0;
Index Hit Rate (Target: > 99%)¶
SELECT
relname AS table,
round(idx_blks_hit * 100.0 / nullif(idx_blks_hit + idx_blks_read, 0), 2) AS idx_hit_rate,
round(heap_blks_hit * 100.0 / nullif(heap_blks_hit + heap_blks_read, 0), 2) AS heap_hit_rate
FROM pg_statio_user_tables
ORDER BY idx_blks_read + heap_blks_read DESC
LIMIT 15;
If index or heap hit rates are below 95%, increase shared_buffers or the server's RAM.
Summary¶
PostgreSQL performance tuning in 2026 is a layered discipline. The layers, from highest to lowest impact, are:
The Tuning Hierarchy¶
| Layer | Tool | When to apply |
|---|---|---|
| Profile first | pg_stat_statements | Always — before any change |
| Indexes | B-tree, GIN, BRIN, partial, covering | Every slow query |
| Connection pooling | PgBouncer (transaction mode) | > 100 concurrent connections |
| Configuration | shared_buffers, work_mem, effective_cache_size | After profiling |
| Autovacuum | Scale factors, per-table overrides | When dead tuple % is high |
| Partitioning | Range by date, list by tenant | > 50M rows with clear partition key |
| Schema design | Right types, hot/cold separation | At design time |
The 10 Rules That Compound¶
- Measure with
pg_stat_statementsfirst. Never guess which query is slow. EXPLAIN (ANALYZE, BUFFERS)— not justEXPLAIN. Estimated vs actual rows diverge constantly.- Column order in compound indexes matters: equality columns first, range columns last.
INCLUDEcolumns turn B-tree indexes into covering indexes — the fastest read path.- Partial indexes on filtered subsets are smaller, faster, and cheaper to maintain.
- PgBouncer in transaction mode is not optional above 200 connections. Set PostgreSQL
max_connections = 100. shared_buffers= 25% of RAM is a starting point, not a ceiling. Monitor cache hit rates.- Autovacuum defaults are wrong for large tables. Lower scale factors and raise cost limits.
- Partitioning is a schema decision, not a query fix. It helps only when queries filter on the partition key.
- Functions on indexed columns in WHERE kill indexes. Rewrite to range queries or use expression indexes.
Every technique here starts with measurement, makes one change, and measures again. That discipline — not any single setting — is what separates databases that stay fast from ones that require emergency tuning at 3 AM.
Based on PostgreSQL 17, PgBouncer 1.23, and pg_partman 5.x on Rocky Linux 9. Techniques apply to any PostgreSQL 14+ installation.
Questions or discussion? Connect on LinkedIn, X or reach out via email.
Discussion
Have thoughts on this post? Share them below — questions, corrections, or your own experience are all welcome.