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.
Visit sqlite.org/download.html and download the Precompiled Binaries for Windows bundle — grab sqlite-tools-win-x64-*.zip.
Right-click the ZIP and select Extract All. You'll find three executables: sqlite3.exe, sqldiff.exe, and sqlite3_analyzer.exe.
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.
Open a new Command Prompt or PowerShell and run:
sqlite3 --versionYou should see something like 3.46.0 2024-05-23...
choco install sqlite in an elevated terminal for a one-step install.Open Terminal and run:
sqlite3 --versionIf you see a version number, you're done! If not, continue below.
If you have Homebrew installed (the standard macOS package manager), run:
brew install sqlite3Homebrew 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"sqlite3 --versionSQLite3 is available in the default apt repositories:
sudo apt update
sudo apt install sqlite3 libsqlite3-devsudo dnf install sqlite sqlite-develOn older CentOS/RHEL with yum:
sudo yum install sqlite sqlite-develsudo pacman -S sqlitesqlite3 --versionYou should see the installed version number printed to stdout.
./configure && make && sudo make install.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.
# 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$ 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...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.
.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)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).headers on and .mode table — it makes every query output much easier to read.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.
-- 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
);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 existsPRIMARY KEY uniquely identifies each row. AUTOINCREMENT makes SQLite assign the next number automatically. NOT NULL requires a value. UNIQUE prevents duplicates.
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.
-- 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);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.emailSELECT 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.
-- 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;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 |
+----------+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 — 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;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)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.
-- 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;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 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.
-- 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;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 |
+---------+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.
-- 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;-- 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 |
+-------+-----------+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.
-- 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;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 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.
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 nullsqlite> 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 |
+---------------------------+-----------------+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.
-- 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';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 usersA 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.
-- 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;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 |
+--------------+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).
-- 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;-- 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 |
+------+---------+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.
-- 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;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 |
+-----+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.
-- 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;-- 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 |
+----+-------+-------+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.
-- 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;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 |
+-------------+NEW.column refers to the incoming value and OLD.column refers to the previous value (for UPDATE and DELETE).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.
-- 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();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 |
+------------------+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.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.
-- 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);-- 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;-- 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';-- 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 |
+--------+-------+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.
-- 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;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.
-- 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 offsqlite> .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.
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.
-- 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;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 |
+--------+----------+-------+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.
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;[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 |
+-------+---------+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.
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.
-- 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;[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 |
+---------+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.
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;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 |
+--------+---------------------------+-------------+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.
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;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 |
+-----------------+------+(x1...> as its continuation prompt, where the number is how many brackets are still open.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.
#!/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$ 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: 1000Most 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.
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;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 |
+----------+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.
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.
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.
Open it the same way as any SQLite file. Every lesson below starts with .mode table, which draws the boxed tables you see in the output.
sqlite3 northwind.dbEach arrow runs from a foreign key (FK) to the primary key (PK) it references, from the “many” side to the “one” side. Header numbers are row counts. Click a table to browse it in the Explorer, or read why each connection exists.
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.
| Foreign key → what it references | Kind | Why the connection exists |
|---|---|---|
| The sale: who bought what | ||
| Orders.CustomerID → Customers.CustomerID | One-to-many | A customer places many orders over time, and each order belongs to exactly one customer. The order stores only the key, so the company name and address are kept once, in Customers. Two of the 91 customers have no orders yet, which is only possible because they have their own table. |
| "Order Details".OrderID → Orders.OrderID | One-to-many | An order can contain any number of products (from 1 to 25 here). That can't be a fixed set of columns on Orders, so each line is its own row that points back at its order. |
| "Order Details".ProductID → Products.ProductID | One-to-many | The same product appears on many order lines. Together with OrderID it forms the composite key (OrderID, ProductID), so a product can appear on an order only once. This makes Order Details the junction for the many-to-many between Orders and Products. |
| Orders.EmployeeID → Employees.EmployeeID | One-to-many | Records which salesperson took the order. Sales-by-employee reports follow this arrow. |
| Orders.ShipVia → Shippers.ShipperID | One-to-many | Records which of the three carriers delivers the order. The column name differs from the key it references; a foreign key needs matching values, not matching names. |
| The catalogue: what is sold | ||
| Products.SupplierID → Suppliers.SupplierID | One-to-many | Each product is bought from one supplier (77 products from 29 suppliers). The supplier's contact details are stored once, however many products they supply. |
| Products.CategoryID → Categories.CategoryID | One-to-many | Groups the products into 8 categories for menus and reports. Each category's description and picture are stored once, not repeated on every product. |
| The staff: who reports to whom, and where they sell | ||
| Employees.ReportsTo → Employees.EmployeeID | Self-reference | A manager is also an employee, so the key points back at its own table. Andrew Fuller's ReportsTo is NULL because he is at the top. See lesson 4. |
| EmployeeTerritories.EmployeeID → Employees.EmployeeID EmployeeTerritories.TerritoryID → Territories.TerritoryID | Many-to-many | An employee covers several territories, and a territory may have several employees. Neither table can hold a list, so this junction table holds one row per pairing, 49 in all. Its primary key is both columns, so the same pairing can't be entered twice. |
| Territories.RegionID → Region.RegionID | One-to-many | The 53 territories roll up into 4 regions: Eastern, Western, Northern and Southern. |
| Customer types: designed but never filled in | ||
| CustomerCustomerDemo.CustomerID → Customers.CustomerID CustomerCustomerDemo.CustomerTypeID → CustomerDemographics.CustomerTypeID | Many-to-many | Lets a customer carry several marketing types. It uses the same junction pattern as EmployeeTerritories. Microsoft shipped the design without any data, so both tables have 0 rows. |
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).
-- 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;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 |
+--------------+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.
.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 Shipperssqlite> .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
);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 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);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)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: 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;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 |
+-------------+----------------------+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.
-- 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;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 |
+------------------------+-------+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.
-- 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;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 |
+------------+------------------+-------------------------------+--------+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.
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;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) |
+-----------------------+--------+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: 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;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 |
+------------+-------+-------+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.
-- 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;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 |
+------------------+--------+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.
-- 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;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 |
+---------+-----------+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: 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';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 |
+--------+-----------------------+-------------------+--------------+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.
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;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 |
+------------+---------------+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.
-- 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;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 |
+---------------------------------+---------------+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)$ python3 cust_order_hist.py
'ALFKI' -> [('Escargots de Bourgogne', 40), ('Chartreuse verte', 21), ('Flotemysost', 20)]
"x' OR '1'='1" -> []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).
-- 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;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 |
+----------------+----------------------------------+-------+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.
-- 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;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;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.
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;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 |
+--------+------------+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.
.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';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=?)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.
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.bmpsqlite> .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
Categories.Picture exactly as above (shown here converted to PNG). Microsoft drew all eight category pictures in 16 colours.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.
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;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 |
+------------------------+-------+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;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 |
+------------------+-----------+SELECT ProductName, UnitsInStock, UnitsOnOrder, ReorderLevel
FROM Products
WHERE Discontinued = 0 AND UnitsInStock + UnitsOnOrder <= ReorderLevel;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 |
+-----------------------+--------------+--------------+--------------+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;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 |
+---------+-----------+--------+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;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 |
+-----------------------------------+-----------+----------+SELECT OrderID, OrderDate, ShippedDate,
julianday(ShippedDate) - julianday(OrderDate) AS days
FROM Orders WHERE ShippedDate IS NOT NULL ORDER BY days DESC, OrderID LIMIT 5;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 |
+---------+------------+-------------+------+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";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 |
+---------------+---------------+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);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 |
+---------+-------------+------------------+----------+-----------+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) | SQLite | Why |
|---|---|---|
| nvarchar(40), nchar(5), ntext | TEXT | SQLite has one text type, and it doesn't enforce declared lengths. |
| int IDENTITY(1,1) | INTEGER PRIMARY KEY | The column becomes the rowid; a new row gets one more than the current highest. |
| money, real | REAL | An 8-byte float. Round money results with round(x, 2). |
| bit | INTEGER 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). |
| image | BLOB | The 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 tables | Customers_PostalCode, … | Index names are database-wide in SQLite. Duplicate index pairs were merged (lesson 16). |
| ALTER TABLE … NOCHECK CONSTRAINT ALL | PRAGMA defer_foreign_keys = ON | Employee 1 reports to employee 2, who loads later; deferred keys are checked at COMMIT. |
| FirstName + ' ' + LastName | FirstName || ' ' || LastName | + is arithmetic in SQLite and turns text into 0 (lesson 11). |
| CONVERT(money, x/100)*100 | round(x, 2) | Microsoft's trick for rounding to the cent. |
| RIGHT JOIN in Quarterly Orders | LEFT JOIN, tables swapped | The same result. RIGHT JOIN needs SQLite 3.39 (2022), so this keeps the file readable by older copies. |
| SET ROWCOUNT 10 / TOP 10 | LIMIT 10 | Lesson 12. |
| DATENAME(yy, d), CONVERT(nvarchar, d, 111) | strftime('%Y', d) | SQLite's one date-formatting function. |
| CREATE PROCEDURE … @param | a parameterised query in your code | SQLite 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.