Duplicate Keys, VARCHAR Keys and Range Scans
Walk the index's leaves from one value to another, in the order its entries sort
Use an index for <, >, <= and >= as well as =, hand rows up in the order of the index's entries, and get every comparison right: unsigned numbers, bytes with a prefix first, NULL in no range, and many rows with one value.
- A range on an index is a seek to the first entry it admits and a walk along the leaves until one it does not
- Duplicates need nothing new: entries with the same value differ in their table key and lie side by side
- An index scan returns rows in index order, which is not key order
- Sort order is a contract: numbers as numbers, strings as bytes, a prefix first, NULL first and in no range
- An UPDATE that moves rows within the range it is scanning must not meet them again
Why this stage exists
The previous two stages used an index for one question: which rows have exactly this value? An index can answer a better one. Its entries are sorted, so all the values from 'b' up to 'c' sit next to each other in its leaves. Find the first, walk along the leaf links, and stop at the first that is too big: that is a range scan, the same walk the table's own tree already does for id > 25.
This stage also settles the questions that were easy to skip while every lookup was one value: what happens when a value repeats three hundred times, what order strings and numbers really sort in, and where NULL goes.
Duplicates were solved already
An entry is the value followed by the table key, so three rows with city 'x' make three different entries, side by side, ordered by their keys:
So where city = 'x' was already a range: from the first entry with 'x' to the last. Nothing in the tree needs a list of rows per value, a DELETE removes exactly the entry of its row, and duplicates come back in key order for free.
Rows come back in index order
A range scan hands its rows up in the order it meets their entries: by value, then by table key. For name >= 'b' and name < 'c' that is b, ba, bz, whatever their ids. Until now every SELECT without ORDER BY came back in key order; from this stage, one whose plan reads an index comes back in index order. That is not a bug to hide: no SQL engine promises any order without ORDER BY, and this one makes the order it gives part of its contract so the tests can read it.
The ORDER BY and GROUP BY shortcuts of the Querying part assumed rows in table key order, so a range scan of an index turns them off: ORDER BY on the table's key keeps its Sort, and GROUP BY on the table's key is a HashAggregate, not a SortAggregate. An = scan of an index still hands its rows up in key order, since its entries share one value.
Getting the order exactly right
An index is only correct if its idea of order is the same as the comparisons in the WHERE. Three traps, each in the tests:
| Values | Wrong order | Right order | Why |
|---|---|---|---|
256, 1, 255 | 256, 1, 255 (little-endian bytes) | 1, 255, 256 | INT compares as a number |
3000000000, 2 | 3000000000, 2 (signed) | 2, 3000000000 | INT is unsigned |
'b', 'B', 'ab', 'abc', '' | case-folded, or C strings | '', 'B', 'ab', 'abc', 'b' | VARCHAR compares bytes; a prefix comes first |
NULL sorts before every value in the index, so a scan from the start of the index meets the NULL entries first. But a NULL is never in a range: name < 'm' is unknown for a NULL name, exactly as the Filter would decide. So a range with no lower bound must start at the first entry that is not NULL. name is null is not an index bound at all in this part; it stays a Filter over a SeqScan.
The planner's order of preference
In the create-index stage, name > 'b' was a Filter over a SeqScan; it is now a range IndexScan, so the earlier case that checked that plan is retired here.
An UPDATE inside its own range
The index-maintenance stage made every UPDATE find its rows first and change them after. Ranges are where that rule earns its keep:
update t set n = 20 where n > 5;
With an index on n, the scan walks forward from 6. Each row it changes gets n = 20 and a new entry further ahead in the same walk. A scan that changed rows as it went would meet them again and count them twice. Were SET to take n + 10, as other engines allow, it would never stop. Collect the keys first, then change the rows, and each changes once.
The algorithm in words
Plan the range
Key bounds still win, then an equality on an index, then a range on an index.
- 1.Collect the conjuncts that are an indexed column, one of =, <, >, <=, >=, and a literal of the column's type, in that order.
- 2.If any conjunct bounds the table's key, use the table's IndexScan, as before.
- 3.Else if any of them is an =, use the first one: an IndexScan with key=.
- 4.Else take the column of the first one. Its first lower bound and its first upper bound form the range, lower first.
- 5.Everything else goes into a Filter above the scan.
Run the range
Seek once, then follow the leaf links.
- 1.With a lower bound, descend the index to the first entry whose value is at or past it (past it, for >). Without one, start at the first entry whose value is not NULL.
- 2.For each entry, stop at the first one beyond the upper bound; it is not a row and is not counted.
- 3.Otherwise look its table key up in the table, hand the row up, and count it in rows_examined and index_lookups.
- 4.Move to the next entry, following the leaf's link to the next leaf when this one ends.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, section 56.
| Input | Output |
|---|---|
explain select * from t where name >= 'b' and name < 'c'; | [PLAN] └── IndexScan (table=t, index=idx, range: name >= 'b' AND name < 'c') |
explain select * from t where n < 3 and name > 'b'; (indexes on both) | Filter (name > 'b') over IndexScan (table=t, index=by_n, range: n < 3) |
explain select * from t where name > 'b' and n = 1; | Filter (name > 'b') over IndexScan (table=t, index=by_n, key=1) |
explain select * from t where name is null; | Filter (name IS NULL) over SeqScan (table=t) |
select id, name from t where name >= 'b' and name < 'c'; | the rows in name order, then [EXEC] rows_examined=3 index_lookups=3 |
There is no new syntax and no new code.
What you should see when done
$droid --db names.dbWelcome to droid!>create table t (id int, name varchar(20), n int);Create table statement executed successfully>create index idx on t (name);Create index statement executed successfully>insert into t (id, name, n) values (1, 'ba', 1);Insert statement executed successfully (1 rows)>insert into t (id, name, n) values (2, 'B', 2);Insert statement executed successfully (1 rows)>insert into t (id, name, n) values (4, 'bz', 4);Insert statement executed successfully (1 rows)>insert into t (id, name, n) values (5, 'b', 5);Insert statement executed successfully (1 rows)>insert into t (id, name, n) values (6, 'c', 6);Insert statement executed successfully (1 rows)>select id, name from t where name >= 'b' and name < 'c';id | name5 | b1 | ba4 | bz(3 rows)[EXEC] rows_examined=3 index_lookups=3>select id, name from t where name < 'b';id | name2 | B(1 rows)[EXEC] rows_examined=1 index_lookups=1>.exitexiting.. good bye!
Common bugs in this stage
- Comparing INT entries byte by byte. Little-endian bytes sort 256 before 1. Decode and compare numbers.
- Signed INT comparison. 3000000000 becomes negative and sorts first.
- Comparing strings with a locale or case folding.
'B'is 0x42 and sorts before'a', 0x61. - A range with no lower bound starting at the first entry. It hands up the NULL rows. Skip them.
- Counting the entry that ends the range. It is not a row.
- Stopping at the end of a leaf. A range crosses leaves; follow the link.
- Changing rows during the scan.
update t set n = 20 where n > 5meets its own new entries and counts them twice.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C:
memcmpon the shorter length, then the lengths, gives byte order with a prefix first; neverstrcmp, which stops at a zero byte. - C++:
std::string_view::compareandstd::lexicographical_compareonunsigned charboth give byte order. - Rust:
[u8]implementsOrdin exactly this order; compare byte slices, notstrwith any locale. - Zig:
std.mem.order(u8, a, b)returns byte order with a prefix first.
Checklist
One line per thing the tests check. The tags name the test cases.
- An equality that matches 300 rows returns all of them, in key orderall-duplicates-returned
- Deleting one of many equal values removes that row's entry and no otherdelete-one-duplicate
- A range on a VARCHAR index compares bytes, and returns rows in name ordervarchar-range
- A string sorts before every longer string it is a prefix ofprefix-order
- An INT index compares numbers, not bytesint-range-numeric
- NULLs are in the index, but no equality or range returns themnulls-in-index
- An UPDATE that moves rows forward through its own range changes each oncerange-update-once
- explain: a range on an indexed column is an IndexScan with range:, after keys and equalitiesrange-plans