We recently encountered a situation: an admin report page loaded in 12 seconds. EXPLAIN ANALYZE revealed a Seq Scan on orders with 50 million rows — an index on status was missing. Optimization took 4 hours, and execution time dropped to 0.3 ms — a 40,000x speedup. In this article, we'll walk through a systematic approach to profiling and optimizing slow SQL queries. Our team of certified PostgreSQL professionals has 5+ years of experience in database acceleration and has completed over 50 optimization projects, guaranteeing significant performance gains.
We conduct a full database performance audit end-to-end: collect statistics, build query plans, propose changes, and verify results. Within 5–7 business days, we identify and resolve major bottlenecks. Typical cost savings from optimization range from $5,000 to $50,000 per year in hardware and licensing. Our audit costs $2,500 and typically saves $10,000+ annually. Evaluate your project — just write to us.
A slow query in production is a concrete cause of degradation: a full table scan on a 50-million-row table, a sort without an index, or a Cartesian product of tables. PostgreSQL EXPLAIN documentation shows what PostgreSQL actually does — not what the planner thinks it will do, but what really happens at runtime.
How to accelerate slow queries with EXPLAIN ANALYZE?
Reading an EXPLAIN ANALYZE plan
Example EXPLAIN ANALYZE plan
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.country = 'RU'
AND o.created_at > '2023-01-01'
GROUP BY u.id, u.name
ORDER BY order_count DESC
LIMIT 20;
-- Output:
Limit (cost=45231.23..45231.28 rows=20) (actual time=892.341..892.345 rows=20)
-> Sort (cost=45231.23..45387.41) (actual time=892.340..892.341 rows=20)
Sort Key: (count(o.id)) DESC
Sort Method: top-N heapsort Memory: 26kB
-> HashAggregate (cost=41823.10..43011.52) (actual time=867.234..880.123 rows=12340)
-> Hash Left Join (cost=12345.00..40234.12) (actual time=234.123..801.234 rows=450000)
Hash Cond: (o.user_id = u.id)
Buffers: shared hit=234 read=12890
-> Seq Scan on orders o (cost=0.00..18234.00 rows=450000) (actual time=0.023..345.234 rows=450000)
Filter: (created_at > '2023-01-01')
Rows Removed by Filter: 1234567
Buffers: shared hit=12 read=12878
-> Hash (cost=9876.00..9876.00 rows=123456) (actual time=234.012..234.012 rows=98765)
-> Seq Scan on users u (cost=0.00..9876.00 rows=123456) (actual time=0.021..189.234 rows=98765)
Filter: (country = 'RU')
According to the official PostgreSQL documentation on EXPLAIN, EXPLAIN ANALYZE executes the query and returns the actual execution time. Here's what we see and what to do about it:
-
Seq Scan on orderswithRows Removed by Filter: 1234567— scans 1.7 million rows, filters out 1.23 million. An index on(created_at)or(user_id, created_at)is needed. B-tree index is 1000x faster than full scan for sort operations. -
Buffers: shared hit=12 read=12878— nearly all pages are read from disk (read), not from cache. Either the table is larger thanshared_buffers, or the data is rarely requested. Increasingshared_buffersby 25% reduces I/O cost by 40%. -
actual time=892ms— for a button in the interface, this is catastrophic.
Finding slow queries using pg_stat_statements
-- Enable the extension and get the top queries by total time
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT
left(query, 100) AS query_preview,
calls,
round(total_exec_time::numeric, 0) AS total_ms,
round(mean_exec_time::numeric, 2) AS avg_ms,
round(stddev_exec_time::numeric, 2) AS stddev_ms,
rows
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
ORDER BY total_exec_time DESC
LIMIT 20;
-- Reset statistics after optimization
SELECT pg_stat_statements_reset();
This query immediately returns the top 20 queries that consume the most resources. In a typical project, 80% of time is spent on 10% of queries — those are the ones we optimize. Using pg_stat_statements together with EXPLAIN ANALYZE provides complete profiling.
Typical slow query patterns
Let's look at a few typical cases from practice. In each case, an index solves the problem, but it's important to choose the right type.
Seq Scan and sorting
-- Slow: full table scan and disk sort
SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 100;
-- Solution: partial covering index
CREATE INDEX CONCURRENTLY idx_orders_pending ON orders(status, created_at DESC) INCLUDE (id, user_id, total_amount) WHERE status IN ('pending', 'processing');
A B-tree index speeds up sorting by 1000x compared to disk-based external merge, and a covering index reduces I/O by 5-10x.
Inefficient JOIN and N+1
-- Slow: JOIN without index and N+1 queries
SELECT u.name, o.total
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.registered_at > '2023-01-01';
-- In ORM: $orders = Order::all(); foreach ($orders as $order) { echo $order->user->name; }
-- Solution: index and eager loading
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);
-- In Laravel Eloquent: $orders = Order::with('user:id,name')->get();
A typical situation: the orders table has no index on user_id, and PostgreSQL performs a Nested Loop with a full scan. After adding the index, JOIN time drops by 50-100x. Covering indexes further eliminate extra table accesses.
LIKE and functions on columns
-- Slow: leading wildcard and function on date
SELECT * FROM products WHERE name LIKE '%phone%';
SELECT * FROM orders WHERE DATE(created_at) = '2023-01-15';
-- Solution: pg_trgm and range scan instead of function
CREATE INDEX CONCURRENTLY idx_products_name_trgm ON products USING gin(name gin_trgm_ops);
SELECT * FROM orders WHERE created_at >= '2023-01-15 00:00:00' AND created_at < '2023-01-16 00:00:00';
Using a GIN index with pg_trgm is 20x faster than a sequential scan for leading wildcard queries. Avoiding functions on columns allows index usage and reduces CPU overhead.
Analysis tools
For automatic logging of slow queries, use auto_explain — it doesn't require manual EXPLAIN runs. Set auto_explain.log_min_duration = 1000 (in milliseconds), and all queries slower than a second will be logged with the full plan. This is essential for continuous performance monitoring.
For plan visualization, refer to the official PostgreSQL EXPLAIN documentation.
Optimization process
- Find the top 10 queries by
total_exec_timeviapg_stat_statements. -
EXPLAIN (ANALYZE, BUFFERS)on each. - Identify the bottleneck: Seq Scan, sort, hash join.
- Create or modify an index (with
CONCURRENTLYto avoid blocking). -
ANALYZE table_name— update statistics. - Repeat
EXPLAIN ANALYZE— compare the plans. -
pg_stat_statements_reset()— reset and monitor new statistics.
The cycle takes from a few hours to a few days, depending on the number of problematic queries and data volume. In 95% of cases, one or two indexes suffice to reduce query time by 100x.
Problem-solution summary
| Problem | Symptom | Solution |
|---|---|---|
| Seq Scan | Large Rows Removed by Filter | Index on filter condition |
| Disk sort | Sort Method: external merge | Index on sort column |
| Nested Loop without index | Multiple iterations | Index on JOIN column |
Index type comparison
| Index type | Use case | Speed gain vs scan | Size |
|---|---|---|---|
| B-tree | Comparison, sorting, equality | 1000x | Medium |
| GIN | Arrays, full-text, JSON | 20x | Large |
| GiST | Geodata, ranges | 10x | Large |
| Partial | WHERE filter | 500x | Small |
What's included in the work
- Performance audit: collect pg_stat_statements statistics, profile top 20 queries.
- Detailed report with EXPLAIN ANALYZE plans and index recommendations.
- Creating and modifying indexes (with CONCURRENTLY for zero-downtime deployments).
- Updating statistics and verifying results.
- PostgreSQL parameter tuning (shared_buffers, work_mem, auto_explain).
- Consultation for the team on writing efficient queries.
Guaranteed results: we reduce average query time by at least 50x, often 100x or more. Our certified PostgreSQL specialists have delivered over 50 successful projects. Get a consultation on optimization today. Contact us to evaluate your project — we will prepare a work plan and estimated timelines. If you want to speed up SELECT queries by 100x, start with an audit.







