A PostgreSQL performance issue rarely starts with one bad setting.
In production, it usually looks like this: the application team says the database is slow, CPU is not always high, storage graphs look confusing, and nobody changed anything “major”. Then we check deeper and find long transactions, dead tuples, stale statistics, unused indexes, chatty application queries, or checkpoint pressure.
That is why PostgreSQL tuning should not begin with random parameter changes. It should begin with evidence.
This post covers practical areas that DBAs should check before touching memory or kernel settings: SQL patterns, indexing, VACUUM, ANALYZE, connection pressure, bulk loading, and storage behavior. I will also call out a few old tuning tips that should not be blindly used on modern PostgreSQL versions.
The goal is simple: tune PostgreSQL like a production system, not like a copied checklist.
1. First Check the Workload, Not the Parameter File
Before tuning PostgreSQL, understand what kind of workload is hitting it.
Many systems become slow because OLTP traffic, reporting queries, batch jobs, exports, and queue processing all run on the same primary database. PostgreSQL can handle heavy workloads, but it does not like uncontrolled mixed pressure.
A few patterns I always check early:
- Long-running transactions
- Idle sessions inside transactions
- Too many active connections
- Reporting queries on the primary
- Frequently updated singleton rows
- Application-side loops firing thousands of small SQLs
- Large loads done with INSERT instead of COPY
Check long transactions & Idle transactions as well :
// Long running SELECT pid, usename, application_name, state, now() - xact_start AS transaction_age, wait_event_type, wait_event, left(query, 120) AS query_text FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY transaction_age DESC; Check idle transactions: // idle SELECT pid, usename, application_name, now() - state_change AS idle_time, left(query, 120) AS query_text FROM pg_stat_activity WHERE state = 'idle in transaction' ORDER BY idle_time DESC;
This is important because old transactions can stop VACUUM from cleaning dead rows. In Oracle, DBAs think heavily about undo. In PostgreSQL, DBAs must think about old row versions sitting inside the table until VACUUM can remove them.
Production perspective
If table size is growing, autovacuum is running, and dead tuples are not reducing, do not immediately increase autovacuum workers. First check whether an old transaction is holding the cleanup back.
2. Bad SQL Can Waste Good Hardware
One bad SQL is visible. One small bad SQL executed 5 million times is more dangerous.
For PostgreSQL, pg_stat_statements is one of the
first extensions I want enabled in production.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
After enabling it in shared_preload_libraries, check
top SQL by total time:
SELECT calls, round(total_exec_time::numeric, 2) AS total_ms, round(mean_exec_time::numeric, 2) AS avg_ms, rows, left(query, 150) AS sample_query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10; Also check high-frequency SQL: SELECT calls, round(mean_exec_time::numeric, 2) AS avg_ms, left(query, 150) AS sample_query FROM pg_stat_statements ORDER BY calls DESC LIMIT 10;
Some SQL patterns deserve suspicion like below:
Unanchored LIKE
WHERE remarks LIKE '%failed payment%'
For text search, use full text search or
pg_trgm depending on the requirement.
CREATE EXTENSION IF NOT EXISTS pg_trgm;CREATE INDEX CONCURRENTLY idx_customer_notes_trgmON customer_notesUSING gin(note_text gin_trgm_ops);
Huge IN lists
Instead of sending thousands of values in an
IN clause, load them into a temporary table and
join.
CREATE TEMP TABLE temp_ids(id bigint) ON COMMIT DROP;
-- load IDs using COPY or application batch insert
SELECT o.*FROM orders oJOIN temp_ids t ON t.id = o.order_id;
SELECT * everywhere
This looks harmless until the table becomes wide or the query runs thousands of times per minute.
Better:
SELECT order_id, status, created_at FROM orders WHERE customer_id = $1 ORDER BY created_at DESC LIMIT 20; // Use EXPLAIN with buffers when needed: EXPLAIN (ANALYZE, BUFFERS) SELECT order_id, status, created_at FROM orders WHERE customer_id = 1001 ORDER BY created_at DESC LIMIT 20;
Be careful with EXPLAIN ANALYZE in production. It
executes the query.
3. Indexes Are Not Free
Indexes can save a system. They can also quietly slow it down.
Every extra index has a cost during INSERT, UPDATE, DELETE, VACUUM, backup, restore, and replication. So the question is not “can we create an index?” The question is “does this index support a real workload?”
Find tables with heavy sequential reads & indexes which are unused.
# SELECT schemaname, relname, seq_scan, seq_tup_read, idx_scan, n_live_tup, n_dead_tup FROM pg_stat_user_tables ORDER BY seq_tup_read DESC LIMIT 20; Find indexes that look unused: # SELECT schemaname, relname AS table_name, indexrelname AS index_name, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS index_size FROM pg_stat_user_indexes ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC LIMIT 20;
Do not drop an index only because idx_scan = 0.
Check uptime, reporting schedules, constraints, and whether the index is used on a standby.
Create production indexes with less blocking:
CREATE INDEX CONCURRENTLY idx_orders_customer_createdON orders (customer_id, created_at DESC);
For partial workloads, partial indexes are often cleaner:
CREATE INDEX CONCURRENTLY idx_orders_failed_createdON orders (created_at DESC)WHERE status = 'FAILED';
This is useful when only a small subset of the table is queried often.
Caveat
CREATE INDEX CONCURRENTLY takes longer and cannot
run inside a transaction block. If it fails, check for invalid
indexes.
SELECT c.relname AS index_name, i.indisvalid, i.indisready FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE NOT i.indisvalid OR NOT i.indisready;
4. VACUUM Is Core Maintenance, Not Housekeeping
PostgreSQL uses MVCC. When rows are updated or deleted, old row versions remain in the table until VACUUM can clean them.
This is one of the biggest mindset changes for Oracle DBAs. Oracle depends heavily on undo for read consistency. PostgreSQL keeps old row versions in the heap, so table cleanup becomes a daily operational topic.
Check dead tuples and its percentage as well :
SELECT schemaname, relname, n_live_tup, n_dead_tup, last_autovacuum, last_autoanalyze FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 20; Check dead tuple percentage: SELECT relname, n_live_tup, n_dead_tup, round( n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 2 ) AS dead_pct FROM pg_stat_user_tables WHERE n_live_tup + n_dead_tup > 0 ORDER BY dead_pct DESC LIMIT 20;
Manual cleanup after heavy change:
VACUUM (ANALYZE, VERBOSE) public.orders;
Do not jump to VACUUM FULL casually. Regular VACUUM
makes space reusable inside the table.
VACUUM FULL rewrites the table and takes a strong
lock.
For large busy tables, tune autovacuum per table:
ALTER TABLE public.orders SET ( autovacuum_vacuum_scale_factor = 0.02, autovacuum_vacuum_threshold = 5000, autovacuum_analyze_scale_factor = 0.01, autovacuum_analyze_threshold = 5000 );
Production view
For a table with hundreds of millions of rows, default autovacuum thresholds may react too late. Per-table tuning is often better than changing global settings aggressively.
5. ANALYZE: The Planner Needs Fresh Statistics
Many PostgreSQL problems after migration, purge, or bulk load are not caused by missing indexes. They are caused by stale statistics.
Run ANALYZE after major data movement:
ANALYZE public.orders;
Or with cleanup:
VACUUM (ANALYZE) public.orders;
When estimated rows and actual rows differ heavily in an execution plan, statistics may be weak.
For skewed columns, increase statistics target only where needed:
ALTER TABLE public.ordersALTER COLUMN status SET STATISTICS 1000;
ANALYZE public.orders;
For related columns, extended statistics can help:
CREATE STATISTICS st_orders_status_createdON status, created_atFROM public.orders;
ANALYZE public.orders;
Production perspective here ;
After a large archival job, always run ANALYZE on affected tables. Otherwise the planner may still think old data distribution exists.
6. Use COPY for Bulk Loads
For large data loads, row-by-row INSERT is usually a bad idea.
Use server-side COPY:
COPY staging_orders(order_id, customer_id, status, created_at) FROM '/data/load/orders.csv' WITH (FORMAT csv, HEADER true);
If the file is on the client machine, use
psql:
psql -d appdb -c "\copy staging_orders FROM '/tmp/orders.csv' WITH (FORMAT csv, HEADER true)"
A safer production pattern:
- Load into staging table
- Validate row count and duplicates
- Create needed indexes
- Move data into target table
- Run ANALYZE
Example:
INSERT INTO orders(order_id, customer_id, status, created_at) SELECT order_id, customer_id, status, created_at FROM staging_orders; ANALYZE orders;
Caveat
UNLOGGED tables can speed up temporary loads, but
they are not crash-safe. Use them only when the data can be recreated.
CREATE UNLOGGED TABLE staging_orders AS SELECT * FROM orders WITH NO DATA;
7. Connections and Memory Need Practical Control
PostgreSQL creates a backend process per connection. Too many connections can create memory pressure and context switching overhead.
Check connection usage by application :
SELECT application_name, usename, client_addr, state, count(*) AS sessions FROM pg_stat_activity GROUP BY application_name, usename, client_addr, state ORDER BY sessions DESC;
Do not blindly increase max_connections. For many OLTP systems, PgBouncer is a better fix.
Also be careful with work_mem. It is not a single
global memory bucket. Multiple sessions and multiple plan nodes can consume
it.
SHOW max_connections;SHOW work_mem;SHOW shared_buffers;SHOW maintenance_work_mem;
Production view
If the host is under memory pressure, do not say “disable the OOM killer” and move on. Fix the cause: too many connections, bad SQL, oversized memory settings, or other processes on the database server.
PostgreSQL should ideally run on a dedicated host or dedicated VM with predictable memory and I/O.
8. Storage and OS Tuning: Avoid Copy-Paste Settings
Old tuning notes often mention noop,
deadline, and cfq. On modern
Linux systems, especially RHEL 8 and similar platforms, you may see
schedulers such as mq-deadline,
kyber, bfq, or
none.
Check the active scheduler:
cat /sys/block/sdb/queue/scheduler
Example:
[mq-deadline] kyber bfq none
Do not change the scheduler because a random benchmark says so. Test it with your storage type, workload, and platform team.
For PostgreSQL, also watch WAL and checkpoint behavior.
SHOW checkpoint_timeout;SHOW max_wal_size;SHOW synchronous_commit;
Check checkpoint stats:
SELECT checkpoints_timed, checkpoints_req, checkpoint_write_time, checkpoint_sync_time, buffers_checkpoint FROM pg_stat_bgwriter;
If checkpoints_req is increasing frequently,
PostgreSQL may be checkpointing because WAL size is getting exhausted before
time-based checkpoints.
For OS-level visibility:
iostat -x 2vmstat 2
Point to note : Commit slowness is often storage-related. WAL flush latency, synchronous replication, and checkpoint pressure can all show up as application latency.
9. Old Tuning Tips to Recheck
Some PostgreSQL tuning advice is version-specific.
The best example is stats_temp_directory. Older
versions allowed moving statistics temporary files to a RAM-based
filesystem. From PostgreSQL 15 onward, that parameter is removed.
So do not carry this into modern builds:
stats_temp_directory = '/dev/shm/pg_stat_tmp'
Also avoid copied kernel settings like:
vm.dirty_background_ratio = 0vm.dirty_ratio = 0
These are not generic PostgreSQL best practices. Kernel tuning should be tested and agreed with the Linux/platform team.
During PostgreSQL upgrades, review old postgresql.conf
files. A parameter that helped years ago may be obsolete today.
Production Incident : Autovacuum Was Not the Real Problem
Problem
An order table kept growing. Queries became slower. Monitoring showed many dead tuples. The first suggestion was to increase autovacuum settings.
Investigation
The DBA checked dead tuples:
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum FROM pg_stat_user_tables WHERE relname = 'orders';
Then checked old transactions:
One application session was idle in transaction for hours.
Root Cause The old transaction was holding a snapshot. VACUUM was running, but it could not clean everything.
Fix After confirmation with the application team:
SELECT pg_terminate_backend(<pid>);
Then:
VACUUM (ANALYZE, VERBOSE) public.orders;
A guardrail was added:
ALTER SYSTEM SET idle_in_transaction_session_timeout = '10min';SELECT pg_reload_conf();
Lesson Learnt Do not tune autovacuum before checking what is blocking it.
Quick Takeaways
- PostgreSQL tuning starts with workload behavior.
- Long transactions can block cleanup and create bloat.
- High-frequency small SQL can hurt more than one large query.
- Indexes improve reads but add write and maintenance cost.
- VACUUM and ANALYZE are production essentials.
- COPY is the right tool for large loads.
- Old tuning advice must be checked against PostgreSQL version and Linux platform.
Conclusion
PostgreSQL performance tuning is not about changing ten parameters and hoping for the best. It is about following the evidence.
Start with active sessions, long transactions, query frequency, execution plans, dead tuples, statistics, indexes, connections, WAL, checkpoints, and storage latency. Most production issues become clearer when these areas are checked in the right order.
For Oracle DBAs, the biggest shift is MVCC cleanup. PostgreSQL keeps old row versions in the table until VACUUM can remove them. That makes long transactions, autovacuum health, and table bloat daily operational topics.
Also, do not trust old tuning notes blindly. Some advice was valid for
older PostgreSQL versions or older Linux kernels, but not for current
production builds. stats_temp_directory is a good
example. Old I/O scheduler recommendations are another.
A well-tuned PostgreSQL system is not the one with the most customized settings. It is the one where every change has a reason, every query has been measured, and every maintenance task is monitored.
Review your top SQL. Check dead tuples. Validate indexes. Watch checkpoints. Test bulk loads. And most importantly, tune from production evidence, not from memory.
FAQs
1. Should I disable autovacuum?
No. Tune it if needed, but do not disable it. Without VACUUM, dead tuples and transaction ID risk will become serious problems.
2. When should I run ANALYZE manually?
Run it after bulk loads, large deletes, migrations, archival jobs, or major data distribution changes.
3. Is a sequential scan always bad?
No. Sequential scans are normal for small tables or queries reading a large percentage of data. Check row estimates and buffer usage before blaming the scan.
4. Should I increase max_connections?
Not always. More connections can increase memory pressure. For OLTP systems, connection pooling is often better.
5. Is VACUUM FULL a normal maintenance command?
No. Use it carefully. It rewrites the table and requires a strong lock. Regular VACUUM is the normal cleanup operation.
Finally a quick comparison with Oracle and how PostgreSQL handles the MVCC
What was the most misleading PostgreSQL performance issue you handled?
Was it SQL, autovacuum, stale stats, storage, connection pressure, or an application transaction left open?
Share your experience. These real stories are more useful than any tuning checklist.






No comments:
Post a Comment