programming · Level 3

SQL: model and query your first database

Build a small SQLite database with tools that come with Python, question it, change it safely and check SQL written by others, including AI assistants.

By Mickarle Wagstaff-Irons - Micky Irons

  • Level 3Building
  • 140 min
  • 6 chapters
  • Online course and PDF workbook
The Sequenceprogramming / 03

Read, practise and review

Your next step

Start this course online

Register your start, then work through the lessons and exercises at your own pace. You can download the PDF separately. Where a scored exam is available, you will find it after the lessons.

Register or resume your course

Mickai privately records this registration and sends its team a course-start notification. It records your intention to start, not proof of attendance or completion. Our course-start privacy notice explains the details and the 30-day resume cookie.

Follow the Mickai company page on LinkedIn

You can start learning without subscribing or following. Newsletter email confirmation and provider enrolment are recorded separately.

Start with the essentials

The short answer

SQL is the standard language for defining, filling and questioning relational databases, where data lives in tables linked by keys. SQLite keeps a whole database in one file, needs no server and comes with Python. Start with strict tables and constraints, switch foreign keys on, preview every change with a SELECT on a copy, and pass values to SQL with placeholders.

What you will learn

  • You will be able to design related tables with primary keys, foreign keys and constraints, and say why each exists.
  • You will be able to write queries that filter, sort, group, join and nest, and handle NULL on purpose.
  • You will be able to change data safely: on a copy, after a preview, inside a transaction you can roll back.
  • You will be able to read a query plan and add an index where it helps.
  • You will be able to use SQL from Python with placeholders, and explain SQL injection.
  • You will be able to review SQL an AI assistant writes against the real schema before you run it.

Who it is for

Adults who have never used a database and want to store and question data reliably, for a small business, a club, a project or their own programs. You should be happy to type commands into a terminal. Some Python helps in chapter 5 but is not required.

Before you start

  • No database experience is needed. You need a computer with Python 3.12 or later and the confidence to run commands in a terminal. The Python: from first script to a useful automation workbook covers installing Python and is useful background for chapter 5.

Learn in your browser · Chapter 01 of 06

01

Your first table, and why types matter

Open SQLite from Python, make a table and see what flexible typing does to numbers.

A relational database keeps data in tables: a row per thing, such as a plant, and a column per fact, such as its price. SQL is the language for defining, changing and querying them; ISO/IEC 9075 standardises it, though no two engines behave exactly alike.

SQLite runs inside your program, needs no server and keeps a database in one file. Python includes it and, since 3.12, a small SQL shell. Check its version (on macOS and Linux, type python3 for python):

text · 2 lines
python -m sqlite3 -v
SQLite version 3.49.1

Tested here: Python 3.12.10 and SQLite 3.49.1 on Windows 11; STRICT tables need SQLite 3.37.0 or later. python -m sqlite3 alone opens a throwaway in-memory database. At the sqlite> prompt, end each statement with a semicolon (unfinished ones continue at ...); .quit leaves. In SQL transcripts, lines in brackets or naming an error are replies; you type the rest.

text · 13 lines
CREATE TABLE counts (plant TEXT, qty INTEGER);
INSERT INTO counts VALUES ('Lavender', 12);
INSERT INTO counts VALUES ('Fern', '7');
INSERT INTO counts VALUES ('Basil', 'seven');
SELECT plant, qty, typeof(qty) FROM counts;
('Lavender', 12, 'integer')
('Fern', 7, 'integer')
('Basil', 'seven', 'text')
SELECT sum(qty) FROM counts;
(19.0,)
SELECT plant FROM counts WHERE qty > 10;
('Lavender',)
('Basil',)

CREATE TABLE names columns and types, INSERT adds rows, SELECT reads them and typeof() shows how values were stored. A declared type is only a preference, its affinity: '7' became a number, 'seven' stayed text. The sum counted it as zero, and qty > 10 let Basil through: SQLite sorts all text after numbers.

Most other engines use rigid typing, the SQLite documentation notes. A STRICT table does too, converting a value only when nothing is lost:

text · 6 lines
CREATE TABLE strict_counts (plant TEXT, qty INTEGER) STRICT;
INSERT INTO strict_counts VALUES ('Fern', '7');
INSERT INTO strict_counts VALUES ('Basil', 'seven');
IntegrityError (unknown): cannot store TEXT value in INTEGER column strict_counts.qty
SELECT 0.1 + 0.2;
(0.30000000000000004,)

Read the message, not the bracketed label. SQLite before 3.37.0 reports an error on files with STRICT tables. The last line is why money belongs in whole pence: REAL numbers are binary floating point, so decimals are approximate.

Try it yourself · Activity 01

10 min

What does STRICT accept?

Test a STRICT INTEGER column.

  1. In python -m sqlite3, run CREATE TABLE t (n INTEGER) STRICT;.
  2. Predict, then insert 3.0, 3.5, '12' and NULL, one INSERT each.
  3. Run SELECT n, typeof(n) FROM t;.

Which flexible-table result would hurt most?

Worked answer

3.0 and '12' become the integers 3 and 12, losing nothing; 3.5 fails with cannot store REAL value in INTEGER column t.n. NULL passes: STRICT checks types, NOT NULL checks presence. The SELECT prints (3, 'integer'), (12, 'integer') and (None, 'null').

Next chapter

Learn in your browser · Chapter 02 of 06

02

Model the nursery: keys and constraints

Design four related tables for an invented plant nursery, and let the database reject bad data.

Each kind of thing gets its own table, so each fact is stored once: customers, plants, orders and order lines.

A primary key identifies each row; INTEGER PRIMARY KEY numbers new rows for you. A foreign key must match a primary key elsewhere, so no order names a missing customer. Constraints are checked on every change: NOT NULL needs a value, UNIQUE forbids duplicates, CHECK tests a condition.

In a new folder, sql-first, save this as nursery.sql with a plain text editor:

text · 44 lines
PRAGMA foreign_keys = ON;

CREATE TABLE customers (
  id   INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  town TEXT
) STRICT;

CREATE TABLE plants (
  id          INTEGER PRIMARY KEY,
  name        TEXT NOT NULL UNIQUE,
  price_pence INTEGER NOT NULL CHECK (price_pence > 0)
) STRICT;

CREATE TABLE orders (
  id             INTEGER PRIMARY KEY,
  customer_id    INTEGER NOT NULL REFERENCES customers (id),
  ordered_on     TEXT NOT NULL,
  delivery_pence INTEGER NOT NULL DEFAULT 0
) STRICT;

CREATE TABLE order_lines (
  order_id   INTEGER NOT NULL REFERENCES orders (id),
  plant_id   INTEGER NOT NULL REFERENCES plants (id),
  qty        INTEGER NOT NULL CHECK (qty > 0),
  unit_pence INTEGER NOT NULL,
  PRIMARY KEY (order_id, plant_id)
) STRICT;

INSERT INTO customers VALUES
  (1, 'Ada Birch', 'Leeds'), (2, 'Ben Holly', 'York'),
  (3, 'Cara Moss', NULL), (4, 'Dev Rowan', 'Leeds');

INSERT INTO plants VALUES
  (1, 'Lavender', 450), (2, 'Rosemary', 350), (3, 'Fern', 600),
  (4, 'Olive tree', 2500), (5, 'Basil', 250);

INSERT INTO orders VALUES
  (1, 1, '2026-09-01', 0), (2, 2, '2026-09-02', 500),
  (3, 1, '2026-09-05', 500), (4, 3, '2026-09-06', 0);

INSERT INTO order_lines VALUES
  (1, 1, 2, 450), (1, 2, 1, 350), (2, 4, 1, 2500),
  (3, 5, 3, 250), (3, 3, 1, 600), (3, 1, 1, 450), (4, 3, 2, 600);

Order lines record the price paid, because prices change. Dates are year-month-day text, which sorts correctly. Cara Moss's town is NULL: missing or unknown. Save build.py beside it and run it there:

python · 10 lines
# build.py: create nursery.db from nursery.sql, never overwriting.
import sqlite3
from pathlib import Path

if Path("nursery.db").exists():
    raise SystemExit("nursery.db exists: rename it to rebuild.")
con = sqlite3.connect("nursery.db")
con.executescript(Path("nursery.sql").read_text(encoding="utf-8"))
con.close()
print("Built nursery.db")

text · 2 lines
python build.py
Built nursery.db

Foreign keys are off by default. SQLite enforces them only on connections that switch them on, outside a transaction. The build did; the file does not remember. Open python -m sqlite3 nursery.db:

text · 9 lines
PRAGMA foreign_keys;
(0,)
PRAGMA foreign_keys = ON;
INSERT INTO orders (customer_id, ordered_on) VALUES (99, '2026-09-07');
IntegrityError (SQLITE_CONSTRAINT_FOREIGNKEY): FOREIGN KEY constraint failed
INSERT INTO plants (name, price_pence) VALUES ('Fern', 500);
IntegrityError (SQLITE_CONSTRAINT_UNIQUE): UNIQUE constraint failed: plants.name
INSERT INTO plants (name, price_pence) VALUES ('Mint', 0);
IntegrityError (SQLITE_CONSTRAINT_CHECK): CHECK constraint failed: price_pence > 0

Each refusal left the data unchanged. If the order was accepted, foreign keys were off (a mistyped PRAGMA is silently ignored), so rename nursery.db and rebuild. Switch foreign keys on in every session and program.

Try it yourself · Activity 02

15 min

Watch an orphan slip in

See what foreign keys stop, in memory.

  1. In python -m sqlite3, paste the CREATE TABLE statements for customers and orders from nursery.sql.
  2. Insert an order for customer 99, dated '2026-09-07', and select all orders.
  3. Switch foreign keys on, retry with customer 98, then add a customer with a NULL name.

Which real mistake would a constraint have caught?

Worked answer

The orphan is accepted: the SELECT prints (1, 99, '2026-09-07', 0). With foreign keys on, customer 98 fails with FOREIGN KEY constraint failed, and INSERT INTO customers (name) VALUES (NULL); with NOT NULL constraint failed: customers.name.

Next chapter

Learn in your browser · Chapter 03 of 06

03

Ask questions: filters, NULL, groups and joins

Most SQL reads data. Learn the shape of a query, the trap NULL sets and the join that quietly multiplies.

SELECT picks columns, FROM a table, WHERE the rows, ORDER BY their order (DESC for largest first) and LIMIT how many; without ORDER BY, order is undefined. Text goes in single quotes. In python -m sqlite3 nursery.db:

text · 6 lines
SELECT name, price_pence FROM plants
WHERE price_pence < 500 ORDER BY price_pence DESC LIMIT 2;
('Lavender', 450)
('Rosemary', 350)
SELECT name, town FROM customers WHERE town <> 'Leeds';
('Ben Holly', 'York')

Cara Moss is missing. Comparing with NULL gives NULL, meaning unknown, and WHERE keeps only rows where the condition is true: three-valued logic. Test with IS NULL or IS NOT NULL, never = NULL. Python shows NULL as None, true as 1, false as 0:

text · 4 lines
SELECT NULL = NULL, NULL <> 'Leeds', NULL IS NULL;
(None, None, 1)
SELECT NULL OR 1, NULL AND 0, 4 NOT IN (1, 2, NULL);
(1, 0, None)

NULL OR 1 is true whatever the unknown is; NULL AND 0 is false. One NULL in a NOT IN list makes the answer unknown, a classic trap.

Aggregate functions (count, sum, avg, min, max) turn many rows into one value. GROUP BY makes one result row per group, and HAVING filters groups as WHERE filters rows:

text · 4 lines
SELECT order_id, count(*), sum(qty * unit_pence) FROM order_lines
GROUP BY order_id HAVING count(*) > 1;
(1, 2, 1250)
(3, 3, 1800)

A join pairs rows from two tables, usually on a foreign key and the key it points to. JOIN keeps matched rows; LEFT JOIN keeps every left-hand row, filling gaps with NULL. AS sets an alias:

text · 8 lines
SELECT c.name, count(o.id) AS orders
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.name;
('Ada Birch', 2)
('Ben Holly', 1)
('Cara Moss', 1)
('Dev Rowan', 0)

Dev Rowan never ordered: JOIN would drop him, and count(*) would show 1, while count(o.id) skips NULLs. Group by id too, so namesakes stay apart. SQLite accepts ungrouped columns; most engines refuse.

The join that multiplies. What did delivery earn?

text · 5 lines
SELECT sum(delivery_pence) FROM orders;
(1000,)
SELECT sum(o.delivery_pence), sum(l.qty * l.unit_pence)
FROM orders AS o JOIN order_lines AS l ON l.order_id = o.id;
(2000, 6750)

One row per order line counts order 3's 500 pence delivery three times, silently, so count a join's rows before summing across it. The fix totals each table at its own level first, in a subquery: a query in brackets inside another.

text · 6 lines
SELECT sum(o.delivery_pence), sum(g.goods)
FROM orders AS o
JOIN (SELECT order_id, sum(qty * unit_pence) AS goods
      FROM order_lines GROUP BY order_id) AS g
  ON g.order_id = o.id;
(1000, 6750)

Try it yourself · Activity 03

25 min

Question the nursery

Answer four questions from nursery.db.

  1. Plants costing 500 pence or more, dearest first.
  2. Customers not in York, including unknown towns.
  3. Each plant's total qty * unit_pence, highest first.
  4. Customers who never ordered, using NOT IN and a subquery.

Which answer would NULL have silently spoiled?

Worked answer

WHERE price_pence >= 500 ORDER BY price_pence DESC gives Olive tree 2500, Fern 600. WHERE town <> 'York' OR town IS NULL gives Ada Birch, Cara Moss, Dev Rowan; without IS NULL, Cara Moss vanishes. Joining order_lines AS l ON l.plant_id = p.id, grouped by p.id, p.name, gives Olive tree 2500, Fern 1800, Lavender 1350, Basil 750, Rosemary 350. WHERE id NOT IN (SELECT customer_id FROM orders) gives Dev Rowan, safe because customer_id is never NULL.

Next chapter

Learn in your browser · Chapter 04 of 06

04

Change data safely: copies, transactions and indexes

UPDATE and DELETE change a thousand rows as easily as one. Copy first, preview, and keep an undo button.

Practise on a copy. In the nursery.db shell, VACUUM INTO writes a clean copy to a new file, even while the database is in use, and will not write over a non-empty file:

text · 3 lines
VACUUM INTO 'practice.db';
VACUUM INTO 'practice.db';
OperationalError (SQLITE_ERROR): output file already exists

Quit and open python -m sqlite3 practice.db. Before any change, run a SELECT with the same WHERE. Afterwards, changes() reports how many rows the change touched:

text · 13 lines
PRAGMA foreign_keys = ON;
SELECT name, price_pence FROM plants WHERE name = 'Basil';
('Basil', 250)
UPDATE plants SET price_pence = 275 WHERE name = 'Basil';
SELECT changes();
(1,)
SELECT id, name FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);
(4, 'Dev Rowan')
DELETE FROM customers
WHERE id NOT IN (SELECT customer_id FROM orders);
SELECT changes();
(1,)

Without WHERE, UPDATE and DELETE hit every row. A transaction makes statements count together or not at all. The shell saves each statement at once unless BEGIN starts one; COMMIT keeps it, ROLLBACK discards it:

text · 9 lines
BEGIN;
SELECT count(*) FROM order_lines;
(7,)
DELETE FROM order_lines;
SELECT count(*) FROM order_lines;
(0,)
ROLLBACK;
SELECT count(*) FROM order_lines;
(7,)

An index is an extra sorted table of chosen columns pointing back to rows, so SQLite can search rather than scan every row. EXPLAIN QUERY PLAN shows which (read the end of each line):

text · 5 lines
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 1;
(2, 0, 216, 'SCAN orders')
CREATE INDEX orders_customer ON orders (customer_id);
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 1;
(3, 0, 61, 'SEARCH orders USING INDEX orders_customer (customer_id=?)')

SQLite does not index foreign key columns for you, though its documentation recommends it for most real systems; it also warns this output may change between versions. Indexes cost space and upkeep, so add them for real queries on growing tables.

Try it yourself · Activity 04

20 min

Preview, change, undo

Practise the safe routine on practice.db, with foreign keys on.

  1. Preview, then add 25 pence to plants under 400 pence; check changes().
  2. Preview, then delete, plant 2.
  3. After BEGIN;, total the prices, set every price to 1, total, ROLLBACK; and total.
  4. Compare the plan for SELECT * FROM order_lines WHERE plant_id = 3; before and after CREATE INDEX lines_plant ON order_lines (plant_id);.

What would step 3 have cost without BEGIN?

Worked answer

The preview (WHERE price_pence < 400) lists Rosemary 350 and Basil 275; changes() then prints (2,). Plant 2, Rosemary, is in order 1, so its DELETE fails with FOREIGN KEY constraint failed. SELECT sum(price_pence) FROM plants; gives 4225, then 5, then 4225 after the rollback. The plan changes from SCAN order_lines to SEARCH order_lines USING INDEX lines_plant (plant_id=?).

Next chapter

Learn in your browser · Chapter 05 of 06

05

SQL from Python: placeholders, backups and exports

Programs send SQL too. Pass values safely, and keep copies you can restore.

In Python, sqlite3.connect() opens a database and execute() runs one statement. Never paste someone's input into a query's text. Save find.py; it only reads practice.db.

python · 12 lines
# find.py: look up customers by town, unsafely then safely.
import sqlite3

con = sqlite3.connect("practice.db")
town = input("Town: ")

unsafe = f"SELECT name FROM customers WHERE town = '{town}'"
print("Pasted:", con.execute(unsafe).fetchall())

safe = "SELECT name FROM customers WHERE town = ?"
print("Placeholder:", con.execute(safe, (town,)).fetchall())
con.close()

Run it twice, typing what follows Town:.

text · 8 lines
python find.py
Town: York
Pasted: [('Ben Holly',)]
Placeholder: [('Ben Holly',)]
python find.py
Town: x' OR '1'='1
Pasted: [('Ada Birch',), ('Ben Holly',), ('Cara Moss',)]
Placeholder: []

The quote ended the text early and added an always-true condition, returning every customer: SQL injection, which OWASP warns can let attackers read, change or destroy data. Innocent input breaks it too: Bishop's Stortford causes a syntax error. A placeholder (?) sends the value separately, as data only; the Python documentation says always use one.

Backups and exports. VACUUM INTO and Python's Connection.backup() copy a database safely while it is in use; copying the file is safe only when no transaction is in progress, the SQLite documentation warns. For spreadsheets, export CSV:

python · 12 lines
# export.py: copy the plants table to plants.csv, never overwriting.
import csv
import sqlite3

con = sqlite3.connect("nursery.db")
cur = con.execute("SELECT * FROM plants ORDER BY id")
with open("plants.csv", "x", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    writer.writerow([col[0] for col in cur.description])
    writer.writerows(cur)
con.close()
print("Wrote plants.csv")

cur.description holds the column names. A trap: connect() creates an empty database if the file is missing, so a script run in the wrong folder fails with no such table.

Try it yourself · Activity 05

20 min

Break it, fix it, back it up

Make the lookup safe, then practise export and backup.

  1. Run find.py with York, then x' OR '1'='1, then Bishop's Stortford.
  2. Delete the unsafe = and print("Pasted lines, then retry the injection text.
  3. Run export.py twice, and open plants.csv in a text editor.
  4. In the practice.db shell, run VACUUM INTO 'practice-backup.db';.

Where else could typed text end up inside SQL?

Worked answer

York gives Ben Holly both ways. The injection returns all three remaining customers when pasted, [] with the placeholder; Bishop's Stortford stops the script at the pasted query with near "s": syntax error. After the fix, the injection prints only Placeholder: []. The export prints Wrote plants.csv: a header, id,name,price_pence, and five plants. A second run stops with FileExistsError. VACUUM INTO creates the backup.

Next chapter

Learn in your browser · Chapter 06 of 06

06

Review SQL an AI assistant writes

An assistant drafts SQL in seconds. Check it against the real schema before it touches anything.

A chat or editor assistant writes plausible, unchecked SQL, guessing what it has not seen. Give it your CREATE TABLE statements and a few invented rows, never real records: prompts can leave your control, and customers' names and orders are personal data under UK GDPR, the ICO explains.

Asked to raise plants under £5 by 10 per cent and list the top three customers by spend since 1 September, with emails, an assistant might answer:

text · 10 lines
UPDATE plants SET price_pence = price_pence * 1.1;

SELECT c.name, c.email, sum(l.unit_pence) AS spend
FROM customers c
JOIN orders o ON o.id = c.id
JOIN order_lines l ON l.order_id = o.id
WHERE o.ordered_on >= 2026-09-01
GROUP BY c.name
ORDER BY spend DESC
FETCH FIRST 3 ROWS ONLY;

Scroll sideways to see every column.

Review SQL an AI assistant writes · Table 1
CheckLook for
ColumnsEach exists in your CREATE TABLE.
Join keysEach ON pairs a foreign key with its target.
WHEREOn every UPDATE and DELETE, previewed.
TypesDates quoted; text never compared with numbers.
TotalsQuantities counted; rows not multiplied.
DialectAccepted by your database and version.

Make a fresh copy (VACUUM INTO 'review.db'; in the nursery.db shell) and open it. The preview shows the UPDATE would hit all five plants:

text · 12 lines
SELECT count(*) FROM plants;
(5,)
UPDATE plants SET price_pence = price_pence * 1.1;
IntegrityError (unknown): cannot store REAL value in INTEGER column plants.price_pence
SELECT c.email FROM customers c;
OperationalError (SQLITE_ERROR): no such column: c.email
SELECT count(*) FROM orders WHERE ordered_on >= 2026-09-01;
(4,)
SELECT 2026-09-01;
(2016,)
SELECT name FROM plants ORDER BY name FETCH FIRST 3 ROWS ONLY;
OperationalError (SQLITE_ERROR): near "FETCH": syntax error

STRICT refused fractional pence that a flexible table would have stored silently. Unquoted, 2026-09-01 is arithmetic (2016), which the TEXT column compares as the text '2016', so the filter keeps everything. LIMIT is not standard SQL, notes the PostgreSQL documentation, but SQLite lacks the standard FETCH FIRST. Name your database and version, then check anyway.

Try it yourself · Activity 06

25 min

Fix the assistant's SQL

On review.db, find every fault and write corrected versions.

  1. Run the SELECT without c.email, ending LIMIT 3;. Note the answer.
  2. List every fault in both statements.
  3. Write and run a corrected SELECT.
  4. After BEGIN;, preview and run a corrected UPDATE, check it, then ROLLBACK;.

Which fault would you have missed if the query had simply run?

Worked answer

It returns Ben Holly 2500, Cara Moss 1300, Ada Birch 800: tidy and wrong. Faults: the UPDATE has no WHERE, and * 1.1 makes fractional pence; no email column; o.id = c.id for o.customer_id = c.id; qty ignored; an unquoted date; grouping by name; FETCH FIRST. Corrected: SELECT c.name, sum(l.qty * l.unit_pence) AS spend FROM customers AS c JOIN orders AS o ON o.customer_id = c.id JOIN order_lines AS l ON l.order_id = o.id WHERE o.ordered_on >= '2026-09-01' GROUP BY c.id, c.name ORDER BY spend DESC LIMIT 3; gives Ada Birch 3050, Ben Holly 2500, Cara Moss 1200. UPDATE plants SET price_pence = round(price_pence * 1.1) WHERE price_pence < 500; changes 3 rows: Lavender 495, Rosemary 385, Basil 275.

Know your limits. This is SQL's core, not the whole craft. For many programs writing at once, or sharing a database over a network, the SQLite documentation recommends a client/server engine.

Assessment options

Keep learning

The complete workbook

This workbook takes someone who has never used a database to a small, well-designed SQLite database for an invented plant nursery. You will define tables, keys and constraints, query with filters, groups, joins and subqueries, change data safely on copies and inside transactions, add an index and use SQL from Python without injection. It ends by reviewing SQL an AI assistant wrote.

  1. 01
    Your first table, and why types matter

    Open SQLite from Python, make a table and see what flexible typing does to numbers.

    Read here · 1 exercise
  2. 02
    Model the nursery: keys and constraints

    Design four related tables for an invented plant nursery, and let the database reject bad data.

    Read here · 1 exercise
  3. 03
    Ask questions: filters, NULL, groups and joins

    Most SQL reads data. Learn the shape of a query, the trap NULL sets and the join that quietly multiplies.

    Read here · 1 exercise
  4. 04
    Change data safely: copies, transactions and indexes

    UPDATE and DELETE change a thousand rows as easily as one. Copy first, preview, and keep an undo button.

    Read here · 1 exercise
  5. 05
    SQL from Python: placeholders, backups and exports

    Programs send SQL too. Pass values safely, and keep copies you can restore.

    Read here · 1 exercise
  6. 06
    Review SQL an AI assistant writes

    An assistant drafts SQL in seconds. Check it against the real schema before it touches anything.

    Read here · 1 exercise

Also inside: a 10-point checklist, a glossary of 12 terms and 10 questions and answers to test yourself. 6 hands-on exercises, each with a worked answer at the back where the workbook gives one.

The PDF is free. We ask for your name and email before downloading. Read the privacy notice at the download form. Free to download and use for personal learning, study groups and inside your own team. Please do not resell the workbooks or republish them as your own. Link people to trust-agent.ai instead.

Get your free PDF workbook

Enter your name and email, then download the complete workbook. This form also works without JavaScript.

We privately record your name, email and request IP for this download. Read our privacy notice and retention policy.

Visit Mickai on LinkedIn

Newsletter confirmation does not affect your download. Following Mickai on LinkedIn is optional; this site does not verify followers.

Test yourself

Questions and answers

Do I need to install a database server to learn SQL?

No. SQLite runs inside the program that uses it and keeps each database in one ordinary file, with no server or account. Python includes it, and Python 3.12 or later includes a small shell you start with python -m sqlite3. This workbook was tested with Python 3.12.10 and SQLite 3.49.1.

Will what I learn here work in other databases?

Mostly. SELECT, WHERE, GROUP BY, joins, INSERT, UPDATE and DELETE carry over, and SQL is an ISO standard, but no two engines behave exactly alike. SQLite's flexible typing and its foreign keys being off by default are its own. Check syntax such as LIMIT against your engine's documentation before you rely on it.

Why are foreign keys off by default in SQLite?

For backwards compatibility. SQLite parsed foreign keys long before it enforced them, and when enforcement arrived in version 3.6.19, switching it on by default would have broken existing databases whose declarations were wrong. Switch it on for every connection with PRAGMA foreign_keys = ON;. The documentation advises careful developers not to assume either setting.

What is the difference between WHERE and HAVING?

WHERE filters rows before they are grouped. HAVING filters the groups afterwards, so it can test aggregate results such as count(*) > 1. A query can use both: WHERE to keep only this month's orders, then HAVING to keep only customers with more than one of them.

Why does my query miss rows where a column is empty?

If the value is NULL, a comparison such as town <> 'Leeds' gives unknown, and WHERE keeps only rows where the condition is true. Add OR town IS NULL, or decide deliberately to leave them out. An empty text value, '', is not NULL in SQLite: it is a real value and compares normally.

How do I undo an UPDATE or DELETE?

Inside a transaction, ROLLBACK undoes everything since BEGIN. Outside one, the shell saves each statement at once and there is no undo. That is why you preview with SELECT, practise on a copy and keep backups made with VACUUM INTO.

When should I add an index?

When a real query on a growing table searches or joins on a column, including foreign key columns, which SQLite does not index for you. Check with EXPLAIN QUERY PLAN: SCAN means the whole table is read, SEARCH means only some rows are visited. An index takes space and has to be kept up to date, so do not add one to every column.

What is SQL injection and how do I prevent it?

It happens when input is pasted into the text of a query, so a crafted value changes what the query does, for example to return every row. Pass values with placeholders such as ?, so they are only ever treated as data. OWASP lists parameterised queries as a primary defence. Placeholders cannot stand in for table or column names: take those from your own code.

Can I let an AI assistant write my SQL?

It can draft SQL quickly, but check everything: that each column exists, that joins use the right keys, that every UPDATE and DELETE has a WHERE, that values are quoted and typed correctly, and that the syntax suits your database. Give it the schema and invented sample rows, never real personal data, and test on a copy.

How should I back up an SQLite database?

Use VACUUM INTO 'backup.db'; in the shell, or Connection.backup() from Python: both work while the database is in use. Copying the file is safe only when no transaction is in progress. For a copy you can read, Python's iterdump() gives the whole database as SQL text. Keep backups somewhere separate, and practise restoring one.

Write as you go

My notes

Use this scratchpad for your own notes, working or answers as you read. Notes are saved in this browser on this device. Downloading makes a local copy; it does not send your notes to Mickai.

Consolidate your learning

Review your work

Work through the exercises and questions, then compare your reasoning with the worked answers. A scored exam and verifiable certificate are not yet available for this course.

Review the questions

Learn the language

Key terms

Relational database
A database that stores data in tables of rows and columns, linked to each other by keys.
SQL
The language for defining, changing and querying relational databases, standardised as ISO/IEC 9075, with differences between engines.
Table
A named set of rows that share the same columns. Each row is one thing; each column is one fact about it.
Primary key
The column or columns that identify each row. No two rows may share a primary key.
Foreign key
A column whose value must match the primary key of a row in another table. SQLite enforces this only when foreign keys are switched on.
Constraint
A rule the database checks on every change, such as NOT NULL, UNIQUE, CHECK or a foreign key.

6 of the workbook's 12 terms. The complete glossary is in the workbook.

Follow the evidence

Sources and checks

Facts last checked: .

Examples in this workbook were run on: Python 3.12.10 with its built-in sqlite3 module and SQLite 3.49.1, on Windows 11 (2026-09-26).

These workbooks use AI assistance. See how the workbooks are made.

  1. About SQLiteSQLite
  2. Datatypes in SQLiteSQLite
  3. STRICT tablesSQLite
  4. SQLite foreign key supportSQLite
  5. Pragma statements supported by SQLiteSQLite
  6. CREATE TABLESQLite
  7. SELECTSQLite
  8. SQL language expressionsSQLite
  9. Built-in aggregate functionsSQLite
  10. Built-in scalar SQL functionsSQLite
  11. UPDATESQLite
  12. DELETESQLite
  13. TransactionSQLite
  14. VACUUMSQLite
  15. EXPLAIN QUERY PLANSQLite
  16. Query planningSQLite
  17. How to corrupt an SQLite database fileSQLite
  18. Quirks, caveats and gotchas in SQLiteSQLite
  19. Appropriate uses for SQLiteSQLite
  20. sqlite3: DB-API 2.0 interface for SQLite databasesPython Software Foundation
  21. SQL InjectionOWASP Foundation
  22. SQL Injection Prevention Cheat SheetOWASP Cheat Sheet Series
  23. What is personal data?Information Commissioner's Office
  24. ISO/IEC 9075-1:2023 Database languages SQL, Part 1: FrameworkInternational Organization for Standardization
  25. SELECT (compatibility: LIMIT and OFFSET)PostgreSQL Global Development Group

Created by Mickarle Wagstaff-Irons - Micky Irons with the Mickai team. Published by Mickai LTD. Last updated 26 September 2026.

NextKeep going

Where to go next