Databases and SQL
Store, query and join data with the language every database speaks.
Create a free account Try part of it here
Why it matters
Almost every application keeps its data in a database: users, orders, conversations, the logs of every model call. SQL is the language nearly all of them speak, and it has lasted fifty years. AI work leans on it too. You'll query usage and cost data, store chat history, and keep vector embeddings in Postgres. Models are often asked to write SQL, so you need to read and check it.
What you'll be able to do
- Design tables with primary and foreign keys
- Write SQL to filter, sort, aggregate and join data
- Pass values with placeholders so queries are safe from SQL injection
- Explain what indexes and transactions do, and when you need them
What it covers
- Why not use files?
- Aggregates and GROUP BY
- Joins
- Revenue per customer
- Placeholders, always: SQL injection
- Indexes: fast lookups
- Three bugs that don't raise errors
Why not use files?
A CSV file is a notebook on a shared desk. It works until two people write in it at once, a crash leaves a page half-written, or you need "all orders over $100 from customers in Ohio" from ten million rows. A database is more like a well-run records office. It stores data in tables, answers questions written in SQL (Structured Query Language), finds rows fast with indexes, and keeps data consistent even when many users write at once.
Python ships with SQLite, a complete database stored in one file (or in memory). It's what this lesson uses. PostgreSQL is the usual choice for production servers. Its SQL is nearly identical for everything here.
Tables, rows and keys
A table is like a spreadsheet with strict columns. Each column has a name and a type, and each row is one record. Two kinds of key tie tables together. Think of a customer card and an order slip with the customer's number written on it:
- A primary key uniquely identifies each row, like
id. That's the number on the card. - A foreign key is a column holding another table's primary key. That's the number on the slip, and it's how an order points at its customer.
import sqlite3 db = sqlite3.connect(":memory:") # a fresh database in memory db.executescript(""" CREATE TABLE customers ( id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL, region TEXT ); CREATE TABLE orders ( id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(id), total REAL NOT NULL, status TEXT NOT NULL ); INSERT INTO customers (email, region) VALUES ('ada@shop.test', 'west'), ('lin@shop.test', 'mountain'), ('sam@shop.test', 'west'); INSERT INTO orders (customer_id, total, status) VALUES (1, 249.0, 'paid'), (1, 25.0, 'paid'), (2, 89.5, 'refunded'), (2, 120.0, 'paid'); """) for row in db.execute("SELECT id, email, region FROM customers WHERE region = 'west' ORDER BY email"): print(row)
SELECT picks columns, FROM names the table, WHERE filters rows and ORDER BY sorts them. SQL reads close to English, and you write what you want, not how to loop for it.
Aggregates and GROUP BY
A till receipt boils many items down to one total. Aggregate functions do the same to many rows: COUNT, SUM, AVG, MIN and MAX. GROUP BY is like sorting a stack of receipts into one pile per customer, then totaling each pile. It makes one result row per group. HAVING filters groups, the way WHERE filters rows.
import sqlite3 db = sqlite3.connect(":memory:") db.execute("CREATE TABLE o (who, total)") db.executemany("INSERT INTO o VALUES (?, ?)", [("a", 5), ("a", 7), ("b", 1)]) print(db.execute("SELECT SUM(total) FROM o WHERE who = 'a'").fetchone()[0])
Show the answer
12
WHERE keeps only the two rows for 'a', and SUM adds up their totals: 5 + 7 = 12.
Joins
Say you have a class roster and a list of lunch orders, and you want to line them up. A join combines rows from two tables where a condition matches, usually a foreign key equal to a primary key.
An inner join keeps only rows with a match on both sides: only the students who ordered lunch. A left join keeps every row from the left table, filling in NULL (SQL's None) where nothing matches: the whole roster, with blanks for students who didn't order.
In plain words: an inner join shows only the matches. A left join keeps everyone on the first list and leaves blanks where there's no match.
SQL runs in this order, not the order you write it, which explains many confusing errors.
Revenue per customer
One query joins the tables, keeps paid orders, groups by customer and adds up the totals. The left join keeps Sam, who hasn't bought anything.
import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE customers (id INTEGER PRIMARY KEY, email TEXT UNIQUE NOT NULL); CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total REAL, status TEXT); INSERT INTO customers (email) VALUES ('ada@shop.test'), ('lin@shop.test'), ('sam@shop.test'); INSERT INTO orders (customer_id, total, status) VALUES (1, 249.0, 'paid'), (1, 25.0, 'paid'), (2, 89.5, 'refunded'), (2, 120.0, 'paid'); """) query = """ SELECT c.email, COUNT(o.id) AS paid_orders, COALESCE(SUM(o.total), 0) AS revenue FROM customers c LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid' GROUP BY c.id ORDER BY revenue DESC """ for row in db.execute(query): print(row)
c and o are short aliases for the tables. The status check sits in the join's ON condition, so customers without paid orders still appear, with no matching order. COALESCE replaces Sam's NULL sum with 0. Move o.status = 'paid' into a WHERE clause and Sam would vanish, because WHERE runs after the join and drops his row.
Show the answer
SELECT status, COUNT(*) FROM orders GROUP BY status
GROUP BY status makes one row per status, and COUNT(*) counts the rows in each group.
Placeholders, always: SQL injection
Imagine a form with a "name" box, and someone writes "and also hand over the safe key" in it. A careless clerk reads the whole form aloud and obeys. Never build SQL by pasting values into a string. Here's why:
import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE users (email TEXT, is_admin INTEGER); INSERT INTO users VALUES ('ada@shop.test', 1), ('lin@shop.test', 0); """) typed = "nobody' OR '1'='1" # what an attacker types into a login form unsafe = f"SELECT email FROM users WHERE email = '{typed}'" print("unsafe:", db.execute(unsafe).fetchall()) # returns EVERY user safe = db.execute("SELECT email FROM users WHERE email = ?", (typed,)).fetchall() print("safe: ", safe) # no match, as it should be
The attacker's quote mark ended the string early and added OR '1'='1', which is always true. That's SQL injection, one of the most damaging and common security bugs ever found. With a placeholder (? in SQLite, %s in many Postgres drivers), the value travels separately from the SQL. The database never treats it as code, whatever characters it contains.
In plain words: the query is the form, and the user's text only ever goes in the box.
The same rule applies to SQL that a model writes. Run it with a read-only account, and never paste user text into it.
Show the answer
db.execute("SELECT * FROM customers WHERE email = ?", (email,))
Only the placeholder version keeps the value separate from the SQL. The other three paste text into the query.
Indexes: fast lookups
Without help, finding WHERE email = 'ada@shop.test' means checking every row: O(n). An index is a sorted structure on a column, usually a B-tree, a tree with many branches per node. It finds a row in O(log n), like the index at the back of a book. Primary keys and UNIQUE columns get one automatically. You add others for columns you search or join on often:
import sqlite3 db = sqlite3.connect(":memory:") db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, total REAL)") plan = "EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 7" print(db.execute(plan).fetchall()[0][-1]) db.execute("CREATE INDEX orders_by_customer ON orders(customer_id)") print(db.execute(plan).fetchall()[0][-1])
SCAN reads the whole table. SEARCH ... USING INDEX jumps straight to the rows. Indexes cost disk space and slow down writes a little, the way a book's index has to be redone when the book changes. So add them for real queries, not every column.
Transactions: all or nothing
A vending machine either takes your money and gives you the snack, or you keep your money. Nobody wants just one half. Moving stock from one warehouse to another is two updates. If the program crashes between them, stock vanishes. A transaction groups statements so they all happen, or none do. You commit to keep the changes, or roll back to undo them all. In Python, with db: commits when the block ends and rolls back if an exception escapes.
Databases promise four properties, called ACID: changes are atomic (all or nothing), keep the data consistent with its rules, are isolated from other users' unfinished changes, and are durable once committed.
Note: You'll also hear about NoSQL databases (document stores such as MongoDB, key-value stores such as Redis) and ORMs such as SQLAlchemy, which build SQL from Python classes. They rest on the same ideas: keys, indexes and transactions.
Show the answer
So both happen or neither does, even if the program crashes between them
Without a transaction, a crash after the first UPDATE would make money vanish. A transaction is all or nothing.
Three bugs that don't raise errors
f-strings in queries. "It's only from our own app" is how injection happens, since values come from users eventually. Always use placeholders.
The LEFT JOIN that stops being one. A LEFT JOIN keeps customers with no orders. Add WHERE orders.total > 10, and those customers vanish, because their total is NULL, which fails the test. The query quietly becomes an inner join. Put that condition in the ON clause to keep them.
Two statements with no transaction. Moving money takes a subtract and an add. If the program dies between them, the money has gone. Wrap them in a transaction, so both happen or neither does.
And don't index every column. Each index speeds up some reads and slows down every write. Add indexes for the queries you really run.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
What's a foreign key?