Skip to the solution
MediumSystem Design15 min read

EXPLAIN ANALYZE shows Seq Scan on a 10M-row table — how do you diagnose and fix it?

963 views

The problem

Question presented to candidate: "You run EXPLAIN ANALYZE on a slow query against a 10-million-row orders table and the plan says Seq Scan. Walk me through how you decide whether that is actually wrong, how you find the cause, and how you fix it safely in production."

What a strong answer should cover:

  • A Seq Scan is not automatically a bug: compare the rows the query returns with the rows in the table, and the planner estimate (rows=) with the actual count; for a large share of the table a Seq Scan is the cheapest plan
  • The signal that an index is missing or unusable: very few actual rows but a huge Rows Removed by Filter and a buffer count close to the whole table
  • The common causes: no index on the filtered column; an index that cannot be used because the column is wrapped in a function, cast or wrong-typed literal (non-sargable predicate), a leading-wildcard LIKE, an OR across columns, the wrong column order in a composite index; stale statistics that make the planner over-estimate the matching rows
  • The fixes: a matching (or expression / composite / partial) index built with CREATE INDEX CONCURRENTLY, rewriting the predicate so the bare column is compared, ANALYZE and a higher statistics target, then re-running EXPLAIN (ANALYZE, BUFFERS) to prove the change
  • Using SET enable_seqscan = off only as a diagnostic in one session, never as the fix; checking cost settings such as random_page_cost on SSD storage

Clarifying questions expected:

  • How many rows does the query return, and how many rows does the table have?
  • Is this query new or did it get slow recently (data growth, a deploy, a bulk load)?
  • Can we add an index on this table online, and how write-heavy is it?

Code / implementation expected: Yes — read the plan, create the right index (or rewrite the predicate, or ANALYZE) and show the plan change.

Schema used by the examples:

  • explain
  • seq-scan
  • index-scan

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

15 min read · 13 sections

Run the code

Edit the query and run it against the tables for this question.

SQLSQLite: SCAN vs SEARCH — no index, an index, and a function that hides the column
PostgreSQL: the symptom — a Seq Scan that throws away 199,990 of 200,000 rows
SET max_parallel_workers_per_gather = 0;
CREATE TABLE big_orders (
  id bigint PRIMARY KEY,
  user_id int NOT NULL,
  status text NOT NULL,
  amount numeric NOT NULL,
  created_at date NOT NULL
) WITH (autovacuum_enabled = off);
INSERT INTO big_orders
SELECT g, (g % 20000) + 1,
       CASE WHEN g % 20 = 0 THEN 'cancelled' ELSE 'shipped' END,
       (g % 500) + 1, DATE '2024-01-01' + (g % 366)
FROM generate_series(1, 200000) AS g;
ANALYZE big_orders;

EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, amount FROM big_orders WHERE user_id = 4242;
PostgreSQL: five plans side by side — add an index, break it with a cast, fix with an expression index
SET max_parallel_workers_per_gather = 0;
CREATE TABLE big_orders (
  id bigint PRIMARY KEY,
  user_id int NOT NULL,
  status text NOT NULL,
  amount numeric NOT NULL,
  created_at date NOT NULL
) WITH (autovacuum_enabled = off);
INSERT INTO big_orders
SELECT g, (g % 20000) + 1,
       CASE WHEN g % 20 = 0 THEN 'cancelled' ELSE 'shipped' END,
       (g % 500) + 1, DATE '2024-01-01' + (g % 366)
FROM generate_series(1, 200000) AS g;
CREATE TABLE big_users (
  id int PRIMARY KEY,
  email text NOT NULL
) WITH (autovacuum_enabled = off);
INSERT INTO big_users
SELECT g, CASE WHEN g % 2 = 0 THEN 'User' ELSE 'user' END || g || '@Example.com'
FROM generate_series(1, 20000) AS g;
ANALYZE big_orders;
ANALYZE big_users;
-- plan(q): the plan of q as rows, so several plans fit in one result set
CREATE FUNCTION plan(q text) RETURNS SETOF text LANGUAGE plpgsql AS $$
DECLARE r record;
BEGIN
  FOR r IN EXECUTE 'EXPLAIN (COSTS OFF) ' || q LOOP
    RETURN NEXT r."QUERY PLAN";
  END LOOP;
END $$;
CREATE TABLE steps (n serial, step text, plan_line text);

INSERT INTO steps (step, plan_line)
SELECT '1 no index', plan('SELECT id FROM big_orders WHERE user_id = 4242');

CREATE INDEX big_orders_user_id_idx ON big_orders (user_id);
INSERT INTO steps (step, plan_line)
SELECT '2 index on user_id', plan('SELECT id FROM big_orders WHERE user_id = 4242');

INSERT INTO steps (step, plan_line)
SELECT '3 cast hides column', plan('SELECT id FROM big_orders WHERE user_id::text = ''4242''');

CREATE INDEX big_users_email_idx ON big_users (email);
INSERT INTO steps (step, plan_line)
SELECT '4 lower(email)', plan('SELECT id FROM big_users WHERE lower(email) = ''user4243@example.com''');

CREATE INDEX big_users_lower_email_idx ON big_users (lower(email));
ANALYZE big_users;
INSERT INTO steps (step, plan_line)
SELECT '5 expression index', plan('SELECT id FROM big_users WHERE lower(email) = ''user4243@example.com''');

SELECT step, plan_line FROM steps ORDER BY n;
PostgreSQL: stale statistics — the planner expects 42,994 rows, gets 30, and picks a Seq Scan until ANALYZE
SET max_parallel_workers_per_gather = 0;
-- A job queue: when statistics were last gathered, most jobs were pending
CREATE TABLE jobs (
  id int PRIMARY KEY,
  status text NOT NULL
) WITH (autovacuum_enabled = off);
INSERT INTO jobs SELECT g, CASE WHEN g % 5 = 0 THEN 'done' ELSE 'pending' END
FROM generate_series(1, 30000) AS g;
CREATE INDEX jobs_status_idx ON jobs (status);
ANALYZE jobs;
-- Workers then finished almost everything: 30 pending jobs remain
UPDATE jobs SET status = 'done' WHERE status = 'pending' AND id > 37;

CREATE FUNCTION est(q text) RETURNS TABLE (plan_node text, estimated_rows numeric, actual_rows numeric)
LANGUAGE plpgsql AS $$
DECLARE j json;
BEGIN
  EXECUTE 'EXPLAIN (ANALYZE, FORMAT JSON) ' || q INTO j;
  plan_node := j->0->'Plan'->>'Node Type';
  estimated_rows := (j->0->'Plan'->>'Plan Rows')::numeric;
  actual_rows := (j->0->'Plan'->>'Actual Rows')::numeric;
  RETURN NEXT;
END $$;

CREATE TABLE seen (n serial, stats text, plan_node text, estimated_rows numeric, actual_rows numeric);
INSERT INTO seen (stats, plan_node, estimated_rows, actual_rows)
SELECT 'stale', * FROM est('SELECT id FROM jobs WHERE status = ''pending''');
ANALYZE jobs;
INSERT INTO seen (stats, plan_node, estimated_rows, actual_rows)
SELECT 'fresh', * FROM est('SELECT id FROM jobs WHERE status = ''pending''');
SELECT stats, plan_node, estimated_rows, actual_rows FROM seen ORDER BY n;

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 30 of 82 in SQL

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All SQL questions
Up nextMedium

Why might EXISTS outperform a JOIN for existence checks?

Continue