Back to Database Instructions
v 5.3
What is SQLite3?

SQLite is a self-contained, serverless, zero-configuration, transactional SQL database engine. Unlike MySQL or PostgreSQL, it stores the entire database in a single .db file on disk — no daemon, no setup, just a file you can open and query.

Why SQLite? It's built into Python, PHP, Node.js, Android, iOS, and countless other platforms. It's the most widely deployed database in the world.
Select your OS:
Installing SQLite3 on Windows Windows
1

Download the precompiled binaries

Visit sqlite.org/download.html and download the Precompiled Binaries for Windows bundle — grab sqlite-tools-win-x64-*.zip.

2

Extract the ZIP file

Right-click the ZIP and select Extract All. You'll find three executables: sqlite3.exe, sqldiff.exe, and sqlite3_analyzer.exe.

3

Add to your PATH

Move the folder (e.g. C:\sqlite) and add it to your System PATH so you can call sqlite3 from any terminal. Search for Environment Variables → Edit System Environment Variables → Path → New.

4

Verify the installation

Open a new Command Prompt or PowerShell and run:

sqlite3 --version

You should see something like 3.46.0 2024-05-23...

Alternative: If you use Chocolatey, run choco install sqlite in an elevated terminal for a one-step install.
Installing SQLite3 on macOS macOS
Good news: SQLite3 comes pre-installed on macOS! You may not need to do anything at all.
1

Check if it's already installed

Open Terminal and run:

sqlite3 --version

If you see a version number, you're done! If not, continue below.

2

Install via Homebrew (recommended)

If you have Homebrew installed (the standard macOS package manager), run:

brew install sqlite3
3

Update your shell PATH (if needed)

Homebrew installs to a custom prefix. To use the Homebrew version instead of the system one, add it to your path in ~/.zshrc or ~/.bash_profile:

# For Apple Silicon (M1/M2/M3): export PATH="/opt/homebrew/opt/sqlite/bin:$PATH" # For Intel Mac: export PATH="/usr/local/opt/sqlite/bin:$PATH"
4

Verify

sqlite3 --version
Installing SQLite3 on Linux Linux
1

Debian / Ubuntu / Mint

SQLite3 is available in the default apt repositories:

sudo apt update sudo apt install sqlite3 libsqlite3-dev
2

Fedora / RHEL / CentOS

sudo dnf install sqlite sqlite-devel

On older CentOS/RHEL with yum:

sudo yum install sqlite sqlite-devel
3

Arch Linux

sudo pacman -S sqlite
4

Verify

sqlite3 --version

You should see the installed version number printed to stdout.

Building from source? Download the amalgamation source from sqlite.org, then run ./configure && make && sudo make install.
Beginner
Your first steps — open a database, create a table, and run basic queries
Step 1 — Opening the SQLite Shell

Open a terminal and type sqlite3 followed by a filename. If the file doesn't exist, SQLite creates it automatically. You'll land at the sqlite> prompt.

A database is an organised collection of data that a program can store, search, and change reliably. Most database systems, such as MySQL or PostgreSQL, run as a separate server program that other programs connect to over a network. SQLite is different: it is serverless. The entire database, including every table, row, and index, lives in one ordinary file on disk, and the SQLite library reads and writes that file directly. There is nothing to install as a service, no user accounts to set up, and nothing running in the background. That simplicity is why SQLite is the most widely deployed database in the world: it is built into every smartphone, every major web browser, and countless desktop programs.

The sqlite3 command is SQLite's command-line shell: an interactive program where you type a command, press Enter, and see the result immediately. Giving it a filename opens that database, creating the file if it doesn't exist yet. Run it with no filename and you get an in-memory database, which lives only in RAM and vanishes when you quit. That makes it perfect for experiments you don't want to keep. The sqlite> prompt tells you the shell is waiting for input. If a statement runs over several lines, the prompt changes to ...> until you finish it with a semicolon.

Serverless database
A database that is a single file read and written directly by a library, with no separate server program.
Shell
An interactive text program that runs each command as you type it and prints the result.
In-memory database
A temporary database held in RAM; it disappears when the shell closes.
# Create (or open) a database file sqlite3 mydb.db # Open an in-memory database — gone when you exit sqlite3 # Check the version without entering the shell sqlite3 --version
Terminal Output
$ sqlite3 mydb.db SQLite version 3.46.0 2024-05-23 13:25:27 Enter ".help" for usage hints. sqlite> █ -- opening in-memory database: $ sqlite3 SQLite version 3.46.0 2024-05-23 13:25:27 Enter ".help" for usage hints. Connected to a transient in-memory database. Use ".open FILENAME" to reopen on a persistent database. sqlite> █ -- checking version only: $ sqlite3 --version 3.46.0 2024-05-23 13:25:27 bda12674b4179f3...
Tip: Every SQLite database is just a file. You can copy, move, or email it like any other file.
Step 2 — Essential Dot-Commands

Dot-commands start with a . and are shell meta-commands — they are NOT SQL. They control how the shell behaves and displays output.

Inside the shell you can type two completely different kinds of input, and telling them apart is the first thing to learn. SQL statements, such as SELECT, CREATE TABLE, and INSERT, are the standard database language, understood by SQLite and by every other SQL database. They must end with a semicolon, and they are what you would also send from a Python, PHP, or C program. Dot-commands are instructions to the shell program itself, not to the database. They start with a period, take up exactly one line, need no semicolon, and exist only in this command-line tool.

Dot-commands fall into two groups. Some let you inspect the database: .tables lists the tables, .schema shows the CREATE statement that defined each one, and .databases shows which file is open. Others change how results are displayed: .headers on prints column names above results, and .mode chooses a layout such as a boxed table, fixed columns, or CSV for exporting to a spreadsheet. These display settings reset every time you start the shell. If you want them permanently, put them in a file named .sqliterc in your home folder, and the shell runs it at startup.

SQL statement
A command in the standard database language; it ends with a semicolon and works in any SQL database.
Dot-command
A one-line instruction to the sqlite3 shell itself, starting with a period; it isn't SQL.
Schema
The structure of a database: its tables, columns, types, and constraints.
.help -- list all dot-commands .tables -- list tables in the database .schema -- show CREATE statements for all tables .schema users -- show schema for one specific table .headers on -- show column names above results .mode table -- format results as an ASCII table (most readable) .mode column -- fixed-width column format .mode csv -- comma-separated output .databases -- show attached databases .quit -- exit the shell (also Ctrl+D)
Terminal Output
sqlite> .tables orders products users sqlite> .schema users CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT UNIQUE, age INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); sqlite> .databases main: /home/gordon/mydb.db r/w sqlite> .headers on sqlite> .mode table (no output — settings applied silently)
First thing to do every time you open the shell: run .headers on and .mode table — it makes every query output much easier to read.
Step 3 — Create Your First Table

A table is where your data lives. Each column has a name and a data type. SQLite has five storage types: INTEGER, TEXT, REAL, BLOB, and NULL.

A relational database stores data in tables, grids of rows and columns much like a spreadsheet, but with firmer rules. Each column describes one attribute, such as a name or an age, and each row is one record, such as one user. CREATE TABLE defines that structure, the table's schema, before any data goes in. Every table should have a primary key: a column whose value uniquely identifies each row, so a row can always be found, changed, or referred to from another table. In SQLite, a column declared INTEGER PRIMARY KEY is special: it becomes the row's internal ID (the rowid), and SQLite fills it in automatically when you don't supply a value.

Constraints are rules the database enforces for you, so bad data is rejected at the door. NOT NULL means the column must always have a value, UNIQUE forbids two rows from sharing a value (ideal for email addresses), and DEFAULT supplies a value when an insert leaves the column out. IF NOT EXISTS makes a script safe to run twice. One SQLite quirk is worth knowing early: its column types are flexible. A column's declared type is a preference, called type affinity, rather than a strict rule. SQLite converts values where it sensibly can (the text '42' stored in an INTEGER column becomes the number 42), but it will still accept 'abc' in that column rather than raise an error. Since version 3.37 you can add the word STRICT after a table definition to get rigid, error-raising types instead.

Primary key
A column (or set of columns) whose value uniquely identifies each row.
Constraint
A rule enforced by the database, such as NOT NULL, UNIQUE, or DEFAULT.
Type affinity
SQLite's flexible typing: a column's declared type is a preference, not a strict rule.
-- The simplest possible table CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT, age INTEGER ); -- Safer version — won't error if table already exists CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT UNIQUE, age INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP );
Terminal Output
sqlite> CREATE TABLE users ( ...> id INTEGER PRIMARY KEY, ...> name TEXT, ...> age INTEGER ...> ); (no output = success) sqlite> .tables users sqlite> .schema users CREATE TABLE users ( id INTEGER PRIMARY KEY, name TEXT, age INTEGER ); -- If you try to create it again without IF NOT EXISTS: sqlite> CREATE TABLE users (id INTEGER PRIMARY KEY); Parse error: table users already exists

PRIMARY KEY uniquely identifies each row. AUTOINCREMENT makes SQLite assign the next number automatically. NOT NULL requires a value. UNIQUE prevents duplicates.

Step 4 — Insert Data

INSERT adds new rows to a table. The statement names the table, lists the columns you're filling, and then gives the values in the same order: the first value goes into the first column named, the second into the second, and so on. Any column you leave out receives its DEFAULT value, or NULL if it has none, and an INTEGER PRIMARY KEY is numbered automatically. A single INSERT can add many rows by listing several parenthesised groups after VALUES, which is both shorter and faster than repeating the statement.

Two details trip up newcomers. First, in SQL, text values go in single quotes: 'Alice'. Double quotes are meant for the names of tables and columns. To put an apostrophe inside a text value, double it: 'O''Brien'. Second, when a program builds an INSERT from user input, it must never paste that input directly into the SQL text. Real applications use parameters (placeholders such as ?) and pass the values separately. This prevents SQL injection, where a malicious value rewrites the query. After an insert, the built-in function last_insert_rowid() returns the ID of the row that was just created.

Row / record
One entry in a table, with a value (or NULL) for every column.
NULL
SQL's marker for “no value” or “unknown”; it isn't zero or an empty string.
SQL injection
An attack where untrusted input is pasted into SQL text; prevented by using parameters.
-- Insert one row (specify columns explicitly) INSERT INTO users (name, email, age) VALUES ('Gordon', 'gordon@example.com', 30); -- Insert multiple rows at once INSERT INTO users (name, age) VALUES ('Alice', 25), ('Bob', 34), ('Carol', 29);
Terminal Output
sqlite> INSERT INTO users (name, email, age) ...> VALUES ('Gordon', 'gordon@example.com', 30); (no output = 1 row inserted successfully) sqlite> INSERT INTO users (name, age) ...> VALUES ('Alice', 25), ('Bob', 34), ('Carol', 29); (no output = 3 rows inserted successfully) -- If you break a constraint, you get an error: sqlite> INSERT INTO users (name, email, age) VALUES ('Eve', 'gordon@example.com', 22); Runtime error: UNIQUE constraint failed: users.email
Best practice: Always name your columns explicitly. It makes your SQL resilient to future table changes.
Step 5 — Read Data with SELECT

SELECT is how you ask a database questions, and it is by far the most used SQL statement. Its result is itself a small table, called a result set. A query is built from clauses, each with one job. SELECT lists the columns you want (* means all of them). FROM names the table. WHERE keeps only the rows that satisfy a condition. ORDER BY sorts them, ascending by default or descending with DESC. LIMIT caps how many come back, and adding OFFSET skips some first, which is how page 2 of a list is built.

SQL is declarative: you describe what result you want, not how to find it, and the database works out an efficient way to produce it. It helps to know that SQLite logically processes the clauses in a different order from how you write them: first FROM (which table), then WHERE (which rows), then SELECT (which columns), then ORDER BY, then LIMIT. Also, rows have no guaranteed order unless you ask for one. Without ORDER BY, SQLite may return them in any order it likes, and that order can change as the table grows. Finally, COUNT(*) is the first of the aggregate functions you'll meet at the Intermediate level: instead of listing rows, it reports how many there are.

Query
A request for data, written as a SELECT statement.
Clause
One part of a statement with a single job: FROM, WHERE, ORDER BY, LIMIT…
Result set
The rows and columns a query returns: a temporary table.
-- Get everything SELECT * FROM users; -- Get specific columns only SELECT name, age FROM users; -- Filter rows with WHERE SELECT * FROM users WHERE age > 25; -- Sort and limit SELECT * FROM users ORDER BY age ASC; SELECT * FROM users LIMIT 2; -- Count rows SELECT COUNT(*) FROM users; SELECT COUNT(*) FROM users WHERE age > 25;
Terminal Output
sqlite> SELECT * FROM users; +----+--------+--------------------+-----+---------------------+ | id | name | email | age | created_at | +----+--------+--------------------+-----+---------------------+ | 1 | Gordon | gordon@example.com | 30 | 2024-05-02 14:22:01 | | 2 | Alice | | 25 | 2024-05-02 14:22:01 | | 3 | Bob | | 34 | 2024-05-02 14:22:01 | | 4 | Carol | | 29 | 2024-05-02 14:22:01 | +----+--------+--------------------+-----+---------------------+ sqlite> SELECT name, age FROM users; +--------+-----+ | name | age | +--------+-----+ | Gordon | 30 | | Alice | 25 | | Bob | 34 | | Carol | 29 | +--------+-----+ sqlite> SELECT * FROM users WHERE age > 25; +----+--------+--------------------+-----+---------------------+ | id | name | email | age | created_at | +----+--------+--------------------+-----+---------------------+ | 1 | Gordon | gordon@example.com | 30 | 2024-05-02 14:22:01 | | 3 | Bob | | 34 | 2024-05-02 14:22:01 | | 4 | Carol | | 29 | 2024-05-02 14:22:01 | +----+--------+--------------------+-----+---------------------+ sqlite> SELECT * FROM users ORDER BY age ASC; +----+--------+-------+-----+---------------------+ | id | name | email | age | created_at | +----+--------+-------+-----+---------------------+ | 2 | Alice | | 25 | 2024-05-02 14:22:01 | | 4 | Carol | | 29 | 2024-05-02 14:22:01 | | 1 | Gordon | | 30 | 2024-05-02 14:22:01 | | 3 | Bob | | 34 | 2024-05-02 14:22:01 | +----+--------+-------+-----+---------------------+ sqlite> SELECT COUNT(*) FROM users; +----------+ | COUNT(*) | +----------+ | 4 | +----------+ sqlite> SELECT COUNT(*) FROM users WHERE age > 25; +----------+ | COUNT(*) | +----------+ | 3 | +----------+
Step 6 — Update & Delete Rows

UPDATE changes values in rows that already exist, and DELETE removes rows. Both work on every row that matches their WHERE clause, and that is the single most important fact about them. With a precise WHERE, such as a primary-key match like WHERE id = 1, exactly one row is affected. With a loose one, many rows are. And with no WHERE at all, every row in the table is changed or deleted. The shell won't ask for confirmation, and outside a transaction (covered at the Advanced level) there is no undo. After either statement, SELECT changes(); reports how many rows were affected.

That is why professionals follow a simple habit: write the WHERE clause first, run it as a SELECT to see exactly which rows it matches, and only then turn it into an UPDATE or DELETE. Three statements remove things at different scales. DELETE FROM users WHERE … removes chosen rows. DELETE FROM users empties the table but keeps its structure, so you can insert into it again. DROP TABLE users removes the table itself: structure, data, indexes and all.

UPDATE / DELETE
Change / remove every row matching the WHERE clause.
Rows affected
How many rows a statement changed; shown by SELECT changes().
DROP
Removes a whole database object, such as a table, rather than its rows.

UPDATE — change existing data

-- Change one row by id UPDATE users SET age = 31 WHERE id = 1; -- Change multiple columns UPDATE users SET age = 31, email = 'new@x.com' WHERE id = 1;

DELETE — remove rows

-- Delete one specific row DELETE FROM users WHERE id = 2; -- Delete all rows (keeps table) DELETE FROM users; -- Remove the table entirely DROP TABLE IF EXISTS users;
Terminal Output
sqlite> UPDATE users SET age = 31 WHERE id = 1; (no output = 1 row updated) -- Verify the change: sqlite> SELECT id, name, age FROM users WHERE id = 1; +----+--------+-----+ | id | name | age | +----+--------+-----+ | 1 | Gordon | 31 | +----+--------+-----+ sqlite> DELETE FROM users WHERE id = 2; (no output = 1 row deleted) -- Verify Alice is gone: sqlite> SELECT id, name FROM users; +----+--------+ | id | name | +----+--------+ | 1 | Gordon | | 3 | Bob | | 4 | Carol | +----+--------+ -- Dangerous: UPDATE without WHERE — hits ALL rows! sqlite> UPDATE users SET age = 99; (no output — but now EVERY user has age = 99!) sqlite> DROP TABLE IF EXISTS users; (no output = table dropped successfully) sqlite> .tables (empty — no tables remain)
Rule #1: Always write your WHERE clause first and verify it with a SELECT before running UPDATE or DELETE. A missing WHERE updates or deletes every row.
Intermediate
Filtering, aggregation, joins, constraints, and modifying existing tables
Advanced WHERE Filtering

A WHERE condition is a boolean expression: for each row it works out to true or false, and only rows where it's true are kept. You combine simple conditions with AND (both must hold) and OR (either may hold). AND is evaluated before OR, just as multiplication comes before addition, so a OR b AND c means a OR (b AND c). Always add parentheses when you mix them; it's the most common source of “why does this return the wrong rows?” SQL provides shorthands for common patterns. BETWEEN x AND y is an inclusive range, and IN (…) matches any value in a list, which is tidier than a chain of ORs.

LIKE matches text against a pattern: % stands for any run of characters (including none), and _ for exactly one character. In SQLite, LIKE ignores upper and lower case for English letters, so 'Bob' LIKE 'b%' is true. Use GLOB when you need case-sensitive matching. Finally, NULL needs special care, because it means “unknown”. Any comparison with an unknown value is itself unknown, so email = NULL is never true, not even for rows where the email really is missing. SQL calls this three-valued logic (true, false, unknown). To test for missing values, always use IS NULL or IS NOT NULL.

Boolean expression
A condition that evaluates to true or false for each row.
Wildcard
In LIKE patterns, % matches any run of characters and _ matches exactly one.
Three-valued logic
SQL's true / false / unknown; any comparison with NULL is unknown.
-- AND / OR logic SELECT * FROM users WHERE age > 25 AND age < 40; SELECT * FROM users WHERE name = 'Alice' OR name = 'Bob'; -- BETWEEN (inclusive on both ends) SELECT * FROM users WHERE age BETWEEN 25 AND 40; -- IN — match any value in a list SELECT * FROM users WHERE name IN ('Alice', 'Bob', 'Carol'); -- NOT IN — exclude a list SELECT * FROM users WHERE name NOT IN ('Alice', 'Bob'); -- LIKE — pattern matching (% = any chars, _ = one char) SELECT * FROM users WHERE name LIKE 'G%'; -- starts with G SELECT * FROM users WHERE email LIKE '%@gmail.com'; SELECT * FROM users WHERE name LIKE '_ob'; -- Bob, Rob, etc. -- IS NULL / IS NOT NULL SELECT * FROM users WHERE email IS NULL; SELECT * FROM users WHERE email IS NOT NULL;
Terminal Output
sqlite> SELECT * FROM users WHERE age > 25 AND age < 40; +----+--------+--------------------+-----+ | id | name | email | age | +----+--------+--------------------+-----+ | 1 | Gordon | gordon@example.com | 30 | | 3 | Bob | | 34 | | 4 | Carol | carol@example.com | 29 | +----+--------+--------------------+-----+ sqlite> SELECT * FROM users WHERE name IN ('Alice', 'Bob', 'Carol'); +----+-------+-----------------+-----+ | id | name | email | age | +----+-------+-----------------+-----+ | 2 | Alice | alice@gmail.com | 25 | | 3 | Bob | | 34 | | 4 | Carol | carol@example.com | 29 | +----+-------+-----------------+-----+ sqlite> SELECT * FROM users WHERE name LIKE 'G%'; +----+--------+--------------------+-----+ | id | name | email | age | +----+--------+--------------------+-----+ | 1 | Gordon | gordon@example.com | 30 | +----+--------+--------------------+-----+ sqlite> SELECT * FROM users WHERE email IS NULL; +----+------+-------+-----+ | id | name | email | age | +----+------+-------+-----+ | 3 | Bob | | 34 | +----+------+-------+-----+ sqlite> SELECT * FROM users WHERE email IS NOT NULL; +----+--------+--------------------+-----+ | id | name | email | age | +----+--------+--------------------+-----+ | 1 | Gordon | gordon@example.com | 30 | | 2 | Alice | alice@gmail.com | 25 | | 4 | Carol | carol@example.com | 29 | +----+--------+--------------------+-----+
Aggregate Functions & GROUP BY

Aggregate functions summarize multiple rows into a single result. Combine them with GROUP BY to compute per-group statistics.

Ordinary functions work on one value at a time. Aggregate functions work on a whole column of rows and collapse them into a single summary value: COUNT counts, SUM adds, AVG averages, and MIN/MAX find the extremes. Aggregates skip NULLs, which explains a subtle difference: COUNT(*) counts rows, while COUNT(email) counts only the rows where the email is not NULL. AS gives a result column a readable name, called an alias.

GROUP BY turns a single summary into a summary per category. SQLite sorts the rows into buckets that share the same value (for example, one bucket per order status), then runs the aggregate once for each bucket, producing one output row per group. To filter those groups, use HAVING. The difference from WHERE is timing: WHERE removes individual rows before they are grouped, and HAVING removes whole groups after the aggregates are calculated. That's why WHERE COUNT(*) > 5 is an error but HAVING COUNT(*) > 5 works. One rule keeps grouped queries correct: every column in the SELECT list should either appear in GROUP BY or be inside an aggregate.

Aggregate function
Summarises many rows into one value: COUNT, SUM, AVG, MIN, MAX.
GROUP BY
Splits rows into groups sharing a value, producing one summary row per group.
HAVING vs WHERE
Filters groups after aggregation / filters rows before grouping.
-- Built-in aggregate functions SELECT COUNT(*) FROM users; -- total rows SELECT COUNT(email) FROM users; -- rows where email is not NULL SELECT SUM(age) FROM users; -- sum of all ages SELECT AVG(age) FROM users; -- average age SELECT MIN(age), MAX(age) FROM users; -- min and max -- GROUP BY — aggregate per category -- Suppose we have an orders table with a status column SELECT status, COUNT(*) AS total FROM orders GROUP BY status; -- HAVING — filter groups (like WHERE but for aggregates) SELECT status, COUNT(*) AS total FROM orders GROUP BY status HAVING total > 5; -- Column aliases with AS SELECT name, age AS years_old FROM users; SELECT ROUND(AVG(age), 1) AS avg_age FROM users;
Terminal Output
sqlite> SELECT COUNT(*) FROM users; +----------+ | COUNT(*) | +----------+ | 4 | +----------+ sqlite> SELECT COUNT(email) FROM users; +--------------+ | COUNT(email) | +--------------+ | 3 | +--------------+ sqlite> SELECT AVG(age) FROM users; +----------+ | AVG(age) | +----------+ | 29.5 | +----------+ sqlite> SELECT MIN(age), MAX(age) FROM users; +----------+----------+ | MIN(age) | MAX(age) | +----------+----------+ | 25 | 34 | +----------+----------+ -- With an orders table (status, total columns): sqlite> SELECT status, COUNT(*) AS total FROM orders GROUP BY status; +-----------+-------+ | status | total | +-----------+-------+ | delivered | 12 | | pending | 3 | | shipped | 7 | +-----------+-------+ sqlite> SELECT ROUND(AVG(age), 1) AS avg_age FROM users; +---------+ | avg_age | +---------+ | 29.5 | +---------+
Joining Tables

JOINs combine rows from two or more tables based on a related column. Set up two tables first:

Good database design avoids storing the same fact twice. Instead of writing a department's name on every employee's row, you store departments in their own table and give each employee a dept_id that points to one department. This is called normalisation, and it means a department's name only ever needs changing in one place. The pointing column is a foreign key: its values are the primary keys of rows in another table. The REFERENCES clause declares that relationship, and in SQLite it is only enforced once PRAGMA foreign_keys = ON has been run (see the PRAGMA step).

A JOIN reassembles the related rows at query time. The ON condition says how the rows line up: e.dept_id = d.id. Short table aliases such as e and d keep the query readable and resolve columns that share a name. An INNER JOIN returns only the pairs that match in both tables, so an employee with no department disappears. A LEFT JOIN keeps every row from the left table and fills the right side's columns with NULL where nothing matches, which is exactly how you find “employees without a department”. A self-join joins a table to itself under two aliases, to compare rows within it. Since version 3.39.0 (2022), SQLite also supports RIGHT JOIN and FULL OUTER JOIN.

Foreign key
A column holding the primary key of a row in another table, linking the two.
Normalisation
Designing tables so each fact is stored once, with relationships instead of copies.
INNER vs LEFT JOIN
Only matching pairs / every left-table row, with NULLs where nothing matches.
-- Setup: two related tables CREATE TABLE departments ( id INTEGER PRIMARY KEY, name TEXT ); CREATE TABLE employees ( id INTEGER PRIMARY KEY, name TEXT, dept_id INTEGER REFERENCES departments(id) ); -- INNER JOIN — only rows that match in BOTH tables SELECT e.name, d.name AS department FROM employees e INNER JOIN departments d ON e.dept_id = d.id; -- LEFT JOIN — all employees, even those with no department SELECT e.name, d.name AS department FROM employees e LEFT JOIN departments d ON e.dept_id = d.id; -- Self-join — compare rows within the same table SELECT a.name, b.name AS colleague FROM employees a JOIN employees b ON a.dept_id = b.dept_id WHERE a.id != b.id;
Terminal Output
-- After inserting sample rows into departments and employees: sqlite> SELECT e.name, d.name AS department FROM employees e INNER JOIN departments d ON e.dept_id = d.id; +-------+-------------+ | name | department | +-------+-------------+ | Alice | Engineering | | Bob | Marketing | | Carol | Engineering | +-------+-------------+ -- LEFT JOIN includes Dave, who has no department (NULL): sqlite> SELECT e.name, d.name AS department FROM employees e LEFT JOIN departments d ON e.dept_id = d.id; +-------+-------------+ | name | department | +-------+-------------+ | Alice | Engineering | | Bob | Marketing | | Carol | Engineering | | Dave | | +-------+-------------+ -- Self-join: list each employee with their colleagues in the same dept: sqlite> SELECT a.name, b.name AS colleague FROM employees a JOIN employees b ON a.dept_id = b.dept_id WHERE a.id != b.id; +-------+-----------+ | name | colleague | +-------+-----------+ | Alice | Carol | | Carol | Alice | +-------+-----------+
INNER JOIN = only matched rows. LEFT JOIN = all left-table rows + matched right-table rows (NULLs where no match). SQLite 3.39.0 (2022) and later also support RIGHT JOIN and FULL OUTER JOIN; older versions don't.
Altering Tables

Applications change over time, and their tables have to change with them. Altering a live table's structure without losing its data is called a schema migration. ALTER TABLE makes the most common changes in place: ADD COLUMN appends a new column (existing rows get its default value, or NULL), RENAME TO renames the table, and RENAME COLUMN renames a column. Since version 3.35.0 (2021), DROP COLUMN removes one, as long as nothing such as an index or a foreign key depends on it.

SQLite's ALTER TABLE is still deliberately smaller than those of server databases. It can't change a column's type or add a constraint to an existing column. For those changes, the standard technique is to rebuild the table: create a new table with the structure you want, copy the rows across with INSERT INTO … SELECT or CREATE TABLE … AS SELECT, drop the old table, and rename the new one into its place. Do it inside a transaction, so a failure halfway through can't leave you with half a table. In real projects, migrations are saved as numbered script files, so every copy of the database can be upgraded the same way, in the same order.

Schema migration
A controlled change to a database's structure that keeps its existing data.
ALTER TABLE
Changes a table in place: add, rename, or drop a column, or rename the table.
Table rebuild
Create a new table, copy the rows over, drop the old one, and rename the new one.
-- Add a new column ALTER TABLE users ADD COLUMN phone TEXT; -- Rename the table ALTER TABLE users RENAME TO customers; -- Rename a column (SQLite 3.25.0+) ALTER TABLE users RENAME COLUMN age TO birth_year; -- Drop a column (SQLite 3.35.0+) ALTER TABLE users DROP COLUMN phone; -- Older SQLite, or to change a column's type: -- recreate the table without the column CREATE TABLE users_new AS SELECT id, name, email FROM users; -- omit 'age' DROP TABLE users; ALTER TABLE users_new RENAME TO users;
Terminal Output
sqlite> ALTER TABLE users ADD COLUMN phone TEXT; -- no output; column silently added sqlite> .schema users CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT, email TEXT, age INTEGER, phone TEXT); sqlite> ALTER TABLE users RENAME TO customers; sqlite> ALTER TABLE customers RENAME COLUMN age TO birth_year; -- both return no output on success -- Drop-column workaround (recreate without 'age'): sqlite> CREATE TABLE users_new AS SELECT id, name, email FROM users; sqlite> DROP TABLE users; sqlite> ALTER TABLE users_new RENAME TO users; sqlite> .schema users CREATE TABLE users(id INTEGER, name TEXT, email TEXT);
SQLite limitation: ALTER TABLE is much more restricted than MySQL or PostgreSQL. You can add, rename, and (since 3.35.0) drop columns, but you cannot change a column's type or add constraints to existing columns directly — rebuild the table for those.
Useful Built-in Functions

SQLite includes a library of scalar functions: functions that take one row's values and return one value, unlike aggregates, which summarise many rows. They can be used anywhere an expression is allowed: in the SELECT list to reshape output, in WHERE to filter, or in ORDER BY to sort. The string functions clean and inspect text (TRIM, UPPER, SUBSTR, REPLACE, INSTR), and the || operator joins strings together, which is SQL's version of concatenation. Note that string positions in SQL start at 1, not 0.

SQLite has no separate date type. Dates are normally stored as ISO-8601 text such as '2025-05-02 14:30:00'. That format sorts correctly as plain text, and the date functions (DATE, DATETIME, STRFTIME) read and produce it. Remember too that NULL is contagious: almost any calculation involving NULL produces NULL, so 'Mr ' || NULL is NULL, not 'Mr '. COALESCE(a, b, …) returns its first non-NULL argument, the standard way to substitute a default. IFNULL is the two-argument version.

Scalar function
Takes values from one row and returns one value, e.g. UPPER or ROUND.
Concatenation
Joining strings; SQL uses the || operator.
COALESCE
Returns its first non-NULL argument; the usual way to supply a default for NULL.

Text functions

-- String operations LENGTH('hello') -- 5 UPPER('hello') -- HELLO LOWER('HELLO') -- hello TRIM(' hi ') -- hi SUBSTR('hello',2,3) -- ell REPLACE(name,'o','0') -- swap chars INSTR(name, 'or') -- find position -- Combine columns SELECT first || ' ' || last AS full_name FROM people;

Numeric & date functions

-- Math ABS(-5) -- 5 ROUND(3.14159, 2) -- 3.14 MAX(3, 7, 2) -- 7 MIN(3, 7, 2) -- 2 RANDOM() -- random integer -- Dates DATE('now') -- 2025-05-02 DATETIME('now') -- 2025-05-02 14:30:00 STRFTIME('%Y', 'now') -- 2025 -- Null handling COALESCE(NULL, 'default') -- default IFNULL(col, 0) -- 0 if null
Terminal Output
sqlite> SELECT LENGTH('hello'), UPPER('hello'), TRIM(' hi '); +----------------+----------------+-------------------+ | LENGTH('hello')| UPPER('hello') | TRIM(' hi ') | +----------------+----------------+-------------------+ | 5 | HELLO | hi | +----------------+----------------+-------------------+ sqlite> SELECT SUBSTR('hello', 2, 3), REPLACE('Gordon', 'o', '0'); +-----------------------+-----------------------------+ | SUBSTR('hello', 2, 3) | REPLACE('Gordon', 'o', '0') | +-----------------------+-----------------------------+ | ell | G0rd0n | +-----------------------+-----------------------------+ sqlite> SELECT ABS(-5), ROUND(3.14159, 2), DATE('now'), sqlite_version(); +---------+-------------------+------------+------------------+ | ABS(-5) | ROUND(3.14159, 2) | DATE('now')| sqlite_version() | +---------+-------------------+------------+------------------+ | 5 | 3.14 | 2026-05-02 | 3.45.1 | +---------+-------------------+------------+------------------+ sqlite> SELECT COALESCE(NULL, 'default'), IFNULL(NULL, 0); +---------------------------+-----------------+ | COALESCE(NULL, 'default') | IFNULL(NULL, 0) | +---------------------------+-----------------+ | default | 0 | +---------------------------+-----------------+
Advanced
Indexes, views, subqueries, transactions, CTEs, triggers, and performance
Indexes — Speed Up Queries

An index lets SQLite find rows without scanning every row. Create them on columns you frequently filter or sort on.

Without help, answering WHERE email = 'g@x.com' means reading every row in the table and checking it. That is a full table scan, and it grows slower as the table grows. An index is a separate, sorted copy of one or more columns, each entry pointing back to its row. SQLite stores indexes as B-trees, balanced search trees that find any value in a handful of steps even among millions of rows, the way a book's index takes you straight to a page instead of making you read every chapter. An index also speeds up ORDER BY on its columns, and a UNIQUE index doubles as a constraint.

Indexes aren't free, which is why databases don't index everything automatically. Each one takes disk space, and every INSERT, UPDATE, and DELETE must also update every index on the table, which slows writes. So index the columns you actually search, join, or sort on, especially ones with many distinct values. A composite index on (user_id, created_at) follows the leftmost-prefix rule: it helps queries filtering on user_id, or on user_id and created_at together, but not on created_at alone, just as a phone book sorted by surname then first name can't find everyone called “Ann”. EXPLAIN QUERY PLAN shows what SQLite will do: SCAN means it reads every row, while SEARCH … USING INDEX means your index is working.

Index
A sorted lookup structure (a B-tree) that finds rows without scanning the whole table.
Full table scan
Reading every row to answer a query; what an index avoids.
Query plan
The database's chosen strategy for running a query, shown by EXPLAIN QUERY PLAN.
-- Create a basic index CREATE INDEX idx_users_name ON users (name); -- Unique index (also enforces constraint) CREATE UNIQUE INDEX idx_users_email ON users (email); -- Composite index (covers multi-column queries) CREATE INDEX idx_orders_user_date ON orders (user_id, created_at); -- View all indexes on a table PRAGMA index_list(users); -- Drop an index DROP INDEX IF EXISTS idx_users_name; -- EXPLAIN QUERY PLAN — see if your index is being used EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = 'g@x.com';
Terminal Output
sqlite> CREATE INDEX idx_users_name ON users (name); sqlite> CREATE UNIQUE INDEX idx_users_email ON users (email); -- no output on success sqlite> PRAGMA index_list(users); +-----+------------------+--------+--------+---------+ | seq | name | unique | origin | partial | +-----+------------------+--------+--------+---------+ | 0 | idx_users_email | 1 | c | 0 | | 1 | idx_users_name | 0 | c | 0 | +-----+------------------+--------+--------+---------+ sqlite> EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = 'g@x.com'; +----+----+----+--------------------------------------------------------------+ | id | parent | notused | detail | +----+----+----+--------------------------------------------------------------+ | 2 | 0 | 0 | SEARCH users USING INDEX idx_users_email (email=?) | +----+----+----+--------------------------------------------------------------+ -- Without the index, you'd see SCAN instead of SEARCH: -- detail: SCAN users
Trade-off: Indexes speed up reads but slow down writes (INSERT/UPDATE/DELETE) because the index must be updated too. Only index columns you actually query on.
Views — Saved Queries

A view is a named query stored in the database. It looks and acts like a table but doesn't store data itself — it re-runs the query each time you access it.

A view is a SELECT statement stored in the database under a name. Once created, you query it exactly like a table, but it holds no data of its own: every time you read from it, SQLite runs the underlying query against the current data, so a view is never out of date. That's why views are sometimes called virtual tables. Their definitions live alongside tables in sqlite_master, SQLite's built-in catalogue of everything in the database.

Views are a tool for abstraction. They let you write a complicated query once, with its joins, filters, and calculated columns, and give it a meaningful name such as active_users. Everyone who uses the view gets the same definition of “active”, and the rest of your SQL gets simpler. If the underlying tables are reorganised later, you can often update just the view and leave every query that uses it untouched. In SQLite, views are read-only: you can't INSERT or UPDATE through them directly, unless you define INSTEAD OF triggers that say what such a write should do.

View
A named, stored SELECT that behaves like a read-only table.
Abstraction
Hiding a complex query behind a simple, well-named interface.
sqlite_master
SQLite's internal table listing every table, index, view, and trigger.
-- Create a view CREATE VIEW active_users AS SELECT id, name, email FROM users WHERE status = 'active'; -- Use a view like a table SELECT * FROM active_users; SELECT * FROM active_users WHERE name LIKE 'A%'; -- List all views SELECT name FROM sqlite_master WHERE type = 'view'; -- Drop a view DROP VIEW IF EXISTS active_users;
Terminal Output
sqlite> CREATE VIEW active_users AS SELECT id, name, email FROM users WHERE status = 'active'; -- no output on success sqlite> SELECT * FROM active_users; +----+--------+--------------------+ | id | name | email | +----+--------+--------------------+ | 1 | Gordon | gordon@example.com | | 2 | Alice | alice@gmail.com | +----+--------+--------------------+ sqlite> SELECT * FROM active_users WHERE name LIKE 'A%'; +----+-------+-----------------+ | id | name | email | +----+-------+-----------------+ | 2 | Alice | alice@gmail.com | +----+-------+-----------------+ sqlite> SELECT name FROM sqlite_master WHERE type = 'view'; +--------------+ | name | +--------------+ | active_users | +--------------+
Subqueries

A subquery is a SELECT nested inside another SQL statement. It can appear in WHERE, FROM, or SELECT clauses.

A subquery is a complete SELECT placed inside another statement, in parentheses. Its result is used by the outer query, so a question that needs two steps can be answered in one statement. Subqueries come in shapes that match where they appear. A scalar subquery returns a single value and can go anywhere one value is expected, such as comparing each user's age to (SELECT AVG(age) FROM users). A subquery that returns a column of values feeds IN. A subquery in the FROM clause acts as a temporary table, called a derived table, that the outer query can filter and join like any other.

A correlated subquery refers to columns of the outer query, as in WHERE o.user_id = u.id inside an EXISTS. Conceptually it is re-evaluated for each row of the outer query, asking “does this user have any orders?”. EXISTS is true as soon as one matching row is found, which is why the inner query just selects 1: the value itself is never looked at. Many subqueries can also be written as joins, and SQLite often optimises both forms into the same plan. Choose whichever states the question most clearly, and when nesting gets deep, move to CTEs (two steps on).

Subquery
A SELECT nested inside another SQL statement.
Scalar subquery
A subquery that returns exactly one value.
Correlated subquery
A subquery that refers to the outer query's current row.
-- WHERE subquery — find users older than average SELECT name, age FROM users WHERE age > (SELECT AVG(age) FROM users); -- EXISTS subquery — find users who have placed orders SELECT name FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id ); -- IN subquery SELECT * FROM users WHERE id IN ( SELECT user_id FROM orders WHERE total > 100 ); -- FROM subquery (derived table) SELECT dept, avg_age FROM ( SELECT dept_id AS dept, AVG(age) AS avg_age FROM employees GROUP BY dept_id ) WHERE avg_age > 30;
Terminal Output
-- AVG(age) of users = 29.5; rows with age > 29.5: sqlite> SELECT name, age FROM users WHERE age > (SELECT AVG(age) FROM users); +--------+-----+ | name | age | +--------+-----+ | Gordon | 30 | | Bob | 34 | +--------+-----+ -- EXISTS subquery — users who have at least one order: sqlite> SELECT name FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id); +--------+ | name | +--------+ | Gordon | | Alice | +--------+ -- FROM subquery — departments with avg employee age > 30: sqlite> SELECT dept, avg_age FROM (SELECT dept_id AS dept, AVG(age) AS avg_age FROM employees GROUP BY dept_id) WHERE avg_age > 30; +------+---------+ | dept | avg_age | +------+---------+ | 2 | 32.5 | +------+---------+
Transactions

A transaction groups multiple statements into a single all-or-nothing operation. If anything fails, you roll back and nothing is saved.

A transaction groups several statements into one indivisible unit of work. Placing an order might need to insert the order and reduce the stock count. If the program crashed between those two steps, the data would be inconsistent: an order with no matching stock change. Inside a transaction, either every statement takes effect (COMMIT) or none of them do (ROLLBACK). Databases promise four properties, known as ACID. Transactions are Atomic (all or nothing) and leave the data Consistent (every rule still holds). They are Isolated (others never see a half-finished transaction) and Durable (once committed, changes survive a crash or power cut).

By default SQLite runs in autocommit mode: every statement is its own tiny transaction, committed the moment it finishes. BEGIN switches that off until the next COMMIT or ROLLBACK. This also explains the dramatic speed-up for bulk inserts: to guarantee durability, each commit waits for the data to be physically written to disk, so a thousand autocommitted inserts means a thousand waits, while one transaction means one. SAVEPOINT adds named checkpoints inside a transaction, so ROLLBACK TO can undo part of the work without abandoning all of it.

Transaction
A group of statements that succeed or fail together.
ACID
Atomicity, Consistency, Isolation, Durability: the guarantees transactions provide.
Autocommit
SQLite's default: each statement is committed on its own immediately.
-- Wrap multiple operations in a transaction BEGIN TRANSACTION; INSERT INTO orders (user_id, total) VALUES (1, 99.99); UPDATE inventory SET stock = stock - 1 WHERE product_id = 42; -- If everything is OK, save it COMMIT; -- Or undo everything if something went wrong ROLLBACK; -- SAVEPOINT — partial rollback points SAVEPOINT before_update; UPDATE users SET age = 99 WHERE id = 1; ROLLBACK TO before_update; -- undoes only since savepoint RELEASE before_update;
Terminal Output
sqlite> BEGIN TRANSACTION; sqlite> INSERT INTO orders (user_id, total) VALUES (1, 99.99); sqlite> UPDATE inventory SET stock = stock - 1 WHERE product_id = 42; sqlite> COMMIT; -- all changes saved atomically; no output on success -- ROLLBACK example — undo if something goes wrong: sqlite> BEGIN TRANSACTION; sqlite> DELETE FROM orders WHERE id = 5; sqlite> ROLLBACK; -- DELETE is undone; row 5 still exists -- SAVEPOINT — partial rollback: sqlite> SAVEPOINT before_update; sqlite> UPDATE users SET age = 99 WHERE id = 1; sqlite> SELECT age FROM users WHERE id = 1; +-----+ | age | +-----+ | 99 | +-----+ sqlite> ROLLBACK TO before_update; sqlite> SELECT age FROM users WHERE id = 1; +-----+ | age | +-----+ | 30 | +-----+
Why use transactions? Performance — wrapping 1000 INSERTs in one transaction can be 10–100x faster than auto-committing each one. Data integrity — either all changes happen, or none do.
CTEs — Common Table Expressions

A CTE (WITH clause) creates a temporary named result set you can reference in the same query. They make complex queries readable and can be recursive.

A common table expression (CTE) uses the WITH keyword to give a subquery a name at the start of a statement. The main query then refers to it like a table. The result is the same as nesting a subquery, but the query reads top to bottom as a series of named steps: first work out high spenders, then join them to users. You can define several CTEs, separated by commas, and each can build on the ones before it. A CTE exists only for the single statement it belongs to; unlike a view, nothing is stored.

WITH RECURSIVE lets a CTE refer to itself, which solves problems plain SQL can't, such as walking an organisation chart or a folder tree of unknown depth. A recursive CTE has two parts joined by UNION ALL. The anchor selects the starting rows (the boss, who has no manager), and the recursive member finds the next level by joining the table to the rows found so far. SQLite repeats the recursive member until it produces no new rows. That also means you must guarantee it eventually stops: a loop in the data, or a missing stopping condition, runs forever. The chained example also uses RANK() OVER (…), a window function, which is covered in the last step.

CTE
A named temporary result set, defined with WITH, that lasts for one statement.
Recursive CTE
A CTE that references itself, repeating until no new rows appear.
Anchor / recursive member
The starting rows / the step that finds the next level from the previous one.
-- Basic CTE — name a subquery for readability WITH high_spenders AS ( SELECT user_id, SUM(total) AS lifetime FROM orders GROUP BY user_id HAVING lifetime > 500 ) SELECT u.name, hs.lifetime FROM users u JOIN high_spenders hs ON u.id = hs.user_id ORDER BY hs.lifetime DESC; -- Chained CTEs (reference each other) WITH totals AS ( SELECT user_id, SUM(total) AS spend FROM orders GROUP BY user_id ), ranked AS ( SELECT *, RANK() OVER (ORDER BY spend DESC) AS rnk FROM totals ) SELECT * FROM ranked WHERE rnk <= 10; -- Recursive CTE — walk a tree/hierarchy WITH RECURSIVE org_tree(id, name, depth) AS ( SELECT id, name, 0 FROM employees WHERE manager_id IS NULL UNION ALL SELECT e.id, e.name, t.depth + 1 FROM employees e JOIN org_tree t ON e.manager_id = t.id ) SELECT * FROM org_tree ORDER BY depth;
Terminal Output
-- high_spenders CTE — users with lifetime spend > $500: sqlite> WITH high_spenders AS ( ...> SELECT user_id, SUM(total) AS lifetime FROM orders GROUP BY user_id HAVING lifetime > 500 ...> ) ...> SELECT u.name, hs.lifetime FROM users u JOIN high_spenders hs ON u.id = hs.user_id ORDER BY hs.lifetime DESC; +--------+----------+ | name | lifetime | +--------+----------+ | Gordon | 823.50 | | Alice | 612.00 | +--------+----------+ -- Chained CTE with RANK(): sqlite> WITH totals AS (SELECT user_id, SUM(total) AS spend FROM orders GROUP BY user_id), ...> ranked AS (SELECT *, RANK() OVER (ORDER BY spend DESC) AS rnk FROM totals) ...> SELECT * FROM ranked WHERE rnk <= 3; +---------+--------+-----+ | user_id | spend | rnk | +---------+--------+-----+ | 1 | 823.50 | 1 | | 2 | 612.00 | 2 | | 3 | 210.00 | 3 | +---------+--------+-----+ -- Recursive CTE — org hierarchy: sqlite> WITH RECURSIVE org_tree(id, name, depth) AS ( ...> SELECT id, name, 0 FROM employees WHERE manager_id IS NULL ...> UNION ALL ...> SELECT e.id, e.name, t.depth + 1 FROM employees e JOIN org_tree t ON e.manager_id = t.id ...> ) SELECT * FROM org_tree ORDER BY depth; +----+-------+-------+ | id | name | depth | +----+-------+-------+ | 1 | CEO | 0 | | 2 | Alice | 1 | | 3 | Bob | 1 | | 4 | Carol | 2 | +----+-------+-------+
Triggers

A trigger is SQL that runs automatically when a specific event (INSERT, UPDATE, DELETE) happens on a table.

A trigger is a block of SQL stored in the database that runs automatically whenever a particular event (an INSERT, UPDATE, or DELETE) happens on a particular table. Nobody has to remember to call it. That makes triggers ideal for rules that must always hold, no matter which program or person changes the data: stamping an updated_at time, writing an audit log, or keeping a summary table in step. BEFORE triggers run before the change is applied, AFTER triggers run once it's done, and INSTEAD OF triggers (on views) replace the change entirely.

SQLite triggers are row-level: FOR EACH ROW means the body runs once for every row the statement affects. Inside the body, NEW.column is the row's value after the change (for INSERT and UPDATE), and OLD.column is its value before (for UPDATE and DELETE). An optional WHEN condition restricts when the trigger fires. You might expect set_updated_at to fire itself forever, since it updates the same table. It doesn't, because SQLite leaves recursive_triggers off by default. Use triggers thoughtfully: they are invisible in the statements that set them off, so behaviour can surprise someone who doesn't know they exist.

Trigger
SQL that runs automatically on an INSERT, UPDATE, or DELETE.
NEW / OLD
The row's values after / before the change, available inside a trigger.
Row-level trigger
Runs once per affected row; SQLite's only kind (FOR EACH ROW).
-- Auto-set updated_at timestamp on row change CREATE TRIGGER set_updated_at AFTER UPDATE ON users FOR EACH ROW BEGIN UPDATE users SET updated_at = DATETIME('now') WHERE id = NEW.id; END; -- Audit log — record who deleted what CREATE TRIGGER log_deletes BEFORE DELETE ON orders FOR EACH ROW BEGIN INSERT INTO audit_log (action, record_id, happened_at) VALUES ('DELETE', OLD.id, DATETIME('now')); END; -- List all triggers SELECT name FROM sqlite_master WHERE type = 'trigger'; -- Drop a trigger DROP TRIGGER IF EXISTS set_updated_at;
Terminal Output
sqlite> CREATE TRIGGER set_updated_at AFTER UPDATE ON users FOR EACH ROW BEGIN UPDATE users SET updated_at = DATETIME('now') WHERE id = NEW.id; END; -- no output on success sqlite> SELECT name FROM sqlite_master WHERE type = 'trigger'; +----------------+ | name | +----------------+ | set_updated_at | | log_deletes | +----------------+ -- After updating a user, the trigger fires automatically: sqlite> UPDATE users SET age = 31 WHERE id = 1; sqlite> SELECT id, name, updated_at FROM users WHERE id = 1; +----+--------+---------------------+ | id | name | updated_at | +----+--------+---------------------+ | 1 | Gordon | 2026-05-02 14:22:11 | +----+--------+---------------------+ -- DROP TRIGGER removes it cleanly: sqlite> DROP TRIGGER IF EXISTS set_updated_at; sqlite> SELECT name FROM sqlite_master WHERE type = 'trigger'; +-------------+ | name | +-------------+ | log_deletes | +-------------+
Inside a trigger, NEW.column refers to the incoming value and OLD.column refers to the previous value (for UPDATE and DELETE).
PRAGMA — Database Settings & Info

PRAGMAs are special commands that control SQLite's internal settings and expose metadata.

PRAGMA statements are SQLite's own control panel. They aren't part of standard SQL, and other databases configure these things differently. Some PRAGMAs report information, such as table_info (a table's columns and types), index_list, and integrity_check (verify the file isn't corrupted). Others change how SQLite behaves. The crucial distinction is scope. Some settings are stored in the database file and persist: journal_mode = WAL stays on once set. Others apply only to the current connection and are forgotten when it closes.

foreign_keys is the most important per-connection setting. SQLite ships with foreign-key enforcement off for backward compatibility, so REFERENCES clauses are silently ignored until every new connection runs PRAGMA foreign_keys = ON. Applications typically do this right after connecting. WAL (write-ahead logging) mode records changes in a separate log file first, which lets readers keep reading while one writer writes. VACUUM rebuilds the database file to reclaim space left behind by deleted data; while it runs it can need free disk space of up to twice the database's size, and it locks the database.

PRAGMA
A SQLite-specific statement that reads or changes internal settings.
Connection
One open session to a database file; per-connection settings reset when it closes.
WAL mode
Write-ahead logging, which lets readers and one writer work at the same time.
-- Enforce foreign key constraints (OFF by default!) PRAGMA foreign_keys = ON; -- Check integrity of the database file PRAGMA integrity_check; -- See column info for a table PRAGMA table_info(users); -- See all indexes on a table PRAGMA index_list(users); -- Enable WAL mode (better write performance) PRAGMA journal_mode = WAL; -- Tune the cache size (in pages; negative = KB) PRAGMA cache_size = -64000; -- 64MB cache -- Reclaim unused space after deletes VACUUM; -- See SQLite version info SELECT sqlite_version();
Terminal Output
sqlite> PRAGMA foreign_keys = ON; -- no output; now FK constraints are enforced sqlite> PRAGMA integrity_check; +-----------------+ | integrity_check | +-----------------+ | ok | +-----------------+ sqlite> PRAGMA table_info(users); +-----+-------+---------+---------+------------+----+ | cid | name | type | notnull | dflt_value | pk | +-----+-------+---------+---------+------------+----+ | 0 | id | INTEGER | 0 | | 1 | | 1 | name | TEXT | 0 | | 0 | | 2 | email | TEXT | 0 | | 0 | | 3 | age | INTEGER | 0 | | 0 | +-----+-------+---------+---------+------------+----+ sqlite> PRAGMA journal_mode = WAL; +--------------+ | journal_mode | +--------------+ | wal | +--------------+ sqlite> SELECT sqlite_version(); +------------------+ | sqlite_version() | +------------------+ | 3.45.1 | +------------------+
Important: PRAGMA foreign_keys = ON must be run every time you open a connection — it resets to OFF by default. Foreign key constraints are silently ignored if this is off.
UPSERT, Window Functions & JSON

An UPSERT (“update or insert”) solves a very common need: add this row, but if one with the same key already exists, update it instead. ON CONFLICT(id) DO UPDATE does exactly that in one atomic statement, and inside it excluded.column refers to the value you tried to insert. The older INSERT OR REPLACE looks similar but works differently: it deletes the old row and inserts a brand-new one. Any column you didn't mention resets to its default, and delete triggers fire. Prefer the true UPSERT when a row has other data worth keeping.

Window functions calculate across a set of related rows, the “window”, without collapsing them the way GROUP BY does, so every row stays in the result with the calculation beside it. OVER (ORDER BY …) defines the window. ROW_NUMBER and RANK number the rows, SUM(…) OVER produces running totals, and LAG/LEAD look at the previous or next row. Adding PARTITION BY restarts the calculation for each group. SQLite also has built-in JSON support, part of the core since 3.38. JSON documents are stored as text, json_extract pulls values out by a path such as '$.color', and the shorthand operators -> and ->> do the same. This gives you flexible, schema-free attributes inside an otherwise structured table.

UPSERT
Insert a row, or update the existing one if its key already exists.
Window function
A calculation across related rows that keeps every row, defined with OVER (…).
json_extract
Reads a value out of JSON text using a path such as '$.color'.
UPSERT (INSERT OR REPLACE)
-- Insert or update if the key already exists (SQLite 3.24+) INSERT INTO users (id, name, age) VALUES (1, 'Gordon', 31) ON CONFLICT(id) DO UPDATE SET name = excluded.name, age = excluded.age; -- Older approach — replace entire row INSERT OR REPLACE INTO users (id, name, age) VALUES (1, 'Gordon', 31);
Window Functions (SQLite 3.25+)
-- ROW_NUMBER — rank each row within a partition SELECT name, age, ROW_NUMBER() OVER (ORDER BY age DESC) AS rank FROM users; -- Running total SELECT name, total, SUM(total) OVER (ORDER BY created_at) AS running_total FROM orders; -- LAG / LEAD — compare to previous/next row SELECT date, revenue, LAG(revenue) OVER (ORDER BY date) AS prev_revenue FROM daily_sales;
JSON Support (SQLite 3.38+)
-- Store JSON in a TEXT column, query with json_extract INSERT INTO products (name, attrs) VALUES ('Widget', '{"color":"blue","weight":1.5}'); -- Extract a field from JSON SELECT name, json_extract(attrs, '$.color') AS color FROM products; -- Filter by JSON field value SELECT * FROM products WHERE json_extract(attrs, '$.color') = 'blue';
Terminal Output
-- UPSERT: id=1 already exists; age is updated to 31 sqlite> INSERT INTO users (id, name, age) VALUES (1, 'Gordon', 31) ...> ON CONFLICT(id) DO UPDATE SET name = excluded.name, age = excluded.age; sqlite> SELECT id, name, age FROM users WHERE id = 1; +----+--------+-----+ | id | name | age | +----+--------+-----+ | 1 | Gordon | 31 | +----+--------+-----+ -- Window function: rank all users by age descending sqlite> SELECT name, age, ROW_NUMBER() OVER (ORDER BY age DESC) AS rank FROM users; +--------+-----+------+ | name | age | rank | +--------+-----+------+ | Bob | 34 | 1 | | Gordon | 31 | 2 | | Carol | 29 | 3 | | Alice | 25 | 4 | +--------+-----+------+ -- Running order total, earliest to latest sqlite> SELECT name, total, SUM(total) OVER (ORDER BY created_at) AS running_total FROM orders; +--------+--------+---------------+ | name | total | running_total | +--------+--------+---------------+ | Order1 | 49.99 | 49.99 | | Order2 | 99.99 | 149.98 | | Order3 | 199.00 | 348.98 | +--------+--------+---------------+ -- JSON: extract color field from stored JSON sqlite> SELECT name, json_extract(attrs, '$.color') AS color FROM products; +--------+-------+ | name | color | +--------+-------+ | Widget | blue | +--------+-------+
🎓
Master's Degree
How SQLite works inside: integrity, storage, locking, isolation, planning, recursion, durability and time
Graduate-level material. Each lesson explains one part of how a database works inside and then shows it happening. Every Terminal Output block is real, captured on this site's server: lesson 1 from sqlite3 3.46.1, the rest from 3.45.1. Where a lesson uses two terminal windows at once, [A] and [B] mark which window each line was typed in.
1 · Relational Integrity: Foreign Keys, Cascades and Normal Forms

Builds on Basics & Commands › Joining Tables

Basics declared dept_id INTEGER REFERENCES departments(id) and joined on it. A declaration like that is a promise, and this lesson is about who keeps it. The relational model (Codd, 1970) requires referential integrity: every foreign-key value must match a key in the parent table or be NULL. A row that breaks the rule is an orphan, and every join that touches it silently gives the wrong answer.

SQLite does not check this by default. Foreign-key enforcement arrived in version 3.6.19 (2009), and turning it on for everyone would have broken existing databases that already held orphans, so it stays off for backward compatibility. PRAGMA foreign_keys = ON applies only to the connection that runs it, has to be repeated on every new connection, and is silently ignored inside a transaction. In the session below, Dave joins department 99, which doesn't exist, without complaint. PRAGMA foreign_key_check finds him afterwards. With enforcement on, the same insert fails with error 19 (SQLITE_CONSTRAINT). Django runs PRAGMA foreign_keys = ON on every SQLite connection it opens, so a Django project is protected even though the sqlite3 shell isn't.

Referential actions decide what happens to the children when a parent row is deleted or its key changes. NO ACTION, the default, refuses the change if children would be left behind. RESTRICT also refuses, but checks at once, even when the constraint is deferred. CASCADE deletes or updates the children too, and SET NULL / SET DEFAULT detach them. Pick the action by what the link means. Use CASCADE when the child can't exist without its parent (an order line without its order), SET NULL when the link is optional (an employee whose manager leaves), and NO ACTION when deleting the parent would destroy history (a customer with orders). Below, deleting Marketing takes Bob with it, and deleting Alice clears Carol's manager_id. Northwind uses NO ACTION everywhere, which is why you can't delete a customer who has orders.

When the check runs matters as much as whether it runs. An ordinary (immediate) constraint is checked at the end of each statement, not after each row, so one multi-row INSERT may add a child before its parent. DEFERRABLE INITIALLY DEFERRED moves the check to COMMIT, which is what cycles and self-references need when rows arrive in any order. If violations remain at COMMIT, the COMMIT fails and the transaction stays open. The session shows Eve's row still visible after the failed COMMIT, until ROLLBACK removes it. Code that assumes a failed COMMIT ended the transaction carries on inside it. PRAGMA defer_foreign_keys = ON defers every constraint for the current transaction and switches itself off at COMMIT. That is how northwind.db is loaded, because Employees.ReportsTo refers to employees inserted later.

Referential integrity
Every foreign-key value matches a parent key or is NULL. A row that breaks it is an orphan.
Referential action
What a delete or key update on the parent does to the children: NO ACTION, RESTRICT, CASCADE, SET NULL or SET DEFAULT.
Deferred constraint
A constraint checked at COMMIT instead of at the end of each statement.
SQLITE_CONSTRAINT (19)
The error for any constraint failure. The shell prints “FOREIGN KEY constraint failed (19)”.
Enforcement, cascades and deferred checks
-- The Basics schema, now with referential actions CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT NOT NULL); CREATE TABLE employees ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, dept_id INTEGER REFERENCES departments(id) ON DELETE CASCADE, manager_id INTEGER REFERENCES employees(id) ON DELETE SET NULL ); INSERT INTO departments VALUES (1, 'Engineering'), (2, 'Marketing'); INSERT INTO employees VALUES (1, 'Alice', 1, NULL), (2, 'Bob', 2, 1), (3, 'Carol', 1, 1); -- Enforcement is off by default: an orphan goes straight in PRAGMA foreign_keys; INSERT INTO employees VALUES (4, 'Dave', 99, NULL); PRAGMA foreign_key_check; DELETE FROM employees WHERE id = 4; -- Turn it on (per connection, outside any transaction): the same insert now fails PRAGMA foreign_keys = ON; INSERT INTO employees VALUES (4, 'Dave', 99, NULL); -- CASCADE removes Bob; SET NULL clears Carol's manager DELETE FROM departments WHERE id = 2; DELETE FROM employees WHERE id = 1; SELECT * FROM employees; -- A deferred self-reference: checked at COMMIT, not per statement CREATE TABLE staff ( id INTEGER PRIMARY KEY, name TEXT, manager_id INTEGER REFERENCES staff(id) DEFERRABLE INITIALLY DEFERRED ); BEGIN; INSERT INTO staff VALUES (2, 'Bob', 1); INSERT INTO staff VALUES (1, 'Alice', NULL); COMMIT; -- A failed COMMIT leaves the transaction open BEGIN; INSERT INTO staff VALUES (3, 'Eve', 42); COMMIT; SELECT count(*) AS rows_in_txn FROM staff; ROLLBACK; SELECT count(*) AS rows_after FROM staff;
Terminal Output
sqlite> .mode table sqlite> CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT NOT NULL); sqlite> CREATE TABLE employees ( (x1...> id INTEGER PRIMARY KEY, (x1...> name TEXT NOT NULL, (x1...> dept_id INTEGER REFERENCES departments(id) ON DELETE CASCADE, (x1...> manager_id INTEGER REFERENCES employees(id) ON DELETE SET NULL (x1...> ); sqlite> INSERT INTO departments VALUES (1, 'Engineering'), (2, 'Marketing'); sqlite> INSERT INTO employees VALUES (1, 'Alice', 1, NULL), (2, 'Bob', 2, 1), (3, 'Carol', 1, 1); sqlite> PRAGMA foreign_keys; +--------------+ | foreign_keys | +--------------+ | 0 | +--------------+ sqlite> INSERT INTO employees VALUES (4, 'Dave', 99, NULL); sqlite> PRAGMA foreign_key_check; +-----------+-------+-------------+------+ | table | rowid | parent | fkid | +-----------+-------+-------------+------+ | employees | 4 | departments | 1 | +-----------+-------+-------------+------+ sqlite> DELETE FROM employees WHERE id = 4; sqlite> PRAGMA foreign_keys = ON; sqlite> INSERT INTO employees VALUES (4, 'Dave', 99, NULL); Runtime error: FOREIGN KEY constraint failed (19) sqlite> DELETE FROM departments WHERE id = 2; sqlite> DELETE FROM employees WHERE id = 1; sqlite> SELECT * FROM employees; +----+-------+---------+------------+ | id | name | dept_id | manager_id | +----+-------+---------+------------+ | 3 | Carol | 1 | | +----+-------+---------+------------+ sqlite> CREATE TABLE staff ( (x1...> id INTEGER PRIMARY KEY, (x1...> name TEXT, (x1...> manager_id INTEGER REFERENCES staff(id) DEFERRABLE INITIALLY DEFERRED (x1...> ); sqlite> BEGIN; sqlite> INSERT INTO staff VALUES (2, 'Bob', 1); sqlite> INSERT INTO staff VALUES (1, 'Alice', NULL); sqlite> COMMIT; sqlite> BEGIN; sqlite> INSERT INTO staff VALUES (3, 'Eve', 42); sqlite> COMMIT; Runtime error: FOREIGN KEY constraint failed (19) sqlite> SELECT count(*) AS rows_in_txn FROM staff; +-------------+ | rows_in_txn | +-------------+ | 3 | +-------------+ sqlite> ROLLBACK; sqlite> SELECT count(*) AS rows_after FROM staff; +------------+ | rows_after | +------------+ | 2 | +------------+

The missing index on the child side. The parent key must be a PRIMARY KEY or UNIQUE column, so it always has an index; if it isn't, every write fails with “foreign key mismatch”. SQLite never indexes the child column for you. Each time a parent row is deleted, or its key changes, SQLite has to find that row's children, and without an index that means reading the whole child table. In the session below, 1,000 customers who have no orders are deleted. Each delete scans all 200,000 orders, 200 million row visits in all, and takes 8.0 seconds. The shell's .lint fkey-indexes names the missing index. With it in place, the same 1,000 deletes take 0.001 seconds.

The cost only appears once enforcement is on, so switching foreign_keys on in an older database can make deletes suddenly slow for no visible reason. Django creates an index on every ForeignKey column by default, partly for this reason and partly because joins use the same column.

Delete parents with and without a child-side index
-- 2,000 customers; 200,000 orders that belong to customers 1-1,000 PRAGMA foreign_keys = ON; CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT); CREATE TABLE orders ( id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES customers(id), total REAL ); WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 2000) INSERT INTO customers SELECT i, 'customer ' || i FROM n; WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 200000) INSERT INTO orders SELECT i, i % 1000 + 1, i * 0.5 FROM n; -- Delete the 1,000 customers who have no orders .timer on DELETE FROM customers WHERE id > 1000; .timer off -- Ask the shell which foreign keys have no index .lint fkey-indexes -- Add the index, put the customers back, and delete them again CREATE INDEX orders_customer_id ON orders(customer_id); WITH RECURSIVE n(i) AS (SELECT 1001 UNION ALL SELECT i + 1 FROM n WHERE i < 2000) INSERT INTO customers SELECT i, 'customer ' || i FROM n; .timer on DELETE FROM customers WHERE id > 1000; .timer off
Terminal Output
sqlite> .mode table sqlite> PRAGMA foreign_keys = ON; sqlite> CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT); sqlite> CREATE TABLE orders ( (x1...> id INTEGER PRIMARY KEY, (x1...> customer_id INTEGER REFERENCES customers(id), (x1...> total REAL (x1...> ); sqlite> WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 2000) ...> INSERT INTO customers SELECT i, 'customer ' || i FROM n; sqlite> WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 200000) ...> INSERT INTO orders SELECT i, i % 1000 + 1, i * 0.5 FROM n; sqlite> .timer on sqlite> DELETE FROM customers WHERE id > 1000; Run Time: real 8.004 user 6.782335 sys 1.221208 sqlite> .timer off sqlite> .lint fkey-indexes CREATE INDEX 'orders_customer_id' ON 'orders'('customer_id'); --> customers(id) sqlite> CREATE INDEX orders_customer_id ON orders(customer_id); sqlite> WITH RECURSIVE n(i) AS (SELECT 1001 UNION ALL SELECT i + 1 FROM n WHERE i < 2000) ...> INSERT INTO customers SELECT i, 'customer ' || i FROM n; sqlite> .timer on sqlite> DELETE FROM customers WHERE id > 1000; Run Time: real 0.001 user 0.000660 sys 0.000118 sqlite> .timer off

Normal forms: where a foreign key belongs. Normalisation is the design theory behind the decision to split a table and link the parts with a key. Each normal form removes one kind of repeated fact:

First normal form (1NF): every column holds a single value. A comma-separated list of territories in an employee row breaks it, and that is why Northwind has the EmployeeTerritories junction table.
Second normal form (2NF): in a table with a composite key, no column depends on only part of the key. A ProductName column in Order Details would depend on ProductID alone, so it belongs in Products.
Third normal form (3NF): no column depends on another non-key column. A department name stored next to dept_id on every employee depends on the department, not the employee; this is the Basics example.
Boyce–Codd normal form (BCNF): a stricter 3NF for tables with several overlapping candidate keys. Most 3NF designs already meet it.

Storing a value twice is fine when it's a deliberate decision rather than an accident. Northwind's Order Details stores its own UnitPrice, and on 658 of its 2,155 order lines that price differs from the product's current price. That column is not a copy. It records a different fact, the price on the day of the sale (Northwind lesson 5). Lesson 8 handles the general version of that problem: facts that change over time.

Normal form
A rule that removes one kind of redundancy. Each one (1NF, 2NF, 3NF, BCNF) includes the ones before it.
Functional dependency
Column B depends on column A when each value of A fixes exactly one value of B, as a department ID fixes the department name.
Denormalisation
Storing a fact more than once on purpose, usually for speed or to record the value at a point in time.
2 · Pages, B-trees and the File Format

Everything in an SQLite database lives in fixed-size pages, 4,096 bytes by default. Page 1 starts with a 100-byte header; after that, every table and every index is its own B-tree made of pages. dbstat is a built-in virtual table that shows those pages directly, so the storage layout stops being theory and becomes something you can query.

Tables and indexes use two different kinds of tree. A table is a B+tree keyed by rowid: the rows live only in the leaves, and interior pages hold nothing but copies of keys that point downward. An index is a plain B-tree: each entry is stored exactly once, in whichever page it lands in, interior or leaf. The output below shows the difference. people reports 10,090 cells for 10,000 rows, because its one interior page holds 90 extra routing keys. idx_people_email reports exactly 10,000 cells, because its interior entries are index entries.

The numbers also show why B-trees scale. The root's 90 cells point to 91 leaves (90 keys plus a right-most child pointer), and each leaf holds about 110 rows. One interior level already reaches 10,000 rows, and an interior page can point to hundreds of children, so a third level reaches millions of rows. Finding any row by its key costs one page read per level: a WHERE id = ? lookup on a table of tens of millions of rows reads about three or four pages.

Page
The fixed-size unit SQLite reads and writes; 4,096 bytes by default (PRAGMA page_size).
B+tree
A tree whose data lives only in the leaves; interior pages hold routing keys. SQLite uses it for rowid tables.
Fan-out
How many children one interior page points to. High fan-out keeps trees shallow.
dbstat
A virtual table with one row per page: its type, cell count, free space and position in its tree.
Build a table and look at its pages
-- 10,000 rows, generated with a recursive CTE (lesson 6) CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT, email TEXT); WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 10000) INSERT INTO people SELECT i, 'user' || i, 'user' || i || '@example.com' FROM n; CREATE INDEX idx_people_email ON people(email); PRAGMA page_size; PRAGMA page_count; -- One row per B-tree: how many pages, and how they split SELECT name, count(*) AS pages, sum(pagetype = 'internal') AS interior, sum(pagetype = 'leaf') AS leaves, sum(ncell) AS cells FROM dbstat GROUP BY name ORDER BY pages DESC; -- The root page of the table, then its first leaves SELECT pageno, pagetype, ncell FROM dbstat WHERE name = 'people' ORDER BY path LIMIT 3;
Terminal Output
sqlite> .mode table sqlite> CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT, email TEXT); sqlite> WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 10000) ...> INSERT INTO people SELECT i, 'user' || i, 'user' || i || '@example.com' FROM n; sqlite> CREATE INDEX idx_people_email ON people(email); sqlite> PRAGMA page_size; +-----------+ | page_size | +-----------+ | 4096 | +-----------+ sqlite> PRAGMA page_count; +------------+ | page_count | +------------+ | 163 | +------------+ sqlite> SELECT name, count(*) AS pages, ...> sum(pagetype = 'internal') AS interior, ...> sum(pagetype = 'leaf') AS leaves, sum(ncell) AS cells ...> FROM dbstat GROUP BY name ORDER BY pages DESC; +------------------+-------+----------+--------+-------+ | name | pages | interior | leaves | cells | +------------------+-------+----------+--------+-------+ | people | 92 | 1 | 91 | 10090 | | idx_people_email | 70 | 1 | 69 | 10000 | | sqlite_schema | 1 | 0 | 1 | 2 | +------------------+-------+----------+--------+-------+ sqlite> SELECT pageno, pagetype, ncell FROM dbstat ...> WHERE name = 'people' ORDER BY path LIMIT 3; +--------+----------+-------+ | pageno | pagetype | ncell | +--------+----------+-------+ | 2 | internal | 90 | | 3 | leaf | 126 | | 4 | leaf | 116 | +--------+----------+-------+
Check the arithmetic: 92 + 70 + 1 = 163 pages, the same as PRAGMA page_count. Every page in the file belongs to exactly one B-tree, or to the free list after a DELETE.
3 · Atomic Commit, Journals and Locks

By default SQLite runs in rollback-journal mode (journal_mode = delete). Before changing a page in the database file, it copies the original page into a separate -journal file. If the power fails halfway through a commit, the next connection finds the journal and copies the old pages back. That is how a single file gets atomic commits without a server.

Concurrency is controlled by five lock states on the file. SHARED lets a connection read, and many can hold it at once. RESERVED means “I intend to write”: only one connection can hold it, but readers carry on. PENDING blocks new readers while a writer waits for the current ones to finish, and EXCLUSIVE is held for the moment the writer changes the file. The result is one writer at a time. In the session below, terminal A takes RESERVED with BEGIN IMMEDIATE. Terminal B can still read and sees the old balances, because A's changes are still only in A's memory. But B cannot write.

A second writer does not have to fail at once: .timeout (or sqlite3_busy_timeout, or Django's timeout option) makes it wait and retry. The shell's default timeout is zero, which is why B fails at once here. Whether waiting is good enough depends on how much of the time the write lock is taken. Multiply writes per second by the average write-transaction time. That gives the fraction of time the lock is busy, the utilisation ρ from queueing theory. Waits grow roughly as ρ / (1 − ρ): below about 20% they are rare, and past about 50% queues build quickly.

Rollback journal
A file of original pages, written before the database is changed, used to undo an interrupted commit.
RESERVED lock
Held by the one connection that intends to write; readers may continue.
SQLITE_BUSY (5)
The error returned when a lock cannot be obtained. The shell prints it as “database is locked (5)”.
Utilisation (ρ)
Writes per second × seconds per write: the fraction of time the write lock is held.
Two terminals, one database

Open two terminal windows on the same file. Lines marked A run in the first, lines marked B in the second, in the order shown.

-- Setup, once, in either terminal CREATE TABLE accounts (id INTEGER PRIMARY KEY, owner TEXT, balance INTEGER); INSERT INTO accounts VALUES (1, 'Alice', 100), (2, 'Bob', 50); -- A PRAGMA journal_mode; BEGIN IMMEDIATE; UPDATE accounts SET balance = balance - 30 WHERE id = 1; -- B SELECT owner, balance FROM accounts; UPDATE accounts SET balance = balance + 30 WHERE id = 2; -- A UPDATE accounts SET balance = balance + 30 WHERE id = 2; COMMIT; -- B SELECT owner, balance FROM accounts;
Terminal Output
[A] sqlite> .mode table [B] sqlite> .mode table [A] sqlite> PRAGMA journal_mode; +--------------+ | journal_mode | +--------------+ | delete | +--------------+ [A] sqlite> BEGIN IMMEDIATE; [A] sqlite> UPDATE accounts SET balance = balance - 30 WHERE id = 1; [B] sqlite> SELECT owner, balance FROM accounts; +-------+---------+ | owner | balance | +-------+---------+ | Alice | 100 | | Bob | 50 | +-------+---------+ [B] sqlite> UPDATE accounts SET balance = balance + 30 WHERE id = 2; Runtime error: database is locked (5) [A] sqlite> UPDATE accounts SET balance = balance + 30 WHERE id = 2; [A] sqlite> COMMIT; [B] sqlite> SELECT owner, balance FROM accounts; +-------+---------+ | owner | balance | +-------+---------+ | Alice | 70 | | Bob | 80 | +-------+---------+
Case study: measuring the lock on a live server

This site runs on SQLite in rollback-journal mode. Watching for the -journal file of its main database every 0.67 ms for ten minutes (2026-09-25, early morning) found 7 write transactions. The median lock was held for 3.1 ms and the longest for 5.5 ms, so the lock was busy 0.004% of the time. Even at 4 ms per commit, one writer can manage about 240 commits a second (lesson 7), so an average load like this is far below the limit. The “database is locked” errors the site did have came from somewhere else: the trap in the next lesson.

4 · Isolation, Snapshots and the Lock-Upgrade Trap

Builds on Basics & Commands › PRAGMA (WAL mode)

WAL lets readers and a writer work at the same time by giving each read transaction a fixed snapshot: whatever had been committed when it first read. In the session below, B keeps seeing Alice's balance as 100 even after A has committed 70. That is what makes SQLite's transactions serializable, the strictest isolation level in the SQL standard.

Now B tries to write. Its snapshot is out of date, and if the update were allowed B would compute 100 − 30 and overwrite A's change. That is a lost update. SQLite refuses. Note the timing: B had a 5-second busy timeout, yet the error came back in 0.000 s. Waiting cannot help, because the snapshot will never become current, so SQLite skips the busy handler and returns at once (the extended code, SQLITE_BUSY_SNAPSHOT, is 517). The only way forward is to roll back and start again.

Rollback-journal mode has the same trap in a different form. A transaction that reads first holds SHARED and then asks to upgrade to RESERVED. If another connection holding RESERVED is waiting to commit, both would wait for each other forever, so SQLite returns SQLITE_BUSY to the upgrader without waiting. This is exactly what Django's update_or_create does: a SELECT, then a write, inside one transaction. On this site it caused 81 “database is locked” errors even though a generous busy timeout was set. The fix is to say up front that you will write. BEGIN IMMEDIATE takes the write lock before the first read, so a conflict turns into an ordinary wait. Or write in one statement, such as an INSERT … ON CONFLICT DO UPDATE. Its transaction starts as a write instead of upgrading from a read, so SQLite can safely let it wait and retry.

Snapshot
The fixed view of committed data a read transaction sees from its first read until it ends.
Lost update
Two transactions read the same value, and one overwrites the other's change with a result based on stale data.
BEGIN IMMEDIATE
Starts a transaction and takes the write lock at once, before any reads.
A stale snapshot cannot write
-- Setup, once, on a fresh file PRAGMA journal_mode = WAL; CREATE TABLE accounts (id INTEGER PRIMARY KEY, owner TEXT, balance INTEGER); INSERT INTO accounts VALUES (1, 'Alice', 100), (2, 'Bob', 50); -- B: wait up to 5 seconds for locks .timeout 5000 -- A and B both start a transaction and read Alice's balance BEGIN; SELECT balance FROM accounts WHERE id = 1; -- A withdraws 30 and commits UPDATE accounts SET balance = balance - 30 WHERE id = 1; COMMIT; -- B still sees 100, and its write fails immediately SELECT balance FROM accounts WHERE id = 1; UPDATE accounts SET balance = balance - 30 WHERE id = 1; ROLLBACK; -- B again, the right way: take the write lock before reading BEGIN IMMEDIATE; SELECT balance FROM accounts WHERE id = 1; UPDATE accounts SET balance = balance - 30 WHERE id = 1; COMMIT;
Terminal Output
[A] sqlite> .mode table [B] sqlite> .mode table [A] sqlite> PRAGMA journal_mode; +--------------+ | journal_mode | +--------------+ | wal | +--------------+ [B] sqlite> .timeout 5000 [A] sqlite> BEGIN; [B] sqlite> BEGIN; [A] sqlite> SELECT balance FROM accounts WHERE id = 1; +---------+ | balance | +---------+ | 100 | +---------+ [B] sqlite> SELECT balance FROM accounts WHERE id = 1; +---------+ | balance | +---------+ | 100 | +---------+ [A] sqlite> UPDATE accounts SET balance = balance - 30 WHERE id = 1; [A] sqlite> COMMIT; [B] sqlite> SELECT balance FROM accounts WHERE id = 1; +---------+ | balance | +---------+ | 100 | +---------+ [B] sqlite> .timer on [B] sqlite> UPDATE accounts SET balance = balance - 30 WHERE id = 1; Run Time: real 0.000 user 0.000014 sys 0.000083 Runtime error: database is locked (5) [B] sqlite> .timer off [B] sqlite> ROLLBACK; [B] sqlite> BEGIN IMMEDIATE; [B] sqlite> SELECT balance FROM accounts WHERE id = 1; +---------+ | balance | +---------+ | 70 | +---------+ [B] sqlite> UPDATE accounts SET balance = balance - 30 WHERE id = 1; [B] sqlite> COMMIT; [B] sqlite> SELECT balance FROM accounts WHERE id = 1; +---------+ | balance | +---------+ | 40 | +---------+
Result: 100 − 30 − 30 = 40. Both withdrawals count. Without the error, B's update would have produced 70 and one withdrawal would have vanished.
5 · The Query Planner and Cost-Based Optimisation

Builds on Basics & Commands › Indexes

Beyond SCAN and SEARCH, two more plan lines matter. With a plain index, SQLite descends the index B-tree to each match and then makes a second lookup in the table, by rowid, to fetch the row. USING COVERING INDEX means the index alone holds every column the query needs, so the table is never touched. USE TEMP B-TREE FOR ORDER BY means the results are sorted after the fact, because no index delivers them in order.

On the 200,000-row table below, adding an index turns a 15 ms scan into a lookup too fast for the shell's timer to register. The time saved grows with the size of the table: a scan costs work in proportion to the number of rows, while an index lookup costs only a few page reads for each match.

When several plans are possible, SQLite estimates the cost of each and picks the cheapest. Since version 3.8.0 its planner (the “next-generation query planner”) has kept only the few cheapest partial join orders at each step (its “N nearest neighbours” search) rather than trying every possible order. The estimates depend on statistics: ANALYZE samples each index and writes a summary to sqlite_stat1. The line 200000 40 1 means the index covers 200,000 rows, each customer_id matches about 40 of them, and each (customer_id, total) pair matches about 1. Stale or missing statistics are a common cause of a sudden bad plan in production.

Covering index
An index that contains every column a query reads, so the table itself is never visited.
Selectivity
The fraction of rows a condition matches. The more selective a condition, the more an index on it helps.
ANALYZE
Collects index statistics into sqlite_stat1 for the planner's cost estimates.
Cost-based optimiser
A planner that estimates the cost of several alternative plans and runs the cheapest.
Watch the plan change
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, status TEXT, total REAL); WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 200000) INSERT INTO orders SELECT i, i % 5000, CASE WHEN i % 20 = 0 THEN 'refunded' ELSE 'paid' END, (i % 997) / 10.0 FROM n; EXPLAIN QUERY PLAN SELECT sum(total) FROM orders WHERE customer_id = 42; CREATE INDEX idx_orders_customer ON orders(customer_id); EXPLAIN QUERY PLAN SELECT sum(total) FROM orders WHERE customer_id = 42; CREATE INDEX idx_orders_customer_total ON orders(customer_id, total); EXPLAIN QUERY PLAN SELECT sum(total) FROM orders WHERE customer_id = 42; EXPLAIN QUERY PLAN SELECT * FROM orders WHERE status = 'refunded' ORDER BY total DESC LIMIT 5; ANALYZE; SELECT tbl, idx, stat FROM sqlite_stat1;
Terminal Output
sqlite> .mode table sqlite> CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, status TEXT, total REAL); sqlite> WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 200000) ...> INSERT INTO orders SELECT i, i % 5000, CASE WHEN i % 20 = 0 THEN 'refunded' ELSE 'paid' END, (i % 997) / 10.0 FROM n; sqlite> EXPLAIN QUERY PLAN SELECT sum(total) FROM orders WHERE customer_id = 42; QUERY PLAN `--SCAN orders sqlite> .timer on sqlite> SELECT sum(total) FROM orders WHERE customer_id = 42; +------------+ | sum(total) | +------------+ | 1338.0 | +------------+ Run Time: real 0.015 user 0.008173 sys 0.003303 sqlite> .timer off sqlite> CREATE INDEX idx_orders_customer ON orders(customer_id); sqlite> EXPLAIN QUERY PLAN SELECT sum(total) FROM orders WHERE customer_id = 42; QUERY PLAN `--SEARCH orders USING INDEX idx_orders_customer (customer_id=?) sqlite> .timer on sqlite> SELECT sum(total) FROM orders WHERE customer_id = 42; +------------+ | sum(total) | +------------+ | 1338.0 | +------------+ Run Time: real 0.000 user 0.000000 sys 0.000349 sqlite> .timer off sqlite> CREATE INDEX idx_orders_customer_total ON orders(customer_id, total); sqlite> EXPLAIN QUERY PLAN SELECT sum(total) FROM orders WHERE customer_id = 42; QUERY PLAN `--SEARCH orders USING COVERING INDEX idx_orders_customer_total (customer_id=?) sqlite> EXPLAIN QUERY PLAN SELECT * FROM orders WHERE status = 'refunded' ORDER BY total DESC LIMIT 5; QUERY PLAN |--SCAN orders `--USE TEMP B-TREE FOR ORDER BY sqlite> ANALYZE; sqlite> SELECT tbl, idx, stat FROM sqlite_stat1; +--------+---------------------------+-------------+ | tbl | idx | stat | +--------+---------------------------+-------------+ | orders | idx_orders_customer_total | 200000 40 1 | | orders | idx_orders_customer | 200000 40 | +--------+---------------------------+-------------+
6 · Recursive Queries, Graphs and Fixed Points

Builds on Basics & Commands › CTEs

Underneath, SQLite runs a recursive CTE with a queue. The anchor seeds the queue, and the recursive step runs once for each row taken off it, adding any new rows it produces. The query ends when the queue is empty. Because each row goes through the recursive step exactly once, this is the same idea as semi-naive evaluation in Datalog. It lets plain SQL compute a transitive closure: everything reachable from a starting point in a graph.

Graphs can have cycles. The route table below includes DSM → ORD → DEN → DSM, so with UNION ALL the queue never empties and the query only stops because of LIMIT. Switching to UNION discards any row that has already been produced, so the loop reaches a fixed point (no new rows) and ends by itself. This works because the graph is finite.

To list the paths themselves, rather than just the cities reached, carry the path along as a column and refuse to revisit a city already on it. instr is enough here because every airport code is exactly three letters. With labels of different lengths you would add separators so that one label can't match inside another. Rows come off SQLite's queue first in, first out, so the recursion runs breadth-first: in the first query every city one hop away appears before any city two hops away.

Transitive closure
Every node reachable from a start node by following edges any number of times.
Fixed point
The state where one more round of the recursive step adds nothing new.
UNION vs UNION ALL
UNION discards duplicate rows, which stops cycles; UNION ALL keeps them all.
Reachability and simple paths in a graph with a cycle
CREATE TABLE routes (src TEXT, dst TEXT); INSERT INTO routes VALUES ('DSM','ORD'), ('ORD','DEN'), ('DEN','DSM'), ('DEN','SFO'), ('ORD','JFK'); -- UNION ALL: loops round the cycle for ever (LIMIT stops it) WITH RECURSIVE reach(city) AS ( SELECT 'DSM' UNION ALL SELECT r.dst FROM routes r JOIN reach ON r.src = reach.city ) SELECT city FROM reach LIMIT 8; -- UNION: discards repeats, so it reaches a fixed point WITH RECURSIVE reach(city) AS ( SELECT 'DSM' UNION SELECT r.dst FROM routes r JOIN reach ON r.src = reach.city ) SELECT city FROM reach; -- Every simple path, never revisiting a city WITH RECURSIVE trip(city, path, hops) AS ( SELECT 'DSM', 'DSM', 0 UNION ALL SELECT r.dst, trip.path || '>' || r.dst, trip.hops + 1 FROM routes r JOIN trip ON r.src = trip.city WHERE instr(trip.path, r.dst) = 0 ) SELECT path, hops FROM trip ORDER BY hops, path;
Terminal Output
sqlite> .mode table sqlite> CREATE TABLE routes (src TEXT, dst TEXT); sqlite> INSERT INTO routes VALUES ('DSM','ORD'), ('ORD','DEN'), ('DEN','DSM'), ('DEN','SFO'), ('ORD','JFK'); sqlite> WITH RECURSIVE reach(city) AS ( (x1...> SELECT 'DSM' (x1...> UNION ALL (x1...> SELECT r.dst FROM routes r JOIN reach ON r.src = reach.city (x1...> ) ...> SELECT city FROM reach LIMIT 8; +------+ | city | +------+ | DSM | | ORD | | DEN | | JFK | | DSM | | SFO | | ORD | | DEN | +------+ sqlite> WITH RECURSIVE reach(city) AS ( (x1...> SELECT 'DSM' (x1...> UNION (x1...> SELECT r.dst FROM routes r JOIN reach ON r.src = reach.city (x1...> ) ...> SELECT city FROM reach; +------+ | city | +------+ | DSM | | ORD | | DEN | | JFK | | SFO | +------+ sqlite> WITH RECURSIVE trip(city, path, hops) AS ( (x1...> SELECT 'DSM', 'DSM', 0 (x1...> UNION ALL (x1...> SELECT r.dst, trip.path || '>' || r.dst, trip.hops + 1 (x1...> FROM routes r JOIN trip ON r.src = trip.city (x1...> WHERE instr(trip.path, r.dst) = 0 (x1...> ) ...> SELECT path, hops FROM trip ORDER BY hops, path; +-----------------+------+ | path | hops | +-----------------+------+ | DSM | 0 | | DSM>ORD | 1 | | DSM>ORD>DEN | 2 | | DSM>ORD>JFK | 2 | | DSM>ORD>DEN>SFO | 3 | +-----------------+------+
Reading the prompt: inside an open parenthesis the 3.45 shell shows (x1...> as its continuation prompt, where the number is how many brackets are still open.
7 · Durability and the Cost of fsync

Builds on Basics & Commands › Transactions

This lesson measures the wait behind every commit. For a commit to be durable, the data must be on the disk itself, not just in the operating system's memory. To make sure, SQLite calls fsync, which waits for the disk to confirm the write, and that wait is by far the most expensive part of a commit. The benchmark below timed 1,000 single-row inserts on this site's server under four settings. In every case the CPU time was under a second. The rest of the time was spent waiting for the disk.

A is SQLite's default: a rollback journal with synchronous = FULL and one commit per insert. Each commit writes and flushes the journal, updates the database, flushes it again and deletes the journal, which comes to about 4.2 ms, or roughly 240 commits a second. B runs the same inserts in one transaction and is about 350 times faster, because it pays the disk cost once. C uses WAL, where a commit appends to the log and flushes once, which roughly halves the cost. D is WAL with synchronous = NORMAL, where a commit doesn't wait for the disk at all and flushes happen only at checkpoints.

D is not free. The database cannot be corrupted, but after a power cut or operating-system crash the last few commits can be missing. Your application was told they succeeded, and they are gone. Whether that is acceptable depends on the application, and it is the classic trade between durability and throughput that every database makes somewhere.

fsync
An operating-system call that returns only when the disk confirms the data has been written.
synchronous
The PRAGMA controlling how often SQLite calls fsync: OFF, NORMAL, FULL or EXTRA.
Checkpoint
In WAL mode, copying the log's pages back into the main database file.
The benchmark script
#!/bin/bash set -euo pipefail for i in $(seq 1000); do echo "INSERT INTO t VALUES ($i);"; done > inserts.sql (echo 'BEGIN;'; cat inserts.sql; echo 'COMMIT;') > batched.sql echo "== A: rollback journal, synchronous=FULL, one commit per insert" sqlite3 a.db 'CREATE TABLE t (x INTEGER);' time sqlite3 a.db < inserts.sql echo "== B: rollback journal, synchronous=FULL, all 1,000 in one transaction" sqlite3 b.db 'CREATE TABLE t (x INTEGER);' time sqlite3 b.db < batched.sql echo "== C: WAL, synchronous=FULL, one commit per insert" sqlite3 c.db 'PRAGMA journal_mode=WAL; CREATE TABLE t (x INTEGER);' > /dev/null time sqlite3 -cmd 'PRAGMA synchronous=FULL' c.db < inserts.sql echo "== D: WAL, synchronous=NORMAL, one commit per insert" sqlite3 d.db 'PRAGMA journal_mode=WAL; CREATE TABLE t (x INTEGER);' > /dev/null time sqlite3 -cmd 'PRAGMA synchronous=NORMAL' d.db < inserts.sql echo "== row counts" for f in a b c d; do echo "$f.db: $(sqlite3 $f.db 'SELECT count(*) FROM t;')"; done
Terminal Output
$ bash run.sh == A: rollback journal, synchronous=FULL, one commit per insert real 0m4.228s user 0m0.127s sys 0m0.725s == B: rollback journal, synchronous=FULL, all 1,000 in one transaction real 0m0.012s user 0m0.003s sys 0m0.005s == C: WAL, synchronous=FULL, one commit per insert real 0m1.802s user 0m0.051s sys 0m0.247s == D: WAL, synchronous=NORMAL, one commit per insert real 0m0.041s user 0m0.009s sys 0m0.021s == row counts a.db: 1000 b.db: 1000 c.db: 1000 d.db: 1000
Your numbers will differ: fsync speed depends on the disk and on whether it has a battery-backed write cache. The ratios between the four settings are what carry over.
8 · Bitemporal Data: What Was True, and When We Knew It

Most tables store only the current value. A bitemporal table stores two separate timelines. Valid time is when a fact is true in the world: here, the fiscal year the revenue belongs to. Transaction time is when the database believed it: from recorded_at until replaced_at. When a company restates last year's revenue, you don't overwrite the old figure. You close its transaction-time period and add a new row. Nothing is ever lost, and the database can answer “what did we know on 30 June 2025?” as easily as “what do we know now?”

This matters wherever history is analysed: audits, regulatory reporting and, above all, testing investment strategies on past data. A backtest that uses today's restated figures is using information nobody had at the time, called look-ahead bias, and it makes a strategy look better than it could ever have been. An as-of query removes that bias by filtering on transaction time.

Periods are stored half-open, [start, end), so one period can end on the same day the next begins without the two overlapping. Two periods overlap exactly when a.start < b.end AND b.start < a.end. PostgreSQL can enforce “no overlapping periods” declaratively with an exclusion constraint (EXCLUDE USING gist). SQLite has no such constraint, so a BEFORE INSERT trigger with RAISE(ABORT, …) enforces it instead. The trigger rejects the careless insert below, and the correct restatement, which closes the old row and inserts the new one in one transaction, goes through. ISO-8601 dates stored as text sort in date order, so plain < comparisons work.

Valid time
When a fact is true in the modelled world, such as the fiscal year a figure describes.
Transaction time
When the database recorded a fact, and when it was superseded.
Half-open interval
[start, end): includes the start, excludes the end, so adjacent periods never overlap.
Look-ahead bias
Using information in a historical test that was not available at the time being tested.
A restatement, stored without losing history
CREATE TABLE revenue ( ticker TEXT NOT NULL, fiscal_year INTEGER NOT NULL, amount_m INTEGER NOT NULL, recorded_at TEXT NOT NULL, replaced_at TEXT NOT NULL DEFAULT '9999-12-31' ); -- No two versions of the same fact may be believed at the same time CREATE TRIGGER revenue_no_overlap BEFORE INSERT ON revenue WHEN EXISTS (SELECT 1 FROM revenue WHERE ticker = NEW.ticker AND fiscal_year = NEW.fiscal_year AND recorded_at < NEW.replaced_at AND NEW.recorded_at < replaced_at) BEGIN SELECT RAISE(ABORT, 'transaction-time periods overlap'); END; -- Original 10-K INSERT INTO revenue (ticker, fiscal_year, amount_m, recorded_at) VALUES ('ACME', 2024, 1200, '2025-02-20'); -- Wrong: add the restated figure without closing the old one INSERT INTO revenue (ticker, fiscal_year, amount_m, recorded_at) VALUES ('ACME', 2024, 1150, '2026-02-18'); -- Right: close the old version and add the new one atomically BEGIN; UPDATE revenue SET replaced_at = '2026-02-18' WHERE ticker = 'ACME' AND fiscal_year = 2024 AND replaced_at = '9999-12-31'; INSERT INTO revenue (ticker, fiscal_year, amount_m, recorded_at) VALUES ('ACME', 2024, 1150, '2026-02-18'); COMMIT; SELECT * FROM revenue; -- As known on 30 June 2025, and as known today SELECT amount_m FROM revenue WHERE ticker = 'ACME' AND fiscal_year = 2024 AND recorded_at <= '2025-06-30' AND '2025-06-30' < replaced_at; SELECT amount_m FROM revenue WHERE ticker = 'ACME' AND fiscal_year = 2024 AND recorded_at <= date('now') AND date('now') < replaced_at;
Terminal Output
sqlite> .mode table sqlite> CREATE TABLE revenue ( (x1...> ticker TEXT NOT NULL, (x1...> fiscal_year INTEGER NOT NULL, (x1...> amount_m INTEGER NOT NULL, (x1...> recorded_at TEXT NOT NULL, (x1...> replaced_at TEXT NOT NULL DEFAULT '9999-12-31' (x1...> ); sqlite> CREATE TRIGGER revenue_no_overlap BEFORE INSERT ON revenue ...> WHEN EXISTS (SELECT 1 FROM revenue (x1...> WHERE ticker = NEW.ticker AND fiscal_year = NEW.fiscal_year (x1...> AND recorded_at < NEW.replaced_at AND NEW.recorded_at < replaced_at) ...> BEGIN ...> SELECT RAISE(ABORT, 'transaction-time periods overlap'); ...> END; sqlite> INSERT INTO revenue (ticker, fiscal_year, amount_m, recorded_at) ...> VALUES ('ACME', 2024, 1200, '2025-02-20'); sqlite> INSERT INTO revenue (ticker, fiscal_year, amount_m, recorded_at) ...> VALUES ('ACME', 2024, 1150, '2026-02-18'); Runtime error: transaction-time periods overlap (19) sqlite> BEGIN; sqlite> UPDATE revenue SET replaced_at = '2026-02-18' ...> WHERE ticker = 'ACME' AND fiscal_year = 2024 AND replaced_at = '9999-12-31'; sqlite> INSERT INTO revenue (ticker, fiscal_year, amount_m, recorded_at) ...> VALUES ('ACME', 2024, 1150, '2026-02-18'); sqlite> COMMIT; sqlite> SELECT * FROM revenue; +--------+-------------+----------+-------------+-------------+ | ticker | fiscal_year | amount_m | recorded_at | replaced_at | +--------+-------------+----------+-------------+-------------+ | ACME | 2024 | 1200 | 2025-02-20 | 2026-02-18 | | ACME | 2024 | 1150 | 2026-02-18 | 9999-12-31 | +--------+-------------+----------+-------------+-------------+ sqlite> SELECT amount_m FROM revenue ...> WHERE ticker = 'ACME' AND fiscal_year = 2024 ...> AND recorded_at <= '2025-06-30' AND '2025-06-30' < replaced_at; +----------+ | amount_m | +----------+ | 1200 | +----------+ sqlite> SELECT amount_m FROM revenue ...> WHERE ticker = 'ACME' AND fiscal_year = 2024 ...> AND recorded_at <= date('now') AND date('now') < replaced_at; +----------+ | amount_m | +----------+ | 1150 | +----------+
Close the other door too: the trigger guards INSERT only. A production design would add a BEFORE UPDATE trigger allowing just one change, setting replaced_at on a row that is still open, so history can't be rewritten.
Thesis Projects & Reading List
Two thesis-sized projects
Point-in-time correctness for restated financial data

Question: how much do backtest results change when they use figures as originally filed instead of today's restated figures?

Method: load SEC XBRL filings into a bitemporal schema (lesson 8). Compare a trigger-based SQLite design with PostgreSQL range types and an exclusion constraint on correctness, query speed and index size. Then run the same strategy on both views of the data.

Contribution: a measured size for look-ahead bias, and a reusable schema for avoiding it.

Single-writer locking vs. MVCC under a real workload

Question: at what write rate and transaction length does SQLite's single writer stop being enough, and how well does a queueing model predict it?

Method: record a production write trace (lessons 3 and 7 show how to measure it). Replay it against SQLite with a rollback journal, SQLite in WAL mode and PostgreSQL, and compare throughput, tail latency and lock waits with the ρ / (1 − ρ) prediction.

Contribution: a tested rule of thumb for when to move off SQLite, based on data instead of folklore.

Primary sources
Books and papers
  • Codd, “A Relational Model of Data for Large Shared Data Banks”, Communications of the ACM 13(6), 1970.
  • Hellerstein, Stonebraker & Hamilton, Architecture of a Database System, Foundations and Trends in Databases, 2007.
  • Gray & Reuter, Transaction Processing: Concepts and Techniques, Morgan Kaufmann, 1993.
  • Berenson et al., “A Critique of ANSI SQL Isolation Levels”, SIGMOD 1995.
  • Graefe, Modern B-Tree Techniques, Foundations and Trends in Databases, 2011.
  • Snodgrass, Developing Time-Oriented Database Applications in SQL, Morgan Kaufmann, 2000.
⚓
Northwind Traders
Microsoft's classic sample database, and every lesson it was built to teach, in SQLite
Every Terminal Output block on this tab is real, captured from sqlite3 3.45.1 on this site's server, running against the same northwind.db you can download below. Lessons that write anything end with ROLLBACK or are refused by a constraint, so your copy stays identical to the download. Each lesson also links its queries to the Northwind Explorer, a read-only copy you can query in your browser.
What Northwind is, and why it is still worth learning

Northwind Traders is a made-up company that imports speciality foods (Ipoh Coffee from Singapore, Chartreuse from France, Gorgonzola from Italy) and sells them to shops and restaurants in 21 countries. Microsoft created it as the sample database for Microsoft Access in the early 1990s, and SQL Server 7.0 and 2000 installed it alongside pubs. From SQL Server 2005, Microsoft replaced both with AdventureWorks, but Northwind stayed the database every SQL tutorial seemed to use. Microsoft still publishes the original install script, instnwnd.sql, on GitHub under the MIT licence.

It lasted because it is small enough to keep in your head and still realistic. In 13 tables it has a business you already understand and every relationship type: one-to-many, many-to-many through a junction table, and a table that points at itself. It has deliberate copies of data, NULLs with a meaning, derived money values, dates, pictures, and a set of saved reports. It also has 830 orders from July 1996 to May 1998, enough data that a wrong query gives an answer that looks wrong.

This tab teaches what Northwind was built to teach, in the order a course would: the model (tables, keys, relationships, normalisation), asking questions (filtering, NULL, joins, aggregates, dates, subqueries), Microsoft's own reports (views, stored procedures, and window functions, which Northwind predates), and keeping data correct (constraints, transactions, indexes, BLOBs). It ends with exercises and an account of what changed in the move to SQLite.

How the tables connect, and why

Every arrow on the diagram above is a foreign key: a column in one table that holds the primary key of a row in another table. Order 10248 has CustomerID = 'VINET', and that value is the entire link. Orders holds no copy of the customer's name or address, only the key, and a query uses the key to look up the rest. Each arrow starts at the column holding the key (the child table, the “many” side) and points to the table that key identifies (the parent table, the “one” side).

Why split the data at all? Northwind could have been a single wide spreadsheet with one row per order line and the customer, product, supplier and salesperson typed out on every row. Each customer's address would then be repeated on every line of every order they placed. To change a phone number you'd have to find every copy, and a copy you missed would contradict the others. A new customer couldn't be recorded until they ordered something. Splitting the data into tables stores each fact once, and the foreign keys put the pieces back together when you ask a question. Lesson 5 covers the few copies Northwind keeps on purpose.

Northwind's 13 tables are joined by 14 foreign keys. The table below groups them into four areas of the business and explains why each one exists.

Following a connection in SQL. To follow an arrow in a query, join the child's foreign-key column to the parent's primary key, for example JOIN Customers c ON c.CustomerID = o.CustomerID. The query below starts at order 10248 and follows eight arrows. Its three lines repeat the same customer, employee, manager and shipper on every row, because a one-to-many join copies the “one” side onto each of its “many” rows. That repetition exists only in the query result; the database still stores each fact once.

PRAGMA foreign_key_list shows a table's connections without the diagram. pragma_foreign_key_check lists every row that points at a key that doesn't exist; Northwind has none. SQLite enforces these links on new writes only when PRAGMA foreign_keys = ON is set (lesson 14).

Foreign key
A column whose values must match the primary key of a row in another table (or the same table).
Parent / child table
The parent table owns the key; the child table holds a copy of it in a foreign-key column. The arrow runs from child to parent.
Composite key
A primary key made of two or more columns, such as (OrderID, ProductID). Each junction table uses one.
Self-reference
A foreign key that points at its own table, like ReportsTo.
List a table's connections, then follow them
-- Ask SQLite which connections a table has PRAGMA foreign_key_list(Orders); -- Follow every arrow out from one order SELECT c.CompanyName AS customer, e.FirstName || ' ' || e.LastName AS employee, m.FirstName || ' ' || m.LastName AS manager, s.CompanyName AS shipper, p.ProductName AS product, cat.CategoryName AS category, sup.CompanyName AS supplier FROM Orders o JOIN Customers c ON c.CustomerID = o.CustomerID JOIN Employees e ON e.EmployeeID = o.EmployeeID LEFT JOIN Employees m ON m.EmployeeID = e.ReportsTo JOIN Shippers s ON s.ShipperID = o.ShipVia JOIN "Order Details" od ON od.OrderID = o.OrderID JOIN Products p ON p.ProductID = od.ProductID JOIN Categories cat ON cat.CategoryID = p.CategoryID JOIN Suppliers sup ON sup.SupplierID = p.SupplierID WHERE o.OrderID = 10248 ORDER BY od.ProductID; -- Check that no row points at a key that does not exist SELECT count(*) AS broken_links FROM pragma_foreign_key_check;
Terminal Output
sqlite> .mode table sqlite> PRAGMA foreign_key_list(Orders); +----+-----+-----------+------------+------------+-----------+-----------+-------+ | id | seq | table | from | to | on_update | on_delete | match | +----+-----+-----------+------------+------------+-----------+-----------+-------+ | 0 | 0 | Shippers | ShipVia | ShipperID | NO ACTION | NO ACTION | NONE | | 1 | 0 | Employees | EmployeeID | EmployeeID | NO ACTION | NO ACTION | NONE | | 2 | 0 | Customers | CustomerID | CustomerID | NO ACTION | NO ACTION | NONE | +----+-----+-----------+------------+------------+-----------+-----------+-------+ sqlite> SELECT c.CompanyName AS customer, ...> e.FirstName || ' ' || e.LastName AS employee, ...> m.FirstName || ' ' || m.LastName AS manager, ...> s.CompanyName AS shipper, ...> p.ProductName AS product, ...> cat.CategoryName AS category, ...> sup.CompanyName AS supplier ...> FROM Orders o ...> JOIN Customers c ON c.CustomerID = o.CustomerID ...> JOIN Employees e ON e.EmployeeID = o.EmployeeID ...> LEFT JOIN Employees m ON m.EmployeeID = e.ReportsTo ...> JOIN Shippers s ON s.ShipperID = o.ShipVia ...> JOIN "Order Details" od ON od.OrderID = o.OrderID ...> JOIN Products p ON p.ProductID = od.ProductID ...> JOIN Categories cat ON cat.CategoryID = p.CategoryID ...> JOIN Suppliers sup ON sup.SupplierID = p.SupplierID ...> WHERE o.OrderID = 10248 ...> ORDER BY od.ProductID; +---------------------------+-----------------+---------------+------------------+-------------------------------+----------------+------------------------------------+ | customer | employee | manager | shipper | product | category | supplier | +---------------------------+-----------------+---------------+------------------+-------------------------------+----------------+------------------------------------+ | Vins et alcools Chevalier | Steven Buchanan | Andrew Fuller | Federal Shipping | Queso Cabrales | Dairy Products | Cooperativa de Quesos 'Las Cabras' | | Vins et alcools Chevalier | Steven Buchanan | Andrew Fuller | Federal Shipping | Singaporean Hokkien Fried Mee | Grains/Cereals | Leka Trading | | Vins et alcools Chevalier | Steven Buchanan | Andrew Fuller | Federal Shipping | Mozzarella di Giovanni | Dairy Products | Formaggi Fortini s.r.l. | +---------------------------+-----------------+---------------+------------------+-------------------------------+----------------+------------------------------------+ sqlite> SELECT count(*) AS broken_links FROM pragma_foreign_key_check; +--------------+ | broken_links | +--------------+ | 0 | +--------------+
Run it in the read-only Explorer: Connections of Orders · Order 10248 across eight tables
🗂️
Part 1 · The Model
Tables, keys, and the three kinds of relationship Northwind was designed to show
1 · Meet Northwind: the tables

Northwind Traders imports speciality foods from around the world and resells them. Its database records each step of that business. Suppliers provide Products, which belong to Categories. Customers place Orders, each order has one or more lines in Order Details, an Employee takes the order, and one of three Shippers delivers it. Employees also cover sales Territories, grouped into four Regions.

.tables lists tables and views together: 13 tables and 16 views. The views are saved queries; Part 3 covers them. The first query uses UNION ALL to stack five one-row counts into a single result, which is a quick way to size up a database you've just opened.

One table name has a space in it, and the first time you type it you will get an error. Without quotes, SQLite reads Order as the start of ORDER BY, and the statement stops making sense. Double quotes turn any text into an identifier: "Order Details". (Single quotes make a string value, which is something different.) Microsoft kept the space from the original Access database. It teaches the quoting rule well, but in your own schemas use names that never need quotes.

Identifier
The name of a table, column, view or index. Double quotes let it contain spaces or reserved words.
UNION ALL
Stacks the rows of two SELECTs with the same number of columns, keeping duplicates.
.schema
A shell command that prints the CREATE statement SQLite stored for a table.
List the tables, count the rows, and meet the quoting rule
.tables -- How many rows in the five busiest tables? SELECT 'Customers' AS tbl, count(*) AS n FROM Customers UNION ALL SELECT 'Orders', count(*) FROM Orders UNION ALL SELECT 'Order Details', count(*) FROM "Order Details" UNION ALL SELECT 'Products', count(*) FROM Products UNION ALL SELECT 'Employees', count(*) FROM Employees; -- A table name with a space must be quoted SELECT count(*) FROM Order Details; SELECT count(*) FROM "Order Details"; .schema Shippers
Terminal Output
sqlite> .mode table sqlite> .tables Alphabetical list of products Orders Qry Categories Product Sales for 1997 Category Sales for 1997 Products Current Product List Products Above Average Price Customer and Suppliers by City Products by Category CustomerCustomerDemo Quarterly Orders CustomerDemographics Region Customers Sales Totals by Amount EmployeeTerritories Sales by Category Employees Shippers Invoices Summary of Sales by Quarter Order Details Summary of Sales by Year Order Details Extended Suppliers Order Subtotals Territories Orders sqlite> SELECT 'Customers' AS tbl, count(*) AS n FROM Customers ...> UNION ALL SELECT 'Orders', count(*) FROM Orders ...> UNION ALL SELECT 'Order Details', count(*) FROM "Order Details" ...> UNION ALL SELECT 'Products', count(*) FROM Products ...> UNION ALL SELECT 'Employees', count(*) FROM Employees; +---------------+------+ | tbl | n | +---------------+------+ | Customers | 91 | | Orders | 830 | | Order Details | 2155 | | Products | 77 | | Employees | 9 | +---------------+------+ sqlite> SELECT count(*) FROM Order Details; Parse error: near "Order": syntax error SELECT count(*) FROM Order Details; ^--- error here sqlite> SELECT count(*) FROM "Order Details"; +----------+ | count(*) | +----------+ | 2155 | +----------+ sqlite> .schema Shippers CREATE TABLE Shippers ( ShipperID INTEGER PRIMARY KEY, CompanyName TEXT NOT NULL, Phone TEXT );
Run it in the read-only Explorer: Row counts
2 · Primary keys: natural, surrogate and composite

Every table needs a primary key: a column, or group of columns, whose value is different in every row. Northwind uses all three common kinds. Customers have a natural key, a five-letter code made from the company name (ALFKI for Alfreds Futterkiste), which people can read and remember. Orders, products and employees have surrogate keys: plain numbers with no meaning of their own. A surrogate key never has to change. A natural key does when the thing it was made from changes, for example when a company is renamed.

Order Details has a composite key: (OrderID, ProductID). Neither column is unique on its own, because an order has many lines and a product appears on many orders, but the pair is. In pragma_table_info the pk column numbers the key's columns 1 and 2, and every other column gets 0. The key also enforces a business rule: an order can't list the same product twice. Try it and SQLite refuses the second line.

A foreign key is a column that holds another table's primary key. pragma_foreign_key_list('Orders') shows that each order points at one customer, one employee and one shipper. Microsoft named the shipper column ShipVia, not ShipperID, so a foreign key doesn't need the same name as the key it references.

Natural key
A key made from real data people already use, such as a customer code or an ISBN.
Surrogate key
A meaningless number assigned by the database. In SQLite, an INTEGER PRIMARY KEY.
Composite key
A primary key made of two or more columns that are unique only together.
Foreign key
A column whose values must exist as the primary key of another (or the same) table.
Inspect the keys
-- Natural key: a five-letter code people can read SELECT CustomerID, CompanyName FROM Customers LIMIT 3; -- Surrogate key: a number with no meaning of its own SELECT OrderID, CustomerID, EmployeeID, ShipVia FROM Orders LIMIT 3; -- Composite key: pk = 1 and 2 mark the two key columns SELECT name, type, pk FROM pragma_table_info('Order Details'); -- Where do the foreign keys in Orders point? SELECT "from", "table", "to" FROM pragma_foreign_key_list('Orders'); -- The composite key refuses a second line for the same product INSERT INTO "Order Details" (OrderID, ProductID, UnitPrice, Quantity) VALUES (10248, 11, 14, 1);
Terminal Output
sqlite> .mode table sqlite> SELECT CustomerID, CompanyName FROM Customers LIMIT 3; +------------+------------------------------------+ | CustomerID | CompanyName | +------------+------------------------------------+ | ALFKI | Alfreds Futterkiste | | ANATR | Ana Trujillo Emparedados y helados | | ANTON | Antonio Moreno Taquería | +------------+------------------------------------+ sqlite> SELECT OrderID, CustomerID, EmployeeID, ShipVia FROM Orders LIMIT 3; +---------+------------+------------+---------+ | OrderID | CustomerID | EmployeeID | ShipVia | +---------+------------+------------+---------+ | 10248 | VINET | 5 | 3 | | 10249 | TOMSP | 6 | 1 | | 10250 | HANAR | 4 | 2 | +---------+------------+------------+---------+ sqlite> SELECT name, type, pk FROM pragma_table_info('Order Details'); +-----------+---------+----+ | name | type | pk | +-----------+---------+----+ | OrderID | INTEGER | 1 | | ProductID | INTEGER | 2 | | UnitPrice | REAL | 0 | | Quantity | INTEGER | 0 | | Discount | REAL | 0 | +-----------+---------+----+ sqlite> SELECT "from", "table", "to" FROM pragma_foreign_key_list('Orders'); +------------+-----------+------------+ | from | table | to | +------------+-----------+------------+ | ShipVia | Shippers | ShipperID | | EmployeeID | Employees | EmployeeID | | CustomerID | Customers | CustomerID | +------------+-----------+------------+ sqlite> INSERT INTO "Order Details" (OrderID, ProductID, UnitPrice, Quantity) ...> VALUES (10248, 11, 14, 1); Runtime error: UNIQUE constraint failed: Order Details.OrderID, Order Details.ProductID (19)
Run it in the read-only Explorer: Customer keys · Order Details columns · Orders foreign keys
3 · One-to-many and many-to-many

Most of Northwind is one-to-many. One customer has many orders, one order has many lines, and one category has many products. The foreign key always sits on the many side: Orders.CustomerID, not a list of order numbers stored in Customers. Joining from the one side to the many side and counting is the most common question you'll ask a database.

Employees and territories are many-to-many. An employee covers several territories, and the design allows a territory to have several employees. A many-to-many relationship can't be stored with a single foreign key, so Northwind uses a junction table, EmployeeTerritories, with one row per link and a composite key made of two foreign keys. To get from employees to regions you join through it: Employees → EmployeeTerritories → Territories → Region.

The data is narrower than the design allows. The 49 links cover 49 different territories, so no territory is actually shared, and the LEFT JOIN finds four of the 53 territories that nobody covers. This is a real modelling lesson: the schema says what may be true, and only a query tells you what is true.

One-to-many
Each row on the "one" side matches zero or more rows on the "many" side. The foreign key lives on the many side.
Many-to-many
Rows on both sides can match several rows on the other. It needs a junction table.
Junction table
A table whose rows each link one row of table A to one row of table B, also called a bridge or link table.
Walk a one-to-many and a many-to-many relationship
-- One-to-many: one customer, many orders SELECT c.CompanyName, count(o.OrderID) AS orders FROM Customers c JOIN Orders o ON o.CustomerID = c.CustomerID GROUP BY c.CustomerID ORDER BY orders DESC LIMIT 5; -- Many-to-many: employees and territories, through a junction table SELECT e.FirstName || ' ' || e.LastName AS employee, r.RegionDescription AS region, count(*) AS territories FROM Employees e JOIN EmployeeTerritories et ON et.EmployeeID = e.EmployeeID JOIN Territories t ON t.TerritoryID = et.TerritoryID JOIN Region r ON r.RegionID = t.RegionID GROUP BY e.EmployeeID, r.RegionID ORDER BY e.EmployeeID; -- The design allows sharing a territory; the data never does SELECT count(*) AS links, count(DISTINCT TerritoryID) AS territories, count(DISTINCT EmployeeID) AS employees FROM EmployeeTerritories; -- LEFT JOIN finds the territories nobody covers SELECT t.TerritoryID, t.TerritoryDescription FROM Territories t LEFT JOIN EmployeeTerritories et ON et.TerritoryID = t.TerritoryID WHERE et.EmployeeID IS NULL;
Terminal Output
sqlite> .mode table sqlite> SELECT c.CompanyName, count(o.OrderID) AS orders ...> FROM Customers c JOIN Orders o ON o.CustomerID = c.CustomerID ...> GROUP BY c.CustomerID ORDER BY orders DESC LIMIT 5; +------------------------------+--------+ | CompanyName | orders | +------------------------------+--------+ | Save-a-lot Markets | 31 | | Ernst Handel | 30 | | QUICK-Stop | 28 | | Folk och fä HB | 19 | | Hungry Owl All-Night Grocers | 19 | +------------------------------+--------+ sqlite> SELECT e.FirstName || ' ' || e.LastName AS employee, ...> r.RegionDescription AS region, count(*) AS territories ...> FROM Employees e ...> JOIN EmployeeTerritories et ON et.EmployeeID = e.EmployeeID ...> JOIN Territories t ON t.TerritoryID = et.TerritoryID ...> JOIN Region r ON r.RegionID = t.RegionID ...> GROUP BY e.EmployeeID, r.RegionID ORDER BY e.EmployeeID; +------------------+----------+-------------+ | employee | region | territories | +------------------+----------+-------------+ | Nancy Davolio | Eastern | 2 | | Andrew Fuller | Eastern | 7 | | Janet Leverling | Southern | 4 | | Margaret Peacock | Eastern | 3 | | Steven Buchanan | Eastern | 7 | | Michael Suyama | Western | 5 | | Robert King | Western | 10 | | Laura Callahan | Northern | 4 | | Anne Dodsworth | Northern | 7 | +------------------+----------+-------------+ sqlite> SELECT count(*) AS links, count(DISTINCT TerritoryID) AS territories, ...> count(DISTINCT EmployeeID) AS employees ...> FROM EmployeeTerritories; +-------+-------------+-----------+ | links | territories | employees | +-------+-------------+-----------+ | 49 | 49 | 9 | +-------+-------------+-----------+ sqlite> SELECT t.TerritoryID, t.TerritoryDescription ...> FROM Territories t LEFT JOIN EmployeeTerritories et ON et.TerritoryID = t.TerritoryID ...> WHERE et.EmployeeID IS NULL; +-------------+----------------------+ | TerritoryID | TerritoryDescription | +-------------+----------------------+ | 29202 | Columbia | | 72716 | Bentonville | | 75234 | Dallas | | 78759 | Austin | +-------------+----------------------+
4 · Self-reference: the org chart

Employees.ReportsTo is a foreign key that points back into Employees. A table can reference itself, and this is how org charts, threaded comments, folder trees and bills of materials are usually stored. To read it you join the table to itself under two aliases: e for the employee and m for the manager. It has to be a LEFT JOIN, because Andrew Fuller, the Vice President, reports to nobody. An inner join would drop him.

A self-join climbs only one level. To walk the whole tree, however deep, use a recursive CTE. The first SELECT (the anchor) finds the root, the person with ReportsTo IS NULL. The second SELECT joins each new level to the rows found so far, and SQLite repeats it until a level comes back empty. The path column builds a breadcrumb like Fuller > Buchanan > King. Sorting by it puts each manager directly above their own staff, and depth drives the indent.

The tree is three levels deep. Fuller has five direct reports. One of them, Steven Buchanan, the Sales Manager, runs three more representatives of his own.

Self-referencing key
A foreign key that points at the primary key of its own table.
Table alias
A short second name for a table within one query (Employees e). It is required when the same table appears twice.
Recursive CTE
WITH RECURSIVE: an anchor query plus a recursive query that runs repeatedly until it adds no new rows.
Read the hierarchy one level, then all levels
-- Join Employees to itself: e is the employee, m the manager SELECT e.EmployeeID, e.FirstName || ' ' || e.LastName AS employee, e.Title, m.FirstName || ' ' || m.LastName AS reports_to FROM Employees e LEFT JOIN Employees m ON m.EmployeeID = e.ReportsTo; -- Walk the whole tree with a recursive CTE WITH RECURSIVE chart(EmployeeID, name, depth, path) AS ( SELECT EmployeeID, FirstName || ' ' || LastName, 0, LastName FROM Employees WHERE ReportsTo IS NULL UNION ALL SELECT e.EmployeeID, e.FirstName || ' ' || e.LastName, chart.depth + 1, chart.path || ' > ' || e.LastName FROM Employees e JOIN chart ON e.ReportsTo = chart.EmployeeID ) SELECT substr('........', 1, depth * 4) || name AS org_chart, depth FROM chart ORDER BY path;
Terminal Output
sqlite> .mode table sqlite> SELECT e.EmployeeID, e.FirstName || ' ' || e.LastName AS employee, e.Title, ...> m.FirstName || ' ' || m.LastName AS reports_to ...> FROM Employees e LEFT JOIN Employees m ON m.EmployeeID = e.ReportsTo; +------------+------------------+--------------------------+-----------------+ | EmployeeID | employee | Title | reports_to | +------------+------------------+--------------------------+-----------------+ | 1 | Nancy Davolio | Sales Representative | Andrew Fuller | | 2 | Andrew Fuller | Vice President, Sales | | | 3 | Janet Leverling | Sales Representative | Andrew Fuller | | 4 | Margaret Peacock | Sales Representative | Andrew Fuller | | 5 | Steven Buchanan | Sales Manager | Andrew Fuller | | 6 | Michael Suyama | Sales Representative | Steven Buchanan | | 7 | Robert King | Sales Representative | Steven Buchanan | | 8 | Laura Callahan | Inside Sales Coordinator | Andrew Fuller | | 9 | Anne Dodsworth | Sales Representative | Steven Buchanan | +------------+------------------+--------------------------+-----------------+ sqlite> WITH RECURSIVE chart(EmployeeID, name, depth, path) AS ( (x1...> SELECT EmployeeID, FirstName || ' ' || LastName, 0, LastName (x1...> FROM Employees WHERE ReportsTo IS NULL (x1...> UNION ALL (x1...> SELECT e.EmployeeID, e.FirstName || ' ' || e.LastName, chart.depth + 1, (x1...> chart.path || ' > ' || e.LastName (x1...> FROM Employees e JOIN chart ON e.ReportsTo = chart.EmployeeID (x1...> ) ...> SELECT substr('........', 1, depth * 4) || name AS org_chart, depth ...> FROM chart ORDER BY path; +------------------------+-------+ | org_chart | depth | +------------------------+-------+ | Andrew Fuller | 0 | | ....Steven Buchanan | 1 | | ........Anne Dodsworth | 2 | | ........Robert King | 2 | | ........Michael Suyama | 2 | | ....Laura Callahan | 1 | | ....Nancy Davolio | 1 | | ....Janet Leverling | 1 | | ....Margaret Peacock | 1 | +------------------------+-------+
Run it in the read-only Explorer: Who reports to whom · Org chart (recursive)
5 · Normalisation, and the copies kept on purpose

Normalisation means storing each fact in exactly one place. A category's name lives only in Categories, and a product stores only the number CategoryID. Rename Dairy Products once and all ten dairy products show the new name, because none of them holds a copy. Copies drift apart; references can't.

Northwind also breaks the rule twice, and it does so deliberately. Each Order Details line has its own UnitPrice, a copy of the product's price on the day of the sale. Queso Cabrales was sold at 14.00, then 16.80, then 21.00, and the catalogue now says 21.00. If the order line only pointed at Products, every old invoice would be re-priced each time the catalogue changed. Across the database, 658 of the 2,155 lines were charged a price that is no longer the list price.

The second copy is the ship-to address on every order. A customer can ask for delivery somewhere else. Around the Horn's office is on Hanover Square in London, but all 13 of its orders were shipped to Brook Farm near Colchester. An order also has to keep showing where it actually went after the customer moves. A copy is the right design when it records what was true at that moment, not a second version of a fact that is true now.

Normalisation
Organising tables so each fact is stored once and everything else refers to it by key.
Denormalisation
Storing a copy of data on purpose, usually to record history or to speed up reads.
Point-in-time value
A value copied at the moment of a transaction (price charged, address shipped to) that must not change later.
One place for current facts, copies for historical ones
-- A category's name is stored once; products hold only its id SELECT p.ProductName, p.CategoryID, c.CategoryName FROM Products p JOIN Categories c ON c.CategoryID = p.CategoryID WHERE p.ProductID IN (1, 11, 38); -- Queso Cabrales: what customers paid, and what it costs today SELECT od.UnitPrice AS charged, min(o.OrderDate) AS first_sale, max(o.OrderDate) AS last_sale, count(*) AS lines, p.UnitPrice AS list_price_now FROM "Order Details" od JOIN Orders o ON o.OrderID = od.OrderID JOIN Products p ON p.ProductID = od.ProductID WHERE od.ProductID = 11 GROUP BY od.UnitPrice ORDER BY first_sale; -- Across every order line SELECT count(*) AS lines, sum(od.UnitPrice <> p.UnitPrice) AS old_price FROM "Order Details" od JOIN Products p ON p.ProductID = od.ProductID; -- Each order keeps its own ship-to address too SELECT count(*) AS orders, sum(o.ShipAddress <> c.Address) AS other_address FROM Orders o JOIN Customers c ON c.CustomerID = o.CustomerID; SELECT o.CustomerID, c.Address AS customer_address, o.ShipAddress, count(*) AS orders FROM Orders o JOIN Customers c ON c.CustomerID = o.CustomerID WHERE o.CustomerID = 'AROUT' GROUP BY o.ShipAddress;
Terminal Output
sqlite> .mode table sqlite> SELECT p.ProductName, p.CategoryID, c.CategoryName ...> FROM Products p JOIN Categories c ON c.CategoryID = p.CategoryID ...> WHERE p.ProductID IN (1, 11, 38); +----------------+------------+----------------+ | ProductName | CategoryID | CategoryName | +----------------+------------+----------------+ | Chai | 1 | Beverages | | Queso Cabrales | 4 | Dairy Products | | Côte de Blaye | 1 | Beverages | +----------------+------------+----------------+ sqlite> SELECT od.UnitPrice AS charged, min(o.OrderDate) AS first_sale, ...> max(o.OrderDate) AS last_sale, count(*) AS lines, ...> p.UnitPrice AS list_price_now ...> FROM "Order Details" od ...> JOIN Orders o ON o.OrderID = od.OrderID ...> JOIN Products p ON p.ProductID = od.ProductID ...> WHERE od.ProductID = 11 GROUP BY od.UnitPrice ORDER BY first_sale; +---------+------------+------------+-------+----------------+ | charged | first_sale | last_sale | lines | list_price_now | +---------+------------+------------+-------+----------------+ | 14.0 | 1996-07-04 | 1996-07-04 | 1 | 21.0 | | 16.8 | 1996-09-03 | 1997-03-28 | 11 | 21.0 | | 21.0 | 1997-05-06 | 1998-05-05 | 26 | 21.0 | +---------+------------+------------+-------+----------------+ sqlite> SELECT count(*) AS lines, sum(od.UnitPrice <> p.UnitPrice) AS old_price ...> FROM "Order Details" od JOIN Products p ON p.ProductID = od.ProductID; +-------+-----------+ | lines | old_price | +-------+-----------+ | 2155 | 658 | +-------+-----------+ sqlite> SELECT count(*) AS orders, sum(o.ShipAddress <> c.Address) AS other_address ...> FROM Orders o JOIN Customers c ON c.CustomerID = o.CustomerID; +--------+---------------+ | orders | other_address | +--------+---------------+ | 830 | 48 | +--------+---------------+ sqlite> SELECT o.CustomerID, c.Address AS customer_address, o.ShipAddress, count(*) AS orders ...> FROM Orders o JOIN Customers c ON c.CustomerID = o.CustomerID ...> WHERE o.CustomerID = 'AROUT' GROUP BY o.ShipAddress; +------------+------------------+-------------------------------+--------+ | CustomerID | customer_address | ShipAddress | orders | +------------+------------------+-------------------------------+--------+ | AROUT | 120 Hanover Sq. | Brook Farm Stratford St. Mary | 13 | +------------+------------------+-------------------------------+--------+
🔎
Part 2 · Asking Questions
Filtering, NULLs, joins, aggregates, dates and subqueries against real business data
6 · Filtering, and the NULL trap

WHERE keeps the rows for which a condition is true. IN (...) is shorthand for several ORs, and in LIKE, % matches any run of characters. Northwind's data is international, so München and Münster come back with their umlauts intact.

Region is where Northwind teaches NULL. Only some countries' addresses have a state or province, so just 31 customers (mostly in the USA, Brazil, Venezuela and Canada) have one, and the other 60 have no region at all. NULL means “unknown”, and any comparison with an unknown value is itself unknown, never true. So Region = NULL matches nothing, not even the 60 NULLs. Use IS NULL. The same logic makes Region <> 'WA' return 28, not 88: the 60 customers with no region are not counted as “not WA”, because SQLite can't say what their region isn't.

Orders use NULL for “hasn't happened yet”: 21 orders, the oldest placed on 8 April 1998, have no ShippedDate. count(Region) counts only non-NULL values, which makes it a quick NULL census, and coalesce() replaces NULL with something readable for display.

NULL
A marker for a missing or unknown value. It is not zero and not an empty string.
Three-valued logic
In SQL a condition is TRUE, FALSE or UNKNOWN. WHERE keeps only rows where it is TRUE.
coalesce(a, b, ...)
Returns the first argument that is not NULL.
Filter rows, and handle the missing ones correctly
SELECT CompanyName, City, Country FROM Customers WHERE Country IN ('Germany', 'Austria') AND City LIKE 'M%'; -- Only 31 of 91 customers have a Region SELECT count(*) AS customers, count(Region) AS with_region FROM Customers; -- The trap: = NULL is never true SELECT count(*) FROM Customers WHERE Region = NULL; SELECT count(*) FROM Customers WHERE Region IS NULL; -- <> skips NULLs too: 31 customers have a Region, 3 of them 'WA' SELECT count(*) FROM Customers WHERE Region <> 'WA'; -- Orders not shipped yet have a NULL ShippedDate SELECT count(*) AS unshipped, min(OrderDate) AS oldest FROM Orders WHERE ShippedDate IS NULL; -- coalesce() swaps NULL for a value of your choosing SELECT CompanyName, coalesce(Region, '(none)') AS region FROM Customers WHERE Country = 'UK' LIMIT 3;
Terminal Output
sqlite> .mode table sqlite> SELECT CompanyName, City, Country FROM Customers ...> WHERE Country IN ('Germany', 'Austria') AND City LIKE 'M%'; +-------------------------+----------+---------+ | CompanyName | City | Country | +-------------------------+----------+---------+ | Blauer See Delikatessen | Mannheim | Germany | | Frankenversand | München | Germany | | Toms Spezialitäten | Münster | Germany | +-------------------------+----------+---------+ sqlite> SELECT count(*) AS customers, count(Region) AS with_region FROM Customers; +-----------+-------------+ | customers | with_region | +-----------+-------------+ | 91 | 31 | +-----------+-------------+ sqlite> SELECT count(*) FROM Customers WHERE Region = NULL; +----------+ | count(*) | +----------+ | 0 | +----------+ sqlite> SELECT count(*) FROM Customers WHERE Region IS NULL; +----------+ | count(*) | +----------+ | 60 | +----------+ sqlite> SELECT count(*) FROM Customers WHERE Region <> 'WA'; +----------+ | count(*) | +----------+ | 28 | +----------+ sqlite> SELECT count(*) AS unshipped, min(OrderDate) AS oldest ...> FROM Orders WHERE ShippedDate IS NULL; +-----------+------------+ | unshipped | oldest | +-----------+------------+ | 21 | 1998-04-08 | +-----------+------------+ sqlite> SELECT CompanyName, coalesce(Region, '(none)') AS region ...> FROM Customers WHERE Country = 'UK' LIMIT 3; +-----------------------+--------+ | CompanyName | region | +-----------------------+--------+ | Around the Horn | (none) | | B's Beverages | (none) | | Consolidated Holdings | (none) | +-----------------------+--------+
Run it in the read-only Explorer: IN and LIKE · IS NULL · Unshipped orders
7 · Joins, and the count(*) trap

An order row on its own is mostly numbers: VINET, 5, 3. A join follows each foreign key to the row it names and brings back readable columns. Each JOIN ... ON adds one table, and the ON clause says which key matches which. Four tables give the company, the salesperson's surname and the shipper's name for each order.

An inner join keeps only rows that have a match on both sides. A left join keeps every row from the left table, and where there is no match it fills the right table's columns with NULL. That makes LEFT JOIN ... WHERE right.key IS NULL the standard way to find rows with nothing related, also called an anti-join. In Northwind it finds the two customers who have never ordered, FISSA and Paris spécialités.

The trap comes when you count after a left join. A customer with no orders still produces one row, padded with NULLs, so count(*) reports 1 order for FISSA. count(o.OrderID) counts only non-NULL values, so it correctly reports 0. Whenever a left join feeds an aggregate, count a column from the right-hand table.

INNER JOIN
Returns only pairs of rows that satisfy the ON condition.
LEFT JOIN
Returns every left row once or more; missing right-hand values become NULL.
Anti-join
A query for rows that have no match: LEFT JOIN plus IS NULL, or NOT EXISTS.
Inner join, left join, and counting correctly
-- INNER JOIN: an order with its customer, salesperson and shipper SELECT o.OrderID, c.CompanyName, e.LastName AS employee, s.CompanyName AS shipper FROM Orders o JOIN Customers c ON c.CustomerID = o.CustomerID JOIN Employees e ON e.EmployeeID = o.EmployeeID JOIN Shippers s ON s.ShipperID = o.ShipVia ORDER BY o.OrderID LIMIT 5; -- LEFT JOIN keeps customers with no orders; their order columns are NULL SELECT c.CustomerID, c.CompanyName FROM Customers c LEFT JOIN Orders o ON o.CustomerID = c.CustomerID WHERE o.OrderID IS NULL; -- count(*) counts the padded row; count(column) does not SELECT c.CustomerID, count(*) AS wrong, count(o.OrderID) AS right FROM Customers c LEFT JOIN Orders o ON o.CustomerID = c.CustomerID WHERE c.CustomerID IN ('ALFKI', 'FISSA', 'PARIS') GROUP BY c.CustomerID;
Terminal Output
sqlite> .mode table sqlite> SELECT o.OrderID, c.CompanyName, e.LastName AS employee, s.CompanyName AS shipper ...> FROM Orders o ...> JOIN Customers c ON c.CustomerID = o.CustomerID ...> JOIN Employees e ON e.EmployeeID = o.EmployeeID ...> JOIN Shippers s ON s.ShipperID = o.ShipVia ...> ORDER BY o.OrderID LIMIT 5; +---------+---------------------------+-----------+------------------+ | OrderID | CompanyName | employee | shipper | +---------+---------------------------+-----------+------------------+ | 10248 | Vins et alcools Chevalier | Buchanan | Federal Shipping | | 10249 | Toms Spezialitäten | Suyama | Speedy Express | | 10250 | Hanari Carnes | Peacock | United Package | | 10251 | Victuailles en stock | Leverling | Speedy Express | | 10252 | Suprêmes délices | Peacock | United Package | +---------+---------------------------+-----------+------------------+ sqlite> SELECT c.CustomerID, c.CompanyName ...> FROM Customers c LEFT JOIN Orders o ON o.CustomerID = c.CustomerID ...> WHERE o.OrderID IS NULL; +------------+--------------------------------------+ | CustomerID | CompanyName | +------------+--------------------------------------+ | FISSA | FISSA Fabrica Inter. Salchichas S.A. | | PARIS | Paris spécialités | +------------+--------------------------------------+ sqlite> SELECT c.CustomerID, count(*) AS wrong, count(o.OrderID) AS right ...> FROM Customers c LEFT JOIN Orders o ON o.CustomerID = c.CustomerID ...> WHERE c.CustomerID IN ('ALFKI', 'FISSA', 'PARIS') ...> GROUP BY c.CustomerID; +------------+-------+-------+ | CustomerID | wrong | right | +------------+-------+-------+ | ALFKI | 6 | 6 | | FISSA | 1 | 0 | | PARIS | 1 | 0 | +------------+-------+-------+
8 · Revenue with GROUP BY and HAVING

Northwind has no revenue column. Revenue is derived: each order line is worth UnitPrice × Quantity × (1 − Discount), where Discount is a fraction such as 0.15. Storing only the inputs means the formula can never fall out of step with the data. Summed over all 2,155 lines, the total comes to $1,265,793.04.

When you round changes the answer. Rounding every line to the cent first, which is what an invoice does, gives $1,265,793.15, eleven cents more. Neither number is wrong: the first is the exact total and the second is what the invoices add up to. Decide which one a report means, and round once, at the step that matches it.

GROUP BY collapses rows into one row per group, and aggregate functions (sum, count, avg) summarise each group. count(DISTINCT od.OrderID) counts orders, not lines. WHERE filters rows before grouping, and HAVING filters groups after, so “employees with more than 100 orders” needs HAVING, because the count doesn't exist until the group does. Beverages leads revenue and Margaret Peacock leads the sales team with 156 orders.

Derived value
A value computed from stored columns when it is needed, instead of being stored.
GROUP BY
Forms one output row per distinct value of the grouping columns.
HAVING
A filter applied to groups after aggregation. WHERE runs before it.
Compute revenue and summarise it
-- Revenue for an order line: price x quantity, less the discount SELECT round(sum(UnitPrice * Quantity * (1 - Discount)), 2) AS revenue FROM "Order Details"; -- Rounding each line first changes the total SELECT round(sum(round(UnitPrice * Quantity * (1 - Discount), 2)), 2) AS rounded_lines FROM "Order Details"; -- GROUP BY: revenue per category SELECT c.CategoryName, count(DISTINCT od.OrderID) AS orders, round(sum(od.UnitPrice * od.Quantity * (1 - od.Discount)), 2) AS revenue FROM "Order Details" od JOIN Products p ON p.ProductID = od.ProductID JOIN Categories c ON c.CategoryID = p.CategoryID GROUP BY c.CategoryName ORDER BY revenue DESC; -- HAVING filters groups, after they are counted SELECT e.FirstName || ' ' || e.LastName AS employee, count(*) AS orders FROM Orders o JOIN Employees e ON e.EmployeeID = o.EmployeeID GROUP BY e.EmployeeID HAVING count(*) > 100 ORDER BY orders DESC;
Terminal Output
sqlite> .mode table sqlite> SELECT round(sum(UnitPrice * Quantity * (1 - Discount)), 2) AS revenue ...> FROM "Order Details"; +------------+ | revenue | +------------+ | 1265793.04 | +------------+ sqlite> SELECT round(sum(round(UnitPrice * Quantity * (1 - Discount), 2)), 2) AS rounded_lines ...> FROM "Order Details"; +---------------+ | rounded_lines | +---------------+ | 1265793.15 | +---------------+ sqlite> SELECT c.CategoryName, count(DISTINCT od.OrderID) AS orders, ...> round(sum(od.UnitPrice * od.Quantity * (1 - od.Discount)), 2) AS revenue ...> FROM "Order Details" od ...> JOIN Products p ON p.ProductID = od.ProductID ...> JOIN Categories c ON c.CategoryID = p.CategoryID ...> GROUP BY c.CategoryName ORDER BY revenue DESC; +----------------+--------+-----------+ | CategoryName | orders | revenue | +----------------+--------+-----------+ | Beverages | 354 | 267868.18 | | Dairy Products | 303 | 234507.29 | | Confections | 295 | 167357.23 | | Meat/Poultry | 161 | 163022.36 | | Seafood | 291 | 131261.74 | | Condiments | 193 | 106047.09 | | Produce | 129 | 99984.58 | | Grains/Cereals | 182 | 95744.59 | +----------------+--------+-----------+ sqlite> SELECT e.FirstName || ' ' || e.LastName AS employee, count(*) AS orders ...> FROM Orders o JOIN Employees e ON e.EmployeeID = o.EmployeeID ...> GROUP BY e.EmployeeID HAVING count(*) > 100 ORDER BY orders DESC; +------------------+--------+ | employee | orders | +------------------+--------+ | Margaret Peacock | 156 | | Janet Leverling | 127 | | Nancy Davolio | 123 | | Laura Callahan | 104 | +------------------+--------+
Run it in the read-only Explorer: Total revenue · Revenue by category · Employees with 100+ orders
9 · Dates stored as text

SQLite has no DATE type. Northwind's dates are stored as TEXT in ISO-8601 form, YYYY-MM-DD, and that choice does real work. ISO dates sort and compare correctly as plain strings, so BETWEEN '1997-01-01' AND '1997-12-31', min(), max() and ORDER BY all behave. Microsoft's script wrote dates as '7/4/1996'. Kept as text, that format compares character by character, so July 4th sorts after December 1st because '7' > '1'. Converting every date to ISO form was the most important step in bringing Northwind to SQLite.

SQLite's date functions read ISO text directly. strftime('%Y', d) extracts the year, and the month gives the quarter with (month + 2) / 3 in integer arithmetic. julianday() turns a date into a day number, so subtracting two of them gives the days in between.

The data runs from 4 July 1996 to 6 May 1998, so 1996 and 1998 are partial years. Compare 152 orders with 408 and you're comparing six months with twelve. Federal Shipping is the fastest carrier at 7.5 days on average, and United Package carries the most orders but is late most often.

ISO-8601
The date format YYYY-MM-DD (optionally with a time). It is the only text format that sorts in date order.
strftime(format, date)
Formats part of a date: %Y year, %m month, %d day, %W week.
julianday(date)
The number of days since 4714 BC as a REAL. The difference of two is a span in days.
Group, measure and compare by date
-- Dates are TEXT in ISO-8601 form SELECT OrderDate, typeof(OrderDate) FROM Orders LIMIT 1; -- Why the script's '7/4/1996' format had to go: text sorts character by character SELECT '7/4/1996' < '12/1/1996' AS us_format, '1996-07-04' < '1996-12-01' AS iso_format; SELECT strftime('%Y', OrderDate) AS year, count(*) AS orders, min(OrderDate) AS first, max(OrderDate) AS last FROM Orders GROUP BY year; -- Days between two dates: subtract their Julian day numbers SELECT s.CompanyName AS shipper, count(*) AS shipped, round(avg(julianday(o.ShippedDate) - julianday(o.OrderDate)), 1) AS avg_days, sum(o.ShippedDate > o.RequiredDate) AS late FROM Orders o JOIN Shippers s ON s.ShipperID = o.ShipVia WHERE o.ShippedDate IS NOT NULL GROUP BY s.ShipperID; -- Quarters: month 1-3 is Q1, and so on SELECT strftime('%Y', o.ShippedDate) || '-Q' || ((CAST(strftime('%m', o.ShippedDate) AS INTEGER) + 2) / 3) AS quarter, round(sum(s.Subtotal), 2) AS sales FROM Orders o JOIN "Order Subtotals" s ON s.OrderID = o.OrderID WHERE o.ShippedDate IS NOT NULL GROUP BY quarter;
Terminal Output
sqlite> .mode table sqlite> SELECT OrderDate, typeof(OrderDate) FROM Orders LIMIT 1; +------------+-------------------+ | OrderDate | typeof(OrderDate) | +------------+-------------------+ | 1996-07-04 | text | +------------+-------------------+ sqlite> SELECT '7/4/1996' < '12/1/1996' AS us_format, '1996-07-04' < '1996-12-01' AS iso_format; +-----------+------------+ | us_format | iso_format | +-----------+------------+ | 0 | 1 | +-----------+------------+ sqlite> SELECT strftime('%Y', OrderDate) AS year, count(*) AS orders, ...> min(OrderDate) AS first, max(OrderDate) AS last ...> FROM Orders GROUP BY year; +------+--------+------------+------------+ | year | orders | first | last | +------+--------+------------+------------+ | 1996 | 152 | 1996-07-04 | 1996-12-31 | | 1997 | 408 | 1997-01-01 | 1997-12-31 | | 1998 | 270 | 1998-01-01 | 1998-05-06 | +------+--------+------------+------------+ sqlite> SELECT s.CompanyName AS shipper, count(*) AS shipped, ...> round(avg(julianday(o.ShippedDate) - julianday(o.OrderDate)), 1) AS avg_days, ...> sum(o.ShippedDate > o.RequiredDate) AS late ...> FROM Orders o JOIN Shippers s ON s.ShipperID = o.ShipVia ...> WHERE o.ShippedDate IS NOT NULL GROUP BY s.ShipperID; +------------------+---------+----------+------+ | shipper | shipped | avg_days | late | +------------------+---------+----------+------+ | Speedy Express | 245 | 8.6 | 12 | | United Package | 315 | 9.2 | 16 | | Federal Shipping | 249 | 7.5 | 9 | +------------------+---------+----------+------+ sqlite> SELECT strftime('%Y', o.ShippedDate) || '-Q' || ...> ((CAST(strftime('%m', o.ShippedDate) AS INTEGER) + 2) / 3) AS quarter, ...> round(sum(s.Subtotal), 2) AS sales ...> FROM Orders o JOIN "Order Subtotals" s ON s.OrderID = o.OrderID ...> WHERE o.ShippedDate IS NOT NULL GROUP BY quarter; +---------+-----------+ | quarter | sales | +---------+-----------+ | 1996-Q3 | 63985.18 | | 1996-Q4 | 129331.4 | | 1997-Q1 | 143703.26 | | 1997-Q2 | 145654.81 | | 1997-Q3 | 144320.29 | | 1997-Q4 | 175168.57 | | 1998-Q1 | 276329.98 | | 1998-Q2 | 161362.25 | +---------+-----------+
Run it in the read-only Explorer: Orders per year · Shipper performance · Sales by quarter
10 · Subqueries and set operations

A subquery is a SELECT nested inside another. A scalar subquery returns one value and can go wherever a value can: Microsoft's view Products Above Average Price compares each price with (SELECT avg(UnitPrice) FROM Products). A correlated subquery refers to the outer row (p2.CategoryID = p.CategoryID), so it is worked out again for each product. That gives the most expensive product in each category without any grouping.

EXISTS asks whether a subquery returns any rows at all, and it stops at the first one. Combining EXISTS with NOT EXISTS describes a pattern that is awkward to write any other way: a customer who ordered in 1996 and never came back. Only Centro comercial Moctezuma fits.

Set operators combine whole result sets. UNION merges two lists and removes duplicates, and Microsoft's view Customer and Suppliers by City uses it to put customers and suppliers in one directory. EXCEPT keeps rows from the first query that the second doesn't have: nine countries where Northwind sells but buys nothing.

Scalar subquery
A subquery that returns exactly one value.
Correlated subquery
A subquery that refers to columns of the outer query, so it is evaluated for each outer row.
UNION / EXCEPT / INTERSECT
Combine two SELECTs: everything, first-minus-second, and only rows in both.
Nest queries and combine result sets
-- Scalar subquery: compare each price with one computed number SELECT ProductName, UnitPrice FROM Products WHERE UnitPrice > (SELECT avg(UnitPrice) FROM Products) ORDER BY UnitPrice DESC LIMIT 5; -- Correlated subquery: re-run for every outer row (its own category) SELECT c.CategoryName, p.ProductName, p.UnitPrice FROM Products p JOIN Categories c ON c.CategoryID = p.CategoryID WHERE p.UnitPrice = (SELECT max(p2.UnitPrice) FROM Products p2 WHERE p2.CategoryID = p.CategoryID) ORDER BY p.UnitPrice DESC; -- NOT EXISTS: customers who ordered in 1996 and never again SELECT c.CustomerID, c.CompanyName FROM Customers c WHERE EXISTS (SELECT 1 FROM Orders o WHERE o.CustomerID = c.CustomerID AND o.OrderDate < '1997-01-01') AND NOT EXISTS (SELECT 1 FROM Orders o WHERE o.CustomerID = c.CustomerID AND o.OrderDate >= '1997-01-01'); -- EXCEPT: countries with customers but no suppliers SELECT Country FROM Customers EXCEPT SELECT Country FROM Suppliers; -- UNION: Microsoft's view merges two tables into one list SELECT * FROM "Customer and Suppliers by City" WHERE City = 'London';
Terminal Output
sqlite> .mode table sqlite> SELECT ProductName, UnitPrice FROM Products ...> WHERE UnitPrice > (SELECT avg(UnitPrice) FROM Products) ...> ORDER BY UnitPrice DESC LIMIT 5; +-------------------------+-----------+ | ProductName | UnitPrice | +-------------------------+-----------+ | Côte de Blaye | 263.5 | | Thüringer Rostbratwurst | 123.79 | | Mishi Kobe Niku | 97.0 | | Sir Rodney's Marmalade | 81.0 | | Carnarvon Tigers | 62.5 | +-------------------------+-----------+ sqlite> SELECT c.CategoryName, p.ProductName, p.UnitPrice ...> FROM Products p JOIN Categories c ON c.CategoryID = p.CategoryID ...> WHERE p.UnitPrice = (SELECT max(p2.UnitPrice) FROM Products p2 (x1...> WHERE p2.CategoryID = p.CategoryID) ...> ORDER BY p.UnitPrice DESC; +----------------+-------------------------+-----------+ | CategoryName | ProductName | UnitPrice | +----------------+-------------------------+-----------+ | Beverages | Côte de Blaye | 263.5 | | Meat/Poultry | Thüringer Rostbratwurst | 123.79 | | Confections | Sir Rodney's Marmalade | 81.0 | | Seafood | Carnarvon Tigers | 62.5 | | Dairy Products | Raclette Courdavault | 55.0 | | Produce | Manjimup Dried Apples | 53.0 | | Condiments | Vegie-spread | 43.9 | | Grains/Cereals | Gnocchi di nonna Alice | 38.0 | +----------------+-------------------------+-----------+ sqlite> SELECT c.CustomerID, c.CompanyName FROM Customers c ...> WHERE EXISTS (SELECT 1 FROM Orders o WHERE o.CustomerID = c.CustomerID (x1...> AND o.OrderDate < '1997-01-01') ...> AND NOT EXISTS (SELECT 1 FROM Orders o WHERE o.CustomerID = c.CustomerID (x1...> AND o.OrderDate >= '1997-01-01'); +------------+----------------------------+ | CustomerID | CompanyName | +------------+----------------------------+ | CENTC | Centro comercial Moctezuma | +------------+----------------------------+ sqlite> SELECT Country FROM Customers EXCEPT SELECT Country FROM Suppliers; +-------------+ | Country | +-------------+ | Argentina | | Austria | | Belgium | | Ireland | | Mexico | | Poland | | Portugal | | Switzerland | | Venezuela | +-------------+ sqlite> SELECT * FROM "Customer and Suppliers by City" WHERE City = 'London'; +--------+-----------------------+-------------------+--------------+ | City | CompanyName | ContactName | Relationship | +--------+-----------------------+-------------------+--------------+ | London | Around the Horn | Thomas Hardy | Customers | | London | B's Beverages | Victoria Ashworth | Customers | | London | Consolidated Holdings | Elizabeth Brown | Customers | | London | Eastern Connection | Ann Devon | Customers | | London | Exotic Liquids | Charlotte Cooper | Suppliers | | London | North/South | Simon Crowther | Customers | | London | Seven Seas Imports | Hari Kumar | Customers | +--------+-----------------------+-------------------+--------------+
📊
Part 3 · Microsoft's Reports
The views and stored procedures that shipped with Northwind, translated to SQLite
11 · Microsoft's views, translated

A view is a named SELECT. It holds no data of its own: .schema shows that Order Subtotals stores only its query, and SQLite runs that query every time you select from the view. Northwind ships 16 views, and they were its main teaching aid. Each one is a report someone would really ask for (Invoices, Sales by Category, Quarterly Orders), saved so that nobody has to rewrite the join.

Views can be built on views. Order Subtotals sums each order's lines, Product Sales for 1997 uses it, and Category Sales for 1997 is built on that. Each layer is simple, and together they answer a hard question. Name the layers well and a report becomes a short chain of readable steps.

All 16 views are written in Microsoft's dialect, T-SQL, and several needed translating. The last query shows why. T-SQL joins strings with +, but in SQLite + always means arithmetic, so 'Nancy' + ' ' + 'Davolio' becomes 0 + 0 + 0 without any error. String concatenation in SQLite is ||. The table under About this conversion lists every change.

View
A stored SELECT you can query like a table. It is re-evaluated on every use.
T-SQL
Transact-SQL, the dialect of Microsoft SQL Server, which Northwind's script was written in.
|| (concatenation)
SQLite's string-join operator. + is numeric addition and turns text into 0.
Query the views and see how they are stored
SELECT name FROM sqlite_schema WHERE type = 'view' ORDER BY name; -- A view on a view on a view: Order Subtotals -> Product Sales for 1997 -> this SELECT * FROM "Category Sales for 1997" ORDER BY CategorySales DESC; SELECT * FROM "Sales Totals by Amount" ORDER BY SaleAmount DESC LIMIT 5; -- A view stores its SELECT, not its rows .schema "Order Subtotals" -- Translating Invoices: T-SQL joins strings with +, SQLite with || SELECT FirstName + ' ' + LastName AS tsql_style, FirstName || ' ' || LastName AS sqlite_style FROM Employees LIMIT 2;
Terminal Output
sqlite> .mode table sqlite> SELECT name FROM sqlite_schema WHERE type = 'view' ORDER BY name; +--------------------------------+ | name | +--------------------------------+ | Alphabetical list of products | | Category Sales for 1997 | | Current Product List | | Customer and Suppliers by City | | Invoices | | Order Details Extended | | Order Subtotals | | Orders Qry | | Product Sales for 1997 | | Products Above Average Price | | Products by Category | | Quarterly Orders | | Sales Totals by Amount | | Sales by Category | | Summary of Sales by Quarter | | Summary of Sales by Year | +--------------------------------+ sqlite> SELECT * FROM "Category Sales for 1997" ORDER BY CategorySales DESC; +----------------+---------------+ | CategoryName | CategorySales | +----------------+---------------+ | Dairy Products | 114749.79 | | Beverages | 102074.32 | | Meat/Poultry | 81338.06 | | Confections | 80894.15 | | Seafood | 65544.19 | | Grains/Cereals | 55948.83 | | Condiments | 55277.61 | | Produce | 53019.98 | +----------------+---------------+ sqlite> SELECT * FROM "Sales Totals by Amount" ORDER BY SaleAmount DESC LIMIT 5; +------------+---------+----------------------------+-------------+ | SaleAmount | OrderID | CompanyName | ShippedDate | +------------+---------+----------------------------+-------------+ | 11188.4 | 10417 | Simons bistro | 1997-01-28 | | 10495.6 | 10479 | Rattlesnake Canyon Grocery | 1997-03-21 | | 10191.7 | 10540 | QUICK-Stop | 1997-06-13 | | 10164.8 | 10691 | QUICK-Stop | 1997-10-22 | | 9921.3 | 10515 | QUICK-Stop | 1997-05-23 | +------------+---------+----------------------------+-------------+ sqlite> .schema "Order Subtotals" CREATE VIEW "Order Subtotals" AS SELECT OrderID, sum(round(UnitPrice * Quantity * (1 - Discount), 2)) AS Subtotal FROM "Order Details" GROUP BY OrderID /* "Order Subtotals"(OrderID,Subtotal) */; sqlite> SELECT FirstName + ' ' + LastName AS tsql_style, ...> FirstName || ' ' || LastName AS sqlite_style ...> FROM Employees LIMIT 2; +------------+---------------+ | tsql_style | sqlite_style | +------------+---------------+ | 0 | Nancy Davolio | | 0 | Andrew Fuller | +------------+---------------+
12 · Stored procedures become parameterised queries

Northwind's SQL Server version also ships seven stored procedures: queries with named parameters such as CustOrderHist @CustomerID, stored and run by the server. SQLite has none. It is a library linked into your program, not a server, so your program is the place to keep the query. The idea that carries over is the parameter: write the SQL once with a placeholder, and supply the value separately each time you run it.

The sqlite3 shell can bind parameters with .parameter set. Watch the types. An unquoted 1997 is bound as an INTEGER, and strftime() returns TEXT, so the comparison is false and SalesByCategory would return nothing at all. Quoting the value twice, "'1997'", binds it as text. Ten Most Expensive Products needs no parameter: its SET ROWCOUNT 10 becomes LIMIT 10.

In application code you pass parameters with the driver's placeholders, ? in Python. The value never becomes part of the SQL text, so a customer ID like x' OR '1'='1 is looked up as a strange ID, not run as SQL. That protection against SQL injection is the main reason stored procedures were popular, and parameterised queries give you the same protection.

Stored procedure
A named, parameterised program stored inside a database server. SQLite has none.
Bound parameter
A placeholder (?, :name, @name) whose value is supplied separately from the SQL text.
SQL injection
An attack in which user input is pasted into SQL text and changes what the statement does.
Run Microsoft's procedures as parameterised queries
-- Ten Most Expensive Products: SET ROWCOUNT 10 becomes LIMIT 10 SELECT ProductName AS TenMostExpensiveProducts, UnitPrice FROM Products ORDER BY UnitPrice DESC LIMIT 10; -- CustOrderHist @CustomerID: bind the parameter, then run the query .parameter set @CustomerID 'ALFKI' SELECT p.ProductName, sum(od.Quantity) AS Total FROM Products p JOIN "Order Details" od ON od.ProductID = p.ProductID JOIN Orders o ON o.OrderID = od.OrderID WHERE o.CustomerID = @CustomerID GROUP BY p.ProductName ORDER BY p.ProductName; -- SalesByCategory @CategoryName, @OrdYear .parameter set @CategoryName 'Seafood' -- Careful: an unquoted 1997 is bound as an INTEGER, and strftime() returns TEXT .parameter set @OrdYear 1997 SELECT strftime('%Y', '1997-03-01') = @OrdYear AS matches; .parameter set @OrdYear "'1997'" SELECT strftime('%Y', '1997-03-01') = @OrdYear AS matches; .parameter list SELECT p.ProductName, round(sum(od.Quantity * (1 - od.Discount) * od.UnitPrice), 0) AS TotalPurchase FROM "Order Details" od JOIN Orders o ON o.OrderID = od.OrderID JOIN Products p ON p.ProductID = od.ProductID JOIN Categories c ON c.CategoryID = p.CategoryID WHERE c.CategoryName = @CategoryName AND strftime('%Y', o.OrderDate) = @OrdYear GROUP BY p.ProductName ORDER BY p.ProductName;
Terminal Output
sqlite> .mode table sqlite> SELECT ProductName AS TenMostExpensiveProducts, UnitPrice ...> FROM Products ORDER BY UnitPrice DESC LIMIT 10; +--------------------------+-----------+ | TenMostExpensiveProducts | UnitPrice | +--------------------------+-----------+ | Côte de Blaye | 263.5 | | Thüringer Rostbratwurst | 123.79 | | Mishi Kobe Niku | 97.0 | | Sir Rodney's Marmalade | 81.0 | | Carnarvon Tigers | 62.5 | | Raclette Courdavault | 55.0 | | Manjimup Dried Apples | 53.0 | | Tarte au sucre | 49.3 | | Ipoh Coffee | 46.0 | | Rössle Sauerkraut | 45.6 | +--------------------------+-----------+ sqlite> .parameter set @CustomerID 'ALFKI' sqlite> SELECT p.ProductName, sum(od.Quantity) AS Total ...> FROM Products p ...> JOIN "Order Details" od ON od.ProductID = p.ProductID ...> JOIN Orders o ON o.OrderID = od.OrderID ...> WHERE o.CustomerID = @CustomerID ...> GROUP BY p.ProductName ORDER BY p.ProductName; +---------------------------------+-------+ | ProductName | Total | +---------------------------------+-------+ | Aniseed Syrup | 6 | | Chartreuse verte | 21 | | Escargots de Bourgogne | 40 | | Flotemysost | 20 | | Grandma's Boysenberry Spread | 16 | | Lakkalikööri | 15 | | Original Frankfurter grüne Soße | 2 | | Raclette Courdavault | 15 | | Rössle Sauerkraut | 17 | | Spegesild | 2 | | Vegie-spread | 20 | +---------------------------------+-------+ sqlite> .parameter set @CategoryName 'Seafood' sqlite> .parameter set @OrdYear 1997 sqlite> SELECT strftime('%Y', '1997-03-01') = @OrdYear AS matches; +---------+ | matches | +---------+ | 0 | +---------+ sqlite> .parameter set @OrdYear "'1997'" sqlite> SELECT strftime('%Y', '1997-03-01') = @OrdYear AS matches; +---------+ | matches | +---------+ | 1 | +---------+ sqlite> .parameter list @CategoryName 'Seafood' @CustomerID 'ALFKI' @OrdYear '1997' sqlite> SELECT p.ProductName, ...> round(sum(od.Quantity * (1 - od.Discount) * od.UnitPrice), 0) AS TotalPurchase ...> FROM "Order Details" od ...> JOIN Orders o ON o.OrderID = od.OrderID ...> JOIN Products p ON p.ProductID = od.ProductID ...> JOIN Categories c ON c.CategoryID = p.CategoryID ...> WHERE c.CategoryName = @CategoryName AND strftime('%Y', o.OrderDate) = @OrdYear ...> GROUP BY p.ProductName ORDER BY p.ProductName; +---------------------------------+---------------+ | ProductName | TotalPurchase | +---------------------------------+---------------+ | Boston Crab Meat | 9815.0 | | Carnarvon Tigers | 15950.0 | | Escargots de Bourgogne | 2076.0 | | Gravad lax | 629.0 | | Ikura | 9936.0 | | Inlagd Sill | 6894.0 | | Jack's New England Clam Chowder | 4957.0 | | Konbu | 813.0 | | Nord-Ost Matjeshering | 6034.0 | | Rogede sild | 3356.0 | | Röd Kaviar | 3518.0 | | Spegesild | 2981.0 | +---------------------------------+---------------+
The same thing from Python

Save as cust_order_hist.py next to northwind.db. The second customer ID is an injection attempt. Because it is passed as a parameter, it is only ever compared as a value.

import sqlite3 con = sqlite3.connect('northwind.db') # Microsoft's CustOrderHist procedure, as a parameterised query CUST_ORDER_HIST = """ SELECT p.ProductName, sum(od.Quantity) AS Total FROM Products p JOIN "Order Details" od ON od.ProductID = p.ProductID JOIN Orders o ON o.OrderID = od.OrderID WHERE o.CustomerID = ? GROUP BY p.ProductName ORDER BY Total DESC, p.ProductName LIMIT 3 """ for customer in ('ALFKI', "x' OR '1'='1"): rows = con.execute(CUST_ORDER_HIST, (customer,)).fetchall() print(repr(customer), '->', rows)
Terminal Output
$ python3 cust_order_hist.py 'ALFKI' -> [('Escargots de Bourgogne', 40), ('Chartreuse verte', 21), ('Flotemysost', 20)] "x' OR '1'='1" -> []
Run it in the read-only Explorer: Ten Most Expensive Products
13 · Window functions: reports Northwind couldn't write

Northwind's reports were written for SQL Server 2000, which had no window functions. A window function computes across a set of related rows, as an aggregate does, but it keeps every row instead of collapsing them. OVER (PARTITION BY Country ORDER BY revenue DESC) means “within each country, ranked by revenue”, so every customer gets a rank in its own country in a single pass.

sum(sales) OVER (ORDER BY month) is a running total: the sum of this month and every month before it. lag(sales) reads the previous row's value, which gives month-over-month change. The first month has nothing before it, so its change is NULL. Before window functions, both of these needed a self-join or a correlated subquery for every row.

row_number() numbers the rows within each partition, and filtering on that number in an outer query gives a top-N per group, here the two best-selling products in each category. SQLite has supported window functions since version 3.25 (2018).

Window function
A function evaluated over a "window" of rows related to the current row, without grouping them away.
PARTITION BY
Splits rows into independent windows, one per value.
rank() / row_number() / lag()
Rank with ties, a unique sequence number, and the value from the previous row.
Rank, accumulate and compare across rows
-- rank(): each customer's place within its own country WITH revenue AS ( SELECT c.Country, c.CompanyName, round(sum(od.UnitPrice * od.Quantity * (1 - od.Discount)), 2) AS revenue FROM Customers c JOIN Orders o ON o.CustomerID = c.CustomerID JOIN "Order Details" od ON od.OrderID = o.OrderID GROUP BY c.CustomerID ) SELECT Country, CompanyName, revenue, rank() OVER (PARTITION BY Country ORDER BY revenue DESC) AS rank_in_country FROM revenue WHERE Country IN ('Mexico', 'UK') ORDER BY Country, rank_in_country; -- Running total and month-over-month change WITH monthly AS ( SELECT strftime('%Y-%m', o.OrderDate) AS month, round(sum(s.Subtotal), 2) AS sales FROM Orders o JOIN "Order Subtotals" s ON s.OrderID = o.OrderID GROUP BY month ) SELECT month, sales, round(sum(sales) OVER (ORDER BY month), 2) AS running_total, round(sales - lag(sales) OVER (ORDER BY month), 2) AS change FROM monthly LIMIT 6; -- row_number(): the two best-selling products in each category SELECT CategoryName, ProductName, units FROM ( SELECT c.CategoryName, p.ProductName, sum(od.Quantity) AS units, row_number() OVER (PARTITION BY c.CategoryID ORDER BY sum(od.Quantity) DESC) AS rn FROM "Order Details" od JOIN Products p ON p.ProductID = od.ProductID JOIN Categories c ON c.CategoryID = p.CategoryID GROUP BY p.ProductID ) WHERE rn <= 2;
Terminal Output
sqlite> .mode table sqlite> WITH revenue AS ( (x1...> SELECT c.Country, c.CompanyName, (x1...> round(sum(od.UnitPrice * od.Quantity * (1 - od.Discount)), 2) AS revenue (x1...> FROM Customers c (x1...> JOIN Orders o ON o.CustomerID = c.CustomerID (x1...> JOIN "Order Details" od ON od.OrderID = o.OrderID (x1...> GROUP BY c.CustomerID (x1...> ) ...> SELECT Country, CompanyName, revenue, ...> rank() OVER (PARTITION BY Country ORDER BY revenue DESC) AS rank_in_country ...> FROM revenue WHERE Country IN ('Mexico', 'UK') ...> ORDER BY Country, rank_in_country; +---------+------------------------------------+----------+-----------------+ | Country | CompanyName | revenue | rank_in_country | +---------+------------------------------------+----------+-----------------+ | Mexico | Tortuga Restaurante | 10812.15 | 1 | | Mexico | Antonio Moreno Taquería | 7023.98 | 2 | | Mexico | Pericles Comidas clásicas | 4242.2 | 3 | | Mexico | Ana Trujillo Emparedados y helados | 1402.95 | 4 | | Mexico | Centro comercial Moctezuma | 100.8 | 5 | | UK | Seven Seas Imports | 16215.32 | 1 | | UK | Eastern Connection | 14761.03 | 2 | | UK | Around the Horn | 13390.65 | 3 | | UK | Island Trading | 6146.3 | 4 | | UK | B's Beverages | 6089.9 | 5 | | UK | Consolidated Holdings | 1719.1 | 6 | | UK | North/South | 649.0 | 7 | +---------+------------------------------------+----------+-----------------+ sqlite> WITH monthly AS ( (x1...> SELECT strftime('%Y-%m', o.OrderDate) AS month, round(sum(s.Subtotal), 2) AS sales (x1...> FROM Orders o JOIN "Order Subtotals" s ON s.OrderID = o.OrderID (x1...> GROUP BY month (x1...> ) ...> SELECT month, sales, ...> round(sum(sales) OVER (ORDER BY month), 2) AS running_total, ...> round(sales - lag(sales) OVER (ORDER BY month), 2) AS change ...> FROM monthly LIMIT 6; +---------+----------+---------------+----------+ | month | sales | running_total | change | +---------+----------+---------------+----------+ | 1996-07 | 27861.9 | 27861.9 | | | 1996-08 | 25485.28 | 53347.18 | -2376.62 | | 1996-09 | 26381.4 | 79728.58 | 896.12 | | 1996-10 | 37515.73 | 117244.31 | 11134.33 | | 1996-11 | 45600.05 | 162844.36 | 8084.32 | | 1996-12 | 45239.63 | 208083.99 | -360.42 | +---------+----------+---------------+----------+ sqlite> SELECT CategoryName, ProductName, units FROM ( (x1...> SELECT c.CategoryName, p.ProductName, sum(od.Quantity) AS units, (x1...> row_number() OVER (PARTITION BY c.CategoryID (x2...> ORDER BY sum(od.Quantity) DESC) AS rn (x1...> FROM "Order Details" od (x1...> JOIN Products p ON p.ProductID = od.ProductID (x1...> JOIN Categories c ON c.CategoryID = p.CategoryID (x1...> GROUP BY p.ProductID (x1...> ) WHERE rn <= 2; +----------------+----------------------------------+-------+ | CategoryName | ProductName | units | +----------------+----------------------------------+-------+ | Beverages | Rhönbräu Klosterbier | 1155 | | Beverages | Guaraná Fantástica | 1125 | | Condiments | Original Frankfurter grüne Soße | 791 | | Condiments | Louisiana Fiery Hot Pepper Sauce | 745 | | Confections | Pavlova | 1158 | | Confections | Tarte au sucre | 1083 | | Dairy Products | Camembert Pierrot | 1577 | | Dairy Products | Raclette Courdavault | 1496 | | Grains/Cereals | Gnocchi di nonna Alice | 1263 | | Grains/Cereals | Wimmers gute Semmelknödel | 740 | | Meat/Poultry | Alice Mutton | 978 | | Meat/Poultry | Pâté chinois | 903 | | Produce | Manjimup Dried Apples | 886 | | Produce | Uncle Bob's Organic Dried Pears | 763 | | Seafood | Boston Crab Meat | 1103 | | Seafood | Jack's New England Clam Chowder | 981 | +----------------+----------------------------------+-------+
🛡️
Part 4 · Keeping Data Correct
Constraints, transactions, indexes and the pictures stored as BLOBs
14 · Constraints: what the database refuses

The most important line in this lesson is the first one. SQLite does not enforce foreign keys unless you turn them on, and the setting applies only to the current connection. With PRAGMA foreign_keys at its default of 0, an order for a customer called ZZZZZ, who doesn't exist, goes straight in. Run PRAGMA foreign_keys = ON and the same INSERT is refused. Deleting ALFKI is refused too, because six orders still point at it. Some frameworks, Django among them, turn it on for every connection they open. In the shell you have to do it yourself.

CHECK constraints enforce rules within a row. The SQLite file keeps Microsoft's rules: quantities must be positive, a discount must be between 0 and 1, and stock can't go below zero. The error message quotes the rule that failed. One rule couldn't be kept: CK_Birthdate required BirthDate < getdate(). SQLite refuses date('now') inside a CHECK, because a check must give the same answer every time it runs and “now” keeps changing. The SQLite file checks BirthDate < HireDate instead.

PRAGMA foreign_key_check scans every existing row for broken references and prints nothing when there are none. Run it after loading data, or after any session in which foreign keys were off.

PRAGMA foreign_keys
Turns foreign key enforcement on (1) or off (0). It is off by default and must be set on each connection.
CHECK constraint
A condition that every row must satisfy. It is tested on INSERT and UPDATE.
Deterministic
Always giving the same result for the same inputs. CHECK constraints may only use deterministic functions.
Try to break the rules (nothing here is saved)
On your own copy: the first INSERT really happens, and only the ROLLBACK removes it. Every other statement is refused, so none of this lesson changes the file.
-- Foreign keys are OFF unless you turn them on, per connection PRAGMA foreign_keys; BEGIN; INSERT INTO Orders (CustomerID, EmployeeID, OrderDate) VALUES ('ZZZZZ', 1, '1998-05-07'); SELECT OrderID, CustomerID FROM Orders WHERE CustomerID = 'ZZZZZ'; ROLLBACK; -- Now with enforcement on PRAGMA foreign_keys = ON; INSERT INTO Orders (CustomerID, EmployeeID, OrderDate) VALUES ('ZZZZZ', 1, '1998-05-07'); -- A parent with children cannot be deleted DELETE FROM Customers WHERE CustomerID = 'ALFKI'; -- CHECK constraints copied from Microsoft (CK_Quantity, CK_Discount) UPDATE "Order Details" SET Quantity = 0 WHERE OrderID = 10248 AND ProductID = 11; UPDATE "Order Details" SET Discount = 1.5 WHERE OrderID = 10248 AND ProductID = 11; -- CK_Birthdate used getdate(); SQLite won't run date('now') in a CHECK CREATE TEMP TABLE t (BirthDate TEXT CHECK (BirthDate < date('now'))); INSERT INTO t VALUES ('1948-12-08'); -- Every existing row passes: no output means no violations PRAGMA foreign_key_check;
Terminal Output
sqlite> .mode table sqlite> PRAGMA foreign_keys; +--------------+ | foreign_keys | +--------------+ | 0 | +--------------+ sqlite> BEGIN; sqlite> INSERT INTO Orders (CustomerID, EmployeeID, OrderDate) VALUES ('ZZZZZ', 1, '1998-05-07'); sqlite> SELECT OrderID, CustomerID FROM Orders WHERE CustomerID = 'ZZZZZ'; +---------+------------+ | OrderID | CustomerID | +---------+------------+ | 11078 | ZZZZZ | +---------+------------+ sqlite> ROLLBACK; sqlite> PRAGMA foreign_keys = ON; sqlite> INSERT INTO Orders (CustomerID, EmployeeID, OrderDate) VALUES ('ZZZZZ', 1, '1998-05-07'); Runtime error: FOREIGN KEY constraint failed (19) sqlite> DELETE FROM Customers WHERE CustomerID = 'ALFKI'; Runtime error: FOREIGN KEY constraint failed (19) sqlite> UPDATE "Order Details" SET Quantity = 0 WHERE OrderID = 10248 AND ProductID = 11; Runtime error: CHECK constraint failed: Quantity > 0 (19) sqlite> UPDATE "Order Details" SET Discount = 1.5 WHERE OrderID = 10248 AND ProductID = 11; Runtime error: CHECK constraint failed: Discount >= 0 AND Discount <= 1 (19) sqlite> CREATE TEMP TABLE t (BirthDate TEXT CHECK (BirthDate < date('now'))); sqlite> INSERT INTO t VALUES ('1948-12-08'); Runtime error: non-deterministic use of date() in a CHECK constraint sqlite> PRAGMA foreign_key_check;
15 · Transactions: placing an order

Taking an order is several writes that only make sense together: the order header, its lines, and a reduction in stock. A transaction makes them atomic. Between BEGIN and COMMIT nothing is final, and ROLLBACK throws all of it away. The last query proves it: still 830 orders, and Chai back at 39 in stock.

RETURNING (SQLite 3.35+) hands back the new key straight from the INSERT, so you don't need a separate lookup. OrderID 11078 is one more than the highest existing order, which is how an INTEGER PRIMARY KEY assigns keys. The order lines copy UnitPrice from Products at this moment, the point-in-time copy from lesson 5. The address is copied the same way.

In the middle, an attempt to sell 100 Chai fails the UnitsInStock >= 0 check. A failed statement inside a transaction undoes only that one statement. The transaction stays open, and your program decides whether to carry on or roll back. That is why an application must check for errors after each statement, instead of only checking the final COMMIT.

Transaction
A group of statements that succeed or fail as a unit (BEGIN ... COMMIT / ROLLBACK).
Atomic
All or nothing: other connections never see half of a transaction.
RETURNING
A clause on INSERT, UPDATE or DELETE that outputs columns of the rows it changed.
Write an order, then roll it back
PRAGMA foreign_keys = ON; BEGIN; -- The order header, copying the ship-to address from the customer INSERT INTO Orders (CustomerID, EmployeeID, OrderDate, RequiredDate, ShipVia, Freight, ShipName, ShipAddress, ShipCity, ShipPostalCode, ShipCountry) SELECT CustomerID, 4, '1998-05-07', '1998-06-04', 2, 12.5, CompanyName, Address, City, PostalCode, Country FROM Customers WHERE CustomerID = 'ALFKI' RETURNING OrderID; -- Two lines, each copying today's list price INSERT INTO "Order Details" (OrderID, ProductID, UnitPrice, Quantity) SELECT 11078, ProductID, UnitPrice, 10 FROM Products WHERE ProductID IN (1, 2); -- Take the stock UPDATE Products SET UnitsInStock = UnitsInStock - 10 WHERE ProductID IN (1, 2) RETURNING ProductID, ProductName, UnitsInStock; -- Overselling is refused by CHECK (UnitsInStock >= 0) UPDATE Products SET UnitsInStock = UnitsInStock - 100 WHERE ProductID = 1; SELECT * FROM "Order Subtotals" WHERE OrderID = 11078; -- Undo all of it ROLLBACK; SELECT count(*) AS orders, (SELECT UnitsInStock FROM Products WHERE ProductID = 1) AS chai_stock FROM Orders;
Terminal Output
sqlite> .mode table sqlite> PRAGMA foreign_keys = ON; sqlite> BEGIN; sqlite> INSERT INTO Orders (CustomerID, EmployeeID, OrderDate, RequiredDate, ShipVia, (x1...> Freight, ShipName, ShipAddress, ShipCity, ShipPostalCode, ShipCountry) ...> SELECT CustomerID, 4, '1998-05-07', '1998-06-04', 2, 12.5, ...> CompanyName, Address, City, PostalCode, Country ...> FROM Customers WHERE CustomerID = 'ALFKI' ...> RETURNING OrderID; +---------+ | OrderID | +---------+ | 11078 | +---------+ sqlite> INSERT INTO "Order Details" (OrderID, ProductID, UnitPrice, Quantity) ...> SELECT 11078, ProductID, UnitPrice, 10 FROM Products WHERE ProductID IN (1, 2); sqlite> UPDATE Products SET UnitsInStock = UnitsInStock - 10 ...> WHERE ProductID IN (1, 2) RETURNING ProductID, ProductName, UnitsInStock; +-----------+-------------+--------------+ | ProductID | ProductName | UnitsInStock | +-----------+-------------+--------------+ | 1 | Chai | 29 | | 2 | Chang | 7 | +-----------+-------------+--------------+ sqlite> UPDATE Products SET UnitsInStock = UnitsInStock - 100 WHERE ProductID = 1; Runtime error: CHECK constraint failed: UnitsInStock >= 0 (19) sqlite> SELECT * FROM "Order Subtotals" WHERE OrderID = 11078; +---------+----------+ | OrderID | Subtotal | +---------+----------+ | 11078 | 370.0 | +---------+----------+ sqlite> ROLLBACK; sqlite> SELECT count(*) AS orders, (SELECT UnitsInStock FROM Products WHERE ProductID = 1) AS chai_stock ...> FROM Orders; +--------+------------+ | orders | chai_stock | +--------+------------+ | 830 | 39 | +--------+------------+
16 · Indexes and query plans

Microsoft's script creates an index on almost every foreign key, and EXPLAIN QUERY PLAN shows why. SEARCH Orders USING INDEX Orders_CustomerID means SQLite jumps straight to ALFKI's six orders. SCAN Orders means it reads all 830 rows, because nothing indexes ShipCountry. On 830 rows nobody would notice; on 83 million they would.

A composite primary key gets an index automatically, here sqlite_autoindex_Order Details_1 on (OrderID, ProductID). Like a phone book sorted by surname and then first name, it can find rows by its first column, but not by its second column alone. That is why ProductID needs an index of its own. For a join, the plan shows the loop order: SQLite scans the customers and, for each Mexican one, looks up orders through the index. It doesn't even read the Orders table, because the index holds every column the query needs: a covering index.

The conversion fixed two index problems in the original. SQL Server scopes index names to a table, so Microsoft named three different indexes PostalCode. SQLite index names are shared across the whole database, so they became Customers_PostalCode and so on. Microsoft also created pairs of identical indexes, such as CustomerID and CustomersOrders, both on Orders(CustomerID). A duplicate index makes every write slower and never speeds up a read, so each pair became one.

SEARCH / SCAN
In a query plan: an index lookup versus reading every row.
Leftmost prefix
A multi-column index helps queries that constrain its first column (first two, ...), not later columns alone.
Covering index
An index that contains every column a query needs, so the table itself is never read.
Ask SQLite how it will run each query
.indexes Orders -- SEARCH = the index finds the rows directly EXPLAIN QUERY PLAN SELECT * FROM Orders WHERE CustomerID = 'ALFKI'; -- SCAN = every row is read; ShipCountry has no index EXPLAIN QUERY PLAN SELECT * FROM Orders WHERE ShipCountry = 'France'; -- The composite primary key's own index serves lookups by its first column EXPLAIN QUERY PLAN SELECT * FROM "Order Details" WHERE OrderID = 10248; -- ...but not by its second column alone, which is why ProductID has its own index EXPLAIN QUERY PLAN SELECT * FROM "Order Details" WHERE ProductID = 11; -- A join: the plan shows which table drives the loop EXPLAIN QUERY PLAN SELECT c.CompanyName, o.OrderID FROM Customers c JOIN Orders o ON o.CustomerID = c.CustomerID WHERE c.Country = 'Mexico';
Terminal Output
sqlite> .mode table sqlite> .indexes Orders Orders_CustomerID Orders_OrderDate Orders_ShipVia Orders_EmployeeID Orders_ShipPostalCode Orders_ShippedDate sqlite> EXPLAIN QUERY PLAN SELECT * FROM Orders WHERE CustomerID = 'ALFKI'; QUERY PLAN `--SEARCH Orders USING INDEX Orders_CustomerID (CustomerID=?) sqlite> EXPLAIN QUERY PLAN SELECT * FROM Orders WHERE ShipCountry = 'France'; QUERY PLAN `--SCAN Orders sqlite> EXPLAIN QUERY PLAN SELECT * FROM "Order Details" WHERE OrderID = 10248; QUERY PLAN `--SEARCH Order Details USING INDEX sqlite_autoindex_Order Details_1 (OrderID=?) sqlite> EXPLAIN QUERY PLAN SELECT * FROM "Order Details" WHERE ProductID = 11; QUERY PLAN `--SEARCH Order Details USING INDEX OrderDetails_ProductID (ProductID=?) sqlite> EXPLAIN QUERY PLAN ...> SELECT c.CompanyName, o.OrderID FROM Customers c ...> JOIN Orders o ON o.CustomerID = c.CustomerID WHERE c.Country = 'Mexico'; QUERY PLAN |--SCAN c `--SEARCH o USING COVERING INDEX Orders_CustomerID (CustomerID=?)
Run it in the read-only Explorer: Plan: indexed lookup · Plan: full scan · Plan: a join
17 · BLOBs: the pictures inside Categories and Employees

Categories.Picture and Employees.Photo hold images as BLOBs: raw bytes that SQLite stores and returns without interpreting them. typeof() reports blob, and length() counts bytes rather than characters. Every category picture is exactly 10,746 bytes.

The first bytes aren't an image header. Microsoft Access wrapped each picture in an OLE object, a container that recorded which program (Paint) should open it. The real Windows bitmap starts where its signature BM (hex 424D) appears, at byte 79, after a 78-byte wrapper. substr() works on BLOBs byte by byte, so substr(Picture, 79) peels the wrapper off. The shell's writefile() saves the result as a file the file command recognises: a 172 × 120, 16-colour bitmap.

Storing images in the database keeps everything in one file, which suits small images like these. For large media, most designs store a file path and keep the bytes on disk. Northwind does both: it also has a PhotoPath column holding the old http://accweb/... address of each photo on Microsoft's internal network.

BLOB
Binary Large OBject: bytes stored exactly as given. X'424D' is a BLOB literal in hex.
OLE object
An Access/Windows wrapper around embedded content, naming the program that created it.
writefile(name, data)
A sqlite3 shell function (not in the core library) that writes bytes to a file and returns the count.
Look inside a BLOB and pull the picture out
SELECT CategoryID, CategoryName, typeof(Picture), length(Picture) FROM Categories LIMIT 3; -- The first bytes are not an image header SELECT hex(substr(Picture, 1, 16)) FROM Categories WHERE CategoryID = 1; -- Find the bitmap's 'BM' signature (hex 424D) SELECT instr(Picture, X'424D') AS bitmap_starts_at FROM Categories WHERE CategoryID = 1; -- Write everything after the 78-byte OLE header to a file SELECT writefile('beverages.bmp', substr(Picture, 79)) FROM Categories WHERE CategoryID = 1; .shell file beverages.bmp
Terminal Output
sqlite> .mode table sqlite> SELECT CategoryID, CategoryName, typeof(Picture), length(Picture) ...> FROM Categories LIMIT 3; +------------+--------------+-----------------+-----------------+ | CategoryID | CategoryName | typeof(Picture) | length(Picture) | +------------+--------------+-----------------+-----------------+ | 1 | Beverages | blob | 10746 | | 2 | Condiments | blob | 10746 | | 3 | Confections | blob | 10746 | +------------+--------------+-----------------+-----------------+ sqlite> SELECT hex(substr(Picture, 1, 16)) FROM Categories WHERE CategoryID = 1; +----------------------------------+ | hex(substr(Picture, 1, 16)) | +----------------------------------+ | 151C2F00020000000D000E0014002100 | +----------------------------------+ sqlite> SELECT instr(Picture, X'424D') AS bitmap_starts_at FROM Categories WHERE CategoryID = 1; +------------------+ | bitmap_starts_at | +------------------+ | 79 | +------------------+ sqlite> SELECT writefile('beverages.bmp', substr(Picture, 79)) ...> FROM Categories WHERE CategoryID = 1; +-------------------------------------------------+ | writefile('beverages.bmp', substr(Picture, 79)) | +-------------------------------------------------+ | 10668 | +-------------------------------------------------+ sqlite> .shell file beverages.bmp beverages.bmp: PC bitmap, Windows 3.x format, 172 x 120 x 4, resolution 2952 x 2952 px/m, 8 important colors, cbSize 10648, bits offset 86
The Beverages category picture: bottles, a teapot and cups
beverages.bmp, extracted from Categories.Picture exactly as above (shown here converted to PNG). Microsoft drew all eight category pictures in 16 colours.
Run it in the read-only Explorer: BLOB sizes
Exercises

Eight questions a Northwind manager might ask. Write the query yourself first, then open the answer: each one shows a query that works and its real output. There is usually more than one correct query.

1 Which five products have sold the most units?
SELECT p.ProductName, sum(od.Quantity) AS units FROM "Order Details" od JOIN Products p ON p.ProductID = od.ProductID GROUP BY p.ProductID ORDER BY units DESC LIMIT 5;
Terminal Output
sqlite> .mode table sqlite> SELECT p.ProductName, sum(od.Quantity) AS units ...> FROM "Order Details" od JOIN Products p ON p.ProductID = od.ProductID ...> GROUP BY p.ProductID ORDER BY units DESC LIMIT 5; +------------------------+-------+ | ProductName | units | +------------------------+-------+ | Camembert Pierrot | 1577 | | Raclette Courdavault | 1496 | | Gorgonzola Telino | 1397 | | Gnocchi di nonna Alice | 1263 | | Pavlova | 1158 | +------------------------+-------+
2 Which salesperson sold the most in 1997, by order value?
SELECT e.FirstName || ' ' || e.LastName AS employee, round(sum(s.Subtotal), 2) AS sales FROM Orders o JOIN "Order Subtotals" s ON s.OrderID = o.OrderID JOIN Employees e ON e.EmployeeID = o.EmployeeID WHERE o.OrderDate BETWEEN '1997-01-01' AND '1997-12-31' GROUP BY e.EmployeeID ORDER BY sales DESC LIMIT 3;
Terminal Output
sqlite> .mode table sqlite> SELECT e.FirstName || ' ' || e.LastName AS employee, round(sum(s.Subtotal), 2) AS sales ...> FROM Orders o ...> JOIN "Order Subtotals" s ON s.OrderID = o.OrderID ...> JOIN Employees e ON e.EmployeeID = o.EmployeeID ...> WHERE o.OrderDate BETWEEN '1997-01-01' AND '1997-12-31' ...> GROUP BY e.EmployeeID ORDER BY sales DESC LIMIT 3; +------------------+-----------+ | employee | sales | +------------------+-----------+ | Margaret Peacock | 128809.81 | | Janet Leverling | 108026.16 | | Nancy Davolio | 93148.1 | +------------------+-----------+
3 Which products need reordering? (still sold, and stock plus stock on order is at or below the reorder level)
SELECT ProductName, UnitsInStock, UnitsOnOrder, ReorderLevel FROM Products WHERE Discontinued = 0 AND UnitsInStock + UnitsOnOrder <= ReorderLevel;
Terminal Output
sqlite> .mode table sqlite> SELECT ProductName, UnitsInStock, UnitsOnOrder, ReorderLevel ...> FROM Products ...> WHERE Discontinued = 0 AND UnitsInStock + UnitsOnOrder <= ReorderLevel; +-----------------------+--------------+--------------+--------------+ | ProductName | UnitsInStock | UnitsOnOrder | ReorderLevel | +-----------------------+--------------+--------------+--------------+ | Nord-Ost Matjeshering | 10 | 0 | 15 | | Outback Lager | 15 | 10 | 30 | +-----------------------+--------------+--------------+--------------+
4 Which countries have the most customers, and how many orders came from each?
SELECT c.Country, count(DISTINCT c.CustomerID) AS customers, count(o.OrderID) AS orders FROM Customers c LEFT JOIN Orders o ON o.CustomerID = c.CustomerID GROUP BY c.Country ORDER BY customers DESC, orders DESC LIMIT 5;
Terminal Output
sqlite> .mode table sqlite> SELECT c.Country, count(DISTINCT c.CustomerID) AS customers, count(o.OrderID) AS orders ...> FROM Customers c LEFT JOIN Orders o ON o.CustomerID = c.CustomerID ...> GROUP BY c.Country ORDER BY customers DESC, orders DESC LIMIT 5; +---------+-----------+--------+ | Country | customers | orders | +---------+-----------+--------+ | USA | 13 | 122 | | Germany | 11 | 122 | | France | 11 | 77 | | Brazil | 9 | 83 | | UK | 7 | 56 | +---------+-----------+--------+
5 Which supplier provides the most products?
SELECT s.CompanyName, s.Country, count(*) AS products FROM Suppliers s JOIN Products p ON p.SupplierID = s.SupplierID GROUP BY s.SupplierID ORDER BY products DESC LIMIT 3;
Terminal Output
sqlite> .mode table sqlite> SELECT s.CompanyName, s.Country, count(*) AS products ...> FROM Suppliers s JOIN Products p ON p.SupplierID = s.SupplierID ...> GROUP BY s.SupplierID ORDER BY products DESC LIMIT 3; +-----------------------------------+-----------+----------+ | CompanyName | Country | products | +-----------------------------------+-----------+----------+ | Pavlova, Ltd. | Australia | 5 | | Plutzer Lebensmittelgroßmärkte AG | Germany | 5 | | New Orleans Cajun Delights | USA | 4 | +-----------------------------------+-----------+----------+
6 Which five orders took longest to ship?
SELECT OrderID, OrderDate, ShippedDate, julianday(ShippedDate) - julianday(OrderDate) AS days FROM Orders WHERE ShippedDate IS NOT NULL ORDER BY days DESC, OrderID LIMIT 5;
Terminal Output
sqlite> .mode table sqlite> SELECT OrderID, OrderDate, ShippedDate, ...> julianday(ShippedDate) - julianday(OrderDate) AS days ...> FROM Orders WHERE ShippedDate IS NOT NULL ORDER BY days DESC, OrderID LIMIT 5; +---------+------------+-------------+------+ | OrderID | OrderDate | ShippedDate | days | +---------+------------+-------------+------+ | 10660 | 1997-09-08 | 1997-10-15 | 37.0 | | 10777 | 1997-12-15 | 1998-01-21 | 37.0 | | 10380 | 1996-12-12 | 1997-01-16 | 35.0 | | 10427 | 1997-01-27 | 1997-03-03 | 35.0 | | 10545 | 1997-05-22 | 1997-06-26 | 35.0 | +---------+------------+-------------+------+
7 What is the average order value, and how many orders beat it?
SELECT round(avg(Subtotal), 2) AS average_order, (SELECT count(*) FROM "Order Subtotals" WHERE Subtotal > (SELECT avg(Subtotal) FROM "Order Subtotals")) AS above_average FROM "Order Subtotals";
Terminal Output
sqlite> .mode table sqlite> SELECT round(avg(Subtotal), 2) AS average_order, ...> (SELECT count(*) FROM "Order Subtotals" (x1...> WHERE Subtotal > (SELECT avg(Subtotal) FROM "Order Subtotals")) AS above_average ...> FROM "Order Subtotals"; +---------------+---------------+ | average_order | above_average | +---------------+---------------+ | 1525.05 | 282 | +---------------+---------------+
8 Which customer placed the single largest order, and what was in it?
SELECT o.OrderID, c.CompanyName, p.ProductName, od.Quantity, od.UnitPrice FROM Orders o JOIN Customers c ON c.CustomerID = o.CustomerID JOIN "Order Details" od ON od.OrderID = o.OrderID JOIN Products p ON p.ProductID = od.ProductID WHERE o.OrderID = (SELECT OrderID FROM "Order Subtotals" ORDER BY Subtotal DESC LIMIT 1);
Terminal Output
sqlite> .mode table sqlite> SELECT o.OrderID, c.CompanyName, p.ProductName, od.Quantity, od.UnitPrice ...> FROM Orders o ...> JOIN Customers c ON c.CustomerID = o.CustomerID ...> JOIN "Order Details" od ON od.OrderID = o.OrderID ...> JOIN Products p ON p.ProductID = od.ProductID ...> WHERE o.OrderID = (SELECT OrderID FROM "Order Subtotals" ORDER BY Subtotal DESC LIMIT 1); +---------+-------------+------------------+----------+-----------+ | OrderID | CompanyName | ProductName | Quantity | UnitPrice | +---------+-------------+------------------+----------+-----------+ | 10865 | QUICK-Stop | Côte de Blaye | 60 | 263.5 | | 10865 | QUICK-Stop | Chartreuse verte | 80 | 18.0 | +---------+-------------+------------------+----------+-----------+
About this conversion: SQL Server to SQLite

northwind.db was built from Microsoft's instnwnd.sql, without hand-copying any rows. A script parses every INSERT in Microsoft's file and loads it, so all 3,308 rows match the original. The data needed no changes except dates. The schema, views and procedures are written in T-SQL, so they had to be translated:

SQL Server (T-SQL)SQLiteWhy
nvarchar(40), nchar(5), ntextTEXTSQLite has one text type, and it doesn't enforce declared lengths.
int IDENTITY(1,1)INTEGER PRIMARY KEYThe column becomes the rowid; a new row gets one more than the current highest.
money, realREALAn 8-byte float. Round money results with round(x, 2).
bitINTEGER CHECK (x IN (0, 1))There is no boolean type.
datetime ‘7/4/1996’TEXT ‘1996-07-04’ISO-8601 text sorts and compares in date order (lesson 9).
imageBLOBThe bytes are stored unchanged, OLE wrapper included (lesson 17).
CHECK (BirthDate < getdate())CHECK (BirthDate < HireDate)CHECK can't call date('now'): it isn't deterministic (lesson 14).
Index PostalCode on three tablesCustomers_PostalCode, …Index names are database-wide in SQLite. Duplicate index pairs were merged (lesson 16).
ALTER TABLE … NOCHECK CONSTRAINT ALLPRAGMA defer_foreign_keys = ONEmployee 1 reports to employee 2, who loads later; deferred keys are checked at COMMIT.
FirstName + ' ' + LastNameFirstName || ' ' || LastName+ is arithmetic in SQLite and turns text into 0 (lesson 11).
CONVERT(money, x/100)*100round(x, 2)Microsoft's trick for rounding to the cent.
RIGHT JOIN in Quarterly OrdersLEFT JOIN, tables swappedThe same result. RIGHT JOIN needs SQLite 3.39 (2022), so this keeps the file readable by older copies.
SET ROWCOUNT 10 / TOP 10LIMIT 10Lesson 12.
DATENAME(yy, d), CONVERT(nvarchar, d, 111)strftime('%Y', d)SQLite's one date-formatting function.
CREATE PROCEDURE … @parama parameterised query in your codeSQLite has no stored procedures (lesson 12).

Northwind sample data © Microsoft Corporation, published in microsoft/sql-server-samples under the MIT licence. The SQLite conversion on this page is offered under the same licence.

SQLite3 Knowledge Quiz
10 questions — click an option to answer
0 / 10 answered
Score: 0 / 0
0/10
0
Correct
0
Incorrect
0%
Score