DEV Community

Deep Fix
Deep Fix

Posted on

Optimizing PostgreSQL Query Performance: Proven Tips, Indexing Strategies & Execution Plan Tuning

Introduction

PostgreSQL is a powerful open‑source RDBMS, but even the best‑written SQL can become a bottleneck under load. In this guide we’ll walk through practical techniques—indexing, query rewriting, and execution‑plan tuning—to boost query performance for developers, engineers, and DevOps teams.


1. Diagnose Before You Optimize

1.1 Use EXPLAIN (ANALYZE, BUFFERS)

EXPLAIN (ANALYZE, BUFFERS) SELECT
    o.id, o.total, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at >= CURRENT_DATE - INTERVAL '30 days'
  AND o.status = 'completed';
Enter fullscreen mode Exit fullscreen mode

The output shows:

  • Seq Scan vs Index Scan
  • Cost, Rows, Actual Time
  • Buffer hits/misses

1.2 Identify Hot Spots with pg_stat_statements

SELECT
    query,
    calls,
    total_time,
    mean_time,
    rows
FROM pg_stat_statements
ORDER BY total_time DESC
LIMIT 10;
Enter fullscreen mode Exit fullscreen mode

Focus on the top‑cost queries first.


2. Indexing Essentials

2.1 Single‑Column B‑Tree Index

CREATE INDEX idx_orders_status_created ON orders (status, created_at DESC);
Enter fullscreen mode Exit fullscreen mode

*Composite index matches the WHERE clause order, eliminating the sequential scan.

2.2 Covering Index (Include Columns)

CREATE INDEX idx_orders_covering ON orders (status, created_at DESC) INCLUDE (total);
Enter fullscreen mode Exit fullscreen mode

PostgreSQL can now satisfy the query from the index alone, avoiding a heap fetch.

2.3 Partial Index for Rare Values

CREATE INDEX idx_orders_pending ON orders (created_at DESC)
WHERE status = 'pending';
Enter fullscreen mode Exit fullscreen mode

Only rows with status='pending' are indexed, keeping the index small and fast.


3. Query Rewriting Techniques

3.1 Prefer JOIN over Sub‑queries when possible

-- Bad (correlated sub‑query)
SELECT o.id,
       (SELECT c.name FROM customers c WHERE c.id = o.customer_id) AS customer_name
FROM orders o;

-- Good (JOIN)
SELECT o.id, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id;
Enter fullscreen mode Exit fullscreen mode

The JOIN version allows the planner to use indexes more effectively.

3.2 Use WHERE predicates early

-- Inefficient
SELECT * FROM large_table WHERE id IN (SELECT id FROM small_table);

-- Efficient
SELECT lt.* FROM large_table lt
JOIN small_table st ON lt.id = st.id;
Enter fullscreen mode Exit fullscreen mode

Joining pushes the filter down, reducing row count early.


4. Configuration Tweaks

Parameter Typical Adjustment Reason
work_mem 64MB (or higher for complex sorts) Gives each sort/join more RAM, avoiding disk spill.
effective_cache_size 3GB on a 4GB‑RAM box Helps the planner estimate available OS cache.
max_parallel_workers_per_gather 24 Enables parallel query execution for large scans.

Remember to reload the config after changes: SELECT pg_reload_conf();


5. Step‑by‑Step Troubleshooting

  1. Run EXPLAIN ANALYZE – locate the highest‑cost node.
  2. Check if an index is used – if a Seq Scan appears, consider a matching index.
  3. Validate index selectivity – use SELECT reltuples FROM pg_class WHERE oid = 'my_table'::regclass;
  4. Adjust work_mem – if you see Disk: ... in the EXPLAIN output, increase it.
  5. Test with SET enable_seqscan = off; – forces index use for debugging (don’t ship to prod).
  6. Re‑run EXPLAIN ANALYZE – confirm the plan improved.
  7. Monitor with pg_stat_activity and pg_stat_statements – ensure the change persists under load.

6. Real‑World Example: Fixing a Slow Dashboard Query

-- Original dashboard query (5‑10 s)
SELECT d.id, d.title, COUNT(c.id) AS comment_cnt
FROM discussions d
LEFT JOIN comments c ON c.discussion_id = d.id
WHERE d.created_at > CURRENT_DATE - INTERVAL '7 days'
GROUP BY d.id;
Enter fullscreen mode Exit fullscreen mode

Fixes Applied

  1. Create a partial index on recent discussions:
CREATE INDEX idx_discussions_recent ON discussions (created_at DESC) WHERE created_at > CURRENT_DATE - INTERVAL '7 days';
Enter fullscreen mode Exit fullscreen mode
  1. Add a covering index for the join column:
CREATE INDEX idx_comments_discussion ON comments (discussion_id) INCLUDE (id);
Enter fullscreen mode Exit fullscreen mode
  1. Rewrite using COUNT(*) FILTER to avoid a full join for zero‑comment rows:
SELECT d.id, d.title,
       COUNT(c.id) FILTER (WHERE c.id IS NOT NULL) AS comment_cnt
FROM discussions d
LEFT JOIN comments c ON c.discussion_id = d.id
WHERE d.created_at > CURRENT_DATE - INTERVAL '7 days'
GROUP BY d.id;
Enter fullscreen mode Exit fullscreen mode

Result: Query time dropped to ~200 ms.


7. Automate Routine Optimizations

You can embed the above patterns into a deployment script. Download the pre‑configured script here to apply recommended indexes automatically.

Alternatively, explore the full repository for more tweaks: Get the complete patch tool or Access the full repository fix.


Conclusion

Optimizing PostgreSQL query performance is a blend of observability, smart indexing, query rewriting, and tuned configuration. By following the systematic approach outlined above, you can turn sluggish queries into lightning‑fast operations, keeping your applications responsive and your infrastructure costs low.

Happy tuning!

Top comments (0)