Contents
Tuning: what to touch, and what to leave alone
pg_sift has about 150 options, and you should not need most of them. The build and the planner decide: CREATE INDEX picks the structures a table pays for and prints one NOTICE that says what it chose and how to turn each choice off; the planner chooses between the aggregate scan and PostgreSQL’s own plan by cost. This page is the short list of what is worth knowing, in the order you meet it. Every option has its full case (what it does, measured gain, cost, how to turn it off) in OPTIONS.md; the generated tables are in USAGE.md; the SQL functions are in REFERENCE.md.
The ten things a user touches
| # | What | Kind | Use it when |
|---|---|---|---|
| 1 | sift.enable_agg = off |
setting | you want the plan PostgreSQL has without pg_sift, for one statement, a role or a database: to compare answers and times, or to find out which side gave a wrong result |
| 2 | sift.explain_why(query) |
function | a query kept the normal plan and you want to know why and what to change |
| 3 | sift.index_choices(index) |
function | you want to read what CREATE INDEX chose for a sift_col index and the command that turns each choice off |
| 4 | track_changes |
index option | the index is read without the table (on by default for the primary key) |
| 5 | key_order |
index option | the table is written in the order of a key and you read ranges, ORDER BY .. LIMIT or the latest rows of each tenant |
| 6 | paths, sift.suggest_paths() |
index option, function | a jsonb column is in a sift_col index and you choose which of its keys are columns |
| 7 | sift.workers |
view | you ask whether maintenance runs, has failed, or an index is due for a rewrite |
| 8 | sift.upgrade_commands() |
function | after ALTER EXTENSION pg_sift UPDATE: what to REINDEX or rewrite (it changes nothing) |
| 9 | stemmer, prefix_index, analyzer = 'keyword' |
index options of sift (BM25) |
you want word forms to match, autocomplete, or exact keys and facets |
| 10 | sift.table_options(), sift.table_set_option() |
functions | you tune one sift_table (its options are set per table, with functions, because PostgreSQL has no table options for an extension before version 20) |
-- 1 the plan without pg_sift, for this transaction only
BEGIN; SET LOCAL sift.enable_agg = off; EXPLAIN SELECT kind, count(*) FROM events GROUP BY kind; ROLLBACK;
-- 2 why the aggregate scan was not used
SELECT arm, used, slug, reason FROM sift.explain_why('SELECT kind, count(*) FROM events GROUP BY 1');
-- 3 what the index build chose (and "off": the command that undoes it)
SELECT choice, kind, value, off FROM sift.index_choices('events_col');
-- 4 reading without the table: keep it, or turn it off
ALTER INDEX events_col SET (track_changes = 'off');
-- 5 rows written in the order of ts
CREATE INDEX events_col ON events USING sift_col (id, kind, ts) WITH (key_order = 'ts');
-- the latest rows of each tenant in one big table
CREATE INDEX events_col ON events USING sift_col (id, tenant_id, ts) WITH (key_order = 'tenant_id, ts');
-- 6 the keys of a jsonb column that are worth a column: the function reads a sample and writes the option
SELECT sift.suggest_paths('events', 'payload');
-- 7 maintenance: running, failed, due
SELECT index, kind, segments, sealed, running, failures, due FROM sift.workers;
-- 8 after an update of the extension
SELECT kind, object, reason, command, required FROM sift.upgrade_commands();
-- 9 BM25: English word forms, prefixes of up to 3 letters, exact keys
CREATE INDEX docs_body ON docs USING sift (body) WITH (stemmer = 'en', prefix_index = 3);
CREATE INDEX docs_kind ON docs USING sift (kind) WITH (analyzer = 'keyword');
-- 10 one sift_table: the value in force, where it comes from, and a change
SELECT * FROM sift.table_options('events');
SELECT sift.table_set_option('events', 'write_buffer_limit', '8MB');
Two more habits are worth having. After a load or a heavy delete, VACUUM is what returns space and merges segments; the background workers (sift.col_auto_merge, sift.table_auto_defrag, sift.async_maintenance) do the same in the background and are on. And sift.stats() / sift.explain_refusals = on are for support, when explain_why is not enough.
You should not need these
Every option has a class in OPTIONS.md. default is on and right: leave it. opt-in is off because it fails a term of the rule for a default (its why says which) and pays only for the workload it names. safety is a limit on memory, locks, I/O or replicas: set one when a limit hurts, not before. diagnostic is for measuring, comparing or explaining a plan, not for a deployed configuration: switching one of these off gives the same rows by another path, which is the point when you must find out whether an optimization gave a wrong result. deprecated is an older spelling that is still accepted.
The diagnostic options (generated from options.tsv):
| Option | Applies to | What it does |
|---|---|---|
build_segments |
BM25 | forces N segments at build and turns the parallel build off |
cluster_advice |
sift_col | sift.cluster_advice() may recommend a physical order or a key_cut for the table, and CREATE INDEX may suggest key_cut |
sift.enable_scan |
BM25 | switches of the ranked scan, the count(*) pushdown, the bitmap of the other quals and the read-ahead |
sift.enable_count |
BM25 | switches of the ranked scan, the count(*) pushdown, the bitmap of the other quals and the read-ahead |
sift.prefetch |
BM25 | switches of the ranked scan, the count(*) pushdown, the bitmap of the other quals and the read-ahead |
sift.log_min_duration |
all | logs promotions, merges and worker runs of at least that long |
sift.simd |
all | caps the SIMD level of every kernel; every level returns the same results bit for bit |
sift.vis_page_cache |
sift_col | the rows of a heap page that a scan reads twice (the units of a key-cut index list rows of the same page) are checked for visibility once |
sift.cluster_advice |
sift_col | sift.cluster_advice() may recommend a physical order or a key_cut for the tables, and CREATE INDEX may suggest key_cut |
sift.agg_left_lateral |
sift_col, sift_table | a LEFT JOIN LATERAL over jsonb_to_recordset or jsonb_array_elements of a list of the scanned row is answered by the scan: a row none of whose elements passes is one tuple with NULL element columns |
sift.agg_probe_direct |
sift_col, sift_table | the keys of a correlated LIMIT 1 child (LEFT JOIN LATERAL .. LIMIT 1 into a table with a btree, an Index Scan or an Index Only Scan) are probed in the child’s btree by the aggregate scan, a sorted batch of keys at a time, instead of running the child’s plan once for each key |
sift.agg_float_step |
sift_col, sift_table | a key, an aggregate argument or a filter of a float column that reads it only through floor, ceil, trunc, round or an integer cast of the column, of the column divided by a constant or of the column times a constant is evaluated by PostgreSQL once for each bucket of the values, not for each distinct value; the bucket numbers of a chunk come from the vector kernels of the scan (AVX2, AVX-512, NEON) |
sift.agg_preagg |
sift_col, sift_table | units whose rows are all visible are answered from the pre-aggregates stored with them, the exact count/sum/min/max, and groups of units from their superunit summary |
sift.agg_preagg_distinct |
sift_col, sift_table | units whose rows are all visible are answered from the pre-aggregates stored with them, the exact count/sum/min/max, and groups of units from their superunit summary |
sift.agg_super |
sift_col, sift_table | units whose rows are all visible are answered from the pre-aggregates stored with them, the exact count/sum/min/max, and groups of units from their superunit summary |
sift.topn_walk |
sift_col, sift_table | ORDER BY .. LIMIT n from the units, bounded by their zones and sorted runs |
sift.topn_zone_keys |
sift_col, sift_table | ORDER BY .. LIMIT n from the units, bounded by their zones and sorted runs |
sift.topn_runs |
sift_col, sift_table | ORDER BY .. LIMIT n from the units, bounded by their zones and sorted runs |
sift.agg_lookup_pushdown |
sift_col, sift_table | the table of a lookup join loads only the rows its own conditions can match (the keys of the scan’s IN list or of a dependent lookup are read by one scan of its btree, from its entries where it holds the columns, while that costs less than the whole table), and keeps its rows between queries (backend cache, shared memory) |
sift.agg_lookup_columns |
sift_col, sift_table | the rows of a lookup table are read from the leaves of a sift_col index of it that holds the columns the join reads, not from the heap; a table of one int4 key whose columns are integer leaves with few distinct tuples of values is kept as one class of its keys for each tuple (rows classed), not row by row; an expression of an inner-joined table’s columns that an index of it holds as a column (a presence mask) is read as one column (column loads) |
sift.agg_probe_rewrite |
sift_col, sift_table | a join to a large btree table (JOIN, comma join), EXISTS, NOT EXISTS, IN and NOT IN over one is also planned as a probe of the table’s btree for each key the scan meets, and kept where that costs less |
sift.agg_case_filter |
sift_col, sift_table | sum, avg, min, max and count over CASE WHEN p THEN x [ELSE NULL] END, sum over CASE WHEN p THEN x ELSE 0 END, count with any ELSE that is not NULL, and min, max (integers) and avg (integers, numerics) with ELSE 0, run as the aggregates of x with a filter on p: the kernels of x under a test of p, not the CASE for every row |
sift.agg_verdict_cache |
sift_col, sift_table | a plan that does not depend on the statements planned before it: the choice between a shared CTE and its inline plan, and between the flattened form of a statement and the form as written, is made in full for every statement (support, benchmarks, comparing two plans) |
sift.agg_share_lookups |
sift_col, sift_table | the table of a lookup join loads only the rows its own conditions can match (the keys of the scan’s IN list or of a dependent lookup are read by one scan of its btree, from its entries where it holds the columns, while that costs less than the whole table), and keeps its rows between queries (backend cache, shared memory) |
sift.agg_semi_lut |
sift_col, sift_table | a semi or anti join (IN, EXISTS, NOT EXISTS) on an int2, int4, int8 or date leaf is answered from a byte table over the keys of the joined table, or a hash set of them where they span more |
sift.agg_connect_found |
sift_col | a semi, anti or inner join to the parent of a connected sift_col index that reads no column of the parent is answered from the flag the index keeps that the parent row was found, less the keys the journal of the parent names (at most 16,384) and plus those of them the parent holds now |
sift.agg_child_first |
sift_col | the count of the rows of a table that have (or have no) row in another by a unique key, and the aggregates of a table left-joined to a table by its key, are planned also from the other table first (its rows counted by their keys, or grouped by them), and kept where that costs less |
sift.agg_key_skew |
sift_col, sift_table | the probes of a lookup join are priced by the rows of the most common keys, not the mean |
sift.agg_dense |
sift_col, sift_table | one switch for each execution shortcut of the aggregate scan: dense chunks, one scan for aggregates over per-row aggregates, UNION ALL arms in one pass, key filters on lookup joins, exact count(DISTINCT) without a sort |
sift.agg_two_level |
sift_col, sift_table | one switch for each execution shortcut of the aggregate scan: dense chunks, one scan for aggregates over per-row aggregates, UNION ALL arms in one pass, key filters on lookup joins, exact count(DISTINCT) without a sort |
sift.agg_dist_stream |
sift_col, sift_table | one switch for each execution shortcut of the aggregate scan: dense chunks, one scan for aggregates over per-row aggregates, UNION ALL arms in one pass, key filters on lookup joins, exact count(DISTINCT) without a sort |
sift.agg_fuse |
sift_col, sift_table | one switch for each execution shortcut of the aggregate scan: dense chunks, one scan for aggregates over per-row aggregates, UNION ALL arms in one pass, key filters on lookup joins, exact count(DISTINCT) without a sort |
sift.agg_join_filter |
sift_col, sift_table | one switch for each execution shortcut of the aggregate scan: dense chunks, one scan for aggregates over per-row aggregates, UNION ALL arms in one pass, key filters on lookup joins, exact count(DISTINCT) without a sort |
sift.agg_rows |
sift_col, sift_table | one switch for each execution shortcut of the aggregate scan: dense chunks, one scan for aggregates over per-row aggregates, UNION ALL arms in one pass, key filters on lookup joins, exact count(DISTINCT) without a sort |
sift.explain_refusals |
sift_col, sift_table | a notice says why a query kept the normal plan instead of SiftAgg |
sift.count_refusals |
sift_col, sift_table | a notice says why a query kept the normal plan instead of SiftAgg |
sift.plan_scale |
sift_col, sift_table | plan as if the tables with a sift_col index had this many times the pages and rows, to see the planner’s choice at a larger size (on every supported PostgreSQL) |
sift.agg_force |
sift_col, sift_table | takes the aggregate scan wherever one is possible, whatever the plans cost, to time the plan the cost model refused |
sift.eval_methods |
sift_col | selects the developer evaluation method named: penalty_maintenance prices the VACUUM that reads ask for |
Same word, three places
An option that means the same thing in the three kinds has the same name, but not the same default, because the work it controls costs different amounts:
| Name | sift (BM25 index) |
sift_col index |
sift_table |
|---|---|---|---|
write_buffer_limit |
index option, kB, 4096 | index option, kB, 4096 | sift.table_write_buffer_limit or table option, kB 2048 to 8192, 4096 |
dead_rewrite_pct |
index option, 50 (100 never rewrites a lone segment) | index option, 0 = by cost (100 never) | sift.table_dead_rewrite_pct or table option, 30 |
vacuum_truncate |
index option, true | index option, true | none |
dict_lookup |
none | index option, auto, on or off |
sift.table_dict_lookup or table option, on or off |
key_table, compression |
none | index options | sift.table_key_table, sift.table_compression, or table options |
| background work | sift.async_maintenance |
sift.col_auto_merge |
sift.table_auto_defrag |
The limits of the background work
The same four questions have three sets of settings. They are different mechanisms with different units and defaults, which is why they were not merged:
| Question | sift_col (rewrite after churn) |
sift_table (background pass) |
Shared |
|---|---|---|---|
| Is it on? | sift.col_auto_merge (on) |
sift.table_auto_defrag (on) |
sift.async_maintenance (on) |
| Limit the WAL | sift.col_auto_merge_wal_pct: percent of the server’s WAL (5; 0 does not wait) |
sift.table_auto_defrag_wal_rate: kB/s (0: no limit) |
sift.maintenance_wal_rate: kB/s of the merges of the worker (0: no limit) |
| Work in one pass | sift.col_auto_merge_pass_mb (1024 MB) |
sift.table_auto_defrag_budget (128 MB) |
sift.vacuum_rewrite_limit (128 MB a VACUUM of a column index or table) |
| Wait while clients are busy | sift.col_auto_merge_max_active (0: never) |
sift.table_auto_defrag_max_active (0: never) |
none |
| Slow down | none | sift.table_auto_defrag_delay (10 ms) |
sift.maintenance_cost_delay (0 ms), sift.maintenance_naptime (2000 ms) |
Two pairs of options that look alike
Both pairs were measured side by side (400,000 rows, a table of an int4 id spread over the heap, an int4 of 100 rows a key, a timestamp and an int4 spread over the heap; instructions of one backend a statement, user space, the mean of 30 statements with other constants; the other automatic choices off so that only the pair differs).
point_tid_columns (the directory of the rows of a key). Leave it on auto: it is the better one in every shape measured. point_columns, the directory of rows it is compared with below, is deprecated (4.3) and lists its columns as heap addresses too; they are column format minor 33 and above, read from 3.7.
| Statement | no directory | point_columns |
point_tid_columns |
|---|---|---|---|
WHERE id = k, all columns, one row |
1.55 M | 133 k | 95 k |
WHERE id = k, the key only |
1.55 M | 148 k | 86 k |
count(*) WHERE id = k |
sequential scan, 218 M | sequential scan, 218 M | 114 k |
count(*) WHERE id IN (5 keys) / (64 keys) |
sequential scan, 335 M / 256 M | sequential scan, 335 M / 256 M | 180 k / 905 k |
WHERE grp = k, 100 rows |
2.07 M | 1.20 M | 540 k |
| the same after 5% of the rows were deleted (not vacuumed) | IN of 64 keys 267 M |
same plan as before | 1.78 M (a unit with a dead row is read) |
| index size (5 columns) | 5.14 MB | 7.39 MB (+43.9%) | 7.50 MB (+45.9%) |
CREATE INDEX, instructions |
1.45 G | 1.60 G (+10.5%) | 1.63 G (+12.4%) |
order_array_columns (the global order of a column). The build chooses the columns (auto, the default) and the arrays answer ORDER BY .. LIMIT, an OFFSET, a range and an IN list over any number of units. order_columns, the sorted run of each unit that earlier releases wrote beside the array, is deprecated (4.3): a build ignores it, the runs already written are read as before, and 4.4 removes it. The run alone read 4 to 6 times the instructions of the array beyond a top-N of 32 for the same bytes.
The words that mean several things
- key.
key_columns(andsift.table_key_table,key_table) are lookup keys: the columns whose distinct values every unit lists, for equality,INandcount(DISTINCT).key_cutis a sort key: the column whose range cuts the units.key_orderis the sort key of the table as a whole and stands forkey_cut,order_columns,order_array_columnsandpoint_tid_columnson it. For ordered reads setkey_orderand nothing else. - rollup.
rollup_leavesis the array elements and map values a unit groups by,rollup_columnsthe scalar columns a unit groups by (the rollup of the rows),rollup_on_demandandsift.col_rollup_on_demand/sift.col_rollup_workerthe refresh of rollups in the background (sift.col_rollup_on_demandasks,sift.col_rollup_workerserves; with the worker off the requests wait forsift.col_rollup_refresh(index)),sift.agg_rollup_costthe price of reading one. - field and section. The text of a
fieldsindex is made of fields;sift.sections()is the function that joins them. cluster_advice. A setting, an index option and a function share the name: the functionsift.cluster_advice()recommends a physical order; the setting and the option only switch the recommendation off.
The values of the list options
(track_changes defaults to auto.) Most options of a sift_col index that take columns accept auto (the build decides), off and a list. What '' (not set) means depends on the option, and the word auto is not accepted by all of them:
| Option | '' means |
auto |
off |
Other words |
|---|---|---|---|---|
key_columns, key_cut, order_array_columns, order_array_group, rollup_columns, count_columns |
automatic | yes | yes | |
calendar_columns |
every date and timestamp column | yes | yes | all |
rollup_leaves |
the byte-budget rule (2% of the unit) | yes | yes | default |
preagg |
the default | yes | yes | default, cube(..) |
order_layout |
auto |
yes | no | whole, compact |
compression |
auto |
yes | no | none, lz4, zstd |
point_columns, point_tid_columns |
none | yes | yes | |
hll_columns, return_columns, key_order |
none | no | yes | |
array_lengths, dict_lookup (enums) |
not allowed | yes | yes | on |
Which function to call
| You want | sift (BM25) |
sift_col |
sift_table |
|---|---|---|---|
| size and free space of the file | sift.index_info |
sift.col_space (free space in detail), sift.col_index_info (rows, bytes, units), sift.col_extents (every run of blocks) |
sift.table_space |
| the segments | sift.segments |
sift.col_segments |
sift.table_segments |
| what it holds | sift.segment_postings |
sift.col_leaves, sift.col_sections |
|
| merge, promote, check | sift.merge, sift.promote, sift.verify |
the same three (sift.col_merge, sift.col_promote, sift.col_verify are the same calls) |
sift.table_maintain |
| maintenance state | sift.workers, sift.worker_state |
sift.workers, sift.col_maintenance_status, sift.col_maintenance_due |
sift.workers (segments, sealed buffers, running), sift.table_space |
| what the build chose or advises | sift.index_choices, sift.index_advice, sift.key_cut_decision, sift.cluster_advice |
||
| what a notice, a choice or a refusal slug means | sift.help(slug), sift.refusal_slugs(), sift.refusal_actions() |
sift.help(slug) |
A function with the prefix of the other kind answers "x" is not a sift index (or sift_col) and changes nothing.