SQLite Cheat Sheet
This SQLite cheat sheet covers 30 sections: the sqlite3 shell and its dot-commands, type affinity and STRICT tables, ALTER TABLE, PRAGMAs, joins, CTEs and window functions, upserts and RETURNING, dates, JSON and JSONB, transactions, FTS5, indexes, the query planner, WAL mode, backups and the result codes you will actually meet. Search it, filter it by level or SQLite version, copy any line with one click, and run real SQL on a sample database right on the page.
(no rows)
Nothing matches that search. Try a shorter keyword such as index, json or pragma. Several words are combined, so every one of them has to match. Keys: / focuses the search, Esc clears it, t switches the theme.
Install & Open
installing, opening databases, a SQL playground and builders
Builders: PRAGMAs, Connection Code, Dates, Import and Export, Backups
Install SQLite
The sqlite3 command line shell
sudo apt install sqlite3 # Debian, Ubuntu sudo dnf install sqlite # Fedora, RHEL sqlite3 --version
macOS ships sqlite3; Homebrew has the current release
sqlite3 --version # the system copy, usually a few releases behind brew install sqlite $(brew --prefix sqlite)/bin/sqlite3 --version
Windows: winget, or the zip from sqlite.org
winget install SQLite.SQLite sqlite3 --version # or unzip sqlite-tools-win-x64-3530400.zip and put sqlite3.exe on the PATH
Python already includes SQLite
python3 -c "import sqlite3; print(sqlite3.sqlite_version)" python3 -m sqlite3 app.db # a small interactive shell, Python 3.12+
Open and Create Databases
A database is one file; opening a missing file creates it
sqlite3 shop.db # opens or creates shop.db sqlite3 # a transient in-memory database sqlite3 :memory: sqlite3 -readonly shop.db sqlite3 shop.db "SELECT count(*) FROM orders;" # run one statement and exit
URI filenames: read only, immutable, shared memory
sqlite3 "file:shop.db?mode=ro" sqlite3 "file:shop.db?immutable=1" # no locking at all: only for files nothing can change sqlite3 "file:memdb1?mode=memory&cache=shared" # an in-memory database several connections can share ATTACH 'file:archive.db?mode=ro' AS archive;
Check what you are running
SELECT sqlite_version(), sqlite_source_id(); PRAGMA compile_options;
sqlite3 Shell
dot-commands, output modes, scripting and settings
Find Your Way Around
The dot-commands you use every day
.help -- every dot-command .open shop.db -- open another database file .databases -- attached databases and their files .tables -- tables and views .tables ord% -- matching a LIKE pattern .schema orders -- the CREATE statements .indexes orders -- indexes (3.53: the pattern matches index names) .quit -- or .exit, or Ctrl+D
Output modes
.mode table -- ASCII boxes .mode box -- Unicode boxes, the interactive default from 3.53 .mode markdown .mode json .mode csv .mode line -- one column per line, for wide rows .headers on SELECT id, name, country FROM customers LIMIT 2;
Timing, changes and query statistics
.timer on -- or .timer once (3.53+) for the next statement only .changes on -- rows changed by each statement .eqp on -- print the query plan before each result .stats on
Files, Scripts and Parameters
Send output to a file, run a script
.once report.csv -- the next result only .output all.txt -- every result until .output stdout .read schema.sql -- run the statements in a file .excel -- the next result opens in your spreadsheet program .www -- the next result opens in your web browser (3.47+)
Scripts from the command line
sqlite3 shop.db < schema.sql sqlite3 -bail shop.db < migrate.sql # stop at the first error sqlite3 -csv -header shop.db "SELECT * FROM orders" > orders.csv sqlite3 -json shop.db "SELECT * FROM products LIMIT 2" sqlite3 shop.db migrate.sql # 3.53: *.sql arguments that are files are run as scripts
Named parameters inside the shell
.parameter init .parameter set :country 'RO' SELECT name FROM customers WHERE country = :country; .parameter list
A startup file
-- ~/.sqliterc runs every time the interactive shell starts .mode box .headers on .timer on .nullvalue NULL PRAGMA foreign_keys = ON;
Built-in helpers worth knowing
.expert -- then a query: suggests indexes for it .lint fkey-indexes -- foreign keys without an index on the child column .dbinfo -- page size, encoding, schema version .recover -- salvage what it can from a damaged file .sha3sum -- a hash of the content, to compare two databases .shell ls -la -- run a shell command
Settings of the Current Session
Show every shell setting
.show
Types & Affinity
storage classes, type affinity, STRICT tables and booleans, dates and money
Storage Classes and Affinity
Five storage classes; the column type is only a preference
CREATE TABLE t (a INTEGER, b TEXT, c);
INSERT INTO t VALUES ('42', 42, x'00ff');
SELECT a, typeof(a), b, typeof(b), typeof(c) FROM t;
How a declared type becomes an affinity
| Declared type contains | Affinity | Examples |
|---|---|---|
INT | INTEGER | INT, INTEGER, BIGINT, TINYINT, and even "POINT" or "CHARINT" |
CHAR, CLOB or TEXT | TEXT | VARCHAR(255), NCHAR(20), TEXT |
BLOB, or no type at all | BLOB | BLOB, a column with no type |
REAL, FLOA or DOUB | REAL | REAL, DOUBLE, FLOAT |
| anything else | NUMERIC | NUMERIC, DECIMAL(10,2), BOOLEAN, DATE, DATETIME, STRING |
The "STRING" trap
CREATE TABLE codes (code STRING);
INSERT INTO codes VALUES ('0123');
SELECT code, typeof(code) FROM codes;
STRICT Tables
Enforce the declared types
CREATE TABLE catalog (
id INTEGER PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
price_cents INTEGER NOT NULL,
attrs ANY
) STRICT;
INSERT INTO catalog (sku, price_cents) VALUES ('OAK-1', 'cheap');
STRICT and WITHOUT ROWID together
CREATE TABLE tags (post_id INTEGER, tag TEXT, PRIMARY KEY (post_id, tag)) STRICT, WITHOUT ROWID;
Booleans, Dates and Money
No boolean, date or decimal types: pick a convention
| Kind of value | Store as | Example |
|---|---|---|
| boolean | INTEGER 0 or 1, with a CHECK | active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)) |
| instant in time | TEXT ISO 8601 in UTC, or INTEGER unix seconds | '2026-09-29 14:05:00' or 1790690700 |
| money | INTEGER cents | price_cents INTEGER NOT NULL |
| UUID | TEXT, or a 16 byte BLOB | lower(hex(randomblob(16))) |
| JSON | TEXT (or BLOB with jsonb) | CHECK (json_valid(doc)) |
TRUE, FALSE and comparisons
SELECT TRUE, FALSE, 1 = TRUE, 'yes' = TRUE;
Casting and checking types
SELECT CAST('12.5kg' AS REAL), CAST('abc' AS INTEGER), CAST(12.9 AS INTEGER);
SELECT typeof(1), typeof(1.0), typeof('1'), typeof(NULL), typeof(x'01');
Check What Is Stored
Count values by storage class
SELECT typeof(price_cents) AS type, count(*) FROM products GROUP BY 1; SELECT typeof(created_at) AS type, count(*) FROM orders GROUP BY 1;
Tables & ALTER
CREATE TABLE, keys, constraints, generated columns and ALTER TABLE
Create Tables
A typical table
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers (id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'paid', 'shipped', 'refunded')),
total_cents INTEGER NOT NULL DEFAULT 0 CHECK (total_cents >= 0),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE INDEX orders_customer ON orders (customer_id, created_at);
INTEGER PRIMARY KEY is the rowid
CREATE TABLE a (id INTEGER PRIMARY KEY, v TEXT); -- id is an alias for the rowid: no extra index
CREATE TABLE b (id INT PRIMARY KEY, v TEXT); -- INT is not INTEGER: a separate unique index
CREATE TABLE c (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT);
INSERT INTO a (v) VALUES ('x') RETURNING id, rowid;
Generated columns
CREATE TABLE people ( first TEXT NOT NULL, last TEXT NOT NULL, full_name TEXT GENERATED ALWAYS AS (first || ' ' || last) VIRTUAL, email TEXT, email_domain TEXT AS (substr(email, instr(email, '@') + 1)) STORED ); CREATE INDEX people_domain ON people (email_domain);
WITHOUT ROWID tables
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT) WITHOUT ROWID;
Conflict clauses on constraints
CREATE TABLE emails (address TEXT UNIQUE ON CONFLICT IGNORE);
INSERT INTO emails VALUES ('a@x.com'), ('a@x.com'); -- second row silently skipped
SELECT count(*) FROM emails;
ALTER TABLE
Rename and add columns
ALTER TABLE orders RENAME TO sales; ALTER TABLE sales RENAME COLUMN total_cents TO amount_cents; ALTER TABLE sales ADD COLUMN note TEXT; ALTER TABLE sales ADD COLUMN channel TEXT NOT NULL DEFAULT 'web';
Drop a column
ALTER TABLE sales DROP COLUMN note;
Set or drop NOT NULL, add or drop a CHECK
ALTER TABLE sales ALTER COLUMN channel SET NOT NULL; ALTER TABLE sales ALTER COLUMN note DROP NOT NULL; ALTER TABLE sales ADD CONSTRAINT amount_positive CHECK (amount_cents >= 0); ALTER TABLE sales DROP CONSTRAINT amount_positive;
Anything else: the 12 step rebuild
PRAGMA foreign_keys = OFF; BEGIN; CREATE TABLE sales_new (id INTEGER PRIMARY KEY, amount_cents INTEGER NOT NULL, created_at TEXT NOT NULL) STRICT; INSERT INTO sales_new (id, amount_cents, created_at) SELECT id, amount_cents, created_at FROM sales; DROP TABLE sales; ALTER TABLE sales_new RENAME TO sales; -- recreate indexes, triggers and views here PRAGMA foreign_key_check; COMMIT; PRAGMA foreign_keys = ON;
Tables From Queries
CREATE TABLE ... AS SELECT
CREATE TABLE ro_customers AS SELECT id, name, email FROM customers WHERE country = 'RO'; SELECT count(*) FROM ro_customers;
A temporary table for the current connection
CREATE TEMP TABLE big_orders AS SELECT id, total_cents FROM orders WHERE total_cents > 30000; SELECT count(*) FROM big_orders;
PRAGMAs
journal mode, synchronous, foreign keys, cache, checks and schema introspection
Settings for Every Connection
A good default block for applications
PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; PRAGMA cache_size = -65536; -- 64 MiB PRAGMA temp_store = MEMORY;
Read a setting
PRAGMA journal_mode; PRAGMA foreign_keys; SELECT * FROM pragma_busy_timeout;
What each common setting does
| PRAGMA | Default | Use |
|---|---|---|
journal_mode | DELETE | WAL for concurrent readers and one writer |
synchronous | FULL | NORMAL is safe in WAL mode and much faster |
foreign_keys | OFF | ON to enforce REFERENCES clauses |
busy_timeout | 0 | milliseconds to wait for a lock before SQLITE_BUSY |
cache_size | -2000 (about 2 MiB) | negative values are KiB |
mmap_size | 0 | bytes of memory mapped I/O for faster reads |
temp_store | FILE | MEMORY for temporary tables and sorts |
user_version | 0 | an integer for your schema migrations |
Checks and Maintenance
Integrity and foreign key checks
PRAGMA integrity_check; -- full check, reads every page PRAGMA quick_check; -- skips index content checks, much faster PRAGMA foreign_key_check; -- rows that break a foreign key
Keep planner statistics fresh
PRAGMA optimize; -- before closing a connection, or every few hours PRAGMA optimize = 0x10002; -- right after opening a long lived connection PRAGMA analysis_limit = 400;
Schema versioning for migrations
PRAGMA user_version; PRAGMA user_version = 7;
Introspection
Columns, indexes and foreign keys of a table
PRAGMA table_info(orders);
PRAGMA index_list(orders);
PRAGMA foreign_key_list(orders);
SELECT name, type, "notnull", pk FROM pragma_table_info('orders');
Everything in the schema
SELECT type, name, tbl_name FROM sqlite_schema ORDER BY type, name; SELECT name FROM pragma_table_list WHERE type = 'table' AND schema = 'main';
More Introspection
Foreign keys of a table
SELECT "table", "from", "to", on_delete FROM pragma_foreign_key_list('order_items');
Index columns, including hidden ones
SELECT * FROM pragma_index_xinfo('orders_customer_created');
SELECT & Filtering
filtering, sorting, paging, CASE, LIKE, GLOB and NULL handling
Filter and Sort
The basic shape
SELECT id, name, country
FROM customers
WHERE country IN ('RO', 'DE') AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 10 OFFSET 20;
LIKE is case insensitive for ASCII; GLOB is case sensitive with shell wildcards
SELECT name FROM customers WHERE name LIKE 'an%'; -- matches Ana SELECT sku FROM products WHERE sku GLOB '[A-Z][A-Z][A-Z]-*'; SELECT name FROM customers WHERE email LIKE '%\_%' ESCAPE '\';
NULL handling
SELECT name FROM customers WHERE phone IS NULL; SELECT coalesce(phone, email, 'none') FROM customers; SELECT nullif(discount, 0) FROM orders; SELECT a IS b FROM t; -- NULL-safe equality: NULL IS NULL is 1 SELECT * FROM t ORDER BY score DESC NULLS LAST;
CASE and Conditional Values
CASE, iif() and if()
SELECT sku, CASE WHEN price_cents >= 10000 THEN 'premium' WHEN price_cents >= 3000 THEN 'standard' ELSE 'budget' END AS tier, iif(price_cents > 5000, 'yes', 'no') AS free_shipping FROM products; SELECT sku, if(price_cents > 10000, 'ships free') FROM products; -- two argument form: NULL when false
Paging and Sampling
Keyset pagination
SELECT id, name, created_at FROM customers WHERE (created_at, id) < (?1, ?2) -- the last row of the previous page ORDER BY created_at DESC, id DESC LIMIT 20;
DISTINCT and duplicates
SELECT DISTINCT country FROM customers ORDER BY 1; SELECT email, count(*) FROM customers GROUP BY email HAVING count(*) > 1;
A random row
SELECT * FROM products ORDER BY random() LIMIT 1; -- fine for small tables SELECT * FROM products WHERE rowid >= (abs(random()) % (SELECT max(rowid) FROM products)) + 1 LIMIT 1;
Case insensitive comparisons with COLLATE NOCASE
SELECT * FROM customers WHERE email = 'ANA@EXAMPLE.COM' COLLATE NOCASE; CREATE INDEX customers_email_nocase ON customers (email COLLATE NOCASE);
Everyday Filters
Top N
SELECT sku, price_cents / 100.0 AS price FROM products ORDER BY price_cents DESC LIMIT 3;
BETWEEN, IN and row values
SELECT count(*) FROM orders WHERE total_cents BETWEEN 10000 AND 30000; SELECT count(*) FROM order_items WHERE (order_id, product_id) IN (VALUES (4, 8), (8, 4), (1, 3));
Joins
inner, left, right and full joins, self joins, anti joins and UPDATE FROM
Join Types
INNER and LEFT JOIN
SELECT o.id, c.name, o.status FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.status = 'paid'; SELECT c.name, count(o.id) AS orders FROM customers c LEFT JOIN orders o ON o.customer_id = c.id GROUP BY c.id;
RIGHT and FULL OUTER JOIN
SELECT c.name, o.id FROM orders o RIGHT JOIN customers c ON c.id = o.customer_id; SELECT a.sku, b.sku FROM warehouse_a a FULL OUTER JOIN warehouse_b b ON b.sku = a.sku;
USING, NATURAL and CROSS JOIN
SELECT * FROM order_items JOIN order_items_archive USING (order_id, product_id); -- only when both tables have the columns SELECT * FROM sizes CROSS JOIN colors; -- every combination
Semi, Anti and Self Joins
Rows with, or without, a match
SELECT name FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id AND o.status = 'refunded'); SELECT name FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
Self join
SELECT e.name, m.name AS manager FROM employees e LEFT JOIN employees m ON m.id = e.manager_id;
Joins in Writes
UPDATE FROM
UPDATE orders SET total_cents = t.total FROM (SELECT order_id, sum(qty * price_cents) AS total FROM order_items GROUP BY order_id) AS t WHERE t.order_id = orders.id;
DELETE with a join condition
DELETE FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE status = 'refunded');
Joins on the Sample Data
Join four tables
SELECT o.id, c.name, p.sku, i.qty FROM order_items i JOIN orders o ON o.id = i.order_id JOIN customers c ON c.id = o.customer_id JOIN products p ON p.id = i.product_id ORDER BY o.id, p.sku LIMIT 4;
Rows with no match: LEFT JOIN ... IS NULL
SELECT p.sku FROM products p LEFT JOIN order_items i ON i.product_id = p.id WHERE i.order_id IS NULL;
Aggregates
GROUP BY, HAVING, group_concat, string_agg, FILTER and statistics
Group and Summarise
GROUP BY and HAVING
SELECT status, count(*) AS n, sum(total_cents) / 100.0 AS revenue FROM orders GROUP BY status HAVING count(*) > 5 ORDER BY revenue DESC;
sum() versus total()
SELECT sum(x), total(x) FROM (SELECT NULL AS x); SELECT sum(9223372036854775807), total(9223372036854775807);
Bare columns with min() and max()
SELECT customer_id, max(created_at), id FROM orders GROUP BY customer_id;
Strings, JSON and Filters
Join values into a string
SELECT customer_id, group_concat(status, ', ') FROM orders GROUP BY customer_id; SELECT customer_id, string_agg(status, ', ' ORDER BY created_at) FROM orders GROUP BY customer_id;
FILTER on one aggregate
SELECT count(*) AS all_orders, count(*) FILTER (WHERE status = 'paid') AS paid, sum(total_cents) FILTER (WHERE status <> 'refunded') AS kept_cents FROM orders;
Aggregate rows into JSON
SELECT c.name, json_group_array(json_object('id', o.id, 'status', o.status)) AS orders
FROM customers c JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;
Median and percentiles
SELECT median(total_cents), percentile(total_cents, 90), percentile_cont(total_cents, 0.9) FROM orders;
Reports
Revenue per month
SELECT strftime('%Y-%m', created_at) AS month, count(*) AS orders, sum(total_cents) / 100.0 AS revenue
FROM orders WHERE status IN ('paid', 'shipped')
GROUP BY month ORDER BY month DESC LIMIT 3;
count(*), count(column) and count(DISTINCT)
SELECT count(*), count(DISTINCT customer_id), count(DISTINCT status) FROM orders;
Subqueries & CTEs
scalar subqueries, IN and EXISTS, WITH, recursive CTEs and materialization hints
Subqueries
Scalar, IN and derived tables
SELECT name, (SELECT count(*) FROM orders o WHERE o.customer_id = c.id) AS orders FROM customers c; SELECT * FROM products WHERE id IN (SELECT product_id FROM order_items WHERE qty >= 3); SELECT avg(n) FROM (SELECT customer_id, count(*) AS n FROM orders GROUP BY customer_id);
WITH: name a subquery
WITH paid AS ( SELECT customer_id, sum(total_cents) AS cents FROM orders WHERE status = 'paid' GROUP BY customer_id ) SELECT c.name, p.cents / 100.0 AS spent FROM paid p JOIN customers c ON c.id = p.customer_id ORDER BY spent DESC LIMIT 5;
Recursive CTEs
A number series
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 5) SELECT i FROM n;
Every day in a range, for reports with no gaps
WITH RECURSIVE d(day) AS (
SELECT date('2026-09-01')
UNION ALL SELECT date(day, '+1 day') FROM d WHERE day < '2026-09-30'
)
SELECT d.day, count(o.id) AS orders
FROM d LEFT JOIN orders o ON date(o.created_at) = d.day
GROUP BY d.day;
Walk a tree
WITH RECURSIVE chain(id, name, manager_id, depth) AS ( SELECT id, name, manager_id, 0 FROM employees WHERE id = 7 UNION ALL SELECT e.id, e.name, e.manager_id, chain.depth + 1 FROM employees e JOIN chain ON e.id = chain.manager_id ) SELECT * FROM chain;
Control whether a CTE is materialized
WITH big AS MATERIALIZED (SELECT * FROM events WHERE type = 'click') SELECT count(*) FROM big WHERE at > '2026-09-01'; WITH small AS NOT MATERIALIZED (SELECT * FROM products) SELECT * FROM small WHERE id = 3;
Subqueries on the Sample Data
Customers who spent more than the average
WITH spend AS (SELECT customer_id, sum(total_cents) AS cents FROM orders GROUP BY customer_id) SELECT c.name, s.cents / 100.0 AS spent FROM spend s JOIN customers c ON c.id = s.customer_id WHERE s.cents > (SELECT avg(cents) FROM spend) ORDER BY s.cents DESC;
Update from an aggregate
UPDATE orders SET total_cents = ( SELECT coalesce(sum(qty * price_cents), 0) FROM order_items WHERE order_id = orders.id ); SELECT changes();
Window Functions
ranking, running totals, moving averages, lag and lead
Ranking
row_number, rank and dense_rank
SELECT customer_id, id, total_cents, row_number() OVER (PARTITION BY customer_id ORDER BY total_cents DESC) AS rn FROM orders;
Latest row per group
SELECT * FROM ( SELECT o.*, row_number() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn FROM orders o ) WHERE rn = 1;
Running Values
Running total and moving average
SELECT day, amount, sum(amount) OVER (ORDER BY day) AS running, avg(amount) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS avg7 FROM daily_sales;
Compare with the previous row
SELECT day, amount, amount - lag(amount) OVER (ORDER BY day) AS change, lead(day) OVER (ORDER BY day) AS next_day FROM daily_sales;
Named windows and frame exclusion
SELECT day, amount, avg(amount) OVER w AS avg_neighbours FROM daily_sales WINDOW w AS (ORDER BY day ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING EXCLUDE CURRENT ROW);
Share of total
SELECT status, count(*) AS n, round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS pct FROM orders GROUP BY status;
Windows on the Sample Data
Running total per customer
SELECT customer_id, id, total_cents, sum(total_cents) OVER (PARTITION BY customer_id ORDER BY created_at) AS running FROM orders WHERE customer_id = 1;
Rank products by revenue
SELECT p.sku, sum(i.qty * i.price_cents) / 100.0 AS revenue, rank() OVER (ORDER BY sum(i.qty * i.price_cents) DESC) AS rnk FROM order_items i JOIN products p ON p.id = i.product_id GROUP BY p.sku LIMIT 5;
Quartiles with ntile()
SELECT quartile, min(total_cents), max(total_cents), count(*) FROM (SELECT total_cents, ntile(4) OVER (ORDER BY total_cents) AS quartile FROM orders) GROUP BY quartile;
Core Functions
strings, numbers, math, blobs and the handy odds and ends
Strings
The everyday string functions
SELECT length('Ștefan'), octet_length('Ștefan'), upper('abc'), substr('SQLite', 1, 3),
instr('a@b.com', '@'), replace('a-b-c', '-', '/'), trim(' x '), 'a' || 'b';
Formatting and concatenation
SELECT format('%s ordered %d items for %.2f', 'Ana', 3, 59.5);
SELECT concat('a', NULL, 'b'), concat_ws('-', '2026', '09', '29');
Characters, escapes and Unicode
SELECT char(72, 105), unicode('A'), unistr('café'), quote('it''s');
Blobs and hex
SELECT hex('SQLite'), unhex('53514C697465'), lower(hex(randomblob(16)));
Numbers and Math
Rounding and integer division
SELECT 7 / 2, 7 / 2.0, 7 % 3, round(2.5), round(3.14159, 2), abs(-4), max(3, 9, 5);
Math functions
SELECT sqrt(2), pow(2, 10), ln(10), log10(1000), log2(8), floor(-1.5), ceil(1.2), pi();
Rows Changed and Other Helpers
After a write
INSERT INTO customers (name, email, country, created_at) VALUES ('Tom', 'tom@example.com', 'UK', datetime('now'));
SELECT last_insert_rowid(), changes(), total_changes();
Types and literals
SELECT typeof(3.0), typeof('3'), quote(x'00ff'), quote(NULL), coalesce(NULL, 'default');
Text Helpers
Split an email and pad columns
SELECT email, substr(email, instr(email, '@') + 1) AS domain FROM customers LIMIT 3;
SELECT format('%-7s %8.2f', sku, price_cents / 100.0) FROM products LIMIT 3;
Insert, Update, Upsert
INSERT, RETURNING, upserts, REPLACE, conflict resolution and bulk loads
Insert and RETURNING
Insert rows and get the new ids back
INSERT INTO customers (name, email, country, created_at)
VALUES ('Ana Pop', 'ana@example.org', 'RO', datetime('now')),
('Radu Ionescu', 'radu@example.org', 'RO', datetime('now'))
RETURNING id, name;
Insert from a query
INSERT INTO archive_orders (id, customer_id, status, created_at) SELECT id, customer_id, status, created_at FROM orders WHERE created_at < '2026-01-01';
Upserts
Insert or update
INSERT INTO page_views (page, day, views) VALUES ('/pricing', '2026-09-29', 1)
ON CONFLICT (page, day) DO UPDATE SET views = views + 1;
INSERT INTO products (sku, name, price_cents) VALUES ('OAK-1', 'Oak table', 21900)
ON CONFLICT (sku) DO UPDATE SET name = excluded.name, price_cents = excluded.price_cents
WHERE excluded.price_cents <> products.price_cents;
Skip duplicates
INSERT INTO tags (name) VALUES ('oak') ON CONFLICT DO NOTHING;
INSERT OR IGNORE INTO tags (name) VALUES ('oak');
Upsert from a SELECT needs a WHERE
INSERT INTO stock (sku, qty) SELECT sku, sum(qty) FROM deliveries WHERE true GROUP BY sku ON CONFLICT (sku) DO UPDATE SET qty = stock.qty + excluded.qty;
REPLACE deletes, then inserts
REPLACE INTO settings (key, value) VALUES ('theme', 'dark');
Update and Delete
Update and delete with RETURNING
UPDATE orders SET status = 'shipped' WHERE id = 42 RETURNING id, status; DELETE FROM sessions WHERE expires_at < unixepoch() RETURNING id;
Conflict resolution on UPDATE
UPDATE OR IGNORE customers SET email = lower(email); UPDATE OR ROLLBACK accounts SET balance = balance - 100 WHERE id = 1;
Empty a table
DELETE FROM logs; -- SQLite has no TRUNCATE; without WHERE it uses a fast truncate path VACUUM; -- give the space back to the file system
Fast bulk inserts
BEGIN; INSERT INTO events (at, type) VALUES (?, ?); -- one prepared statement, executed many times COMMIT;
Writes on the Sample Data
Upsert and see the result
INSERT INTO products (sku, name, price_cents) VALUES ('OAK-1', 'Oak table', 21900)
ON CONFLICT (sku) DO UPDATE SET price_cents = excluded.price_cents
RETURNING id, sku, price_cents;
Delete and return what went
DELETE FROM order_items WHERE product_id = 10 RETURNING order_id, qty;
Dates & Times
date, time, datetime, julianday, unixepoch, strftime, modifiers and time zones
The Date Functions
Current date and time, in UTC
SELECT date('now'), time('now'), datetime('now'), unixepoch(), julianday('now');
Modifiers shift and truncate
SELECT date('now', 'start of month');
SELECT date('now', 'start of month', '+1 month', '-1 day'); -- last day of this month
SELECT datetime('now', '-7 days');
SELECT date('now', '-6 days', 'weekday 1'); -- Monday of this week
SELECT datetime('2026-09-29 14:05', '+90 minutes');
Unix time in and out
SELECT datetime(1790690712, 'unixepoch');
SELECT datetime(1790690712123 / 1000.0, 'unixepoch', 'subsec'); -- milliseconds (3.42+)
SELECT unixepoch('2026-09-29 14:05:12');
SELECT datetime(1790690712, 'auto'); -- detects unix or julian day
Formatting
strftime()
SELECT strftime('%Y-%m', created_at) AS month, count(*) FROM orders GROUP BY month;
SELECT strftime('%d.%m.%Y %H:%M', 'now');
SELECT strftime('%w', 'now'), strftime('%j', 'now'), strftime('%s', 'now');
ISO weeks
SELECT strftime('%G-W%V', '2026-09-29');
Format codes
| Code | Meaning |
|---|---|
%Y %m %d | year, month 01-12, day 01-31 |
%H %M %S | hour 00-23, minute, second |
%f | seconds with milliseconds, SS.SSS |
%s | unix seconds |
%w | weekday, 0 is Sunday |
%j | day of the year |
%W %U | week of the year, weeks starting Monday or Sunday |
%G %V %u | ISO year, ISO week, ISO weekday (3.46+) |
Differences and Time Zones
Differences between dates
SELECT julianday('2026-09-29') - julianday('2026-01-01') AS days;
SELECT (unixepoch('2026-09-29 18:00') - unixepoch('2026-09-29 09:30')) / 3600.0 AS hours;
SELECT timediff('2026-09-29', '2025-06-15');
Local time
SELECT datetime('now', 'localtime');
SELECT datetime('2026-09-29 11:00:00', 'utc'); -- treat the value as local time and convert to UTC
Month arithmetic at the end of a month
SELECT date('2026-01-31', '+1 month');
SELECT date('2026-01-31', '+1 month', 'floor');
Dates on the Sample Data
The last 30 days, and a weekday histogram
SELECT count(*) FROM orders WHERE created_at >= datetime('2026-09-29', '-30 days');
SELECT strftime('%w', created_at) AS weekday, count(*) FROM orders GROUP BY weekday;
JSON & JSONB
->, ->>, json_extract, json_each, json_set, JSONB and JSON indexes
Read JSON
-> and ->>
SELECT '{"a": {"b": [1, 2, 3]}}' -> '$.a' AS obj,
'{"a": {"b": [1, 2, 3]}}' ->> '$.a.b[1]' AS val,
'{"a": {"b": [1, 2, 3]}}' ->> '$.a.b[#-1]' AS last;
Filter on a JSON field
SELECT sku, tags FROM products WHERE tags ->> '$[0]' = 'wood'; SELECT sku FROM products WHERE EXISTS (SELECT 1 FROM json_each(tags) WHERE value = 'kitchen');
Expand an array or object into rows
SELECT p.sku, t.value AS tag
FROM products p, json_each(p.tags) t;
SELECT key, value, type FROM json_each('{"a": 1, "b": [2], "c": null}');
Build and Change JSON
Build JSON
SELECT json_object('id', id, 'sku', sku, 'price', price_cents / 100.0) FROM products LIMIT 1;
SELECT json_array(1, 'two', NULL), json('{ "a" : 1 }');
Change JSON
SELECT json_set('{"a":1}', '$.b', 2, '$.a', 10); -- create or replace
SELECT json_insert('{"a":1}', '$.a', 99, '$.c', 3); -- only create
SELECT json_replace('{"a":1}', '$.a', 5, '$.z', 0); -- only replace
SELECT json_remove('{"a":1,"b":2}', '$.b');
SELECT json_patch('{"a":1,"b":{"c":2}}', '{"b":{"c":null,"d":4}}');
Update a JSON column in place
UPDATE products SET tags = json_insert(tags, '$[#]', 'sale') WHERE sku = 'OAK-1'; SELECT tags FROM products WHERE sku = 'OAK-1';
Validate and inspect
SELECT json_valid('{"a":1}'), json_valid('{a:1}'), json_type('[1,2]'), json_array_length('[1,2,3]');
SELECT json_error_position('{"a":1 "b":2}');
SELECT json_valid('{"a":1,}'), json_valid('{"a":1,}', 2);
JSONB and Indexes
JSONB: the binary form
CREATE TABLE docs (id INTEGER PRIMARY KEY, body BLOB CHECK (json_valid(body, 8)));
INSERT INTO docs (body) VALUES (jsonb('{"type":"invoice","total":120}'));
SELECT body ->> '$.type', json(body) FROM docs;
Index a JSON field
CREATE INDEX docs_type ON docs (body ->> '$.type'); SELECT id FROM docs WHERE body ->> '$.type' = 'invoice';
Newer JSON functions
SELECT json_pretty('{"a":[1,2]}');
SELECT json_array_insert('[1,3]', '$[1]', 2);
JSON Reports
Count values inside JSON arrays
SELECT value AS tag, count(*) AS products FROM products, json_each(products.tags) GROUP BY tag ORDER BY products DESC, tag LIMIT 4;
One JSON object from many rows
SELECT json_group_object(sku, price_cents) FROM products WHERE price_cents > 10000;
Transactions & Locks
BEGIN, IMMEDIATE, savepoints, locking and SQLITE_BUSY
Transactions
Group writes into one transaction
BEGIN IMMEDIATE; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT;
The three kinds of BEGIN
| Statement | Lock taken at BEGIN | Use for |
|---|---|---|
BEGIN or BEGIN DEFERRED | none until the first read or write | read only transactions |
BEGIN IMMEDIATE | the write lock | any transaction that may write |
BEGIN EXCLUSIVE | the write lock; in rollback journal mode also blocks readers | rare maintenance |
Savepoints: nested transactions
BEGIN;
INSERT INTO orders (customer_id, status, created_at) VALUES (1, 'new', datetime('now'));
SAVEPOINT items;
INSERT INTO order_items (order_id, product_id, qty, price_cents) VALUES (last_insert_rowid(), 3, 1, 4500);
ROLLBACK TO items; -- undo only the part after the savepoint
RELEASE items;
COMMIT;
Locking
database is locked
PRAGMA busy_timeout = 5000;
Who can do what at the same time
| Journal mode | Readers during a write | Writers at once |
|---|---|---|
| DELETE, TRUNCATE, PERSIST (rollback journal) | blocked while the writer commits | one |
| WAL | allowed, they see the last committed state | one |
| WAL2 or BEGIN CONCURRENT | not in the standard build | several, on separate branches only |
Read your own snapshot
BEGIN; SELECT count(*) FROM orders; -- the read starts a snapshot SELECT sum(total_cents) FROM orders; -- same snapshot, even if others commit meanwhile COMMIT;
Undo
ROLLBACK undoes everything since BEGIN
BEGIN; DELETE FROM order_items; SELECT count(*) FROM order_items; ROLLBACK; SELECT count(*) FROM order_items;
Full-Text Search
FTS5 tables, MATCH queries, ranking, snippets and external content
FTS5 Basics
Create, fill and search
CREATE VIRTUAL TABLE docs_fts USING fts5(title, body);
INSERT INTO docs_fts (title, body) VALUES ('Oak tables', 'How to oil an oak dining table'), ('Chairs', 'Ash and oak chairs compared');
SELECT title FROM docs_fts WHERE docs_fts MATCH 'oak' ORDER BY rank;
Query syntax
SELECT title FROM docs_fts WHERE docs_fts MATCH 'oak AND table'; SELECT title FROM docs_fts WHERE docs_fts MATCH '"dining table"'; -- phrase SELECT title FROM docs_fts WHERE docs_fts MATCH 'tab*'; -- prefix SELECT title FROM docs_fts WHERE docs_fts MATCH 'title:oak'; -- one column SELECT title FROM docs_fts WHERE docs_fts MATCH 'NEAR(oak table, 3)'; SELECT title FROM docs_fts WHERE docs_fts MATCH 'oak NOT chairs';
Results
Highlight and snippet
SELECT highlight(docs_fts, 1, '<b>', '</b>') FROM docs_fts WHERE docs_fts MATCH 'oak'; SELECT snippet(docs_fts, 1, '[', ']', '...', 8) FROM docs_fts WHERE docs_fts MATCH 'dining';
Tokenizers
CREATE VIRTUAL TABLE t1 USING fts5(body, tokenize = 'porter unicode61 remove_diacritics 2'); CREATE VIRTUAL TABLE t2 USING fts5(body, tokenize = 'trigram'); -- substring search, LIKE '%x%' style
Keep FTS in Sync
An external content table kept in sync by triggers
CREATE VIRTUAL TABLE articles_fts USING fts5(title, body, content='articles', content_rowid='id');
CREATE TRIGGER articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts (rowid, title, body) VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts (articles_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
END;
CREATE TRIGGER articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts (articles_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
INSERT INTO articles_fts (rowid, title, body) VALUES (new.id, new.title, new.body);
END;
INSERT INTO articles_fts (articles_fts) VALUES ('rebuild');
Indexes
creating indexes, composite, covering, partial and expression indexes
Create Indexes
Single column and composite indexes
CREATE INDEX orders_customer ON orders (customer_id); CREATE INDEX orders_status_created ON orders (status, created_at DESC); CREATE UNIQUE INDEX customers_email ON customers (email); DROP INDEX IF EXISTS orders_customer;
Equality columns first, then the sort, then ranges
-- WHERE customer_id = ? AND status = ? AND created_at > ? ORDER BY created_at CREATE INDEX orders_lookup ON orders (customer_id, status, created_at);
Covering index: answer from the index alone
CREATE INDEX orders_cover ON orders (customer_id, status, total_cents); EXPLAIN QUERY PLAN SELECT status, total_cents FROM orders WHERE customer_id = 3;
Partial index
CREATE INDEX orders_open ON orders (created_at) WHERE status IN ('new', 'paid');
SELECT id FROM orders WHERE status IN ('new', 'paid') AND created_at > '2026-09-01';
Expression index
CREATE INDEX customers_email_lower ON customers (lower(email)); SELECT id FROM customers WHERE lower(email) = 'ana@example.com'; CREATE INDEX orders_day ON orders (date(created_at));
Maintain Indexes
List and inspect indexes
SELECT name, tbl_name, sql FROM sqlite_schema WHERE type = 'index' AND sql IS NOT NULL; PRAGMA index_list(orders); PRAGMA index_info(orders_status_created);
Statistics for the planner
ANALYZE; PRAGMA optimize; SELECT * FROM sqlite_stat1 LIMIT 3;
Rebuild indexes
REINDEX; -- every index REINDEX orders; -- the indexes of one table REINDEX EXPRESSIONS; -- expression indexes only (3.53+)
Check an Index Is Used
Plan with and without a usable index
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 3 ORDER BY created_at;
Query Planner
EXPLAIN QUERY PLAN, reading plans, forcing indexes and the planner's statistics
Read a Plan
EXPLAIN QUERY PLAN
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE status = 'paid' ORDER BY created_at DESC;
What the plan lines mean
| Plan line | Meaning |
|---|---|
SCAN t | reads every row of t |
SEARCH t USING INDEX i (a=?) | looks up rows through index i, then reads each row |
SEARCH t USING COVERING INDEX i (a=?) | answers from the index alone, never touching the table |
SEARCH t USING INTEGER PRIMARY KEY (rowid=?) | direct lookup by rowid, the fastest |
USE TEMP B-TREE FOR ORDER BY | sorts in memory or a temporary file |
AUTOMATIC COVERING INDEX | builds a temporary index for this query only; create a real one |
BLOOM FILTER ON t (a=?) | filters with a Bloom filter because no index fits |
CORRELATED SCALAR SUBQUERY | runs a subquery once per outer row |
The fix for the plan above
CREATE INDEX orders_status_created ON orders (status, created_at); EXPLAIN QUERY PLAN SELECT * FROM orders WHERE status = 'paid' ORDER BY created_at DESC;
Influence the Planner
Force or forbid an index
SELECT * FROM orders INDEXED BY orders_status_created WHERE status = 'paid'; SELECT * FROM orders NOT INDEXED WHERE status = 'paid';
Hints about selectivity
SELECT * FROM orders WHERE likely(status = 'shipped') AND customer_id = 3; SELECT * FROM events WHERE unlikely(type = 'error'); SELECT * FROM t WHERE +a = 5; -- the unary + stops SQLite from using an index on a
Let the shell suggest an index
.expert SELECT * FROM orders WHERE customer_id = 3 AND status = 'paid' ORDER BY created_at;
Joins in Plans
Which table drives the join
EXPLAIN QUERY PLAN SELECT c.name, o.id FROM customers c JOIN orders o ON o.customer_id = c.id WHERE c.country = 'RO';
WAL & Concurrency
write-ahead logging, checkpoints, readers and writers, and multi-process access
WAL Mode
Turn on WAL
PRAGMA journal_mode = WAL;
The files you will see
| File | What it is |
|---|---|
app.db | the database |
app.db-wal | committed changes not yet copied into the database |
app.db-shm | a shared memory index into the WAL, rebuilt when needed |
app.db-journal | the rollback journal in the default DELETE mode |
Checkpoints
PRAGMA wal_checkpoint(PASSIVE); -- copy what it can without waiting PRAGMA wal_checkpoint(TRUNCATE); -- copy everything, then empty the -wal file PRAGMA wal_autocheckpoint = 1000; -- pages; the default PRAGMA journal_size_limit = 67108864;
Concurrency Rules
What SQLite handles well
| Situation | Works well? |
|---|---|
| many readers, one writer at a time | yes, with WAL |
| several threads, one connection each | yes, with busy_timeout |
| several processes on one machine | yes |
| a web app with many short write transactions | yes, thousands per second |
| many long write transactions at once | no: writes are serialized |
| the database on NFS, SMB or another network file system | no: locking is unreliable and WAL needs shared memory |
One writer, many readers in application code
-- open one read-write connection for writes, used by one thread or a queue -- open a pool of read only connections (file:app.db?mode=ro) for reads -- every write transaction: BEGIN IMMEDIATE ... COMMIT, kept short
Leave WAL mode
PRAGMA journal_mode = DELETE;
Size & VACUUM
file size, free pages, VACUUM, auto_vacuum, page size and dbstat
File Size
How big, and how much is free
SELECT page_count * page_size AS bytes, freelist_count * page_size AS free_bytes FROM pragma_page_count, pragma_page_size, pragma_freelist_count;
Shrink and defragment
VACUUM; VACUUM INTO 'compact-copy.db';
Reclaim space gradually
PRAGMA auto_vacuum = INCREMENTAL; -- takes effect for a new database, or after one VACUUM VACUUM; PRAGMA incremental_vacuum(500); -- give back up to 500 free pages now
Which tables and indexes use the space
SELECT name, sum(pgsize) / 1024 AS kib FROM dbstat GROUP BY name ORDER BY kib DESC LIMIT 5;
Page size
PRAGMA page_size; -- 4096 by default PRAGMA page_size = 8192; -- only before the first table is created, or before VACUUM VACUUM;
Row Counts
Rows per table in one query
SELECT 'customers' AS tbl, count(*) FROM customers UNION ALL SELECT 'orders', count(*) FROM orders UNION ALL SELECT 'order_items', count(*) FROM order_items UNION ALL SELECT 'products', count(*) FROM products;
Views & Triggers
views, temporary views, triggers, RAISE and audit logs
Views
A saved query
CREATE VIEW paid_orders AS SELECT o.id, c.name, o.total_cents, o.created_at FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.status = 'paid'; SELECT * FROM paid_orders ORDER BY created_at DESC LIMIT 5;
Temporary views and tables
CREATE TEMP VIEW recent AS SELECT * FROM orders WHERE created_at > date('now', '-7 days');
CREATE TEMP TABLE scratch AS SELECT id FROM orders WHERE status = 'new';
Triggers
Keep updated_at current
CREATE TRIGGER products_touch AFTER UPDATE ON products
WHEN old.updated_at IS new.updated_at
BEGIN
UPDATE products SET updated_at = datetime('now') WHERE id = new.id;
END;
Audit log
CREATE TABLE audit (at TEXT DEFAULT CURRENT_TIMESTAMP, tbl TEXT, row_id INTEGER, action TEXT, old_value TEXT);
CREATE TRIGGER orders_audit AFTER UPDATE OF status ON orders
BEGIN
INSERT INTO audit (tbl, row_id, action, old_value) VALUES ('orders', old.id, 'status', old.status);
END;
Reject bad writes with RAISE
CREATE TRIGGER orders_no_backwards BEFORE UPDATE OF status ON orders WHEN old.status = 'shipped' AND new.status = 'new' BEGIN SELECT RAISE(ABORT, 'a shipped order cannot go back to new'); END;
A writable view
CREATE TRIGGER paid_orders_delete INSTEAD OF DELETE ON paid_orders BEGIN UPDATE orders SET status = 'refunded' WHERE id = old.id; END;
Views and Triggers on the Sample Data
A summary view
CREATE VIEW customer_totals AS SELECT c.id, c.name, count(o.id) AS orders, coalesce(sum(o.total_cents), 0) AS cents FROM customers c LEFT JOIN orders o ON o.customer_id = c.id GROUP BY c.id; SELECT name, orders, cents / 100.0 AS spent FROM customer_totals ORDER BY cents DESC LIMIT 3;
Keep a total in step with its items
CREATE TRIGGER items_total AFTER INSERT ON order_items BEGIN UPDATE orders SET total_cents = total_cents + new.qty * new.price_cents WHERE id = new.order_id; END; INSERT INTO order_items VALUES (2, 9, 2, 900); SELECT total_cents FROM orders WHERE id = 2;
List views and triggers
SELECT type, name, tbl_name FROM sqlite_schema WHERE type IN ('view', 'trigger');
Attached Databases
ATTACH, cross-database queries, copying between files and schemas
ATTACH
Query several database files at once
ATTACH DATABASE 'archive.db' AS archive; SELECT count(*) FROM archive.orders; SELECT * FROM main.orders UNION ALL SELECT * FROM archive.orders; DETACH DATABASE archive; PRAGMA database_list;
Copy a table between files
ATTACH 'copy.db' AS copy; CREATE TABLE copy.customers AS SELECT * FROM main.customers;
Schema names
SELECT * FROM main.sqlite_schema; SELECT * FROM temp.sqlite_schema; ATTACH ':memory:' AS mem; CREATE TABLE mem.lookup (k TEXT PRIMARY KEY, v TEXT);
Scratch Space
An in-memory database next to the main file
ATTACH ':memory:' AS scratch; CREATE TABLE scratch.new_ids AS SELECT id FROM orders WHERE status = 'new'; SELECT count(*) FROM scratch.new_ids;
Backup & Restore
.backup, VACUUM INTO, .dump, sqlite3_rsync, Litestream and recovery
Make a Copy
Online backup while the database is in use
sqlite3 shop.db ".backup backup/shop-2026-09-29.db" sqlite3 backup/shop-2026-09-29.db "PRAGMA integrity_check"
A compacted copy with VACUUM INTO
VACUUM INTO '/backups/shop-2026-09-29.db';
Never copy the file with cp while it is open
# wrong while the app is running: the -wal file may hold recent commits cp shop.db shop-copy.db # right: let SQLite make the copy sqlite3 shop.db ".backup shop-copy.db"
Text Dumps and Remote Copies
.dump to SQL text
sqlite3 shop.db .dump > shop.sql sqlite3 shop.db ".dump orders customers" > two-tables.sql sqlite3 restored.db < shop.sql
sqlite3_rsync: copy only the changed pages
sqlite3_rsync shop.db backup@db-replica:/srv/sqlite/shop.db
Continuous replication with Litestream
litestream replicate shop.db s3://my-bucket/shop litestream restore -o shop.db s3://my-bucket/shop
Recover
Salvage a damaged database
sqlite3 broken.db ".recover" | sqlite3 recovered.db sqlite3 recovered.db "PRAGMA integrity_check"
Restore a backup into an open shell
.restore backup/shop-2026-09-29.db
Import & Export
CSV, JSON, SQL and spreadsheets in and out of SQLite
Import
Import a CSV file
.import --csv --skip 1 customers.csv customers .import --csv new_table.csv products_staging -- table created from the header row SELECT count(*) FROM customers;
Import JSON
INSERT INTO products (sku, name, price_cents)
SELECT value ->> '$.sku', value ->> '$.name', value ->> '$.price_cents'
FROM json_each(readfile('products.json'));
CSV to INSERT without the shell
-- paste the CSV into the toolbox above and press "CSV to INSERT": -- it infers INTEGER, REAL and TEXT columns and writes CREATE TABLE ... STRICT plus the INSERTs
Export
Export a query
sqlite3 -csv -header shop.db "SELECT * FROM orders WHERE status = 'paid'" > paid.csv sqlite3 -json shop.db "SELECT id, name FROM customers" > customers.json sqlite3 -markdown shop.db "SELECT status, count(*) FROM orders GROUP BY 1"
From inside the shell
.headers on .mode csv .once paid.csv SELECT * FROM orders WHERE status = 'paid'; .excel SELECT * FROM customers;
Build the JSON in SQL
SELECT json_group_array(json_object('id', id, 'name', name, 'country', country)) FROM customers;
JSON Lines
One JSON object per line
SELECT json_object('id', id, 'sku', sku, 'price', price_cents / 100.0) FROM products LIMIT 3;
Security
parameters, untrusted SQL and files, defensive mode, encryption and permissions
Parameters Always
Bind values, never build SQL text
-- placeholders: ? ?1 :name @name $name SELECT * FROM customers WHERE email = ?; SELECT * FROM customers WHERE email = :email AND country = :country;
The same in Python
cur.execute("SELECT * FROM customers WHERE email = ?", (email,)) # right
cur.execute(f"SELECT * FROM customers WHERE email = '{email}'") # SQL injection
IN lists with a variable number of values
SELECT * FROM orders WHERE id IN (SELECT value FROM json_each(?)); -- bind '[1, 5, 9]'
Untrusted Databases and SQL
Opening a database file from someone else
PRAGMA trusted_schema = OFF; PRAGMA cell_size_check = ON; PRAGMA mmap_size = 0; PRAGMA quick_check;
Extensions are off unless you turn them on
con = sqlite3.connect("app.db")
con.execute("SELECT load_extension('./vec0')")
Encryption and Files
Encryption at rest
| Option | Notes |
|---|---|
| SQLCipher | open source, AES-256, widely used on mobile; its own build of SQLite |
| SQLite Encryption Extension (SEE) | the official commercial extension from the SQLite developers |
| sqlite3mc (SQLite3 Multiple Ciphers) | open source, several cipher schemes |
| disk or volume encryption | protects a lost device, not a copied file |
File permissions and deleted data
chmod 600 shop.db PRAGMA secure_delete = ON;
Names You Cannot Bind
Check identifiers against an allowlist
SORTABLE = {"created_at", "total_cents", "status"}
column = request.args.get("sort", "created_at")
if column not in SORTABLE:
column = "created_at"
rows = con.execute(f"SELECT id FROM orders ORDER BY {column} LIMIT ?", (20,)).fetchall()
Limits & Build Options
size limits, parameters, compile options and what a build includes
Limits
The default limits
| Limit | Default |
|---|---|
| database size | 281 TB with 64 KiB pages, 17.6 TB with the default 4 KiB pages |
| string or BLOB | 1,000,000,000 bytes |
| columns per table | 2,000 (32,767 at compile time) |
| SQL statement length | 1,000,000,000 bytes |
| bound parameters | 32,766 (999 before 3.32) |
| attached databases | 10 (125 at compile time) |
| rows | 2^64, in practice limited by the file size |
| tables in a join | 64 |
Check the limits of your build
PRAGMA compile_options; SELECT * FROM pragma_compile_options WHERE compile_options LIKE 'MAX_%';
Build Options
Options that change what SQL works
| Option | Effect |
|---|---|
SQLITE_ENABLE_FTS5 | FTS5 full-text search |
SQLITE_ENABLE_MATH_FUNCTIONS | sqrt(), pow(), ln() and the other math functions |
SQLITE_ENABLE_PERCENTILE | median(), percentile(), percentile_cont(), percentile_disc() |
SQLITE_ENABLE_RTREE | R*Tree spatial index |
SQLITE_ENABLE_DBSTAT_VTAB | the dbstat table of page usage |
SQLITE_OMIT_LOAD_EXTENSION | no load_extension() at all |
SQLITE_DQS=0 | double quoted strings are always identifiers |
SQLITE_THREADSAFE=0/1/2 | single thread, serialized, or multi thread |
Turn off the double quoted string fallback
-- in the shell, from 3.41 double quoted strings are rejected by default: SELECT * FROM customers WHERE name = "Ana Pop";
Extensions
load_extension, vector search, regex, UUIDs, spatial and popular extension bundles
Load an Extension
From the shell and from code
.load ./vec0 SELECT vec_version();
Popular extensions
| Extension | Adds |
|---|---|
| sqlite-vec | vector search with KNN queries over embeddings |
| sqlean | regexp, uuid, crypto hashes, math, text and more in small modules |
| SpatiaLite | geometry types and GIS functions |
| R*Tree (built in) | range queries over bounding boxes |
| spellfix1 | fuzzy matching of misspelled words |
| ICU | Unicode aware upper(), lower(), LIKE and collations |
Built-in Virtual Tables
Vector search with sqlite-vec
CREATE VIRTUAL TABLE vec_docs USING vec0(embedding float[384]); INSERT INTO vec_docs (rowid, embedding) VALUES (1, ?); -- bind a JSON array or a float32 blob SELECT rowid, distance FROM vec_docs WHERE embedding MATCH ? ORDER BY distance LIMIT 5;
R*Tree for ranges and bounding boxes
CREATE VIRTUAL TABLE places_idx USING rtree(id, min_lat, max_lat, min_lon, max_lon); SELECT id FROM places_idx WHERE min_lat >= 46.7 AND max_lat <= 46.8 AND min_lon >= 23.5 AND max_lon <= 23.7;
Table-valued functions
SELECT value FROM generate_series(1, 10, 3); -- shell and builds with the series extension
SELECT * FROM pragma_table_info('orders');
SQLite From Code
Python, Node.js, Go, Rust, PHP, C# and Java, plus connection habits
Open, Query, Write
The built-in sqlite3 module
import sqlite3
con = sqlite3.connect("app.db", timeout=5, autocommit=False)
con.execute("PRAGMA journal_mode = WAL")
con.execute("PRAGMA foreign_keys = ON")
con.row_factory = sqlite3.Row
rows = con.execute("SELECT id, name FROM customers WHERE country = ?", ("RO",)).fetchall()
with con:
con.execute("UPDATE orders SET status = ? WHERE id = ?", ("shipped", 42))
print(rows[0]["name"])
better-sqlite3, synchronous and fast
import Database from 'better-sqlite3';
const db = new Database('app.db', { timeout: 5000 });
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
const byCountry = db.prepare('SELECT id, name FROM customers WHERE country = ?');
const rows = byCountry.all('RO');
const addMany = db.transaction((list) => { for (const c of list) insert.run(c); });
database/sql with the pure Go driver
import (
"database/sql"
_ "modernc.org/sqlite"
)
db, err := sql.Open("sqlite", "file:app.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)")
if err != nil { log.Fatal(err) }
db.SetMaxOpenConns(1)
var name string
err = db.QueryRow("SELECT name FROM customers WHERE id = ?", 1).Scan(&name)
rusqlite
use rusqlite::{params, Connection};
let conn = Connection::open("app.db")?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.execute("INSERT INTO customers (name, email, country, created_at) VALUES (?1, ?2, ?3, datetime('now'))",
params!["Ana", "ana@example.org", "RO"])?;
let name: String = conn.query_row("SELECT name FROM customers WHERE id = ?1", [1], |r| r.get(0))?;
PDO
$pdo = new PDO('sqlite:' . __DIR__ . '/app.db', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('PRAGMA journal_mode = WAL');
$stmt = $pdo->prepare('SELECT id, name FROM customers WHERE country = :country');
$stmt->execute(['country' => 'RO']);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
Connection Habits
What to do on every connection
| Habit | Why |
|---|---|
| set busy_timeout | wait for locks instead of failing with SQLITE_BUSY |
| PRAGMA foreign_keys = ON | foreign keys are off by default, per connection |
| one writer, several readers | writes are serialized anyway |
| BEGIN IMMEDIATE for write transactions | avoids busy errors when a read upgrades to a write |
| prepared statements with parameters | no SQL injection, less parsing |
| PRAGMA optimize before closing long lived connections | fresh planner statistics |
C#, Java and mobile
// C#: Microsoft.Data.Sqlite
using var con = new SqliteConnection("Data Source=app.db;Foreign Keys=True;Default Timeout=5");
// Java: xerial sqlite-jdbc
Connection c = DriverManager.getConnection("jdbc:sqlite:app.db");
// Android: Room (on top of SQLite); iOS: GRDB or SQLite.swift; Flutter: sqflite or drift
SQLite in the browser
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
const sqlite3 = await sqlite3InitModule();
const db = new sqlite3.oo1.OpfsDb('/app.db'); // persistent, in a Worker, via OPFS
db.exec({ sql: 'SELECT sqlite_version()', rowMode: 'array', callback: (row) => console.log(row) });
More From Code
Python: many rows at once, and dict rows
con.executemany("INSERT INTO tags (name) VALUES (?)", [("oak",), ("ash",), ("elm",)])
con.row_factory = lambda cur, row: {d[0]: v for d, v in zip(cur.description, row)}
print(con.execute("SELECT id, name FROM tags").fetchone())
Python: an in-memory copy and back
mem = sqlite3.connect(":memory:")
con.backup(mem) # copy the file into memory
data = mem.serialize() # the whole database as bytes (3.11+)
Go: a write transaction
tx, err := db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, "UPDATE orders SET status = ? WHERE id = ?", "shipped", 42); err != nil { return err }
return tx.Commit()
Error Codes
result codes, the messages you will meet, and what to do about them
Result Codes
The codes you will see in practice
| Code | Name | Usual cause and fix |
|---|---|---|
| 5 | SQLITE_BUSY | another connection holds the write lock: set busy_timeout, keep transactions short, use BEGIN IMMEDIATE |
| 6 | SQLITE_LOCKED | a conflict inside the same connection, such as dropping a table a running statement reads |
| 19 | SQLITE_CONSTRAINT | UNIQUE, NOT NULL, CHECK, FOREIGN KEY or PRIMARY KEY violated |
| 1 | SQLITE_ERROR | SQL error: syntax, missing table or column |
| 8 | SQLITE_READONLY | the file or its directory is not writable, or the database was opened read only |
| 11 | SQLITE_CORRUPT | the file is damaged: restore a backup, or try .recover |
| 13 | SQLITE_FULL | the disk is full, or the maximum page count was reached |
| 14 | SQLITE_CANTOPEN | wrong path, missing directory, or no permission |
| 18 | SQLITE_TOOBIG | a string or BLOB larger than the limit |
| 21 | SQLITE_MISUSE | the API was used wrongly, such as a statement used after finalize |
| 9 | SQLITE_INTERRUPT | the query was cancelled with sqlite3_interrupt() |
Constraint messages
PRAGMA foreign_keys = ON;
INSERT INTO customers (name, email, country, created_at) VALUES ('X', 'ana@example.com', 'RO', '2026-09-29');
INSERT INTO orders (customer_id, status, created_at) VALUES (999, 'new', '2026-09-29');
INSERT INTO orders (customer_id, status, created_at) VALUES (1, 'lost', '2026-09-29');
Common Messages
What the error text means
| Message | Meaning |
|---|---|
database is locked | SQLITE_BUSY: another writer, or a read transaction left open in your own code |
no such table: x | wrong file opened (a new empty one was created), wrong schema, or a typo |
attempt to write a readonly database | file or folder permissions, or mode=ro |
unable to open database file | the directory does not exist or is not writable |
database disk image is malformed | corruption: stop writing, copy the files, then .recover |
cannot start a transaction within a transaction | BEGIN while one is already open, often from a driver's implicit transaction |
too many SQL variables | more than 32766 parameters; batch the insert or use json_each(?) |
A new empty database instead of yours
sqlite3 "$(pwd)/data/shop.db" ".tables" SELECT file FROM pragma_database_list WHERE name = 'main';
Extended error codes in Python
try:
con.execute("INSERT INTO customers (email) VALUES (?)", ("ana@example.com",))
except sqlite3.IntegrityError as e:
if e.sqlite_errorname == "SQLITE_CONSTRAINT_UNIQUE":
print("email already registered")
Handle Errors in Code
Node.js: catch a constraint error
try {
db.prepare('INSERT INTO customers (name, email, country, created_at) VALUES (?, ?, ?, ?)').run(name, email, 'RO', now);
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') return res.status(409).json({ error: 'email already registered' });
throw err;
}
Retry on SQLITE_BUSY, briefly
for attempt in range(5):
try:
with con:
con.execute("UPDATE counters SET n = n + 1 WHERE id = 1")
break
except sqlite3.OperationalError as e:
if "locked" not in str(e): raise
time.sleep(0.05 * 2 ** attempt)
Versions & Comparisons
release history, what each version added, and SQLite next to PostgreSQL and MySQL
Release History
Recent releases, as of September 29, 2026
| Version | Released | Highlights |
|---|---|---|
| 3.53.4 | July 24, 2026 | current patch release of 3.53 |
| 3.53.0 | April 9, 2026 | ALTER TABLE for NOT NULL and CHECK, REINDEX EXPRESSIONS, json_array_insert(), box output in the shell, 17 digit floats |
| 3.52.0 | March 2026 | withdrawn; its features moved to 3.53 |
| 3.51.3 | March 13, 2026 | fixes the WAL-reset corruption bug |
| 3.51.0 | November 4, 2025 | jsonb_each(), jsonb_tree() |
| 3.50.0 | May 29, 2025 | unistr(), sqlite3_rsync without WAL on the source |
| 3.49.0 | February 6, 2025 | iif() with many arguments |
| 3.48.0 | January 14, 2025 | if(), two argument iif(), .dbtotxt |
| 3.47.0 | October 21, 2024 | median() and percentiles in the shell, sqlite3_rsync, .www |
| 3.46.0 | May 23, 2024 | %G %V strftime codes, floor and ceiling modifiers, json_pretty(), PRAGMA optimize improvements |
| 3.45.0 | January 15, 2024 | JSONB |
| 3.44.0 | November 1, 2023 | ORDER BY in aggregates, string_agg(), concat(), concat_ws() |
Older features you can count on from 3.37
| Version | Feature |
|---|---|
| 3.35 | RETURNING, DROP COLUMN, math functions, MATERIALIZED hints |
| 3.37 | STRICT tables, PRAGMA table_list |
| 3.38 | -> and ->> operators, JSON built in by default, unixepoch() |
| 3.39 | RIGHT and FULL OUTER JOIN, IS DISTINCT FROM |
| 3.41 | unhex(), the shell rejects double quoted strings |
| 3.42 | subsec modifier, json_error_position() |
| 3.43 | timediff(), octet_length() |
Which version am I on, in each place
sqlite3 --version
python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
node -e "console.log(require('better-sqlite3')(':memory:').prepare('select sqlite_version()').pluck().get())"
SELECT sqlite_version();
SQLite and Server Databases
When SQLite fits and when it does not
| Question | SQLite | PostgreSQL or MySQL |
|---|---|---|
| deployment | a library and a file, no server | a server process to run and secure |
| concurrent writers | one at a time | many |
| clients over a network | no, the application must sit next to the file | yes |
| typing | flexible, or STRICT | strict |
| sizes | fine to hundreds of GB | terabytes and beyond |
| best for | apps, mobile, desktop, embedded, single server web apps, tests, analysis files | multi server web apps, heavy concurrent writes, many client programs |
Syntax differences you will hit
| Task | SQLite | PostgreSQL | MySQL |
|---|---|---|---|
| auto id | INTEGER PRIMARY KEY | GENERATED ALWAYS AS IDENTITY | AUTO_INCREMENT |
| upsert | ON CONFLICT (k) DO UPDATE | ON CONFLICT (k) DO UPDATE | ON DUPLICATE KEY UPDATE |
| now | datetime('now') | now() | NOW() |
| string concat | concat(a, b) | concat(a, b) | CONCAT(a, b) |
| list tables | .tables | \dt | SHOW TABLES |
| boolean | INTEGER 0/1 | boolean | TINYINT(1) |
Feature Checks
Ask the build what it includes
SELECT sqlite_compileoption_used('ENABLE_FTS5'), sqlite_compileoption_used('ENABLE_MATH_FUNCTIONS');
SELECT count(*) FROM pragma_function_list WHERE name = 'jsonb';
What Is a SQLite Cheat Sheet
A SQLite cheat sheet is a one page reference to the SQL syntax, sqlite3 shell commands, PRAGMA settings and functions of SQLite, arranged so you can find the right statement in seconds instead of searching the documentation. People also call it a sqlite3 cheat sheet, and they usually mean the same mix of dot-commands, queries and settings.
This SQLite cheat sheet covers 30 sections and 243 snippets for SQLite 3.37 to 3.53. Every snippet that needs a newer release carries a version chip, and the version picker in the toolbar hides what your copy of SQLite cannot run, with presets for the versions that ship with Ubuntu 22.04, Debian 12 and Ubuntu 24.04. Rows that only work in the sqlite3 shell or need a compile option carry a chip too, and portable mode hides them. Each card holds copyable examples with a one line description, the output SQLite prints where it helps, and a short explanation of why SQLite behaves that way.
The playground at the top runs real SQL on a sample shop database with SQLite compiled to WebAssembly, and shows the query plan with an index suggestion. The toolbox also formats and lints SQL for SQLite specific mistakes, converts MySQL and PostgreSQL table definitions and statements, explains type affinity, reads EXPLAIN QUERY PLAN output, turns shell output into CSV or Markdown, and turns CSV or JSON into typed INSERT statements. The builders write PRAGMA blocks for your workload, connection code for eight languages, date expressions, import and export commands and backup scripts. The SQL engine is downloaded once from a public CDN and runs entirely in your browser; nothing you type is sent anywhere.
How It Differs From the Documentation
The SQLite documentation is precise and complete, and spread over hundreds of pages. This page shows the shortest working form of each statement next to the surprise that usually comes with it, such as type affinity, foreign keys being off by default, or integer division. For standard SQL across databases, see the SQL cheat sheet; for a server database with the same upsert syntax, the PostgreSQL cheat sheet.
What This SQLite Cheat Sheet Covers
The 30 sections follow the life of a database file, from creating it to backing it up, and each link jumps to that section above.
- Foundations: installing SQLite and opening databases with the playground and builders, the sqlite3 shell and its dot-commands, types, affinity and STRICT tables, tables, constraints and ALTER TABLE, and PRAGMA settings.
- Querying: SELECT and filtering, joins, aggregates, subqueries and CTEs, window functions, and core functions.
- Changing data: INSERT, UPDATE, upserts and RETURNING, date and time functions, JSON and JSONB, transactions and locking, and FTS5 full-text search.
- Performance: indexes, the query planner, WAL mode and concurrency, and file size and VACUUM.
- Schema logic: views and triggers, and attached databases.
- Operations: backup and restore, import and export, security, limits and build options, and extensions such as sqlite-vec.
- Apps and help: SQLite from Python, Node.js, Go, Rust, PHP, C# and Java, error codes with fixes, and versions and comparisons with PostgreSQL and MySQL.
SQLite Commands List
The commands people look up most often, one line each. The sections above show each of them with options, output and the mistakes that come with them.
| Task | Command |
|---|---|
| Open or create a database | sqlite3 shop.db |
| Show the version | SELECT sqlite_version(); |
| List tables | .tables |
| Show a table definition | .schema orders |
| Show columns | PRAGMA table_info(orders); |
| Readable output | .mode box |
| Create a table | CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL) STRICT; |
| Add a column | ALTER TABLE t ADD COLUMN email TEXT; |
| Rename a table | ALTER TABLE t RENAME TO customers; |
| Delete a table | DROP TABLE IF EXISTS t; |
| Insert and get the id | INSERT INTO t (name) VALUES ('Ana') RETURNING id; |
| Insert or update | INSERT ... ON CONFLICT (email) DO UPDATE SET name = excluded.name; |
| Skip duplicates | INSERT OR IGNORE INTO t (name) VALUES ('Ana'); |
| Update rows | UPDATE t SET name = 'Ana M.' WHERE id = 1; |
| Delete rows | DELETE FROM t WHERE id = 1; |
| Current date and time (UTC) | SELECT datetime('now'); |
| Read a JSON field | SELECT doc ->> '$.name' FROM t; |
| Create an index | CREATE INDEX t_name ON t (name); |
| See a query plan | EXPLAIN QUERY PLAN SELECT ...; |
| Enable foreign keys | PRAGMA foreign_keys = ON; |
| Use WAL mode | PRAGMA journal_mode = WAL; |
| Wait for locks | PRAGMA busy_timeout = 5000; |
| Check the file | PRAGMA integrity_check; |
| Shrink the file | VACUUM; |
| Import a CSV file | .import --csv --skip 1 data.csv t |
| Export to CSV | sqlite3 -csv -header shop.db "SELECT * FROM t" > t.csv |
| Back up while in use | sqlite3 shop.db ".backup backup.db" |
| Dump as SQL | sqlite3 shop.db .dump > shop.sql |
| Run a SQL file | .read schema.sql |
| Time queries | .timer on |
| Attach another file | ATTACH 'archive.db' AS archive; |
| Leave the shell | .quit |
sqlite3 Dot-Commands Cheat Sheet
The sqlite3 command line shell reads lines that start with a dot itself instead of passing them to SQLite. These are the ones worth remembering; they do not work from application code.
| Dot-command | What it does |
|---|---|
.help | list every dot-command |
.open file.db | open another database |
.tables, .schema, .indexes | list tables, show CREATE statements, list indexes |
.mode box, .mode csv, .mode json | change the output format |
.headers on | column names in csv and list modes |
.once file, .output file | send the next result, or all results, to a file |
.import --csv file table | load a CSV file |
.dump, .read file | write the database as SQL, run a SQL file |
.backup file, .restore file | copy the database with the backup API, or load one |
.timer on, .eqp on | show run time, show the query plan |
.expert | suggest indexes for the next query |
.recover | salvage data from a damaged file |
.parameter set :x 1 | bind parameters for testing queries |
.excel, .www | open the next result in a spreadsheet or browser |
.shell cmd | run an operating system command |
.quit | leave |
What SQLite Is
SQLite is an embedded relational database engine: a C library that reads and writes an ordinary file, with no server process, no configuration and no user accounts. The application links the library and talks SQL to it directly, which makes a query a function call rather than a network round trip. D. Richard Hipp started it in 2000, and the source code is in the public domain.
It is almost certainly the most widely deployed database engine: it ships inside every Android and iOS device, every major web browser, macOS and Windows, and countless applications, from desktop programs to aircraft systems. The file format is stable and cross-platform, and the developers have committed to supporting it until at least 2050, which is why the Library of Congress lists SQLite files as a recommended storage format for datasets. SQLite 3.53.4, released on July 24, 2026, is the current version.
Its strengths are simplicity, reliability and speed for the workloads it fits: full ACID transactions, a rich SQL dialect with window functions, CTEs, JSON and full-text search, and a single file that is easy to copy, version and ship.
Where It Fits
SQLite is the default for local storage in Android development and iOS apps, desktop software such as apps built with Electron, command line tools, tests, and data analysis files. It also runs many single server websites well, since one writer at a time handles thousands of short transactions per second. Our guide to the best database for mobile apps compares it with the alternatives. When many machines must write to the same data, a client server database such as PostgreSQL or MySQL is the better tool.
How SQLite Runs a Query
Every statement goes through the same stages inside the library, and knowing them explains most of this cheat sheet.
- The tokenizer and parser turn the SQL text into a parse tree; syntax errors and unknown tables are reported here, when the statement is prepared.
- The query planner considers the available indexes and the statistics in sqlite_stat1, and picks a join order and access path for each table. EXPLAIN QUERY PLAN shows its choice.
- The code generator compiles the plan into a program for SQLite's virtual machine, the VDBE; EXPLAIN without QUERY PLAN shows that bytecode.
- The virtual machine runs the program, walking b-trees: one b-tree per table, keyed by rowid, and one per index.
- The pager reads pages from the file through its cache and handles locking, so only one connection writes at a time.
- On COMMIT the pager makes the change durable: in WAL mode it appends the changed pages to the -wal file and syncs it; in rollback mode it saves the original pages to the -journal file first and then overwrites the database.
- In WAL mode, a checkpoint later copies the changed pages from the -wal file back into the database file.
Most slow queries go wrong at step 2, when no index matches the WHERE clause and ORDER BY, and most database is locked errors come from steps 5 and 6, when a long transaction holds the write lock. The query planner section and the WAL section cover both.
SQLite, PostgreSQL and MySQL Compared
The differences that matter when choosing, or when moving code from one to another.
| Question | SQLite | PostgreSQL | MySQL |
|---|---|---|---|
| Architecture | a library inside the application, one file | client server | client server |
| License | public domain | PostgreSQL License | GPL plus commercial |
| Typing | flexible, or STRICT tables | strict | mostly strict |
| Concurrent writers | one at a time | many, row level locks | many, row level locks |
| Setup and administration | none | install, configure, users, backups | install, configure, users, backups |
| JSON | JSON functions and JSONB blobs | jsonb with GIN indexes | JSON type, multi valued indexes |
| Replication | external tools such as Litestream | streaming and logical | binary log and Group Replication |
Moving a schema from MySQL or PostgreSQL is mostly about types and keys: INTEGER PRIMARY KEY instead of AUTO_INCREMENT or identity columns, TEXT for dates and CHECK constraints for enums and booleans. The converter in the toolbox handles most of it. For the MySQL side, see the MySQL cheat sheet; for documents rather than tables, the MongoDB cheat sheet.
Common SQLite Mistakes
The same handful of surprises catches most people who move to SQLite from a server database.
In Schema and Queries
- Assuming declared types are enforced: without STRICT, a TEXT value lands happily in an INTEGER column.
- Declaring INT PRIMARY KEY instead of INTEGER PRIMARY KEY, which creates a second index instead of using the rowid.
- Expecting foreign keys to work without PRAGMA foreign_keys = ON on every connection.
- Writing strings in double quotes, which SQLite reads as column names first.
- Integer division: 7 / 2 is 3.
- Storing dates in several formats in the same column, which breaks sorting and comparisons.
In Operations
- No busy_timeout, so the first lock conflict becomes a database is locked error.
- Leaving the default rollback journal instead of WAL for an application with concurrent readers.
- Thousands of inserts without a transaction, each paying for its own disk sync.
- Copying the database file with cp while it is in use instead of using .backup or VACUUM INTO.
- Putting the database on a network file system, where locking is unreliable.
- Long read transactions that stop WAL checkpoints, so the -wal file grows without limit.
How to Learn SQLite Well
Start With the Shell
Install the sqlite3 shell, set .mode box and .timer on in ~/.sqliterc, and practise SELECT, joins and aggregates on a real file, or on the sample database in the playground above. Read EXPLAIN QUERY PLAN for every query that matters until SCAN and SEARCH feel familiar. If you work in Python, the Python cheat sheet covers the language around the built-in sqlite3 module, and the JSON cheat sheet helps with JSON columns.
Then Learn What Makes SQLite Different
Understand type affinity and STRICT tables, WAL mode and the one writer rule, and why every connection needs its own PRAGMAs. For mobile work, the Kotlin cheat sheet and the Swift cheat sheet cover the languages around Room and GRDB, and mobile app security best practices explain when a local database needs encryption. After that, FTS5, JSON and backups build on the same ideas.
How This SQLite Cheat Sheet Is Maintained
This page is written and maintained by Bogdan Sandu for TMS Outsource, a software development company. It was checked against the SQLite documentation and the release history up to version 3.53.4 on September 29, 2026. If a snippet is wrong or out of date, the date in the byline shows when it was last reviewed.
SQLite Questions People Actually Ask
The questions developers search for most about SQLite and about this cheat sheet, answered in a few sentences each.
What is SQLite used for?
SQLite stores structured data for a single application in one file: app data on Android and iOS, browser history and settings, desktop programs, command line tools, embedded devices, test databases and data analysis files. It also runs many single server websites, since it handles thousands of short write transactions per second with one writer at a time.
Is SQLite a real database?
Yes. SQLite is a full relational database with ACID transactions, indexes, joins, views, triggers, window functions, CTEs, JSON and full-text search. What it does not have is a server: there are no user accounts, no network protocol and only one writer at a time, because the application reads and writes the database file directly through the library.
How do I list tables in SQLite?
In the sqlite3 shell, .tables lists tables and views and .schema name shows a table's CREATE statement. From SQL or application code, query the schema table: SELECT name FROM sqlite_schema WHERE type = 'table', and use PRAGMA table_info(name) for the columns of one table.
Does SQLite support foreign keys?
Yes, but enforcement is off by default for backward compatibility. Run PRAGMA foreign_keys = ON on every new connection, and create an index on each child column yourself, because SQLite does not create one. PRAGMA foreign_key_check lists rows that already break a constraint.
Which SQLite version should I use?
Use the newest release your platform allows: 3.53.4 as of September 2026. Where you bundle SQLite yourself, upgrade from anything older than 3.51.3 to pick up the fix for the WAL-reset corruption bug. Where the operating system provides SQLite, check sqlite_version() and use the version picker on this page, since Ubuntu 22.04 ships 3.37, Debian 12 ships 3.40 and Ubuntu 24.04 ships 3.45.
How do I fix "database is locked" in SQLite?
Set PRAGMA busy_timeout so connections wait for the lock instead of failing, switch to WAL mode so readers and the writer do not block each other, keep write transactions short and start them with BEGIN IMMEDIATE, and look for code that leaves a transaction or a read cursor open. Never put the file on a network share.
How do I store dates in SQLite?
SQLite has no date type, so pick one format and use it everywhere: ISO 8601 text such as 2026-09-29 14:05:00 in UTC, or INTEGER unix seconds. Both sort correctly and work with the date(), datetime(), strftime() and unixepoch() functions. Convert to local time in the application, where a proper time zone database is available.
How big can a SQLite database be?
The theoretical limit is about 281 terabytes with the largest page size, and 17.6 terabytes with the default 4 KiB pages. In practice SQLite works well into the hundreds of gigabytes; the limits you meet first are one writer at a time and the time it takes to back up or VACUUM a very large file.
Is this SQLite cheat sheet up to date?
Yes. It covers SQLite 3.37 to 3.53 and was checked against the documentation and release history up to 3.53.4 on September 29, 2026. Every snippet that needs a newer release carries a version chip, the version picker hides what your copy of SQLite cannot run, portable mode hides shell only and compile option rows, and the date in the byline changes with every revision.
Can I download this SQLite cheat sheet as a PDF?
Yes. The page has a print stylesheet, so your browser's print command with Save as PDF produces a clean copy: the dark theme switches to a print palette, every output and explanation is expanded, and the search box, navigation and footer are left out. The print core button prints only the core snippets for a short reference, and you can collapse sections or run a search first to print only what is still visible.