SQL Query Optimization with EXPLAIN ANALYZE and pg_stat_statements

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 throug

Development and maintenance of all types of websites:

Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:

Frequently Asked Questions

Latest works

  • image_website-b2b-advance_0.webp
    B2B ADVANCE company website development
    1419
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1287
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    983
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1244
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    983
  • image_bitrix-bitrix-24-1c_fixper_448_0.webp
    Website development for FIXPER company
    998

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 orders with Rows 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 than shared_buffers, or the data is rarely requested. Increasing shared_buffers by 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

  1. Find the top 10 queries by total_exec_time via pg_stat_statements.
  2. EXPLAIN (ANALYZE, BUFFERS) on each.
  3. Identify the bottleneck: Seq Scan, sort, hash join.
  4. Create or modify an index (with CONCURRENTLY to avoid blocking).
  5. ANALYZE table_name — update statistics.
  6. Repeat EXPLAIN ANALYZE — compare the plans.
  7. 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.