Your app's real data lives in a database. SQLite keeps it all in one file — we'll walk through how tables work, how they link, and how SQLite finds your rows fast, with every query shown next to its real result.
← / → to navigate · EN/VI switch below · 60 slides of examples
A shipper spends as much time understanding data as code.
Your app's code decides what happens: rules, flow, UI. It's the instructions, not the content.
Users, orders, products — the things that survive after the app restarts. Losing them means losing the business.
Tables, indexes, all the data — a single file like shop.db, running as a library inside your app's process. Nothing to install or start.
Backing up the database means copying one file. Moving to another machine means sending one file.
One file holds 1 row to millions. The default choice for prototypes, mobile/desktop apps and small web apps.
Debugging, exports, quick fixes, 'why is this row wrong?' — you'll read and write this data constantly.
The file isn't random bytes — it's organized to be fast.
The file is a sequence of fixed-size pages (usually 4 KB). Tables and indexes live inside these pages.
Each table is a B-tree — a balanced, sorted structure that lets SQLite jump to a row instead of reading the whole file.
An index is a separate sorted tree mapping a column's values to the rows' pages. More on this in Part 4.
The write-ahead log lets readers keep reading while a write is in progress — smooth concurrency for small apps.
Spreadsheet vocabulary, but structured — defined once, used everywhere.
A collection of related things: a users table, an orders table.
One record — a single user, a single order.
One attribute, same kind of value in every row: email, total, status.
The shape of your data — which tables exist, what columns they have, what's allowed. Declared once.
-- shop.db — the whole database is this one file
CREATE TABLE users (
id INTEGER PRIMARY KEY, -- unique per user
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER, -- -> users.id (foreign key)
total REAL,
status TEXT
);
| id | name | |
|---|---|---|
| 1 | Ana | ana@ex.com |
| 2 | Bo | bo@ex.com |
| 3 | Cyd | cyd@ex.com |
| id | user_id | total | status |
|---|---|---|---|
| 10 | 1 | 120.00 | paid |
| 11 | 1 | 45.00 | shipped |
| 12 | 2 | 80.00 | paid |
We'll use this shop (users + orders + line_items) in nearly every example below. Note the link already: orders.user_id points at users.id.
Five verbs cover almost everything your app does to data. You need to read them fluently; AI will write them for you.
CREATE TABLE users (id INTEGER PRIMARY KEY,
name TEXT, email TEXT);
INSERT INTO users (id, name, email) VALUES
(1, 'Ana', 'ana@ex.com'),
(2, 'Bo', 'bo@ex.com'),
(3, 'Cyd', 'cyd@ex.com'),
(4, 'Dee', 'dee@ex.com');
| id | name | |
|---|---|---|
| 1 | Ana | ana@ex.com |
| 2 | Bo | bo@ex.com |
| 3 | Cyd | cyd@ex.com |
| 4 | Dee | dee@ex.com |
Make it → add to it. INSERT adds rows to the table — each row is one full record.
SELECT name, email FROM users
WHERE id = 1;
UPDATE users SET name = 'Anna'
WHERE id = 1;
DELETE FROM users WHERE id = 4;
| name | |
|---|---|
| Ana | ana@ex.com |
| id | name | |
|---|---|---|
| 1 | Anna | ana@ex.com |
| 2 | Bo | bo@ex.com |
| 3 | Cyd | cyd@ex.com |
| 4 | Dee | dee@ex.com |
| id | name | |
|---|---|---|
| 1 | Anna | ana@ex.com |
| 2 | Bo | bo@ex.com |
| 3 | Cyd | cyd@ex.com |
Read it → change it → remove it. The CREATE → INSERT → SELECT → UPDATE → DELETE loop is 95% of everyday app SQL.
SQLite is lenient ('type affinity') — it won't scream if you store odd types. But pick sensible ones: comparisons, sorting and indexes behave as you expect.
| Type | Typical use | Example values |
|---|---|---|
| INTEGER | ids, counts, flags, years | id = 1 · qty = 2 |
| TEXT | strings: names, emails, status | 'Ana' · 'paid' |
| REAL | money, measurements | 120.50 |
| BLOB | binary: images, files | photo bytes |
| TIMESTAMP | time (stored as text/number) | '2026-10-01 12:00' |
CREATE TABLE users (
id INTEGER PRIMARY KEY, -- auto id
name TEXT NOT NULL, -- may not be empty
email TEXT NOT NULL UNIQUE, -- one email, ever
age INTEGER DEFAULT 30, -- default when omitted
vip INTEGER NOT NULL DEFAULT 0
CHECK (vip IN (0, 1)) -- only 0 or 1
);
Constraints are guardrails: the database itself rejects bad rows, so the app can't corrupt your data. You'll see the rejections in a moment.
The same five rows we'll use in the examples that follow.
-- every row, every column
SELECT * FROM users;
-- only the columns you need
SELECT name, email FROM users;
-- cap the number of rows
SELECT name FROM users LIMIT 2;
| name |
|---|
| Ana |
| Bo |
SELECT is the workhorse — most app traffic is reading. * is handy in the CLI; in app code, name the columns you want.
WHERE is where the filtering happens — and (Part 4) where an index makes it fast.
| Operator | What it does | Example |
|---|---|---|
| = != | exact match / not equal | WHERE status = 'paid' |
| < > <= >= | compare numbers or dates | WHERE total > 100 |
| LIKE | text pattern with wildcards | WHERE name LIKE 'A%' |
| IN NOT IN | value is (not) in a list | WHERE status IN ('paid','shipped') |
| AND OR | combine conditions | WHERE total > 50 AND status = 'paid' |
-- 4 users: Ana, Bo, Cyd, Dee
-- 4 orders: 10 (u1, 120.00, paid) 11 (u1, 45.00, shipped)
-- 12 (u2, 80.00, paid) 13 (u4, 9.50, pending)
SELECT name FROM users
WHERE name = 'Bo';
SELECT * FROM orders
WHERE total > 50;
| name |
|---|
| Bo |
| id | user_id | total | status |
|---|---|---|---|
| 10 | 1 | 120.00 | paid |
| 12 | 2 | 80.00 | paid |
Read each query as a sentence: 'give me rows where the condition is true'. The result is just the matching rows.
| Pattern | Meaning | Matched product |
|---|---|---|
| k% | starts with k | keyboard |
| %r | ends with r | keyboard |
| %r | ends with r | monitor |
| m%e | starts m, ends e, anything between | mouse |
| %hub | ends with 'hub' | usb-c hub |
| _onitor | any single char, then 'onitor' | monitor |
Each highlighted row = one product matching that pattern (across the 5 products: keyboard, mouse, usb-c hub, monitor, adapter). % = any string (even empty) · _ = exactly one character — this is how you do 'search box' queries.
SELECT * FROM orders
WHERE status = 'paid' AND total > 100;
SELECT name FROM users
WHERE name = 'Bo' OR name = 'Dee';
SELECT * FROM orders
WHERE status IN ('paid','shipped');
| id | user_id | total | status |
|---|---|---|---|
| 10 | 1 | 120.00 | paid |
| name |
|---|
| Bo |
| Dee |
| id | user_id | total | status |
|---|---|---|---|
| 10 | 1 | 120.00 | paid |
| 11 | 1 | 45.00 | shipped |
| 12 | 2 | 80.00 | paid |
AND = stricter (narrower results), OR = looser. IN is just shorthand for a chain of = OR = OR ...
Keys are what make rows trustworthy and tables linkable.
The one column that identifies each row — usually id. Unique, never empty, used to look rows up.
A column pointing at another table's PK — orders.user_id → users.id. This is how tables link.
A constraint: no two rows may share this value — e.g. email, so nobody registers twice.
Every bad write below is rejected by SQLite itself — the app never even sees the row.
-- biggest first
SELECT * FROM orders
ORDER BY total DESC;
-- first two, alphabetically
SELECT name FROM users
ORDER BY name LIMIT 2;
-- result: Ana, Bo
| id | user_id | total | status |
|---|---|---|---|
| 10 | 1 | 120.00 | paid |
| 12 | 2 | 80.00 | paid |
| 11 | 1 | 45.00 | shipped |
| 13 | 4 | 9.50 | pending |
ORDER BY sorts (ASC default, DESC reverse); LIMIT caps the count. Together they power 'top 10', 'latest 20', pagination.
-- how many orders total?
SELECT COUNT(*) FROM orders; -- 4
-- per user: how many orders, how much spent?
SELECT user_id, COUNT(*) AS n, SUM(total) AS spent
FROM orders
GROUP BY user_id;
| user_id | n | spent |
|---|---|---|
| 1 | 2 | 165.0 |
| 2 | 1 | 80.0 |
| 4 | 1 | 9.5 |
GROUP BY collapses rows into one row per group, then the aggregate functions summarize each group. Cyd (id 3) has no orders, so he has no group.
import sqlite3
conn = sqlite3.connect("shop.db")
row = conn.execute("SELECT name FROM users WHERE id = ?", (1,))
print(row.fetchone()) # ('Ana',)
sql = "SELECT name, total FROM orders WHERE user_id =?"
rows = conn.execute(sql, (1,)).fetchall()
print(rows) # [('Ana', 120.0), ('Ana', 45.0)]
connect → execute(query, values) → fetch. The SQL inside is exactly what you've been reading — the app is just the courier.
A tiny terminal program: open the file, inspect, query, quit.
The most important design decision in any app isn't code — it's which tables exist and how they link.
How you split your data is the single most important design decision — architecture, not syntax.
Every order repeats the user's name & address. Change an address → update 500 rows. Duplication breeds bugs.
And the table grows huge, with the same values over and over.
User details live once in users; each order only stores user_id. Change once — JOIN stitches it together on demand.
This is 'normalization' — the discipline of storing each fact exactly once.
Nearly every relationship you'll ever model is one of these two.
One user → many orders. The FK lives on the 'many' side: orders.user_id. One user, many rows point to it.
A book has many authors; an author writes many books. Neither table can hold a single FK for this.
A third table (book_authors) holds just two FKs: book_id + author_id. Many:many = two 1:many through it.
Every order belongs to one user; every order has many line items; every item belongs to one order. That's the whole story of JOIN.
Read it as a map: the FK columns are the strings tying the cards together — JOIN is how you pull data across those strings.
A book has many authors, an author writes many books. book_authors holds exactly two foreign keys, turning 'many:many' into two 1:many relationships.
Each row in book_authors says: 'this author co-wrote this book'. No 'list of authors' column inside books.
Run this before writing any SQL — it's the architect's part of the job.
'Users place orders containing items' → users, orders, line_items. Nouns become tables.
Each table = one thing; its columns = that thing's attributes (what do you need to know about it?).
1:many → FK on the 'many' side. Many:many → add a junction table with two FKs.
PK on every table; FK on link columns; UNIQUE on values that mustn't repeat (email).
CREATE TABLEs plus a sketch. The sketch is what you show people (and AI) to agree on the model.
If you get the tables and the links wrong, no amount of SQL can save you. The model is the architecture.
We split data across tables on purpose. JOIN is how you pull it back together when you need it — let's watch it happen, row by row.
One mechanic, three consequences.
SQLite pairs rows from two tables where the key columns are equal: orders.user_id = users.id.
Each result row combines columns from both tables — the user's name next to their order's total.
Suddenly you can ask 'who spent the most?' — a question no single table can answer alone.
| id | name |
|---|---|
| 1 | Ana |
| 2 | Bo |
| 3 | Cyd |
| 4 | Dee |
| id | user_id | total | status |
|---|---|---|---|
| 10 | 1 | 120.00 | paid |
| 11 | 1 | 45.00 | shipped |
| 12 | 2 | 80.00 | paid |
| 13 | 4 | 9.50 | pending |
SELECT u.name, o.total, o.status
FROM users u
JOIN orders o ON o.user_id = u.id
ORDER BY o.id;
| name | total | status |
|---|---|---|
| Ana | 120.00 | paid |
| Ana | 45.00 | shipped |
| Bo | 80.00 | paid |
| Dee | 9.50 | pending |
Read it as: 'for each order, look up the user and put the name next to the total'. Cyd vanishes because INNER JOIN only keeps matches.
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
ORDER BY u.id;
LEFT JOIN = INNER JOIN, but it keeps every left-table row even when nothing matches on the right.
| name | total |
|---|---|
| Ana | 120.00 |
| Ana | 45.00 |
| Bo | 80.00 |
| Cyd | NULL |
| Dee | 9.50 |
Cyd's row still appears with NULLs — 'this user has no orders'. RIGHT and FULL joins are the other directions; default (INNER) = only matches.
| id | user_id |
|---|---|
| 10 | 1 |
| 11 | 1 |
| 12 | 2 |
| 13 | 4 |
| id | order_id | product | qty |
|---|---|---|---|
| 1 | 10 | keyboard | 1 |
| 2 | 10 | mouse | 2 |
| 3 | 11 | usb-c hub | 1 |
| 4 | 12 | monitor | 1 |
| 5 | 13 | adapter | 3 |
SELECT o.id AS order_id, li.product, li.qty
FROM orders o
JOIN line_items li ON li.order_id = o.id
ORDER BY o.id, li.id;
| order_id | product | qty |
|---|---|---|
| 10 | keyboard | 1 |
| 10 | mouse | 2 |
| 11 | usb-c hub | 1 |
| 12 | monitor | 1 |
| 13 | adapter | 3 |
Each JOIN adds the next table's columns, linked by its FK. This pattern — table → children → grandchildren — is the shape of most reports.
SELECT li.product, li.qty
FROM users u
JOIN orders o ON o.user_id = u.id
JOIN line_items li ON li.order_id = o.id
WHERE u.email = 'ana@ex.com';
| product | qty |
|---|---|
| keyboard | 1 |
| mouse | 2 |
| usb-c hub | 1 |
The order of thinking: JOIN first (stitch the tables), then WHERE (narrow down). This exact shape — 'find X belonging to Y' — is the most common query in any app.
-- Forgot the ON clause:
SELECT u.name, o.total
FROM users u, orders o;
| name | total |
|---|---|
| Ana | 120.00 |
| Ana | 45.00 |
| Ana | 80.00 |
| Ana | 9.50 |
| Bo | 120.00 |
| Bo | 45.00 |
| Bo | 80.00 |
| Bo | 9.50 |
| … 16 rows total — mostly nonsense | |
Without a key to match on, the database pairs every row with every row: 4 users × 4 orders = 16 rows. If a result has more rows than it should — you've probably lost an ON.
The mechanic under the hood is simple — and indexable.
For each row of the left table, SQLite looks up the matching rows in the right table — over and over.
With an index on orders.user_id, each lookup is instant. Without one: a full scan of orders for every user.
A join is only as fast as the indexes on the joined columns. Index both sides' key columns.
Without an index, SQLite scans the table row by row. An index skips the scan — here's exactly how, with proof.
Created in one line: CREATE INDEX idx_orders_user ON orders(user_id);
A book's index maps names → page numbers. Without it, finding one name means flipping through every page.
An index is a small, sorted copy of one column's values with pointers to the rows — jump straight to the match instead of scanning 100,000 rows.
-- find all orders of user 1 (100,000 rows total)
-- no index → full scan:
-- read row 1... user_id = 7 ✗
-- read row 2... user_id = 3 ✗
-- ... 100,000 comparisons
-- with index → sorted lookup:
-- index says: user_id 1 → rows 10, 11
-- jump straight to those 2 rows
-- ~100,000 steps → ~17 steps
The index is a sorted list, so SQLite binary-searches it (~log2 of 100,000 ≈ 17 hops) instead of comparing every row.
SQLite will literally tell you whether it scans or uses the index.
-- index columns that appear in WHERE, ORDER BY, and ON:
CREATE INDEX idx_orders_user ON orders(user_id);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_status ON orders(status);
-- don't index:
-- * a column you never query (pure overhead)
-- * every column (each one taxes every write)
FK columns deserve indexes even if you only use them in JOINs — that's the Part 3 rule in practice.
Rule of thumb: index what you read a lot. Don't index everything — an index is a speed/writing-speed exchange.
WHERE, ORDER BY and JOINs on that column get dramatically faster — from scanning everything to jumping straight there.
Every INSERT/UPDATE must also update the index → writes get slightly slower, and the file grows a bit.
Putting it all together: real sessions, safe app code, when it's the right tool — and the mistakes to avoid.
Count rows, ask a cross-table question, check the schema — all from the CLI.
email = request.args.get('email') # user input
sql = "SELECT * FROM users WHERE email = ?"
rows = conn.execute(sql, (email,)).fetchall()
# ? is a placeholder — SQLite receives the query
# AND the value separately. The value is always
# treated as data, never as SQL.
This one habit — never pasting user input into a SQL string — prevents an entire class of security bugs.
Injection is one of the most common real-world security bugs. The ? style above is the standard fix.
q = "SELECT * FROM users WHERE name = '" + user_input + "'"
If user_input = x' OR '1'='1, the query becomes 'name = 'x' OR '1'='1'' — true for every row. The user's text just became SQL code.
q = "SELECT * FROM users WHERE name = ?" values = (user_input,)
No matter what user_input contains — quotes, OR, DROP TABLE — it's compared as a plain value. Nothing gets executed.
It's the default until you hit a specific wall.
SQLite allows one write at a time (readers go in parallel). Enough for most small/medium apps.
Phones, desktop apps, Raspberry Pi — SQLite is already inside. It was built for exactly this.
Tens of MB to a few GB, millions of rows — still fast on a modern disk. Don't 'pre-scale'.
Several app servers writing at once, writes from multiple machines → a server database (PostgreSQL, MySQL).
You'll meet all four of these in real projects.
Storing the user's name/email inside every order row. One address change = 500 updates. Split the tables.
JOINs and WHEREs on user_id slow down as the table grows. Index the columns you join on.
colors = 'red,blue,green' can't be queried, counted or joined. That's a table with its own rows.
Tables created ad-hoc in different places, no single source of truth. Keep one schema file everyone reads.
You own the model; AI writes the queries; the CLI is your verification tool.
Sketch the tables and links (1:many / m:n) before any SQL exists. Show it to a human to sanity-check.
Describe the model + the question to AI; it writes the SELECT/JOIN. Read it against your sketch.
Open the .db in sqlite3, run it, look at actual rows. Wrong row count? The query or the model is off.
EXPLAIN QUERY PLAN; if you see SCAN where you need speed, add an index on the hot column.
The same verbs, keys and JOINs — now applied to a complete, realistic schema. Every table has a job; here's why each one exists.
users + orders was the tutorial. A real store has to survive money, shipping and time.
Money (payments, refunds), shipping with tracking, stock, reviews, promo codes, abandoned carts — none of it fits in users + orders.
Each noun gets a table, each fact stored exactly once — the Part 2 rule, applied at full size.
orders.user_id, order_items.order_id, reviews.product_id… keys are the links; JOINs are how you query across them.
Prices and addresses are copied into the order at purchase time, so later edits never rewrite history.
One table per thing: people, orders, order lines, products, categories, addresses, money. The FKs are the strings; every report is just a JOIN across them.
Carts are 'thinking of buying', kept apart from 'bought'; shipments live outside the order lifecycle; reviews are the product↔user relationship with data attached; coupons exist for promo accounting.
Read the third column as the test: if a table can't say why it exists, delete it — the data probably belongs elsewhere.
| Table | Key columns | Why it exists |
|---|---|---|
| users | id (PK) · email (UNIQUE) | The hub: one account → many orders, addresses, reviews. Everything user-specific starts here. |
| addresses | id (PK) · user_id (FK) · street · city | Users have many addresses; orders snapshot the one used, so moving house never rewrites old orders. |
| products | id (PK) · name · price · category_id (FK) | The catalog item, independent of orders. price is the CURRENT price — what you paid is captured in order_items. |
| categories | id (PK) · name | 1:many grouping for navigation. One primary category per product — deliberately simple, not m:n. |
| orders | id (PK) · user_id (FK) · total · status | One row per checkout: what was ordered, with total + status at ordering time — the spine of the business. |
| order_items | order_id (FK) · product_id (FK) · qty · unit_price | The m:n junction WITH attributes: qty + unit_price snapshot at purchase — prices change later, history must not. |
Same test as before: every table earns its place by holding facts no other table should hold.
| Table | Key columns | Why it exists |
|---|---|---|
| payments | id (PK) · order_id (FK) · method · amount | Money movement audited separately from the order: refunds and split payments are 1:many, the order stays clean. |
| shipments | id (PK) · order_id (FK) · carrier · tracking_no | Shipping events separate from the order lifecycle — allows split shipments: one order, two carriers, two tracking numbers. |
| carts / cart_items | cart_id (FK) · product_id (FK) · qty | 'Thinking of buying' kept apart from 'bought'. Checkout copies the cart rows into orders + order_items. |
| reviews | product_id (FK) · user_id (FK) · rating · text | The m:n product↔user carrying rating/text — attributes of the relationship. UNIQUE(product_id, user_id): one review per pair. |
| coupons | code (UNIQUE) · discount · expires | Promo usage per order for accounting: which code, how much off, and when it stopped working. |
Recognize these shapes in any schema, yours or someone else's.
order_items.unit_price and orders.address_id store copies at event time, so later edits never rewrite history.
order_items qty/unit_price, reviews rating, cart_items added_at — the relationship itself has data.
products.category_id: one category per product. Only true m:n (orders↔products) needs the junction.
Wishlist, loyalty points — new nouns, new tables, the exact same rules.
Multi-currency and tax, audit log — money movement and accountability get their own tables too.
Design the model first: nouns → tables → links → keys. Every table has a job; verify each one in the sqlite3 CLI.
SELECT p.name, SUM(oi.qty * oi.unit_price) AS revenue
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN orders o ON o.id = oi.order_id
WHERE o.status IN ('paid','shipped')
GROUP BY p.name
ORDER BY revenue DESC
LIMIT 5;
SELECT o.id, o.total, o.status
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE u.email = 'ana@ex.com'
ORDER BY o.id DESC;
| name | revenue |
|---|---|
| monitor | 1299.00 |
| keyboard | 452.00 |
| usb-c hub | 310.00 |
| mouse | 186.00 |
| adapter | 94.00 |
| id | total | status |
|---|---|---|
| 13 | 9.50 | pending |
| 11 | 45.00 | shipped |
| 10 | 120.00 | paid |
Exactly the skills you've learned: chained JOINs, WHERE filters, GROUP BY aggregates, ORDER BY + LIMIT top-N — only the model is bigger now.
The data model — which tables, how they relate — is the architect's decision. SQL is just how the app talks to it.
Design the model first — then let AI write the queries, and verify in the CLI.