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.

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
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
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):
python -m sqlite3 -v
SQLite version 3.49.1Tested 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.
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:
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 minWhat does STRICT accept?
Test a STRICT INTEGER column.
- In
python -m sqlite3, runCREATE TABLE t (n INTEGER) STRICT;. - Predict, then insert
3.0,3.5,'12'andNULL, oneINSERTeach. - 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').
Learn in your browser · Chapter 02 of 06
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:
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:
# 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")python build.py
Built nursery.dbForeign 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:
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 > 0Each 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 minWatch an orphan slip in
See what foreign keys stop, in memory.
- In
python -m sqlite3, paste theCREATE TABLEstatements forcustomersandordersfromnursery.sql. - Insert an order for customer 99, dated
'2026-09-07', and select all orders. - 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.
Learn in your browser · Chapter 03 of 06
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:
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:
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:
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:
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?
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.
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 minQuestion the nursery
Answer four questions from nursery.db.
- Plants costing 500 pence or more, dearest first.
- Customers not in York, including unknown towns.
- Each plant's total
qty * unit_pence, highest first. - Customers who never ordered, using
NOT INand 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.
Learn in your browser · Chapter 04 of 06
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:
VACUUM INTO 'practice.db';
VACUUM INTO 'practice.db';
OperationalError (SQLITE_ERROR): output file already existsQuit 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:
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:
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):
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 minPreview, change, undo
Practise the safe routine on practice.db, with foreign keys on.
- Preview, then add 25 pence to plants under 400 pence; check
changes(). - Preview, then delete, plant 2.
- After
BEGIN;, total the prices, set every price to 1, total,ROLLBACK;and total. - Compare the plan for
SELECT * FROM order_lines WHERE plant_id = 3;before and afterCREATE 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=?).
Learn in your browser · Chapter 05 of 06
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.
# 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:.
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:
# 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 minBreak it, fix it, back it up
Make the lookup safe, then practise export and backup.
- Run
find.pywithYork, thenx' OR '1'='1, thenBishop's Stortford. - Delete the
unsafe =andprint("Pastedlines, then retry the injection text. - Run
export.pytwice, and openplants.csvin a text editor. - In the
practice.dbshell, runVACUUM 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.
Learn in your browser · Chapter 06 of 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:
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.
| Check | Look for |
|---|---|
| Columns | Each exists in your CREATE TABLE. |
| Join keys | Each ON pairs a foreign key with its target. |
| WHERE | On every UPDATE and DELETE, previewed. |
| Types | Dates quoted; text never compared with numbers. |
| Totals | Quantities counted; rows not multiplied. |
| Dialect | Accepted 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:
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 errorSTRICT 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 minFix the assistant's SQL
On review.db, find every fault and write corrected versions.
- Run the
SELECTwithoutc.email, endingLIMIT 3;. Note the answer. - List every fault in both statements.
- Write and run a corrected
SELECT. - After
BEGIN;, preview and run a correctedUPDATE, check it, thenROLLBACK;.
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.
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.
- 01Your first table, and why types matterRead here · 1 exercise
Open SQLite from Python, make a table and see what flexible typing does to numbers.
- 02Model the nursery: keys and constraintsRead here · 1 exercise
Design four related tables for an invented plant nursery, and let the database reject bad data.
- 03Ask questions: filters, NULL, groups and joinsRead here · 1 exercise
Most SQL reads data. Learn the shape of a query, the trap NULL sets and the join that quietly multiplies.
- 04Change data safely: copies, transactions and indexesRead here · 1 exercise
UPDATEandDELETEchange a thousand rows as easily as one. Copy first, preview, and keep an undo button. - 05SQL from Python: placeholders, backups and exportsRead here · 1 exercise
Programs send SQL too. Pass values safely, and keep copies you can restore.
- 06Review SQL an AI assistant writesRead here · 1 exercise
An assistant drafts SQL in seconds. Check it against the real schema before it touches anything.
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.
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 questionsLearn 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.
- About SQLiteSQLite
- Datatypes in SQLiteSQLite
- STRICT tablesSQLite
- SQLite foreign key supportSQLite
- Pragma statements supported by SQLiteSQLite
- CREATE TABLESQLite
- SELECTSQLite
- SQL language expressionsSQLite
- Built-in aggregate functionsSQLite
- Built-in scalar SQL functionsSQLite
- UPDATESQLite
- DELETESQLite
- TransactionSQLite
- VACUUMSQLite
- EXPLAIN QUERY PLANSQLite
- Query planningSQLite
- How to corrupt an SQLite database fileSQLite
- Quirks, caveats and gotchas in SQLiteSQLite
- Appropriate uses for SQLiteSQLite
- sqlite3: DB-API 2.0 interface for SQLite databasesPython Software Foundation
- SQL InjectionOWASP Foundation
- SQL Injection Prevention Cheat SheetOWASP Cheat Sheet Series
- What is personal data?Information Commissioner's Office
- ISO/IEC 9075-1:2023 Database languages SQL, Part 1: FrameworkInternational Organization for Standardization
- 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
Recommended for you
Python: from first script to a useful automation
A complete beginner's route from installing Python to a tested, dry-run-first tool that sorts and reports on a folder, using only what comes with Python.
Recommended for you
Build your first app with an AI coding assistant
Build a small bill-splitting web page with any AI coding assistant while staying in control: brief it, build in steps, review the code, test edge cases and keep safe copies.
Recommended for you
RAG and knowledge bases, step by step
Build a knowledge base a model can answer from: collect, clean, chunk, embed, retrieve, cite and evaluate, with security basics and a hands-on exercise using five short documents.