-- Why a query kept the normal plan. The planner of Custom Scan (SiftAgg) gives up silently for the shapes it does not prove; with -- sift.explain_refusals on it names the first reason each candidate path (arm) gave up for, as a notice, and sift.explain_why plans a SELECT -- without running it and returns the same as rows. One table asserts the slug of every refusal shape; the setting changes no plan. CREATE EXTENSION pg_sift; CREATE EXTENSION pg_sift_helpers; SET max_parallel_workers_per_gather = 0; SET statement_timeout = '30s'; CREATE TABLE events (id int PRIMARY KEY, parent_id int, kind text, ts date, doc jsonb); INSERT INTO events SELECT i, i % 600, 'k' || i % 5, date '2025-01-01' + i % 6, jsonb_build_object('metrics', jsonb_build_object('m', i % 10, 'w', (i % 7)::float8), 'parts', (SELECT jsonb_agg(jsonb_build_object('cat', 'cat_' || (i + j) % 6, 'score', (i * j) % 10)) FROM generate_series(1, 1 + i % 4) j)) FROM generate_series(1, 12000) i; CREATE INDEX events_col ON events USING sift_col (id, parent_id, kind, ts, doc) WITH (paths = 'doc.metrics.* float8, doc.parts[]{cat text, score float8}'); NOTICE: sift_col index "events_col": 2 automatic choices (key_columns, track_changes) DETAIL: key_columns = parent_id; track_changes = id HINT: Reasons, costs and how to turn each off: SELECT * FROM sift.index_choices('events_col'); CREATE TABLE dims (id int PRIMARY KEY, label text); INSERT INTO dims SELECT i, 'd' || i FROM generate_series(0, 599) i; CREATE TABLE plain (id int, kind text, val int); INSERT INTO plain SELECT i, 'k' || (i % 5), i % 100 FROM generate_series(1, 2000) i; -- a partitioned table of which one partition has a column index CREATE TABLE parted (id int, kind text, val int) PARTITION BY HASH (id); CREATE TABLE parted_0 PARTITION OF parted FOR VALUES WITH (MODULUS 2, REMAINDER 0); CREATE TABLE parted_1 PARTITION OF parted FOR VALUES WITH (MODULUS 2, REMAINDER 1); INSERT INTO parted SELECT i, 'k' || (i % 5), i % 100 FROM generate_series(1, 2000) i; CREATE INDEX parted_0_col ON parted_0 USING sift_col (id, kind, val); NOTICE: sift_col index "parted_0_col": 1 automatic choice (rollup_columns), 1 advice (track_changes) DETAIL: advice: track_changes = the table has no one-column primary key; rollup_columns = kind, val HINT: Reasons, costs and how to turn each off: SELECT * FROM sift.index_choices('parted_0_col'); -- a series longer than a spine can be CREATE TABLE ticks (a int, b int); INSERT INTO ticks SELECT i, i + 1 FROM generate_series(0, 12000) i; -- a leaf that one unit holds a string in CREATE TABLE facts (id int, metrics jsonb); INSERT INTO facts SELECT i, jsonb_build_object('m_total', i % 10, 'other', i % 3) FROM generate_series(1, 5000) i; INSERT INTO facts VALUES (5001, '{"m_total": "abc"}'); CREATE INDEX facts_col ON facts USING sift_col (id, metrics) WITH (paths = 'metrics.* float8'); NOTICE: sift_col index "facts_col": 1 advice (track_changes) DETAIL: advice: track_changes = the table has no one-column primary key HINT: Reasons, costs and how to turn each off: SELECT * FROM sift.index_choices('facts_col'); -- a number among strings in a path whose values a guard of jsonb_typeof keeps apart CREATE TABLE guarded (id int PRIMARY KEY, doc jsonb); INSERT INTO guarded SELECT i, CASE WHEN i % 4 = 0 THEN jsonb_build_object('m', 'abc') ELSE jsonb_build_object('m', i % 10) END FROM generate_series(1, 3000) i; CREATE INDEX guarded_col ON guarded USING sift_col (id, doc) WITH (paths = 'doc->m float8'); NOTICE: sift_col index "guarded_col": 1 automatic choice (track_changes) DETAIL: track_changes = id HINT: Reasons, costs and how to turn each off: SELECT * FROM sift.index_choices('guarded_col'); VACUUM (FREEZE, ANALYZE) events; VACUUM (FREEZE, ANALYZE) facts; ANALYZE dims; ANALYZE plain; ANALYZE parted; ANALYZE ticks; -- the arms of a two level aggregate: per row and category, and per row; spine_q joins them to a spine CREATE FUNCTION arms() RETURNS text LANGUAGE sql AS $$ SELECT sift_helpers.agg_events_parts_arms_query() $$; CREATE FUNCTION spine_q(spine text, cond text, grp text DEFAULT 'GROUP BY 1, 2') RETURNS text LANGUAGE sql AS $$ SELECT format('SELECT category, sp.k, avg(score) AS value FROM %s sp(k) JOIN %s ON %s %s', spine, arms(), cond, grp) $$; CREATE FUNCTION repeated(n int) RETURNS text LANGUAGE sql AS $$ SELECT 'SELECT count(*) FROM events GROUP BY ' || string_agg('id + ' || g, ', ') FROM generate_series(1, n) g $$; -- With the table scans off, the pushdown is the plan wherever the query is one it proves; `used` is whether its path beat the normal ones. SET enable_seqscan = off; SET enable_indexscan = off; SET enable_bitmapscan = off; SET enable_indexonlyscan = off; CREATE TABLE shapes (name text, arm text, q text); INSERT INTO shapes VALUES ('used', 'single', 'SELECT kind, count(*), sum(parent_id) FROM events GROUP BY 1'), ('no index', 'single', 'SELECT kind, count(*) FROM plain GROUP BY 1'), ('query kind', 'single', 'SELECT generate_series(1, 2), count(*) FROM events'), ('table kind', 'single', 'SELECT kind, count(*) FROM events TABLESAMPLE SYSTEM (100) GROUP BY 1'), ('partitions', 'single', 'SELECT kind, count(*) FROM parted GROUP BY 1'), ('from shape', 'single', 'WITH c AS MATERIALIZED (SELECT kind FROM events) SELECT kind, count(*) FROM c GROUP BY 1'), ('outer join', 'single', 'SELECT count(*) FROM events e LEFT JOIN LATERAL jsonb_each(e.doc->''metrics'') r ON r.value::text = ''1'''), ('unnest leaf', 'single', 'SELECT x->>''k'', count(*) FROM events e, jsonb_array_elements(e.doc->''nope'') x GROUP BY 1'), ('where', 'single', 'SELECT kind, count(*) FROM events WHERE length(doc::text) > 3 GROUP BY 1'), ('volatile', 'single', 'SELECT kind, count(*) FROM events WHERE random() < 0.5 GROUP BY 1'), ('join condition', 'single', 'SELECT count(*) FROM events e, plain p'), ('join child', 'single', 'SELECT count(*) FROM events e JOIN LATERAL (SELECT 1 FROM plain p WHERE p.id = e.id LIMIT 1 FOR UPDATE) s ON true'), ('volatile child', 'single', 'SELECT count(*) FROM events e JOIN LATERAL (SELECT 1 FROM plain p WHERE p.id = e.id ORDER BY random() LIMIT 1) s ON true'), ('group key', 'single', 'SELECT length(doc::text), count(*) FROM events GROUP BY 1'), ('volatile key', 'single', 'SELECT random(), count(*) FROM events GROUP BY 1'), ('too many keys', 'single', repeated(40)), ('output', 'single', 'SELECT id, kind, count(*) FROM events GROUP BY id'), ('error parity', 'single', 'SELECT sum((doc->>''m'')::float8) FILTER (WHERE jsonb_typeof(doc->''m'') = ''number'') FROM guarded'), ('aggregate', 'single', 'SELECT kind, sum(parent_id) FILTER (WHERE length(doc::text) > 3) FROM events GROUP BY 1'), ('volatile filter', 'single', 'SELECT kind, sum(parent_id) FILTER (WHERE random() > 0.5) FROM events GROUP BY 1'), ('dirty cast', 'single', 'SELECT sum(CAST(r.value AS integer)), avg(CAST(r.value AS float8)) FROM facts f LEFT JOIN LATERAL jsonb_each(f.metrics) r ON r.key = ''m_total'''), ('distinct', 'distinct', 'SELECT DISTINCT kind FROM events'), ('distinct on', 'distinct', 'SELECT DISTINCT ON (kind) kind, parent_id, ARRAY[id] FROM events ORDER BY kind, parent_id'), ('top n', 'topn', 'SELECT id FROM events ORDER BY parent_id, id LIMIT 3'), ('top n order', 'topn', 'SELECT id FROM events ORDER BY id + extract(year FROM now())::int LIMIT 3'), ('top n where', 'topn', 'SELECT id FROM events WHERE length(doc::text) > 3 ORDER BY parent_id LIMIT 3'), ('top n volatile', 'topn', 'SELECT id FROM events WHERE random() < 0.5 ORDER BY parent_id LIMIT 3'), ('top n locked', 'topn', 'SELECT id FROM events ORDER BY parent_id, id LIMIT 3 FOR UPDATE'), ('top n window', 'topn', 'SELECT id, row_number() OVER () FROM events ORDER BY parent_id, id LIMIT 3'), ('top n limit', 'topn', 'SELECT id FROM events ORDER BY parent_id, id LIMIT 3 OFFSET -1'), ('top n no index', 'topn', 'SELECT id FROM plain ORDER BY val, id LIMIT 3'), ('limit 1 lookup', 'lookup', 'SELECT d.kind, count(*) FROM events e LEFT JOIN LATERAL (SELECT kind FROM plain x WHERE x.id = e.parent_id ORDER BY x.val LIMIT 1) d ON true GROUP BY 1'), ('two level', 'two_level', 'SELECT category, avg(score), sum(n)::int FROM ' || arms() || ' GROUP BY 1'), ('two level arms', 'two_level', 'SELECT m.kind, sum(m.n) FROM (SELECT id, kind, 1 AS n FROM events UNION ALL SELECT id, kind, 2 FROM plain) m GROUP BY 1'), ('two level key', 'two_level', 'SELECT category || ''x'', sum(n) FROM ' || arms() || ' GROUP BY 1'), ('two level aggregate', 'two_level', 'SELECT category, max(score) FROM ' || arms() || ' GROUP BY 1'), ('spine', 'two_level', spine_q('(SELECT a FROM ticks WHERE a < 100)', 'm.parent_id = sp.k')), ('spine join', 'two_level', spine_q('generate_series(1, 20)', 'm.id > sp.k')), ('spine rows', 'two_level', spine_q('(SELECT a FROM ticks)', 'm.parent_id = sp.k')), ('spine grouping sets', 'two_level', spine_q('(SELECT a FROM ticks WHERE a < 100)', 'm.parent_id = sp.k', 'GROUP BY ROLLUP (1, 2)')), ('rows', 'rows', spine_q('generate_series(1, 20)', 'm.id > sp.k')), ('rows grouping sets', 'rows', spine_q('generate_series(1, 20)', 'm.id = sp.k', 'GROUP BY ROLLUP (1, 2)')); SELECT s.name, s.arm, w.used, w.slug FROM shapes s LEFT JOIN LATERAL (SELECT * FROM sift.explain_why(s.q) WHERE arm = s.arm) w ON true ORDER BY s.ctid; name | arm | used | slug ---------------------+-----------+------+------------------ used | single | t | no index | single | f | no_source query kind | single | f | query_kind table kind | single | f | table_kind partitions | single | f | partition_source from shape | single | f | from_shape outer join | single | f | outer_join unnest leaf | single | f | unnest_leaf where | single | f | where volatile | single | f | volatile join condition | single | f | join_condition join child | single | f | join_child volatile child | single | f | volatile group key | single | f | group_key volatile key | single | f | volatile too many keys | single | f | too_many_keys output | single | f | output_expr error parity | single | f | error_parity aggregate | single | f | aggregate volatile filter | single | f | volatile dirty cast | single | f | dirty_units distinct | distinct | t | distinct on | distinct | f | distinct_on top n | topn | t | top n order | topn | f | topn_shape top n where | topn | f | where top n volatile | topn | f | volatile top n locked | topn | f | query_kind top n window | topn | f | topn_shape top n limit | topn | f | topn_shape top n no index | topn | f | no_source limit 1 lookup | lookup | f | lateral_limit two level | two_level | t | two level arms | two_level | f | two_arms two level key | two_level | f | two_keys two level aggregate | two_level | f | two_aggs spine | two_level | t | spine join | two_level | f | spine_shape spine rows | two_level | t | spine grouping sets | two_level | t | rows | rows | f | rows_shape rows grouping sets | rows | f | grouping_sets (42 rows) -- a condition on a column the index does not hold names it CREATE TABLE narrow (id int, kind text, note int); INSERT INTO narrow SELECT i, 'k' || i % 5, i % 7 FROM generate_series(1, 2000) i; CREATE INDEX narrow_col ON narrow USING sift_col (id, kind); NOTICE: sift_col index "narrow_col": 1 advice (track_changes) DETAIL: advice: track_changes = the table has no one-column primary key HINT: Reasons, costs and how to turn each off: SELECT * FROM sift.index_choices('narrow_col'); ANALYZE narrow; SELECT arm, used, slug, reason FROM sift.explain_why('SELECT kind, count(*) FROM narrow WHERE note > 3 GROUP BY 1'); arm | used | slug | reason --------+------+-------+---------------------------------------------------------------------------------------------------------------- single | f | where | a condition of the WHERE or ON clause is not one the scan can test (the index does not hold the column "note") (1 row) DROP TABLE narrow; -- a spine of 12,001 rows is used; one above the memory of the lookups is refused SET sift.agg_lookup_max_kb = 512; SELECT arm, used, slug FROM sift.explain_why(spine_q('(SELECT a FROM ticks)', 'm.parent_id = sp.k')) WHERE arm = 'two_level'; arm | used | slug -----------+------+------------ two_level | f | spine_rows (1 row) RESET sift.agg_lookup_max_kb; -- The cost of the scan against the normal plan: with the table scans on, a probe of one key keeps its index scan. RESET enable_seqscan; RESET enable_indexscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; SELECT arm, used, slug, scan_cost > normal_cost AS dearer FROM sift.explain_why('SELECT count(*) FROM events WHERE id = 5'); arm | used | slug | dearer --------+------+------+-------- single | f | cost | t (1 row) -- the costs are those of the grouping stage: a LIMIT above it leaves them as they are SELECT a.scan_cost = b.scan_cost AND a.normal_cost = b.normal_cost AS stage_costs FROM sift.explain_why('SELECT kind, count(*) FROM events GROUP BY 1') a, sift.explain_why('SELECT kind, count(*) FROM events GROUP BY 1 ORDER BY 1 LIMIT 3') b; stage_costs ------------- t (1 row) -- the settings that give up before any shape is looked at SET sift.enable_agg = off; SELECT arm, used, slug FROM sift.explain_why('SELECT kind, count(*) FROM events GROUP BY 1'); arm | used | slug --------+------+---------- single | f | disabled (1 row) RESET sift.enable_agg; -- the first rows of the groups of a DISTINCT ON have to fit in a hash aggregate's memory SET work_mem = '64kB'; SET hash_mem_multiplier = 1; SELECT arm, used, slug FROM sift.explain_why('SELECT DISTINCT ON (id) id, kind FROM events ORDER BY id, ts'); arm | used | slug ----------+------+-------------- distinct | f | first_memory (1 row) RESET work_mem; RESET hash_mem_multiplier; -- only a SELECT is planned SELECT * FROM sift.explain_why('DELETE FROM plain'); ERROR: sift.explain_why takes one SELECT statement or the EXECUTE of a prepared one HINT: Pass the text of a single SELECT, without a second statement. SELECT * FROM sift.explain_why('SELECT 1; SELECT 2'); ERROR: sift.explain_why takes one SELECT statement or the EXECUTE of a prepared one HINT: Pass the text of a single SELECT, without a second statement. -- an error while planning leaves no arm behind: the next query is reported as if it were the first SELECT * FROM sift.explain_why('SELECT kind, count(*) FROM events WHERE 1 / 0 = 1 GROUP BY 1'); ERROR: division by zero SELECT arm, used, slug FROM sift.explain_why('SELECT kind, count(*) FROM plain GROUP BY 1'); arm | used | slug --------+------+----------- single | f | no_source (1 row) -- The notice of a query that is not explained: the first reason, once for each arm, and nothing for the arms that were used. SHOW sift.explain_refusals; sift.explain_refusals ----------------------- off (1 row) SET sift.explain_refusals = on; SELECT kind, count(*) FROM plain GROUP BY 1 ORDER BY 1 LIMIT 1; NOTICE: SiftAgg not used: no_source: no sift_col index or sift_table covers the table DETAIL: arm: single HINT: CREATE INDEX .. USING sift_col on the table; sift.index_choices shows what it would hold. kind | count ------+------- k0 | 400 (1 row) EXPLAIN (COSTS OFF) SELECT kind, count(*) FROM plain GROUP BY 1; NOTICE: SiftAgg not used: no_source: no sift_col index or sift_table covers the table DETAIL: arm: single HINT: CREATE INDEX .. USING sift_col on the table; sift.index_choices shows what it would hold. QUERY PLAN ------------------------- HashAggregate Group Key: kind -> Seq Scan on plain (3 rows) SET enable_seqscan = off; SET enable_indexscan = off; SET enable_bitmapscan = off; SET enable_indexonlyscan = off; EXPLAIN (COSTS OFF) SELECT kind, count(*) FROM events GROUP BY 1; QUERY PLAN ------------------------------- Custom Scan (SiftAgg) Index: events_col Group Keys: kind Pushed Aggregates: count(*) (4 rows) RESET sift.explain_refusals; -- The setting is a report: every plan above is the same with it on (the notices are muted), and it is off again afterwards. CREATE FUNCTION plan_of(q text, report bool) RETURNS text LANGUAGE plpgsql AS $$ DECLARE l text; plan text := ''; BEGIN PERFORM set_config('sift.explain_refusals', report::text, true); FOR l IN EXECUTE 'EXPLAIN (COSTS OFF) ' || q LOOP plan := plan || l || E'\n'; END LOOP; RETURN plan; END $$; SET client_min_messages = error; SELECT count(*) AS shapes, count(*) FILTER (WHERE plan_of(q, false) <> plan_of(q, true)) AS plans_that_differ FROM shapes; shapes | plans_that_differ --------+------------------- 42 | 0 (1 row) SHOW sift.explain_refusals; sift.explain_refusals ----------------------- off (1 row) RESET client_min_messages; -- the refusal of a LIMIT 1 child says what makes its key unique SELECT slug, reason ~ 'no unique index on [a-z_, ]+: end the ORDER BY with the primary key, or create a unique index on those columns, a partial one with WHERE .* CLUSTER on it and VACUUM' AS advice FROM sift.explain_why($q$SELECT d.kind, count(*) FROM events e LEFT JOIN LATERAL (SELECT kind FROM plain x WHERE x.id = e.parent_id ORDER BY x.val LIMIT 1) d ON true GROUP BY 1$q$) WHERE slug = 'lateral_limit'; slug | advice ---------------+-------- lateral_limit | t (1 row) -- a min() planned as a LIMIT 1 sub-select leaves nothing behind for the next statement of the session (the next plan used the freed list of its arms) CREATE TABLE minmax_leftover AS SELECT i AS v FROM generate_series(1, 20000) i; CREATE INDEX minmax_leftover_col ON minmax_leftover USING sift_col (v); NOTICE: sift_col index "minmax_leftover_col": 1 advice (track_changes) DETAIL: advice: track_changes = the table has no one-column primary key HINT: Reasons, costs and how to turn each off: SELECT * FROM sift.index_choices('minmax_leftover_col'); VACUUM (FREEZE, ANALYZE) minmax_leftover; SET sift.explain_refusals = on; SET client_min_messages = error; EXPLAIN (COSTS OFF) SELECT min(v) FROM minmax_leftover; QUERY PLAN ------------------------------ Custom Scan (SiftAgg) Index: minmax_leftover_col Pushed Aggregates: min(v) (3 rows) SET sift.enable_agg = off; -- PostgreSQL 18 prints a Disabled line under the scan, 17 does not SELECT l FROM regexp_split_to_table(plan_of('SELECT min(v) FROM minmax_leftover', true), E'\n') l WHERE l <> '' AND l !~ 'Disabled:'; l ----------------------------------- Aggregate -> Seq Scan on minmax_leftover (2 rows) RESET sift.enable_agg; RESET sift.explain_refusals; RESET client_min_messages; DROP TABLE minmax_leftover; -- the EXECUTE of a prepared SELECT is planned for its values and answers as the statement does SELECT slug, reason ~ 'volatile function' AS reason_kept FROM sift.explain_why($q$SELECT kind, count(*) FROM events WHERE random() < 0.5 GROUP BY 1$q$); slug | reason_kept ----------+------------- volatile | t (1 row) -- the notice of a refusal says what to do about it (the plan under it is not the test's: 17 and 18 print it differently) SET sift.explain_refusals = on; SELECT plan_of('SELECT kind, count(*) FROM events WHERE random() < 0.5 GROUP BY 1', true) <> '' AS planned; NOTICE: SiftAgg not used: volatile: the query calls a volatile function, which PostgreSQL evaluates for every row and the scan cannot reproduce DETAIL: arm: single HINT: None needed, the normal plan is the answer; compute the volatile function outside the statement or in an outer query to get the scan. planned --------- t (1 row) RESET sift.explain_refusals; PREPARE ex_volatile(float8) AS SELECT kind, count(*) FROM events WHERE random() < $1 GROUP BY 1; SELECT slug, reason ~ 'volatile function' AS reason_kept FROM sift.explain_why('EXECUTE ex_volatile(0.5)'); slug | reason_kept ----------+------------- volatile | t (1 row) PREPARE ex_used(int) AS SELECT kind, count(*) FROM events WHERE parent_id = $1 GROUP BY 1; SELECT arm, used, slug FROM sift.explain_why('EXECUTE ex_used(7)'); arm | used | slug --------+------+------ single | t | (1 row) SELECT * FROM sift.explain_why('EXECUTE ex_missing(1)'); ERROR: prepared statement "ex_missing" does not exist SELECT * FROM sift.explain_why('EXECUTE ex_used(1, 2)'); ERROR: wrong number of parameters for prepared statement "ex_used" DETAIL: Expected 1 parameters but got 2. SELECT * FROM sift.explain_why('EXECUTE ex_used(''x'')'); ERROR: invalid input syntax for type integer: "x" LINE 1: SELECT * FROM sift.explain_why('EXECUTE ex_used(''x'')'); ^ PREPARE ex_delete(int) AS DELETE FROM plain WHERE id = $1; SELECT * FROM sift.explain_why('EXECUTE ex_delete(1)'); ERROR: sift.explain_why takes the EXECUTE of a prepared SELECT HINT: The prepared statement "ex_delete" is not a SELECT. DEALLOCATE ALL; DROP TABLE shapes, guarded, events, dims, plain, parted, ticks, facts; DROP FUNCTION arms(), spine_q(text, text, text), repeated(int), plan_of(text, bool); DROP EXTENSION pg_sift_helpers; DROP EXTENSION pg_sift;