NULL and Three-Valued Logic
Store a missing value, and evaluate a WHERE clause with true, false and unknown
Add NULL as a literal and as a stored value, switch on the null bitmap, evaluate comparisons and AND, OR and NOT with three truth values, add IS NULL and NOT NULL, and print NULL in both output modes.
- NULL means unknown: it is not zero, not the empty string, and not equal to anything, itself included
- Three-valued logic: a comparison with NULL is unknown, and AND, OR and NOT have a table for it
- WHERE keeps only the rows that are true: false and unknown are both left out
- IS NULL is the one test that answers true or false about NULL
- The null bitmap: one bit per column, so a NULL takes no bytes in the row
- NOT NULL is a promise the table checks at every INSERT and UPDATE
Why this stage exists
Until now every column of every row had a value. A row inserted without its email got an empty string, and one without its age got 0. That is a lie: nobody said the email was empty, or that the person was zero years old. The value is missing, and a database needs a way to say so. SQL's way is NULL.
NULL is not a value like the others. It means unknown, and that one word explains every rule on this page. Start with the query that surprises everyone:
>select id from t where c = NULL;id(0 rows)[EXEC] rows_examined=5
Rows 3 and 4 have NULL in c, and still nothing comes back. Ask the question in words: is this unknown value equal to an unknown value? The honest answer is not yes, and not no. It is unknown. WHERE keeps a row only when its condition is true, so it keeps none. To find the NULLs, ask a question that does have a yes-or-no answer: where c is null.
Three truth values
A comparison now has three possible answers: true, false and unknown. Any comparison with a NULL on either side is unknown. NOT, AND and OR extend to the third value by one rule: the answer is known when it would be the same whatever the unknown turned out to be.
NOT | |
|---|---|
| true | false |
| false | true |
| unknown | unknown |
AND | true | false | unknown |
|---|---|---|---|
| true | true | false | unknown |
| false | false | false | false |
| unknown | unknown | false | unknown |
OR | true | false | unknown |
|---|---|---|---|
| true | true | true | true |
| false | true | false | unknown |
| unknown | true | unknown | unknown |
unknown AND false is false: whatever the unknown is, the AND fails. unknown OR true is true for the same reason. This is why where not (c = 1 and id = 99) keeps the NULL rows: for them the inside is unknown AND false, which is false, and NOT makes it true.
Short-circuiting still works, with a smaller window. AND can skip its right side only when the left is false, and OR only when it is true. An unknown left side does not decide anything, so the right side has to be evaluated.
Arithmetic follows the same idea. c + 1 with an unknown c is unknown, so it is NULL. So is c * 0: it is tempting to say "anything times zero is zero", but SQL keeps the rule simple, and NULL in gives NULL out. A NULL operand also means there is nothing to divide: NULL / 0 is NULL, not a division by zero.
The literal NULL fits any type. name = NULL, id = NULL and NULL + 1 pass the type check and evaluate to unknown or NULL. A real type error elsewhere in the WHERE is still 00403. A NULL is never a key bound either: id = NULL stays in the Filter over a SeqScan, since no key equals it.
IS NULL and IS NOT NULL are parsed at the level of the comparisons of the previous stage, one per level: below + and -, which bind tighter, and above NOT. So c + 1 is null asks about c + 1, and not c is null is NOT (c IS NULL). They are not a suffix stuck to the nearest operand: read that way, c + 1 is null would add a truth value to c.
Storing nothing
Since varlen-rows, every row has carried a null bitmap: one bit per column, all zero so far. Now a NULL column sets its bit and writes nothing, not even a length. Here is (1, NULL, 30) in a table t3 (id int, name varchar(20), age int):
- row_sizebytes 0–3 (4 bytes) · 15: shorter, since name takes no bytes
- column_countbytes 4–5 (2 bytes) · 3
- null_bitmapbytes 6–6 (1 bytes) · 0x02: bit 1 set, column 1 (name) is NULL
- idbytes 7–10 (4 bytes) · 1
- agebytes 11–14 (4 bytes) · 30; name has no bytes at all
The bitmap is why the earlier format reserved it: an empty string is a value with a length of 0, and NULL is no value at all. They have to be told apart in the bytes, and they are.
A column an INSERT leaves out is now NULL. Earlier stages filled it with 0 or an empty string, because there was nothing else to put there; now there is. The first column is the key, and a key that is unknown cannot be found or ordered, so it can never be NULL.
NOT NULL
Some columns must always have a value. create table p (id int, name varchar(20) not null, age int); makes name one of them. Giving it NULL, or leaving it out of an INSERT, is 01503, and so is an UPDATE that sets it to NULL. The check runs after the type checks and before the row is written, so a refused row changes nothing. catalog list shows the constraint in the table's column list: name VARCHAR(20) NOT NULL.
The algorithm in words
Evaluate with three truth values
The evaluator from the previous stage returns a typed value. Now a value can also be NULL, and a truth value can be unknown, which you can treat as a NULL truth value.
- 1.A column whose bitmap bit is set gives NULL; the literal NULL gives NULL.
- 2.An arithmetic operator: if either operand is NULL, the result is NULL. Only otherwise check for overflow and division by zero.
- 3.A comparison: if either operand is NULL, the result is unknown. Otherwise compare as before.
- 4.IS NULL gives true when its operand is NULL and false otherwise; IS NOT NULL the opposite. Neither is ever unknown. In the parser they sit at the level of the comparisons, one per level: below + and -, above NOT.
- 5.NOT swaps true and false and leaves unknown alone.
- 6.AND evaluates its left side. False: the answer is false, and the right side is not evaluated. Otherwise evaluate the right side: false gives false, two trues give true, and anything else is unknown.
- 7.OR is the mirror image: a true left side answers true at once; otherwise a true right side gives true, two falses give false, and anything else is unknown.
- 8.The WHERE keeps the row only when the root is true.
Store and read a NULL
The bitmap has been in every row since the varlen-rows stage, always zero. Now it carries information.
- 1.When encoding a row, for each column in table order: if its value is NULL, set bit (column number mod 8) of bitmap byte (column number / 8), counting bits from the least significant, and write no bytes for it. Otherwise write the column as before.
- 2.The row size counts only the bytes written, so a row with NULLs is shorter.
- 3.When decoding, check each column's bit before reading its bytes: a set bit means NULL and no bytes to skip.
- 4.A column an INSERT leaves out is NULL. Before writing the row, check each column in table order: a NOT NULL column holding NULL, given or left out, or the first column given NULL, is 01503 and nothing is written. Leaving the first column out is still 00406, checked earlier.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, sections 3 and 41.
| Input | Output |
|---|---|
select * from t; with NULLs, table mode | 3 | 30 | NULL | cy |
| the same in CSV mode | 3,30,,cy |
tokenize c is not null | [IDENTIFIER - c], [KEYWORD_IS - is], [KEYWORD_NOT - not], [KEYWORD_NULL - null] |
ast select * from t where c + 1 is not null; | Where: (c + 1) IS NOT NULL |
ast select * from t where not c is null; | Where: NOT (c IS NULL) |
ast insert into t (id, c) values (1, null); | Values: [1, NULL] |
ast create table p (id int, name varchar(20) not null, age int); | Columns: [id int, name varchar(20) NOT NULL, age int] |
catalog list | [CATALOG] p root=2 (id INT, name VARCHAR(20) NOT NULL, age INT) |
serialize insert into t3 (id, name, age) values (1, NULL, 30); | [SERIALIZE] Row size: 15 bytes, [SERIALIZE] Layout: row_size:4@0 column_count:2@4 null_bitmap:1@6 id:4@7 age:4@11, [SERIALIZE] Bytes: 0f 00 00 00 03 00 02 01 00 00 00 1e 00 00 00 |
btree find t3 1 | [BTREE] Row: id=1 name=NULL age=30 |
deserialize of a row with a NULL name | [DESERIALIZE] Field name = NULL |
| NULL for a NOT NULL column, given or left out; NULL given to the first column | [ERROR:01503] <message> |
| an INSERT that leaves the first column out | [ERROR:00406] <message>, as before |
Layout: lists only the columns that are stored, so a NULL column has no entry. deserialize is 00503 for a row whose bitmap marks the first column, or a column past column_count. IS NULL and IS NOT NULL print with capitals, after their operand, which is in parentheses unless it is a column or a literal.
What you should see when done
$droid --db people.dbWelcome to droid!>create table p (id int, name varchar(20) not null, age int);Create table statement executed successfully>insert into p (id, name, age) values (1, 'ada', 36);Insert statement executed successfully (1 rows)>insert into p (id, name) values (2, 'bob');Insert statement executed successfully (1 rows)>insert into p (id, age) values (3, 40);[ERROR:01503] Column name cannot be NULL>select * from p;id | name | age1 | ada | 362 | bob | NULL(2 rows)[EXEC] rows_examined=2>select name from p where age > 30;nameada(1 rows)[EXEC] rows_examined=2>select name from p where not (age > 30);name(0 rows)[EXEC] rows_examined=2>select name from p where age is null;namebob(1 rows)[EXEC] rows_examined=2>.mode csv>select * from p;id,name,age1,ada,362,bob,[EXEC] rows_examined=2>.exitexiting.. good bye!
Bob's age was left out, so it is NULL. He is not older than 30, and he is not not older than 30 either: both queries leave him out. Only is null finds him. The messages are yours to word; the tests check only the codes.
Common bugs in this stage
- NULL as false. Comparisons with NULL return false, so
not (c = 1)keeps the NULL rows. Unknown must survive NOT. - NULL equal to NULL. A comparison that looks at two "missing" markers and calls them equal makes
NULL = NULLtrue. Any comparison with a NULL side is unknown. - AND short-circuits on unknown. Only false decides an AND.
unknown AND falsemust evaluate the right side to reach false. - Reading a NULL column's bytes. The decoder reads a length that is not there and every column after it is garbage. Check the bit first.
- The bit order backwards. Column 0 is the least significant bit of the first bitmap byte, and column 8 starts the second byte.
- Writing a length of 0 for NULL. Then NULL and the empty string are the same bytes, and the row size is wrong by two.
- The default still 0. A column left out must be NULL now; a left-over default makes
is nullfind nothing. - NOT NULL checked after the write. The error is printed but the row is in the tree. Check before.
- CSV printing
NULL. CSV mode prints an empty field; only table mode prints the word.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C and C++: a value struct with an
is_nullflag next to its type works; so does a separateTYPE_NULL. Truth values fit in a small enum of three. Set a bit withbitmap[i / 8] |= 1u << (i % 8). - Rust:
Option<Value>makes NULL impossible to forget, and anenum Truth { True, False, Unknown }keeps three-valued logic out ofbool. - Zig: an optional (
?Value) for a value that may be NULL, and an enum for the three truth values;std.bit_setis there if you want it, though one shift and a mask is all a bitmap byte needs.
Checklist
One line per thing the tests check. The tags name the test cases.
- A comparison with NULL keeps no row, NULL = NULL includedeq-null-no-rows
- != leaves the NULL rows outneq-excludes-null
- IS NULL and IS NOT NULL split the table in twois-null-partitions
- NOT of unknown is unknownnot-unknown
- unknown OR true is trueor-true-rescues
- unknown AND false is false, so NOT of it is trueand-false-dominates
- Arithmetic with NULL gives NULL, even times zero, and never divides by zeronull-arith
- NULL fits any type, and does not hide a type errornull-type-rules
- A column an INSERT leaves out is NULLomitted-column-insert
- NULL is a value for INSERT and UPDATEexplicit-null-values
- NULL in a NOT NULL column, or in the first column, is 01503 and changes nothingnot-null-violation
- A NULL is still NULL in the next sessionnull-survives-reopen
- NULL prints as NULL in table mode and as an empty field in CSVnull-rendering
- A NULL sets its bitmap bit and takes no bytes; deserialize and btree find show itnull-bitmap-set
- null and is are keywords; ast, explain and the catalog print NULL and NOT NULLtokenize-ast-catalog