Part 6 · Professional Python · Module 44

Databases & SQL

Almost every professional program stores data that must outlive the process, be shared safely, and be queried in ways nobody planned for. Relational databases and SQL have done that job for fifty years. In this module you design schemas properly, learn SQL from zero with a real database running in your browser, and use it from Python the way production code does.

⏱ ~3 hoursPrerequisites: M40, M13, M09

The relational model

Suppose you keep your app's data in a JSON file. It works until two processes write at once (one overwrites the other), until the file is 2 GB and you need "all orders over $100 from last March" (you must load everything), or until a crash halfway through a write leaves the file corrupt. A database management system (DBMS) solves exactly these problems: concurrent access, fast queries over data larger than memory, and durability across crashes.

A relational database, proposed by Edgar Codd in 1970, stores data in tables (formally relations). Each table has named, typed columns (attributes) and any number of rows (tuples). The rows have no inherent order; you ask for an order when you query.

Python ships with sqlite3, a binding to SQLite, a complete relational database that lives in a single file (or in memory) with no server. It is the most widely deployed database in the world: it is in every phone and every browser. It runs right here in the page, too.

Each row comes back as a tuple. INTEGER PRIMARY KEY is special in SQLite: it becomes an alias for the internal row id and fills itself in automatically, which is why we never supplied id.

Designing tables: normalization to 3NF

The hardest part of databases is not SQL; it is deciding which tables to have. A bad design causes anomalies, bugs that come from storing the same fact in more than one place. Here is a typical spreadsheet someone exported from an online shop:

order_iddatecustomercustomer_emailitems
12026-03-01Adaada@example.comPen x2 ($1.50), Notebook x1 ($4.00)
22026-03-02Benben@example.comPen x10 ($1.50)
32026-03-05Adaada@NEW.comStapler x1 ($9.00)

Three anomalies are hiding here:

Normalization is a step-by-step method for removing these by splitting tables so that each fact lives in exactly one place. The first three normal forms cover almost all practical cases.

First normal form (1NF): one value per cell

Every column holds a single, atomic value, and there are no repeating groups. The items column breaks this: it packs a list into a string, so you cannot ask "how many pens did we sell?" without parsing text. Fix it by giving each order line its own row:

order_idproductunit_priceqtydatecustomercustomer_email
1Pen1.5022026-03-01Adaada@example.com
1Notebook4.0012026-03-01Adaada@example.com
2Pen1.50102026-03-02Benben@example.com

Second normal form (2NF): depend on the whole key

The key of that table is now the pair (order_id, product). 2NF says every non-key column must depend on the whole key, not part of it. But date and customer depend only on order_id, and unit_price depends only on product. Those are partial dependencies. Split them out: an orders table keyed by order_id, a products table keyed by product, and an order_items table keyed by (order_id, product_id) that keeps only qty.

Third normal form (3NF): nothing but the key

3NF removes transitive dependencies: a non-key column that depends on another non-key column. In orders, customer_email depends on customer, which depends on order_id. Move customer facts into a customers table and store only customer_id in orders. The classic summary: every non-key attribute must depend on "the key, the whole key, and nothing but the key".

erDiagram
  CUSTOMERS ||--o{ ORDERS : places
  ORDERS ||--|{ ORDER_ITEMS : contains
  PRODUCTS ||--o{ ORDER_ITEMS : "appears in"
  CUSTOMERS {
    INTEGER id PK
    TEXT name
    TEXT email UK
  }
  ORDERS {
    INTEGER id PK
    INTEGER customer_id FK
    TEXT ordered_on
  }
  PRODUCTS {
    INTEGER id PK
    TEXT name UK
    INTEGER price_cents
  }
  ORDER_ITEMS {
    INTEGER order_id PK, FK
    INTEGER product_id PK, FK
    INTEGER qty
  }
    

Read the crow's-foot notation as "one customer places zero or more orders; one order contains one or more items". Now every fact appears once: changing Ada's email is a single-row update, and products and customers exist on their own.

Sometimes teams denormalize on purpose, keeping a redundant copy (say, an order's total) to make a hot query fast. That is a performance trade-off made after measuring, and it comes with the duty to keep the copy in sync. Start normalized.

SQL from zero: CREATE, INSERT, SELECT

SQL statements fall into families. DDL (data definition) shapes the schema: CREATE TABLE, ALTER TABLE, DROP TABLE. DML (data manipulation) changes rows: INSERT, UPDATE, DELETE. And queries read rows: SELECT. Keywords are case-insensitive; writing them in capitals is just a convention that helps them stand out.

CREATE TABLE products (
    id          INTEGER PRIMARY KEY,
    name        TEXT    NOT NULL UNIQUE,
    price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
    category    TEXT    NOT NULL DEFAULT 'misc'
);

Constraints are rules the database enforces for you, on every write, from every program: NOT NULL (a value is required), UNIQUE (no duplicates), CHECK (an arbitrary condition), DEFAULT (a value when none is given), PRIMARY KEY, and REFERENCES (a foreign key). Put rules in the schema whenever you can: application bugs come and go, but the constraint is always there.

A basic query has this shape. Only SELECT and FROM are required:

SELECT name, price_cents / 100.0 AS price   -- which columns (and computed expressions)
FROM products                                -- which table
WHERE category = 'office' AND price_cents < 500   -- which rows
ORDER BY price_cents DESC, name              -- in what order
LIMIT 3;                                     -- how many

NULL: the absence of a value

NULL means "unknown" or "missing". It is not zero and not the empty string, and it uses three-valued logic: any comparison with NULL is itself NULL (unknown), and WHERE keeps only rows where the condition is true. So WHERE x = NULL matches nothing. Use IS NULL and IS NOT NULL, and COALESCE(x, default) to substitute a value.

The third line surprises people: x != 1 is unknown for the two NULL rows, so only the row with 3 matches. COUNT(x) counts non-NULL values; COUNT(*) counts rows.

UPDATE and DELETE

UPDATE products SET price_cents = price_cents + 50 WHERE category = 'kitchen';
DELETE FROM products WHERE name = 'Sticker';

Aggregation: GROUP BY and HAVING

Aggregate functions collapse many rows into one value: COUNT, SUM, AVG, MIN, MAX. With GROUP BY, they collapse each group of rows that share the grouping columns, producing one output row per group. HAVING then filters groups, the way WHERE filters rows.

The key to understanding SQL is the logical order in which a query is evaluated, which is not the order it is written in:

  1. FROM (and joins): build the working set of rows
  2. WHERE: keep rows that match (no aggregates allowed here; groups do not exist yet)
  3. GROUP BY: form groups
  4. HAVING: keep groups that match (aggregates allowed)
  5. SELECT: compute output columns and aliases
  6. ORDER BY: sort (can use the aliases)
  7. LIMIT/OFFSET: cut

JOINs: combining tables

Normalization splits data into tables; joins put it back together for a query. A join pairs rows from two tables according to a condition, usually "this foreign key equals that primary key".

Venn diagrams are a popular but misleading picture of joins (they describe set operations on one kind of thing, and they hide the fact that one row can match many). Think in rows instead:

customers 1 Ada 2 Ben 3 Cy orders 101 cust 1 102 cust 1 103 cust 3 104 cust 9 INNER JOIN ON o.customer_id = c.id Ada 101 Ada 102 Cy 103 Ben (no orders) and order 104 (no customer) vanish. LEFT JOIN orders Ada 101 Ada 102 Ben NULL Cy 103 Every customer is kept; Ben gets NULLs.
A join produces one output row per matching pair. Ada matches two orders, so she appears twice. A LEFT JOIN keeps unmatched left rows and pads them with NULL.

Order 104 points at customer 9, who does not exist. That is an orphan, and it was only possible because this demo table has no foreign key constraint. With customer_id INTEGER REFERENCES customers(id) and foreign keys switched on, the insert would have been rejected.

How does a database actually join?

Conceptually, the simplest strategy is a nested loop: for each left row, scan the right rows for matches. That is O(n·m). Real planners use an index on the join column (O(n log m)) or build a hash table of one side (a hash join, O(n + m), the same idea as the dictionary-based grouping you know from M23). Step through a left join written in plain Python:

Subqueries and CTEs

A subquery is a query nested inside another. It can produce a single value (a scalar subquery), a list for IN, or a yes/no for EXISTS. A correlated subquery refers to the outer query's current row, so conceptually it runs once per outer row.

-- scalar: products more expensive than average
SELECT name FROM products WHERE price_cents > (SELECT AVG(price_cents) FROM products);

-- IN: customers who bought a kettle
SELECT name FROM customers WHERE id IN (
    SELECT o.customer_id FROM orders o JOIN order_items i ON i.order_id = o.id
    JOIN products p ON p.id = i.product_id WHERE p.name = 'Kettle');

-- correlated EXISTS: customers with at least one order over 40
SELECT name FROM customers c WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.id AND o.total > 40);

A common table expression (CTE), introduced with WITH, names a subquery so you can read the query top to bottom, like assigning intermediate variables. CTEs are the best tool for keeping complex SQL readable.

Indexes and EXPLAIN QUERY PLAN

Without help, WHERE email = 'ada@example.com' must look at every row: a full table scan, O(n). An index is a separate, sorted data structure (a B-tree, a wide, shallow cousin of the binary search tree from M24) that maps column values to row locations, turning the lookup into O(log n). With a million rows, that is about 20 steps instead of a million. Explore how a search tree narrows the search at each level:

Primary keys and UNIQUE columns get an index automatically. You add others with CREATE INDEX. To see what the planner will do, prefix a query with EXPLAIN QUERY PLAN: SCAN means reading the whole table, SEARCH ... USING INDEX means a B-tree lookup.

Notice the last plan: SCAN users USING COVERING INDEX. Because age is not the leftmost column, SQLite cannot search the index; it still reads all of it, just the smaller index instead of the table (a covering index contains every column the query needs). That is cheaper than a table scan but still O(n).

Indexes are not free. Each one takes space and must be updated on every INSERT, UPDATE, and DELETE, so a write-heavy table with ten indexes gets slow. Index the columns you filter, join, and sort on frequently, and verify with the query plan rather than guessing.

sqlite3 from Python: parameters and row factories

The DB-API 2.0 (PEP 249) is the common interface for database drivers in Python, so what you learn with sqlite3 carries over to PostgreSQL drivers such as psycopg. The core objects are a connection (a session with the database) and a cursor (the result of a statement, which you iterate or call fetchone(), fetchmany(n), or fetchall() on). conn.execute(...) is a shortcut that creates a cursor for you.

Parameterized queries: never build SQL with f-strings

Pasting user input into SQL text lets the input become code. That is SQL injection, one of the most exploited vulnerabilities in history. The fix is parameters: put a placeholder in the SQL and pass the values separately. The driver sends them as data, so they can never change the statement's structure. sqlite3 supports ? (positional) and :name (named) placeholders.

Row factories: rows you can read by name

By default rows are tuples, so code says row[2] and breaks when someone reorders the columns. Set conn.row_factory to change what each row becomes. sqlite3.Row is a fast built-in that supports both index and name access; a custom factory can build dataclasses directly.

Transactions and ACID

A transaction groups statements into one unit that either happens completely or not at all. Moving $50 from Alice to Bob is two updates; a crash between them must never leave the money in neither account. Databases promise ACID:

Atomicity

All statements commit, or none do. A failure rolls the whole transaction back.

Consistency

A transaction moves the database from one valid state to another; every constraint holds at commit.

Isolation

Concurrent transactions do not see each other's half-finished work. Levels such as READ COMMITTED and SERIALIZABLE trade strictness for throughput.

Durability

Once committed, the change survives a crash or power loss (it has reached disk, typically via a write-ahead log).

In sqlite3, a connection used as a context manager manages this for you: with conn: commits when the block finishes normally and rolls back if an exception escapes. (It does not close the connection; use contextlib.closing(conn) or call conn.close() for that.)

Two more tools: SAVEPOINT name / ROLLBACK TO name undo part of a transaction, and PRAGMA journal_mode = WAL (write-ahead logging) lets SQLite readers proceed while one writer writes, which is the setting to use for a web app on SQLite.

Migrations, ORMs, pooling, and choosing a database

Migrations

Your schema will change: new columns, new tables, new indexes. A migration is a versioned, ordered script that moves a database from schema version N to N+1. The database records which version it is at (often in a one-row table), and on startup or deploy you apply every pending migration in order, each in a transaction. Never edit a migration that has already run somewhere; write a new one. You will implement exactly this pattern with a schema_version table in Project 6.

In the SQLAlchemy world the standard tool is Alembic. It generates migration files (with upgrade() and downgrade() functions) by comparing your models to the database, and tracks the current revision in an alembic_version table.

pixi add alembic
pixi run alembic init migrations
pixi run alembic revision --autogenerate -m "add recipes.servings"
pixi run alembic upgrade head      # apply all pending migrations
pixi run alembic downgrade -1      # undo the last one

ORMs: SQLAlchemy 2.0

An object-relational mapper (ORM) maps classes to tables and objects to rows, so you work with Python objects and the ORM writes the SQL. SQLAlchemy is the standard in Python; its 2.0 style uses typed Mapped[...] annotations and an explicit select() construct.

ORM strengths

  • Less boilerplate for create/read/update/delete
  • Portable across databases
  • Composable, type-checked queries; relationships as attributes
  • Integrates with Alembic for migrations

ORM costs

  • Another abstraction to learn and debug
  • Easy to write slow code without noticing, especially the N+1 problem: one query for the list, then one per item for a relationship (fix with selectinload())
  • Complex analytics are often clearer in raw SQL

A sensible professional stance: know SQL well (an ORM never excuses you from it), use an ORM or SQLAlchemy Core for application CRUD if your team likes it, and drop to SQL for reporting. Always look at the SQL an ORM generates (echo=True) when performance matters.

Connection pooling

Opening a connection to a server database costs a network round trip, authentication, and memory on the server (a PostgreSQL connection is a whole process). A connection pool keeps a set of open connections and lends them out per request, then takes them back. SQLAlchemy's engine pools automatically (create_engine(url, pool_size=5, max_overflow=10, pool_pre_ping=True)); large deployments add an external pooler such as PgBouncer. Size pools deliberately: 50 app workers with 20 connections each is 1,000 connections, more than most databases want.

SQLite or PostgreSQL?

SQLitePostgreSQL
DeploymentA library and a file; zero administrationA server process; users, roles, backups, upgrades
ConcurrencyMany readers, one writer at a time (WAL helps)Many concurrent writers (MVCC)
TypesFlexible affinity (use STRICT)Strict, rich: timestamptz, JSONB, arrays, UUID, ranges
ScaleExcellent up to moderate write loads on one machineLarge data, many clients, replication
Best forApps, CLIs, tests, embedded, single-server sites, data filesMulti-user web services and shared data

Practice

Each exercise receives an open sqlite3 connection that the tests create and fill. Write the SQL inside the function and return plain Python values.

Table people(name TEXT, age INTEGER). Write adults(conn) that returns a list of the names of people aged 18 or over, sorted alphabetically. Return plain strings, not tuples.

Hint

SELECT name FROM people WHERE age >= 18 ORDER BY name, then unpack each one-element row: [name for (name,) in rows].

Show solution
import sqlite3

def adults(conn):
    rows = conn.execute("SELECT name FROM people WHERE age >= 18 ORDER BY name")
    return [name for (name,) in rows]

Table users(id INTEGER PRIMARY KEY, email TEXT UNIQUE, name TEXT). Write find_user(conn, email) that returns a dict with keys id, email, name for the matching user, or None. It must use a parameterized query: hostile input must simply find nothing.

Hint

Use "... WHERE email = ?", (email,). To build the dict without changing conn.row_factory, zip cursor.description names with the row values, or set row_factory on the cursor (cur = conn.cursor(); cur.row_factory = sqlite3.Row).

Show solution
import sqlite3

def find_user(conn, email):
    cursor = conn.execute("SELECT id, email, name FROM users WHERE email = ?", (email,))
    row = cursor.fetchone()
    if row is None:
        return None
    names = [column[0] for column in cursor.description]
    return dict(zip(names, row))

Table sales(region TEXT, amount INTEGER). Write big_regions(conn, min_total) that returns a list of (region, total) tuples for regions whose total is at least min_total, largest total first (ties broken by region name).

Hint

GROUP BY region HAVING SUM(amount) >= ? then ORDER BY total DESC, region.

Show solution
import sqlite3

def big_regions(conn, min_total):
    sql = """
        SELECT region, SUM(amount) AS total
        FROM sales
        GROUP BY region
        HAVING SUM(amount) >= ?
        ORDER BY total DESC, region
    """
    return conn.execute(sql, (min_total,)).fetchall()

Tables customers(id, name) and orders(id, customer_id, total). Write spend_report(conn) returning (name, order_count, total_spent) for every customer, including those with no orders (count 0, total 0), sorted by total_spent descending then name. Also write no_orders(conn) returning the sorted names of customers who never ordered.

Hint

Use COUNT(o.id) (which skips the NULL padding) and COALESCE(SUM(o.total), 0). For the anti-join, keep rows WHERE o.id IS NULL.

Show solution
import sqlite3

def spend_report(conn):
    sql = """
        SELECT c.name, COUNT(o.id) AS n, COALESCE(SUM(o.total), 0) AS spent
        FROM customers AS c
        LEFT JOIN orders AS o ON o.customer_id = c.id
        GROUP BY c.id
        ORDER BY spent DESC, c.name
    """
    return conn.execute(sql).fetchall()

def no_orders(conn):
    sql = """
        SELECT c.name FROM customers AS c
        LEFT JOIN orders AS o ON o.customer_id = c.id
        WHERE o.id IS NULL
        ORDER BY c.name
    """
    return [name for (name,) in conn.execute(sql)]

Table accounts(name TEXT PRIMARY KEY, balance INTEGER) with no CHECK constraint. Write transfer(conn, src, dst, amount) that moves money in one transaction. Raise ValueError (and change nothing) if amount is not positive, if either account does not exist, or if src has insufficient funds. On success, the change must be committed.

Hint

Inside with conn:, read the source balance, check it, and run both UPDATEs, checking cursor.rowcount == 1 for each. Raising inside the with block rolls back anything already done.

Show solution
import sqlite3

def transfer(conn, src, dst, amount):
    if amount <= 0:
        raise ValueError("amount must be positive")
    with conn:
        row = conn.execute("SELECT balance FROM accounts WHERE name = ?", (src,)).fetchone()
        if row is None:
            raise ValueError(f"unknown account {src!r}")
        if row[0] < amount:
            raise ValueError("insufficient funds")
        credited = conn.execute("UPDATE accounts SET balance = balance + ? WHERE name = ?", (amount, dst))
        if credited.rowcount != 1:
            raise ValueError(f"unknown account {dst!r}")
        conn.execute("UPDATE accounts SET balance = balance - ? WHERE name = ?", (amount, src))

Mini-project: a normalized recipe database

Mini-projectRecipe book on SQLite

Design and build a small recipe database and a Python module that queries it. The data starts as a messy CSV where each line is one recipe with its ingredients packed into one cell ("Pancakes","flour:200g;milk:300ml;egg:2","breakfast,quick"). Your job is to normalize it and answer real questions with SQL.

  1. Create a project: pixi init recipes, then cd recipes. No extra packages are needed; sqlite3 is in the standard library. Add pixi add --feature dev pytest for tests.
  2. Draw the ER diagram first: recipes, ingredients, recipe_ingredients (with quantity and unit), tags, and recipe_tags. Identify every primary and foreign key. Check your design against 1NF, 2NF, and 3NF.
  3. Write schema.sql with constraints (NOT NULL, UNIQUE, CHECK (quantity > 0), REFERENCES ... ON DELETE CASCADE) and indexes on the foreign keys.
  4. Write load.py that parses the CSV and inserts everything in one transaction with parameterized executemany calls. Run it with pixi run python load.py recipes.csv.
  5. Write queries.py with functions that take a connection: recipes containing an ingredient, recipes you can make from a given pantry (a set of ingredient names), the most-used ingredients, and recipes with a given tag but without another.
  6. Test it with pytest using an in-memory database fixture, and run pixi run pytest.
  • The ER diagram and schema are in 3NF; each fact is stored once
  • PRAGMA foreign_keys = ON is set on every connection, and deleting a recipe cascades
  • Loading is a single transaction: a bad line rolls the whole import back
  • Every query uses placeholders; no f-strings build SQL
  • "What can I cook with my pantry?" is answered with one SQL query (hint: GROUP BY with HAVING)
  • EXPLAIN QUERY PLAN shows index searches for your ingredient lookups
  • At least eight pytest tests pass

Stretch goals: add a schema_version table and a second migration that adds servings; rewrite the queries with SQLAlchemy 2.0 Core and compare; add a full-text search table with SQLite's FTS5 for searching instructions.

Quiz

Recap

Going further