Query Optimization in PostgreSQL: Real Examples From Real Projects
PostgreSQL query optimization is the process of analyzing slow queries using EXPLAIN ANALYZE output, identifying the root cause (missing index, sequential scan, poor join order, N+1 pattern), and applying targeted fixes that reduce execution time. Optimization in PostgreSQL is evidence-driven: the planner's cost estimates and actual row counts in EXPLAIN output reveal which part of the query is expensive and why.
Written by Yashveer Singh, founder of Yashveer Labs.
What you need to know
- EXPLAIN ANALYZE is the diagnostic tool. Run it before and after every optimization to confirm the improvement is real and not coincidental.
- Sequential scans on large tables are almost always the root cause of slow queries. The fix is an index on the filter or sort column.
- N+1 query patterns are invisible in per-query profiling but catastrophic at the application layer. Look at query counts, not just query duration.
- Statistics drift after large data inserts. Run ANALYZE on tables that have changed significantly before debugging query plan issues.
- Index maintenance has a write cost. Do not add indexes speculatively. Each index slows INSERT, UPDATE, and DELETE operations on the table.
The core argument
PostgreSQL query optimization has a repeatable diagnostic process. The frustrating part is that teams skip the diagnostic step and go straight to guessing. They add an index because a blog post said indexes help, or they rewrite the query because the code looks complex, without measuring whether either change actually reduced execution time. The result is a mix of effective and ineffective changes with no understanding of which did what.
The right process is: run EXPLAIN ANALYZE on the slow query, read the output to find the expensive node, understand why that node is expensive (missing index, stale statistics, poor join order), apply the targeted fix, and run EXPLAIN ANALYZE again to confirm the improvement. This takes longer than guessing but produces reliable results. On a project I worked on for Nexli, a query that was taking 4 seconds on a 500K row table dropped to 12 milliseconds after reading the EXPLAIN output and adding a composite index on two filter columns. The guess before reading the output was to add a single-column index, which would have helped but not nearly as much.
The fixes that appear most frequently in my experience across different projects are: missing indexes on foreign keys (an ORM creates the foreign key constraint but not the index, so JOIN operations do sequential scans on the child table), missing indexes on timestamp columns used in date range filters (very common in reporting queries), and N+1 patterns in ORMs that are configured without eager loading. These three account for the majority of slow queries I have diagnosed in production PostgreSQL systems.
Common mistakes
- Adding indexes without checking if they are used. An index added without confirming that queries use it through EXPLAIN ANALYZE may never be used by the planner, while still adding write overhead. After adding an index, run EXPLAIN ANALYZE on the query that should benefit and verify the plan shows an Index Scan on the new index.
- Running EXPLAIN without ANALYZE. EXPLAIN without ANALYZE shows the planner's estimated plan based on table statistics, not the actual execution. The estimated plan may differ significantly from what PostgreSQL actually does. Always use EXPLAIN (ANALYZE, BUFFERS) for accurate diagnosis.
- Optimizing queries that are not actually slow. A query that runs in 5ms is not worth optimizing. Find the actual slow queries through pg_stat_statements or slow query logs, not through reading code and guessing. Optimization effort on fast queries produces no user-visible improvement.
- Ignoring connection overhead and pool sizing. A query that executes in 1ms but spends 50ms waiting for a connection from an undersized connection pool looks like a slow query in application-level tracing. Check connection pool utilization before diagnosing query performance. The fix may be connection pool sizing, not query optimization.
- **Using SELECT * in high-frequency queries.** Fetching all columns when only a few are needed transfers more data from PostgreSQL to the application, increases memory allocation, and prevents the planner from using index-only scans. In high-frequency query paths, selecting only the needed columns reduces data transfer and enables covering indexes to serve the query entirely from the index without touching the table.
Where to start
- Find the actual slow queries. Enable pg_stat_statements if it is not already active. Query it for the slowest queries by total execution time (
ORDER BY total_exec_time DESC LIMIT 20). This gives the real list of optimization targets, not guesses. Slow query logs (log_min_duration_statement = 100ms) provide the same information in the application logs.
- Run EXPLAIN (ANALYZE, BUFFERS) on each slow query. Look for Seq Scan nodes on large tables, large divergence between estimated and actual row counts (statistics issue), and nodes with high actual time. The node with the highest actual time is the optimization target. Hash joins with very large memory usage may indicate a work_mem configuration issue rather than a missing index.
- Apply the targeted fix and measure the improvement. For sequential scans: add an index on the filter column. For stale statistics: run ANALYZE on the table. For N+1 patterns: add eager loading or rewrite with a JOIN. After each fix, re-run EXPLAIN ANALYZE to confirm the plan changed and the execution time improved. Document the before/after timing so the impact is measurable.
Related reading
Frequently asked
Closing note from the author
I keep these closing notes short on purpose. Most engineers writing about this topic are not the engineer you want to hire. I might be. Yashveer Singh, founder of Yashveer Labs. The contact channel is Instagram. The proof is the portfolio. The standard is in the work. If we are aligned, you will know within five minutes of the first message.
Posts that line up with this one.
- Backend, APIs, and System Design
Idempotency Keys: A Pattern Every Senior Engineer Should Master
Idempotency keys are a small implementation with an outsized impact on system reliability. Here is the pattern, the edge cases, and the production pitfalls that most introductions skip.
- Backend, APIs, and System Design
JSON Columns in Postgres: When They Make Sense
JSON columns in Postgres are genuinely useful for flexible, semi-structured data. They are also frequently misused as a shortcut to avoid schema design. Here is when to use them and when to use normalized tables instead.
- Backend, APIs, and System Design
Kafka in 2026: When You Need It and When You Do Not
Kafka is powerful, but most startups reach for it before they need it. Here is how to decide.
- Backend, APIs, and System Design
Lambda Cold Starts: Why They Still Matter in 2026
Cold starts have improved significantly but have not been eliminated. Here is the current state of cold start latency, which use cases still require mitigation, and the practical patterns that keep them from affecting users.