droid
ANALYZE and Table Statistics
Indexes and the Optimizer · Stage 7 of 11

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.

Not run yet · 10 tests
Concepts in this stage
  • 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
Where this stage sits in the engine
Talking to the user
REPL
Sessions
Printer
Server
Wire protocol
Understanding SQL
Lexer
Parser
Validator
Answering a query
Planner
Executor
Keeping changes safe
Transactions
Write-ahead log
Undo log
Lock manager
Read views
Storing rows
Row formatSlotted page
B+Tree
Reaching the disk
PagerDatabase file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

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

StatisticWhat the planner will do with it
rowsthe size of everything: a full scan hands up this many rows
pageswhat a full scan costs: it reads each page once
ndv, distinct values that are not NULLcity = 'x' keeps about one row in ndv
min, maxage < 5 keeps about (5 − min) / (max − min + 1) of the rows
nullscity is null keeps nulls rows
droid session
>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

1

ANALYZE a table

One pass over the rows, one write to the catalog.

  1. 1.Check the table exists (01607).
  2. 2.Walk the table's tree once. Count the rows, and count every page of the tree: internal nodes, leaves and overflow pages.
  3. 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. 4.Replace the table's statistics in the catalog with the new ones, inside the statement, so a transaction can take them back.
2

Print them

stats <table> reads the catalog and prints; it never counts.

  1. 1.With no statistics stored, print [STATS] t no statistics.
  2. 2.Otherwise print the table line, then one line per column the statistics have, in table order.
  3. 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.

InputOutput
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 NULLndv=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

shell
$droid --db stats.db
Welcome 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
>.exit
exiting.. 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::set per column, or sort a std::vector and use std::unique.
  • Rust: a BTreeSet<Value> per column orders and deduplicates in one go.
  • Zig: sort a slice with std.sort.pdq and 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 NULLs
    stats-format
  • pages counts the table's tree: more rows, more pages
    stats-pages-grow
  • Statistics survive reopening
    stats-persist
  • Writes do not change statistics; ANALYZE again does
    stats-not-automatic
  • A table never analysed has no statistics
    stats-before-analyze
  • An empty table, and a column of NULLs, have zeros and NULL bounds
    analyze-empty-and-nulls
  • ANALYZE of a missing table is 01607; ANALYZE with no table is 00302
    analyze-errors
  • ANALYZE inside a rolled-back transaction leaves the old statistics
    analyze-rollback
  • DROP TABLE drops the statistics with the table
    drop-table-drops-stats
  • analyze is a keyword; ast of ANALYZE
    analyze-printing