LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

PostgreSQL for Developers: Indexes, EXPLAIN ANALYZE, JSONB, and Tuning for Real Workloads

postgresqldatabasessqlperformancebackendindexingjsonb

PostgreSQL is one of those tools where the gap between “uses it” and “understands it” is enormous. Most application developers use Postgres as a black box: write SQL, get results. That works until it doesn’t — until a query takes 30 seconds, a table grows to 100 million rows, or you need to model data that doesn’t fit neatly into columns.

This guide covers the parts of Postgres that actually matter for developers building real applications: how indexes work and which type to use when, how to read EXPLAIN ANALYZE output to understand what Postgres is actually doing, JSONB for flexible semi-structured data, and the configuration tuning that moves the needle on real workloads.


Indexes: The Foundation of Query Performance

An index is a separate data structure that Postgres maintains alongside your table to make certain queries faster. The trade-off is always the same: indexes speed up reads and slow down writes (because every INSERT, UPDATE, and DELETE has to update the index too).

How B-Tree Indexes Work

The default index type in Postgres is a B-tree (balanced tree). Values are stored in sorted order in a tree structure, allowing Postgres to find rows in O(log n) time rather than O(n) for a sequential scan.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- Create a B-tree index
CREATE INDEX idx_users_email ON users(email);

-- Composite index (order matters)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

-- Partial index — only indexes rows matching the condition
CREATE INDEX idx_orders_pending ON orders(created_at)
  WHERE status = 'pending';

-- Expression index — indexes a computed value
CREATE INDEX idx_users_lower_email ON users(lower(email));

The column order in composite indexes matters. An index on (user_id, status) can be used for:

  • Queries filtering on user_id alone
  • Queries filtering on both user_id AND status

It cannot efficiently support:

  • Queries filtering on status alone (no user_id prefix)

Think of it like a phone book sorted by last name, then first name. You can find “Smith, John” fast. You can’t find all “Johns” without scanning the whole book.

Partial Indexes: Underused and Powerful

Partial indexes index only a subset of rows. They’re smaller, faster to scan, and cheaper to maintain:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- Only 0.1% of orders are 'pending' — index just those
CREATE INDEX idx_orders_pending_created ON orders(created_at)
  WHERE status = 'pending';

-- Only index non-deleted users
CREATE INDEX idx_users_active_email ON users(email)
  WHERE deleted_at IS NULL;

-- Unique constraint only for active (non-cancelled) subscriptions
CREATE UNIQUE INDEX idx_subscriptions_active_user
  ON subscriptions(user_id)
  WHERE status != 'cancelled';

A partial unique index for the last example enforces “one active subscription per user” without preventing multiple cancelled subscriptions — something a normal unique constraint can’t do.

Index Types Beyond B-Tree

Index Type Use Case
BTREE (default) Equality, range queries, ORDER BY, most queries
HASH Equality only — rarely better than B-tree in practice
GIN Full-text search, arrays, JSONB containment (@>)
GiST Geometric data, range types, nearest-neighbor search
BRIN Very large tables where data is naturally ordered (timestamps, sequential IDs)
pg_trgm Fuzzy string matching (LIKE '%foo%', similarity)

GIN for JSONB and full-text search:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
-- Create GIN index on JSONB column for containment queries
CREATE INDEX idx_events_data ON events USING GIN(data);

-- Now this query uses the index
SELECT * FROM events WHERE data @> '{"type": "login"}';

-- Full-text search GIN index
CREATE INDEX idx_articles_fts ON articles
  USING GIN(to_tsvector('english', title || ' ' || body));

SELECT * FROM articles
WHERE to_tsvector('english', title || ' ' || body)
   @@ to_tsquery('english', 'kubernetes & deployment');

BRIN for time-series tables:

1
2
3
4
5
-- Table has 500M rows of sensor data inserted chronologically
-- A BRIN index is tiny (pages instead of rows) and effective for range queries
CREATE INDEX idx_sensor_readings_time ON sensor_readings
  USING BRIN(recorded_at)
  WITH (pages_per_range = 128);

Finding Missing and Unused Indexes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
-- Tables with high sequential scans (possible missing indexes)
SELECT
  schemaname,
  tablename,
  seq_scan,
  seq_tup_read,
  idx_scan,
  ROUND(seq_scan::numeric / NULLIF(seq_scan + idx_scan, 0) * 100, 1) AS seq_scan_pct
FROM pg_stat_user_tables
WHERE seq_scan > 100
ORDER BY seq_tup_read DESC
LIMIT 20;

-- Unused indexes (wasting write performance)
SELECT
  schemaname,
  tablename,
  indexname,
  idx_scan,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_relation_size(indexrelid) DESC;

-- Index sizes
SELECT
  indexname,
  pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;

EXPLAIN ANALYZE: Reading the Query Plan

EXPLAIN ANALYZE is the single most important tool for understanding slow queries. It shows you exactly what Postgres did to execute your query — which indexes were used, how many rows were scanned, and where time was spent.

1
2
3
4
5
6
7
8
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.email, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2025-01-01'
GROUP BY u.email
ORDER BY order_count DESC
LIMIT 10;

Reading the Output

Limit  (cost=1823.45..1823.47 rows=10 width=48)
       (actual time=45.231..45.234 rows=10 loops=1)
  ->  Sort  (cost=1823.45..1848.45 rows=10000 width=48)
            (actual time=45.229..45.230 rows=10 loops=1)
        Sort Key: (count(o.id)) DESC
        Sort Method: top-N heapsort  Memory: 25kB
        ->  HashAggregate  (cost=1423.12..1523.12 rows=10000 width=48)
                           (actual time=38.112..43.891 rows=9823 loops=1)
              Group Key: u.email
              ->  Hash Left Join  (cost=423.00..1148.12 rows=55000 width=16)
                                  (actual time=5.234..28.443 rows=55231 loops=1)
                    Hash Cond: (o.user_id = u.id)
                    ->  Seq Scan on orders  (cost=0.00..521.00 rows=55000 width=8)
                                           (actual time=0.023..8.234 rows=55000 loops=1)
                    ->  Hash  (cost=348.00..348.00 rows=6000 width=16)
                              (actual time=4.891..4.891 rows=6000 loops=1)
                          ->  Index Scan using idx_users_created_at on users
                                (cost=0.29..348.00 rows=6000 width=16)
                                (actual time=0.041..3.234 rows=6000 loops=1)
                                Index Cond: (created_at > '2025-01-01')
Planning Time: 0.892 ms
Execution Time: 45.341 ms

Key things to read:

cost=X..Y — Estimated cost in arbitrary units. The first number is startup cost (before first row), the second is total cost. Higher is slower. These are estimates — compare with actual time.

actual time=X..Y rows=N loops=L — Actual milliseconds and row count. If rows (estimate) differs wildly from actual, Postgres has bad statistics — run ANALYZE tablename.

Node types to recognize:

Node What it means
Seq Scan Full table scan — fine for small tables, bad for large ones
Index Scan Used an index, fetches heap for each row
Index Only Scan Index contains all needed columns — fastest
Bitmap Heap Scan Collects matching pages then fetches in bulk — good for range queries
Hash Join Builds hash table of smaller relation, probes with larger
Nested Loop For each row in outer, find matches in inner — good when inner is small
Merge Join Both inputs sorted, merge — good for large sorted inputs

Warning signs:

1
2
3
4
5
6
7
8
9
-- Seq Scan on a large table
Seq Scan on orders  (cost=0..52100.00 rows=1000000 ...)

-- Rows estimate vs actual wildly off (stale statistics)
(cost=... rows=100)   -- estimated
(actual rows=85000)   -- actual — 850x off!

-- Very high loops count (N+1 query hiding in the plan)
Nested Loop  (... loops=50000)

The BUFFERS Option

Adding BUFFERS to EXPLAIN ANALYZE shows cache hit rates:

Seq Scan on large_table  (... actual time=234.1..1823.4 ...)
  Buffers: shared hit=1240 read=48329
  • shared hit — pages found in Postgres’s shared buffer cache (fast, in RAM)
  • read — pages read from disk (slow)

A query with mostly read is I/O-bound — increasing shared_buffers or adding an index helps.

pg_stat_statements: Finding Your Worst Queries

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
-- Enable the extension (add to postgresql.conf: shared_preload_libraries = 'pg_stat_statements')
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Top 10 queries by total time
SELECT
  LEFT(query, 100) AS query_snippet,
  calls,
  ROUND(total_exec_time::numeric, 2) AS total_ms,
  ROUND(mean_exec_time::numeric, 2) AS mean_ms,
  ROUND(stddev_exec_time::numeric, 2) AS stddev_ms,
  rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

-- Queries with high variance (sometimes fast, sometimes slow)
SELECT
  LEFT(query, 100) AS query_snippet,
  calls,
  ROUND(mean_exec_time::numeric, 2) AS mean_ms,
  ROUND(stddev_exec_time::numeric, 2) AS stddev_ms,
  ROUND(stddev_exec_time / NULLIF(mean_exec_time, 0), 2) AS cv
FROM pg_stat_statements
WHERE calls > 100
ORDER BY cv DESC
LIMIT 10;

JSONB: Flexible Schemas Without Giving Up SQL

JSONB stores JSON as a decomposed binary format — it’s parsed on insert, making writes slightly slower but reads and indexing much faster than the json type (which stores raw text).

Basic JSONB Operations

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
-- Create a table with JSONB
CREATE TABLE events (
  id         BIGSERIAL PRIMARY KEY,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  user_id    BIGINT NOT NULL,
  type       TEXT NOT NULL,
  data       JSONB NOT NULL DEFAULT '{}'
);

-- Insert
INSERT INTO events (user_id, type, data) VALUES
  (42, 'login',   '{"ip": "1.2.3.4", "user_agent": "Mozilla/5.0"}'),
  (42, 'purchase', '{"amount": 49.99, "items": ["widget", "gadget"], "currency": "USD"}'),
  (99, 'login',   '{"ip": "5.6.7.8", "mfa": true}');

-- Access operators
SELECT data->'amount'          FROM events WHERE type = 'purchase';  -- returns JSON
SELECT data->>'ip'             FROM events WHERE type = 'login';     -- returns text
SELECT data#>>'{items,0}'      FROM events WHERE type = 'purchase';  -- nested path

-- Containment: does this JSON contain this subset?
SELECT * FROM events WHERE data @> '{"mfa": true}';
SELECT * FROM events WHERE data @> '{"items": ["widget"]}';

-- Key existence
SELECT * FROM events WHERE data ? 'mfa';
SELECT * FROM events WHERE data ?| ARRAY['mfa', 'ip'];  -- has either key
SELECT * FROM events WHERE data ?& ARRAY['ip', 'user_agent'];  -- has both keys

-- Update JSONB
UPDATE events
SET data = data || '{"processed": true}'    -- merge/overwrite key
WHERE type = 'purchase';

UPDATE events
SET data = data - 'ip'                      -- remove key
WHERE type = 'login' AND user_id = 99;

Indexing JSONB

1
2
3
4
5
6
7
8
9
-- GIN index for containment and key existence queries
CREATE INDEX idx_events_data ON events USING GIN(data);

-- Specific expression index for a frequently queried key
CREATE INDEX idx_events_ip ON events((data->>'ip'));

-- Partial GIN index on a subset of rows
CREATE INDEX idx_events_login_data ON events USING GIN(data)
  WHERE type = 'login';

JSONB for Semi-Structured Data: A Pattern

JSONB shines when different rows need different attributes — the “polymorphic data” problem:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
-- A notifications table where different notification types have different data
CREATE TABLE notifications (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT NOT NULL,
  type        TEXT NOT NULL,   -- 'email', 'sms', 'push', 'webhook'
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  sent_at     TIMESTAMPTZ,
  payload     JSONB NOT NULL
);

-- Email notification
INSERT INTO notifications (user_id, type, payload) VALUES
  (42, 'email', '{"to": "user@example.com", "subject": "Your order shipped", "template": "order_shipped", "order_id": 9981}');

-- Push notification
INSERT INTO notifications (user_id, type, payload) VALUES
  (42, 'push', '{"device_token": "abc123", "title": "Order shipped!", "badge": 1}');

-- Query email notifications for a user, extract specific fields
SELECT
  id,
  created_at,
  payload->>'to'       AS recipient,
  payload->>'subject'  AS subject
FROM notifications
WHERE user_id = 42
  AND type = 'email'
  AND sent_at IS NULL;

-- Aggregate over JSONB: count notifications by type per user
SELECT
  user_id,
  type,
  COUNT(*) AS count,
  MAX(created_at) AS latest
FROM notifications
GROUP BY user_id, type
ORDER BY user_id, count DESC;

jsonb_agg and jsonb_build_object: Constructing JSON in SQL

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
-- Build a JSON summary of a user's recent orders
SELECT
  u.id,
  u.email,
  jsonb_agg(
    jsonb_build_object(
      'order_id', o.id,
      'total',    o.total,
      'status',   o.status,
      'items',    o.item_count
    )
    ORDER BY o.created_at DESC
  ) AS recent_orders
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id, u.email;

Configuration Tuning

The default PostgreSQL configuration is deliberately conservative — it assumes a shared server with minimal RAM. For a dedicated database server (or even a VPS with a few GB of RAM), these settings dramatically change performance.

The Essential Parameters

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# postgresql.conf — values for a server with 8 GB RAM dedicated to Postgres

# Memory
shared_buffers = 2GB          # 25% of RAM — Postgres's main cache
effective_cache_size = 6GB    # 75% of RAM — tells planner how much OS cache exists
work_mem = 64MB               # Per sort/hash operation (can be many simultaneously!)
maintenance_work_mem = 512MB  # For VACUUM, CREATE INDEX, etc.
wal_buffers = 64MB            # WAL write buffer

# Parallelism
max_worker_processes = 8
max_parallel_workers = 8
max_parallel_workers_per_gather = 4   # Parallel query workers per query

# WAL / Durability
wal_compression = on          # Compress WAL — usually a win
checkpoint_timeout = 15min    # How often to checkpoint
max_wal_size = 4GB            # Before forcing a checkpoint
min_wal_size = 1GB

# Connections
max_connections = 100         # Use a connection pooler (PgBouncer) for high concurrency

# Query planner cost settings (tune for SSD)
random_page_cost = 1.1        # Default is 4.0 — SSD random I/O is near sequential
effective_io_concurrency = 200 # For SSD — number of concurrent I/O operations

# Logging (useful for development and slow query analysis)
log_min_duration_statement = 1000   # Log queries taking > 1 second
log_checkpoints = on
log_lock_waits = on
log_temp_files = 0                  # Log all temp file creation

Scaling work_mem Carefully

work_mem is the memory allocated per sort or hash operation, and a single complex query can have multiple operations running in parallel. Setting it to 256 MB on a server with 100 connections and complex queries means you could theoretically use 25+ GB of RAM from work_mem alone.

The safe pattern: keep work_mem moderate globally, increase it for specific sessions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- Set for a specific session running a heavy analytical query
SET work_mem = '512MB';
SELECT ... complex aggregation ...;
RESET work_mem;

-- Or in a transaction
BEGIN;
SET LOCAL work_mem = '256MB';
-- heavy query here
COMMIT;

Connection Pooling with PgBouncer

PostgreSQL forks a backend process per connection. Forking is expensive, and hundreds of connections creates significant overhead. PgBouncer sits in front of Postgres and maintains a smaller pool of actual backend connections:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 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 = md5
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction       # transaction pooling — most efficient
max_client_conn = 1000        # clients can connect freely
default_pool_size = 20        # only 20 real backend connections
min_pool_size = 5
reserve_pool_size = 5

log_connections = 0
log_disconnections = 0

With pool_mode = transaction, a backend connection is returned to the pool after each transaction. An application with 500 connections might only need 20 actual Postgres backends.

Important: transaction pooling is incompatible with prepared statements and advisory locks. If your application uses these, use session pooling mode instead.

VACUUM and Autovacuum

PostgreSQL uses MVCC (Multi-Version Concurrency Control) — updates and deletes don’t immediately remove rows, they mark them as dead. VACUUM reclaims that space. Autovacuum runs this automatically, but its defaults are conservative.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
-- Check autovacuum activity
SELECT
  schemaname,
  tablename,
  n_dead_tup,
  n_live_tup,
  ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct,
  last_autovacuum,
  last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC
LIMIT 20;

-- Tables with bloat (dead tuples as % of total)
-- If dead_pct is consistently > 10-20%, autovacuum isn't keeping up

Tune autovacuum per-table for high-churn tables:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- High-write table: vacuum more aggressively
ALTER TABLE events SET (
  autovacuum_vacuum_scale_factor = 0.01,   -- vacuum when 1% dead (default 20%)
  autovacuum_analyze_scale_factor = 0.005, -- analyze when 0.5% changed
  autovacuum_vacuum_cost_delay = 2         -- less throttling (default 20ms)
);

-- Large mostly-read table: back off autovacuum
ALTER TABLE historical_data SET (
  autovacuum_vacuum_scale_factor = 0.1,
  autovacuum_analyze_scale_factor = 0.05
);

Practical Patterns

Upsert with ON CONFLICT

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- Insert or update (upsert)
INSERT INTO user_preferences (user_id, key, value, updated_at)
VALUES (42, 'theme', 'dark', NOW())
ON CONFLICT (user_id, key)
DO UPDATE SET
  value = EXCLUDED.value,
  updated_at = EXCLUDED.updated_at;

-- Insert or do nothing
INSERT INTO event_dedup (event_id, received_at)
VALUES ('evt_abc123', NOW())
ON CONFLICT (event_id) DO NOTHING;

Window Functions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- Running total of revenue
SELECT
  date_trunc('day', created_at) AS day,
  SUM(amount) AS daily_revenue,
  SUM(SUM(amount)) OVER (ORDER BY date_trunc('day', created_at)) AS running_total
FROM orders
WHERE created_at > NOW() - INTERVAL '90 days'
GROUP BY 1
ORDER BY 1;

-- Rank users by spend within each country
SELECT
  user_id,
  country,
  total_spend,
  RANK() OVER (PARTITION BY country ORDER BY total_spend DESC) AS country_rank
FROM (
  SELECT u.id AS user_id, u.country, SUM(o.total) AS total_spend
  FROM users u JOIN orders o ON o.user_id = u.id
  GROUP BY u.id, u.country
) spend;

-- Find previous and next values (lead/lag)
SELECT
  order_id,
  created_at,
  LAG(created_at) OVER (PARTITION BY user_id ORDER BY created_at) AS prev_order_at,
  created_at - LAG(created_at) OVER (PARTITION BY user_id ORDER BY created_at) AS days_since_last
FROM orders;

CTEs and Recursive Queries

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
-- Common Table Expression for readability
WITH recent_orders AS (
  SELECT * FROM orders
  WHERE created_at > NOW() - INTERVAL '7 days'
    AND status != 'cancelled'
),
high_value_users AS (
  SELECT user_id, SUM(total) AS week_spend
  FROM recent_orders
  GROUP BY user_id
  HAVING SUM(total) > 500
)
SELECT u.email, hvu.week_spend
FROM high_value_users hvu
JOIN users u ON u.id = hvu.user_id
ORDER BY hvu.week_spend DESC;

-- Recursive CTE: walk a category tree
WITH RECURSIVE category_tree AS (
  -- Base case: root categories
  SELECT id, name, parent_id, 0 AS depth, name::text AS path
  FROM categories
  WHERE parent_id IS NULL

  UNION ALL

  -- Recursive case: children
  SELECT c.id, c.name, c.parent_id, ct.depth + 1,
         ct.path || ' > ' || c.name
  FROM categories c
  JOIN category_tree ct ON ct.id = c.parent_id
)
SELECT * FROM category_tree ORDER BY path;

Avoiding N+1 Queries with Lateral Joins

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
-- Instead of querying recent orders per user in application code (N+1),
-- use LATERAL to do it in one query
SELECT
  u.id,
  u.email,
  recent.orders
FROM users u
CROSS JOIN LATERAL (
  SELECT jsonb_agg(
    jsonb_build_object('id', o.id, 'total', o.total, 'status', o.status)
    ORDER BY o.created_at DESC
  ) AS orders
  FROM orders o
  WHERE o.user_id = u.id
  LIMIT 5
) recent
WHERE u.created_at > NOW() - INTERVAL '30 days';

Monitoring in Production

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
-- Active queries and wait events
SELECT
  pid,
  now() - query_start AS duration,
  state,
  wait_event_type,
  wait_event,
  LEFT(query, 80) AS query
FROM pg_stat_activity
WHERE state != 'idle'
  AND query_start < NOW() - INTERVAL '5 seconds'
ORDER BY duration DESC;

-- Lock contention
SELECT
  blocked.pid AS blocked_pid,
  blocked_activity.query AS blocked_query,
  blocking.pid AS blocking_pid,
  blocking_activity.query AS blocking_query
FROM pg_catalog.pg_locks blocked
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked.pid = blocked_activity.pid
JOIN pg_catalog.pg_locks blocking ON blocking.relation = blocked.relation
  AND blocking.locktype = blocked.locktype
  AND blocking.pid != blocked.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking.pid = blocking_activity.pid
WHERE NOT blocked.granted;

-- Table sizes with bloat estimate
SELECT
  tablename,
  pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
  pg_size_pretty(pg_relation_size(schemaname||'.'||tablename)) AS table_size,
  pg_size_pretty(pg_indexes_size(schemaname||'.'||tablename)) AS index_size,
  n_live_tup,
  n_dead_tup
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 20;

Quick Reference: When to Use What

Situation What to do
Slow query on a large table EXPLAIN (ANALYZE, BUFFERS) first, then add targeted index
Equality filter on a column B-tree index
Range filter on a timestamp B-tree index; BRIN if table is huge and sequential
LIKE '%foo%' search pg_trgm GIN index
Full-text search GIN index on tsvector
JSONB containment (@>) GIN index on JSONB column
Frequent access to a JSON key Expression index on (data->>'key')
High connection count PgBouncer in transaction mode
Slow after many updates/deletes Check dead tuple count; tune autovacuum
Query plan looks wrong ANALYZE tablename to refresh statistics
Memory pressure under load Reduce work_mem, use connection pooler
SSD storage Set random_page_cost = 1.1, effective_io_concurrency = 200

PostgreSQL rewards the developers who take time to understand it. Most performance problems have a clear diagnosis path: find the slow query with pg_stat_statements, read its plan with EXPLAIN ANALYZE, add or fix an index, verify the plan changed. Repeat until fast. The tools are all there — it’s just a matter of knowing how to use them.

Comments