ANALYZE and Table Statistics
Count what the optimizer will need to know, and keep it until someone asks again
Add ANALYZE, which reads a table once and stores in the catalog its row and page counts and, per column, the number of distinct values, the minimum, the maximum and the NULL count; print them with stats, and never change them unless ANALYZE runs again.
- An optimizer knows nothing about the data unless something counts it
- A handful of numbers per column is enough to estimate how many rows a predicate keeps
- Statistics are a snapshot: they go stale as the table changes, and that is a choice, not a bug
- Stored in the catalog, statistics are durable and transactional like any other metadata
Why this stage exists
The stages so far chose plans by rule: an index whenever the WHERE lets you use one. That rule is wrong as often as it is right. An index on city is a gift for city = 'Oslo' in a table of a thousand cities, and a trap in a table where every row says 'Oslo': every row it finds costs a descent of the table, and a full scan would have read each page once.
To tell the two apart, the planner needs to know things about the data: how many rows, how many pages, how many different cities. Nothing knows that unless something counts it. That is ANALYZE. This stage counts and stores; the next one uses the numbers.
What is counted
| Statistic | What the planner will do with it |
|---|---|
rows | the size of everything: a full scan hands up this many rows |
pages | what a full scan costs: it reads each page once |
ndv, distinct values that are not NULL | city = 'x' keeps about one row in ndv |
min, max | age < 5 keeps about (5 − min) / (max − min + 1) of the rows |
nulls | city is null keeps nulls rows |
>analyze t;Analyze statement executed successfully>stats t[STATS] t rows=1000 pages=42[STATS] id ndv=1000 min=1 max=1000 nulls=0[STATS] city ndv=10 min='c0' max='c9' nulls=5[STATS] age ndv=100 min=0 max=99 nulls=0
Counting distinct values is the one part that needs memory: you have to remember which values you have seen. A set of the values is fine at this size. Real engines estimate it from a sample instead, because a set of a billion strings does not fit anywhere.
Never automatic
ANALYZE runs when someone types it, and at no other time. Insert a thousand more rows and stats t still says 1000. Delete every row and it still says 1000. This is deliberate. Statistics that change behind your back make plans change behind your back, and a test, or a person reading explain, could never predict which plan a query gets. The cost-model stage shows the price of the other side: a plan chosen from stale numbers.
Statistics are kept in the catalog next to the table they describe. So they survive reopening, ROLLBACK takes back an ANALYZE run inside the transaction, and DROP TABLE takes them with the table.
The algorithm in words
ANALYZE a table
One pass over the rows, one write to the catalog.
- 1.Check the table exists (01607).
- 2.Walk the table's tree once. Count the rows, and count every page of the tree: internal nodes, leaves and overflow pages.
- 3.For each column, keep the number of NULLs, the smallest and largest value that is not NULL, and the set of distinct values that are not NULL, using the order of the index stage: numbers as numbers, strings as bytes.
- 4.Replace the table's statistics in the catalog with the new ones, inside the statement, so a transaction can take them back.
Print them
stats <table> reads the catalog and prints; it never counts.
- 1.With no statistics stored, print [STATS] t no statistics.
- 2.Otherwise print the table line, then one line per column the statistics have, in table order.
- 3.Print values as literals: numbers bare, strings quoted, NULL for a column with no value.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, section 59.
| Input | Output |
|---|---|
analyze t; | Analyze statement executed successfully |
stats t | [STATS] t rows=<n> pages=<p>, then [STATS] <column> ndv=<d> min=<v> max=<v> nulls=<k> per column |
stats t before any ANALYZE | [STATS] t no statistics |
| a column with no value that is not NULL | ndv=0 min=NULL max=NULL nulls=<k> |
analyze nope; | [ERROR:01607] <message> |
analyze; | [ERROR:00302] <message> |
stats nope | [ERROR:00401] <message> |
ast analyze t; | Statement: ANALYZE, Table: t |
analyze is a keyword. explain takes no ANALYZE statement: explain analyze will mean something else in the explain-analyze stage.
What you should see when done
$droid --db stats.dbWelcome to droid!>create table t (id int, city varchar(10));Create table statement executed successfully>insert into t (id, city) values (1, 'oslo');Insert statement executed successfully (1 rows)>insert into t (id, city) values (2, 'oslo');Insert statement executed successfully (1 rows)>insert into t (id) values (3);Insert statement executed successfully (1 rows)>stats t[STATS] t no statistics>analyze t;Analyze statement executed successfully>stats t[STATS] t rows=3 pages=1[STATS] id ndv=3 min=1 max=3 nulls=0[STATS] city ndv=1 min='oslo' max='oslo' nulls=1>insert into t (id, city) values (4, 'rome');Insert statement executed successfully (1 rows)>stats t[STATS] t rows=3 pages=1[STATS] id ndv=3 min=1 max=3 nulls=0[STATS] city ndv=1 min='oslo' max='oslo' nulls=1>.exitexiting.. good bye!
Common bugs in this stage
- Counting NULL as a distinct value, or letting it be the minimum. NULLs are counted only in
nulls. - Comparing strings for min and max with a locale. Use the byte order of the index stage, so
'B'is below'a'. - Updating statistics on INSERT. They change only when ANALYZE runs.
- Keeping statistics in memory. They vanish on reopen, and survive a ROLLBACK they should not.
- Counting only leaves for pages. Internal nodes and overflow pages are pages of the tree too.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: for distinct values, sort a copy of the column's values and count the changes; it needs no hash table.
- C++: a
std::setper column, or sort astd::vectorand usestd::unique. - Rust: a
BTreeSet<Value>per column orders and deduplicates in one go. - Zig: sort a slice with
std.sort.pdqand count where neighbours differ.
Checklist
One line per thing the tests check. The tags name the test cases.
- ANALYZE counts rows, pages, and per column distinct values, min, max and NULLsstats-format
- pages counts the table's tree: more rows, more pagesstats-pages-grow
- Statistics survive reopeningstats-persist
- Writes do not change statistics; ANALYZE again doesstats-not-automatic
- A table never analysed has no statisticsstats-before-analyze
- An empty table, and a column of NULLs, have zeros and NULL boundsanalyze-empty-and-nulls
- ANALYZE of a missing table is 01607; ANALYZE with no table is 00302analyze-errors
- ANALYZE inside a rolled-back transaction leaves the old statisticsanalyze-rollback
- DROP TABLE drops the statistics with the tabledrop-table-drops-stats
- analyze is a keyword; ast of ANALYZEanalyze-printing