→ From Coder to Shipper
From Coder to Shipper · Data

SQLite — data, JOIN, indexing

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

Big picture

SQLite: the real data, in one file

A shipper spends as much time understanding data as code.

Code = logic

Your app's code decides what happens: rules, flow, UI. It's the instructions, not the content.

Data = the real thing

Users, orders, products — the things that survive after the app restarts. Losing them means losing the business.

One .db file, no server

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.

Copy = backup

Backing up the database means copying one file. Moving to another machine means sending one file.

Right size, scales quietly

One file holds 1 row to millions. The default choice for prototypes, mobile/desktop apps and small web apps.

You'll look at this daily

Debugging, exports, quick fixes, 'why is this row wrong?' — you'll read and write this data constantly.

Under the hood

What's actually inside the .db file

The file isn't random bytes — it's organized to be fast.

Everything is pages

The file is a sequence of fixed-size pages (usually 4 KB). Tables and indexes live inside these pages.

Rows live in B-trees

Each table is a B-tree — a balanced, sorted structure that lets SQLite jump to a row instead of reading the whole file.

Indexes are extra B-trees

An index is a separate sorted tree mapping a column's values to the rows' pages. More on this in Part 4.

WAL keeps readers going

The write-ahead log lets readers keep reading while a write is in progress — smooth concurrency for small apps.

Vocabulary

Tables, rows, columns — and the schema

Spreadsheet vocabulary, but structured — defined once, used everywhere.

Table

A collection of related things: a users table, an orders table.

Row

One record — a single user, a single order.

Column

One attribute, same kind of value in every row: email, total, status.

Schema

The shape of your data — which tables exist, what columns they have, what's allowed. Declared once.

A tiny schema

Our worked example: a small shop

schema.sql
-- 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
);
users — schema becomes rows
idnameemail
1Anaana@ex.com
2Bobo@ex.com
3Cydcyd@ex.com
orders — each row points at users via user_id
iduser_idtotalstatus
101120.00paid
11145.00shipped
12280.00paid

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.

01
Part 1

Basics — the five verbs and WHERE

Five verbs cover almost everything your app does to data. You need to read them fluently; AI will write them for you.

Core SQL

The five verbs — CREATE + INSERT

create_insert.sql
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');
users
idnameemail
1Anaana@ex.com
2Bobo@ex.com
3Cydcyd@ex.com
4Deedee@ex.com

Make it → add to it. INSERT adds rows to the table — each row is one full record.

Core SQL

The five verbs — SELECT, UPDATE, DELETE

select_update_delete.sql
SELECT name, email FROM users
WHERE id = 1;

UPDATE users SET name = 'Anna'
WHERE id = 1;

DELETE FROM users WHERE id = 4;
SELECT — result
nameemail
Anaana@ex.com
users — after UPDATE (row id=1 renamed)
idnameemail
1Annaana@ex.com
2Bobo@ex.com
3Cydcyd@ex.com
4Deedee@ex.com
users — after DELETE (row id=4 gone)
idnameemail
1Annaana@ex.com
2Bobo@ex.com
3Cydcyd@ex.com

Read it → change it → remove it. The CREATE → INSERT → SELECT → UPDATE → DELETE loop is 95% of everyday app SQL.

Data types

Pick a type per column

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.

TypeTypical useExample values
INTEGERids, counts, flags, yearsid = 1 · qty = 2
TEXTstrings: names, emails, status'Ana' · 'paid'
REALmoney, measurements120.50
BLOBbinary: images, filesphoto bytes
TIMESTAMPtime (stored as text/number)'2026-10-01 12:00'
Constraints

Rules the database enforces for you

constraints.sql
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.

Worked example

Add rows, read them back

The same five rows we'll use in the examples that follow.

ops@vps — zsh
$ sqlite3 shop.db
sqlite>
$ 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');
$ SELECT * FROM users;
1|Ana|ana@ex.com
2|Bo|bo@ex.com
3|Cyd|cyd@ex.com
4|Dee|dee@ex.com
SELECT

Reading data: three shapes of SELECT

select.sql
-- 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;
LIMIT 2 — result of the last query
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 clause

Filtering: the operators

WHERE is where the filtering happens — and (Part 4) where an index makes it fast.

OperatorWhat it doesExample
= !=exact match / not equalWHERE status = 'paid'
< > <= >=compare numbers or datesWHERE total > 100
LIKEtext pattern with wildcardsWHERE name LIKE 'A%'
IN NOT INvalue is (not) in a listWHERE status IN ('paid','shipped')
AND ORcombine conditionsWHERE total > 50 AND status = 'paid'
Worked example

WHERE on our shop data

where.sql
-- 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;
users — name = 'Bo'
name
Bo
orders — total > 50
iduser_idtotalstatus
101120.00paid
12280.00paid

Read each query as a sentence: 'give me rows where the condition is true'. The result is just the matching rows.

LIKE patterns

LIKE: patterns with wildcards

PatternMeaningMatched 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.

Worked example

Combining conditions: AND, OR, IN

combine.sql
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');
status = 'paid' AND total > 100
iduser_idtotalstatus
101120.00paid
name = 'Bo' OR name = 'Dee'
name
Bo
Dee
status IN ('paid','shipped')
iduser_idtotalstatus
101120.00paid
11145.00shipped
12280.00paid

AND = stricter (narrower results), OR = looser. IN is just shorthand for a chain of = OR = OR ...

Keys & constraints

Primary key, foreign key, UNIQUE

Keys are what make rows trustworthy and tables linkable.

Primary key (PK)

The one column that identifies each row — usually id. Unique, never empty, used to look rows up.

Foreign key (FK)

A column pointing at another table's PK — orders.user_id → users.id. This is how tables link.

UNIQUE

A constraint: no two rows may share this value — e.g. email, so nobody registers twice.

Worked example

Watch the database say 'no'

Every bad write below is rejected by SQLite itself — the app never even sees the row.

ops@vps — zsh
$ sqlite3 shop.db
sqlite>
$ INSERT INTO users (id, name, email) VALUES (1, 'Bo', 'bo@ex.com');
Error: UNIQUE constraint failed: users.id
$ INSERT INTO users (id, name, email) VALUES (5, 'X', 'ana@ex.com');
Error: UNIQUE constraint failed: users.email
$ INSERT INTO users (id, name) VALUES (6, 'NoEmail');
Error: NOT NULL constraint failed: users.email
$ PRAGMA foreign_keys = ON;
$ INSERT INTO orders (id, user_id, total) VALUES (99, 42, 10.0);
Error: FOREIGN KEY constraint failed
Shaping results

ORDER BY and LIMIT

order.sql
-- biggest first
SELECT * FROM orders
ORDER BY total DESC;

-- first two, alphabetically
SELECT name FROM users
ORDER BY name LIMIT 2;

-- result: Ana, Bo
orders — ORDER BY total DESC
iduser_idtotalstatus
101120.00paid
12280.00paid
11145.00shipped
1349.50pending

ORDER BY sorts (ASC default, DESC reverse); LIMIT caps the count. Together they power 'top 10', 'latest 20', pagination.

Shaping results

Aggregates: COUNT, SUM — and GROUP BY

group.sql
-- 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;
GROUP BY user_id — result
user_idnspent
12165.0
2180.0
419.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.

How the app talks to it

Your Flask/Python code is just a query wrapper

app.py
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.

The sqlite3 CLI

Your everyday window into the data

A tiny terminal program: open the file, inspect, query, quit.

ops@vps — zsh
$ sqlite3 shop.db
sqlite>
$ .tables
users orders line_items
$ .schema orders
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER,
total REAL, status TEXT)
$ SELECT name FROM users LIMIT 5;
Ana
Bo
Cyd
Dee
$ .quit
02
Part 2

The data model — how tables relate

The most important design decision in any app isn't code — it's which tables exist and how they link.

Why split?

One fat table vs. related tables

How you split your data is the single most important design decision — architecture, not syntax.

One fat table (tempting)

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.

Related tables (right way)

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.

Relationships

Only two shapes: 1:many and many:many

Nearly every relationship you'll ever model is one of these two.

1:many (the common one)

One user → many orders. The FK lives on the 'many' side: orders.user_id. One user, many rows point to it.

Many:many (trickier)

A book has many authors; an author writes many books. Neither table can hold a single FK for this.

The junction table

A third table (book_authors) holds just two FKs: book_id + author_id. Many:many = two 1:many through it.

The data model

Your shop, at a glance

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.

users

idPK
name
emailUNIQUE

orders

idPK
user_idFK → users.id
total
status

line_items

idPK
order_idFK → orders.id
product
qty

Read it as a map: the FK columns are the strings tying the cards together — JOIN is how you pull data across those strings.

The data model

Many:many — the junction table

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.

books

idPK
title
year

book_authors

book_idFK → books.id
author_idFK → authors.id

authors

idPK
name
country

Each row in book_authors says: 'this author co-wrote this book'. No 'list of authors' column inside books.

Designing

How to design a model from scratch

Run this before writing any SQL — it's the architect's part of the job.

  1. 1

    Spot the nouns

    'Users place orders containing items' → users, orders, line_items. Nouns become tables.

  2. 2

    One table per noun

    Each table = one thing; its columns = that thing's attributes (what do you need to know about it?).

  3. 3

    Link them

    1:many → FK on the 'many' side. Many:many → add a junction table with two FKs.

  4. 4

    Pick the keys

    PK on every table; FK on link columns; UNIQUE on values that mustn't repeat (email).

  5. 5

    Write it down

    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.
— the heart of Part 2
03
Part 3

JOIN — stitching tables back together

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.

JOIN · the core idea

What a JOIN actually does

One mechanic, three consequences.

Match on the key

SQLite pairs rows from two tables where the key columns are equal: orders.user_id = users.id.

Merge the columns

Each result row combines columns from both tables — the user's name next to their order's total.

Ask cross-table questions

Suddenly you can ask 'who spent the most?' — a question no single table can answer alone.

Worked example

INNER JOIN, step by step

users
idname
1Ana
2Bo
3Cyd
4Dee
orders
iduser_idtotalstatus
101120.00paid
11145.00shipped
12280.00paid
1349.50pending
inner_join.sql
SELECT u.name, o.total, o.status
FROM   users u
JOIN   orders o ON o.user_id = u.id
ORDER BY o.id;
result — Cyd (id 3) absent
nametotalstatus
Ana120.00paid
Ana45.00shipped
Bo80.00paid
Dee9.50pending

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.

Worked example

INNER vs LEFT JOIN — see the difference

left_join.sql
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.

LEFT JOIN — result
nametotal
Ana120.00
Ana45.00
Bo80.00
CydNULL
Dee9.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.

Worked example

Chaining joins across three tables

orders
iduser_id
101
111
122
134
line_items
idorder_idproductqty
110keyboard1
210mouse2
311usb-c hub1
412monitor1
513adapter3
chain_join.sql
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;
result
order_idproductqty
10keyboard1
10mouse2
11usb-c hub1
12monitor1
13adapter3

Each JOIN adds the next table's columns, linked by its FK. This pattern — table → children → grandchildren — is the shape of most reports.

Worked example

JOIN + WHERE: 'What did Anna buy?'

join_where.sql
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';
result — Anna's purchased items
productqty
keyboard1
mouse2
usb-c hub1

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.

Worked example · gone wrong

The missing-ON trap: a cartesian product

cartesian.sql
-- Forgot the ON clause:
SELECT u.name, o.total
FROM   users u, orders o;
users × orders — every pair, no key
nametotal
Ana120.00
Ana45.00
Ana80.00
Ana9.50
Bo120.00
Bo45.00
Bo80.00
Bo9.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.

JOIN · performance

How JOINs stay fast

The mechanic under the hood is simple — and indexable.

Nested loop

For each row of the left table, SQLite looks up the matching rows in the right table — over and over.

Index on the FK

With an index on orders.user_id, each lookup is instant. Without one: a full scan of orders for every user.

Rule of thumb

A join is only as fast as the indexes on the joined columns. Index both sides' key columns.

04
Part 4

Indexing — making lookups fast

Without an index, SQLite scans the table row by row. An index skips the scan — here's exactly how, with proof.

What it is

The index: a book's index

Created in one line: CREATE INDEX idx_orders_user ON orders(user_id);

The book analogy

A book's index maps names → page numbers. Without it, finding one name means flipping through every page.

The mechanism

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.

Worked example

Scan vs index: 100,000 orders

scan_vs_index.txt
-- 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.

Proof

EXPLAIN QUERY PLAN: watch the strategy change

SQLite will literally tell you whether it scans or uses the index.

ops@vps — zsh
$ sqlite3 shop.db
sqlite>
$ EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = 'x@ex.com';
SCAN users
$ CREATE INDEX idx_users_email ON users(email);
$ EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = 'x@ex.com';
SEARCH users USING INDEX idx_users_email (email=?)
Where to index

Index the hot columns

indexes.sql
-- 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.

The tradeoff

What an index costs

Rule of thumb: index what you read a lot. Don't index everything — an index is a speed/writing-speed exchange.

The win

WHERE, ORDER BY and JOINs on that column get dramatically faster — from scanning everything to jumping straight there.

The cost

Every INSERT/UPDATE must also update the index → writes get slightly slower, and the file grows a bit.

05
Part 5

SQLite in the real world

Putting it all together: real sessions, safe app code, when it's the right tool — and the mistakes to avoid.

Worked example

A real session on shop.db

Count rows, ask a cross-table question, check the schema — all from the CLI.

ops@vps — zsh
$ sqlite3 shop.db
sqlite>
$ SELECT COUNT(*) FROM orders;
4
$ SELECT u.name, SUM(o.total) AS spent
$ FROM users u JOIN orders o ON o.user_id = u.id
$ GROUP BY u.name ORDER BY spent DESC;
Ana|165.0
Bo|80.0
Dee|9.5
$ .quit
Safe app code

Parameterized queries: ? is your friend

safe.py
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.

Security

SQL injection: the classic bug

Injection is one of the most common real-world security bugs. The ? style above is the standard fix.

String concatenation (dangerous)

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.

Placeholders (safe)

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.

Scope

When SQLite is enough — and when it isn't

It's the default until you hit a specific wall.

Single machine, single writer

SQLite allows one write at a time (readers go in parallel). Enough for most small/medium apps.

Embedded & mobile

Phones, desktop apps, Raspberry Pi — SQLite is already inside. It was built for exactly this.

Small → medium data

Tens of MB to a few GB, millions of rows — still fast on a modern disk. Don't 'pre-scale'.

When to move on

Several app servers writing at once, writes from multiple machines → a server database (PostgreSQL, MySQL).

Mistakes to avoid

Four classic data-model sins

You'll meet all four of these in real projects.

Duplicated user data

Storing the user's name/email inside every order row. One address change = 500 updates. Split the tables.

Unindexed FK columns

JOINs and WHEREs on user_id slow down as the table grows. Index the columns you join on.

Lists in one column

colors = 'red,blue,green' can't be queried, counted or joined. That's a table with its own rows.

Schema drift

Tables created ad-hoc in different places, no single source of truth. Keep one schema file everyone reads.

Your workflow

The shipper's loop with AI on your team

You own the model; AI writes the queries; the CLI is your verification tool.

  1. 1

    Model it

    Sketch the tables and links (1:many / m:n) before any SQL exists. Show it to a human to sanity-check.

  2. 2

    Draft the SQL

    Describe the model + the question to AI; it writes the SELECT/JOIN. Read it against your sketch.

  3. 3

    Verify in the CLI

    Open the .db in sqlite3, run it, look at actual rows. Wrong row count? The query or the model is off.

  4. 4

    Speed it up

    EXPLAIN QUERY PLAN; if you see SCAN where you need speed, add an index on the hot column.

06
Part 6

From our tiny shop to a real ecommerce site

The same verbs, keys and JOINs — now applied to a complete, realistic schema. Every table has a job; here's why each one exists.

Scale-up

From our tiny shop to a real ecommerce site

users + orders was the tutorial. A real store has to survive money, shipping and time.

The new problems

Money (payments, refunds), shipping with tracking, stock, reviews, promo codes, abandoned carts — none of it fits in users + orders.

One table per thing

Each noun gets a table, each fact stored exactly once — the Part 2 rule, applied at full size.

FKs express relationships

orders.user_id, order_items.order_id, reviews.product_id… keys are the links; JOINs are how you query across them.

Snapshots for history

Prices and addresses are copied into the order at purchase time, so later edits never rewrite history.

The data model

The full picture — core commerce

users

idPK
emailUNIQUE
name

orders

idPK
user_idFK → users.id
total · status

order_items

idPK
order_idFK → orders.id
product_idFK → products.id
qty · unit_price

products

idPK
name · price
category_idFK → categories.id

categories

idPK
name

addresses

idPK
user_idFK → users.id
street · city

payments

idPK
order_idFK → orders.id
method · amount

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.

The data model

Beyond checkout

carts

idPK
user_idFK → users.id

cart_items

idPK
cart_idFK → carts.id
product_idFK → products.id
qty · added_at

shipments

idPK
order_idFK → orders.id
carrier · tracking_no

reviews

idPK
product_idFK → products.id
user_idFK → users.id
rating · textUNIQUE pair

coupons

idPK
codeUNIQUE
discount · expires

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.

Design rationale

Why each core table exists

Read the third column as the test: if a table can't say why it exists, delete it — the data probably belongs elsewhere.

TableKey columnsWhy it exists
usersid (PK) · email (UNIQUE)The hub: one account → many orders, addresses, reviews. Everything user-specific starts here.
addressesid (PK) · user_id (FK) · street · cityUsers have many addresses; orders snapshot the one used, so moving house never rewrites old orders.
productsid (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.
categoriesid (PK) · name1:many grouping for navigation. One primary category per product — deliberately simple, not m:n.
ordersid (PK) · user_id (FK) · total · statusOne row per checkout: what was ordered, with total + status at ordering time — the spine of the business.
order_itemsorder_id (FK) · product_id (FK) · qty · unit_priceThe m:n junction WITH attributes: qty + unit_price snapshot at purchase — prices change later, history must not.
Design rationale

Why each extra table exists

Same test as before: every table earns its place by holding facts no other table should hold.

TableKey columnsWhy it exists
paymentsid (PK) · order_id (FK) · method · amountMoney movement audited separately from the order: refunds and split payments are 1:many, the order stays clean.
shipmentsid (PK) · order_id (FK) · carrier · tracking_noShipping events separate from the order lifecycle — allows split shipments: one order, two carriers, two tracking numbers.
carts / cart_itemscart_id (FK) · product_id (FK) · qty'Thinking of buying' kept apart from 'bought'. Checkout copies the cart rows into orders + order_items.
reviewsproduct_id (FK) · user_id (FK) · rating · textThe m:n product↔user carrying rating/text — attributes of the relationship. UNIQUE(product_id, user_id): one review per pair.
couponscode (UNIQUE) · discount · expiresPromo usage per order for accounting: which code, how much off, and when it stopped working.
Design patterns

Three patterns you'll reuse — and what's next

Recognize these shapes in any schema, yours or someone else's.

Snapshot vs reference

order_items.unit_price and orders.address_id store copies at event time, so later edits never rewrite history.

Junctions carry attributes

order_items qty/unit_price, reviews rating, cart_items added_at — the relationship itself has data.

1:many beats m:n when you can

products.category_id: one category per product. Only true m:n (orders↔products) needs the junction.

Next: growth features

Wishlist, loyalty points — new nouns, new tables, the exact same rules.

Next: ops features

Multi-currency and tax, audit log — money movement and accountability get their own tables too.

The takeaway

Design the model first: nouns → tables → links → keys. Every table has a job; verify each one in the sqlite3 CLI.

Querying the real model

Two reports on the ecommerce schema

ecommerce_queries.sql
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;
top 5 products by revenue
namerevenue
monitor1299.00
keyboard452.00
usb-c hub310.00
mouse186.00
adapter94.00
order history — ana@ex.com
idtotalstatus
139.50pending
1145.00shipped
10120.00paid

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.
— the whole course in one sentence

One file. A few tables. Keys to link them.

Design the model first — then let AI write the queries, and verify in the CLI.