Databases & NoSQL

PostgreSQL Performance Tuning: Indexing, Vacuum, Partitioning, and pgBadger

By Domain India Team · DomainIndia EngineeringPublished 9 min read
Knowledge base article
Contents (18 sections)

PostgreSQL's defaults are safe on any hardware, which also means they leave a lot of a modern server unused. This guide walks through the changes that make the biggest difference, in the order you should try them, with the commands to check each one.

Key takeaways

Measure first with EXPLAIN ANALYZE and the slow-query log, then fix the biggest problem. The levers that matter most are the right indexes (b-tree, partial, GIN, BRIN, covering), memory settings sized to your RAM, an autovacuum that keeps up, partitioning for very large tables, and connection pooling with PgBouncer. Server-level settings need a server you control, such as a VPS; indexing and query fixes work anywhere.

When to tune

Default PostgreSQL config is conservative — safe for any hardware but underuses modern VPS resources. Tune when:

  • Queries suddenly slow after table grew past 1M rows
  • pg_stat_activity shows many sessions with a wait_event of type Lock
  • CPU spikes during business hours
  • Disk usage grows much faster than your data (often: bloat from a lagging autovacuum)
  • Long-running maintenance tasks blocking writes

If your site is small (<100K rows) and fast, don't tune. Default PG works.

Level 1 — Right-size memory settings

Edit postgresql.conf. On AlmaLinux or Rocky with the PostgreSQL (PGDG) packages it is /var/lib/pgsql/17/data/postgresql.conf; on Ubuntu or Debian it is /etc/postgresql/17/main/postgresql.conf (both examples are for PostgreSQL 17; use your version number). If in doubt, run SHOW config_file; in psql.

SettingDefault4 GB VPS8 GB VPS16 GB VPS
shared_buffers128MB1GB2GB4GB
effective_cache_size4GB3GB6GB12GB
work_mem4MB8MB16MB32MB
maintenance_work_mem64MB256MB512MB1GB
max_connections100100200300
random_page_cost4.01.1 (SSD)1.11.1
effective_io_concurrency1 (16 from v18)200 (SSD)200200

Apply and restart:

bash
sudo systemctl restart postgresql

A reload (SELECT pg_reload_conf();) is enough for most settings, but shared_buffers and max_connections need a restart. The pgtune online tool gives a starting config for your RAM and workload.

Level 2 — Indexing strategies

b-tree (default) — equality + range

sql
-- Fast for:
--   WHERE email = ...
--   WHERE created_at > '2026-01-01'
--   ORDER BY price
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_created ON orders(created_at);

Composite — multi-column

sql
-- Fast for WHERE status='paid' AND user_id=42 ORDER BY created_at
CREATE INDEX idx_orders_status_user_created
    ON orders(status, user_id, created_at DESC);

Column order matters: columns compared with = first, then the column you filter by range or sort by. An index on (a, b, c) also serves queries on a alone and on a, b, but not on b alone.

Partial — index only relevant rows

Huge win for sparse columns:

sql
-- 95% of orders are 'paid' — only index unusual ones
CREATE INDEX idx_orders_pending
    ON orders(id)
    WHERE status != 'paid';

Index is smaller → faster writes, smaller reads, better cache use.

GIN — full-text search, JSONB, arrays

sql
-- Search JSON metadata
CREATE INDEX idx_products_meta
    ON products USING gin(metadata);

SELECT * FROM products WHERE metadata @> '{"category": "electronics"}';

-- Full-text search
ALTER TABLE articles ADD COLUMN tsv tsvector
    GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX idx_articles_tsv ON articles USING gin(tsv);

SELECT * FROM articles WHERE tsv @@ plainto_tsquery('english', 'hosting tips');

BRIN — huge append-only tables

sql
-- Log table with 100M rows by time
CREATE INDEX idx_logs_created_brin
    ON logs USING brin(created_at);

BRIN index is tiny (KB) vs GB for b-tree. Best when data is physically clustered by the indexed column (append-only).

Covering — index-only scans

sql
-- Query: SELECT id, email FROM users WHERE status = 'active'
CREATE INDEX idx_users_active_covering
    ON users(status) INCLUDE(id, email)
    WHERE status = 'active';

PostgreSQL returns results without touching the table — huge speedup.

Level 3 — EXPLAIN ANALYZE

Before tuning, measure. Always.

sql
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT u.name, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > now() - interval '30 days'
GROUP BY u.name;

Look for:

  • Seq Scan on big tables = missing index
  • Nested Loop over many rows = often a bad row estimate; run ANALYZE, and for diagnosis only, test with SET enable_nestloop = off; in your session
  • Actual rows >> estimated rows = outdated statistics (run ANALYZE)
  • Buffers: shared read=10000 = data not cached; large shared_buffers helps

Visualise plans at explain.depesz.com or pgmustard.com.

Level 4 — Vacuum strategy

PostgreSQL's MVCC keeps "dead tuples" after UPDATE/DELETE. Autovacuum removes them. Without it: bloat grows, queries slow down.

Check bloat:

sql
SELECT schemaname, relname,
       pg_size_pretty(pg_relation_size(relid)) AS size,
       n_dead_tup,
       last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;

If n_dead_tup > 20% of rowcount, autovacuum is behind. Tune:

ini
# postgresql.conf
autovacuum_max_workers = 4
autovacuum_vacuum_scale_factor = 0.05    # vacuum at 5% dead (default 20%)
autovacuum_analyze_scale_factor = 0.02
autovacuum_vacuum_cost_limit = 2000      # aggressive

For high-write tables, set per-table:

sql
ALTER TABLE orders SET (
    autovacuum_vacuum_scale_factor = 0.02,
    autovacuum_analyze_scale_factor = 0.01
);

Manual vacuum for recovery:

sql
VACUUM (VERBOSE, ANALYZE) orders;

-- Last resort: rewrites the table and returns disk space,
-- but holds an exclusive lock for the whole run
VACUUM FULL orders;

Don't VACUUM FULL large tables on a live system: the lock blocks reads and writes, possibly for hours. Use pg_repack instead (see the FAQ).

Level 5 — Partitioning huge tables

When a single table > 50M rows, partitioning by time often helps.

sql
-- Range partition by month
CREATE TABLE events (
    id bigserial,
    created_at timestamptz NOT NULL,
    user_id uuid,
    data jsonb
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_04 PARTITION OF events
    FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');

CREATE TABLE events_2026_05 PARTITION OF events
    FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');

-- An index on the parent is created on every partition,
-- including partitions added later
CREATE INDEX ON events (user_id);

Use pg_partman extension to auto-create future partitions + drop old ones.

Benefits:

  • Query for "last 30 days" hits 1 partition, not all
  • DROP TABLE events_2023_01 instantly reclaims old data
  • Individual partition vacuum/index maintenance

Level 6 — Connection pooling

Each PostgreSQL connection is a separate server process that costs memory and startup time. Use PgBouncer for pooling:

bash
sudo dnf install pgbouncer      # AlmaLinux/Rocky (PGDG repository)
sudo apt install pgbouncer      # Ubuntu/Debian
# then edit /etc/pgbouncer/pgbouncer.ini
ini
[databases]
mydb = host=localhost port=5432 dbname=mydb

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20

The app connects to localhost:6432 instead of :5432: up to 1,000 client connections share 20 real PostgreSQL connections. In transaction mode, session-level features (session SET, advisory locks held across transactions, LISTEN) don't behave as they do on a direct connection, so test your app.

Level 7 — Slow query log + pgBadger

Log slow queries:

ini
# postgresql.conf
log_min_duration_statement = 500  # ms — log queries slower than this
log_line_prefix = '%t [%p]: user=%u,db=%d,app=%a,client=%h '
log_statement = 'ddl'
log_duration = off

Analyse logs with pgBadger (HTML reports):

bash
sudo dnf install pgbadger      # or: sudo apt install pgbadger
# Point it at your log directory (Ubuntu: /var/log/postgresql/,
# PGDG on AlmaLinux: /var/lib/pgsql/<version>/data/log/)
pgbadger /var/log/postgresql/postgresql-*.log -o /root/reports/pgbadger.html

Keep the report out of your public web root, because it contains your SQL. Copy it to your computer, or serve it behind authentication. It shows the slowest queries, the most-called ones, peak hours and errors.

Level 8 — Replication + read replicas

For read-heavy apps, add a streaming replica:

ini
# On primary
wal_level = replica
max_wal_senders = 10
wal_keep_size = 1GB

# On the primary, also create a replication role and allow it in pg_hba.conf
# On the replica, take a base backup and start:
pg_basebackup -h primary-ip -D /var/lib/pgsql/17/data -U replicator -P -R
sudo systemctl start postgresql

Route read queries to replica in your app:

python
write_engine = create_engine(PRIMARY_URL)
read_engine = create_engine(REPLICA_URL)
# Use read_engine for SELECTs, write_engine for writes

Common pitfalls

Too many indexes
Each one slows down writes. If an index is never used (idx_scan = 0 in pg_stat_user_indexes), drop it.
shared_buffers set too high
Much more than about 25% of RAM usually hurts. Leave room for the OS page cache.
Long transactions block autovacuum
A session stuck idle in transaction pins old rows. Set idle_in_transaction_session_timeout = '10min'.
EXPLAIN without ANALYZE
Shows the estimated plan, not what happened. EXPLAIN ANALYZE really runs the query, so wrap writes in a transaction you roll back.
SELECT * in a hot path
Pulls columns you don't use and prevents index-only scans. List the columns you need.
No ANALYZE after a bulk load
The planner uses old statistics. Run ANALYZE table_name after large inserts.

Running this on Domain India

  • Query-level work (indexes, EXPLAIN ANALYZE, ANALYZE, rewriting slow queries) needs only a normal database login, so it applies wherever your PostgreSQL database runs.
  • Server-level settings (postgresql.conf, autovacuum workers, PgBouncer, pgBadger, replicas) need root on the server. A Domain India VPS is self-managed with full root access, so all of this guide applies there. VPS plans include no backups or snapshots, so schedule your own pg_dump or pg_basebackup copies to storage outside the server.
  • The App Platform includes a PostgreSQL database on every plan. Treat it as managed: the query-level advice applies, but don't assume you can change server settings or install extensions; ask support what is possible.

Prices and plans are on the VPS page (from ₹553 a month, excluding 18% GST) and the App Platform page.

FAQ

How do I know if I need tuning?

If you have no slow queries and CPU is <30% during peak — don't tune. If queries occasionally exceed 1 second or CPU >70% sustained — start tuning.

Postgres or MySQL?

Both are solid. Postgres is usually the better default for new apps: richer JSON support with GIN indexes, materialised views, partial and expression indexes, and many extensions. MySQL or MariaDB remains the natural choice for software built for it, such as WordPress.

pg_repack or VACUUM FULL for bloat?

pg_repack rebuilds a table while holding an exclusive lock only briefly at the start and end, so it is the production-safe choice. It needs the extension installed, which requires a server you control. VACUUM FULL locks the table for the whole run; use it only in a maintenance window.

How do I benchmark?

pgbench (ships with Postgres) for generic OLTP load. For real workload, replay slow query log against staging.

Do I need a DBA?

For <1 TB data + <1K req/sec, an engineer + these tuning patterns is enough. At scale (multi-TB, 10K+ req/sec), a DBA pays for themselves.

Ready to tune your own server? Compare VPS plans, or open a support ticket with questions about a plan; tickets get a first response within 15 minutes.

Run PostgreSQL on a server you control

A self-managed Domain India VPS gives you full root access to tune PostgreSQL, run PgBouncer and schedule your own backups.

See VPS plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app