7 Relational Databases and SQL: Modeling, Querying, and Running PostgreSQL
7.2 Motivation: Files Don’t Scale, and TerpTasks Wants a Memory |
7.1 Learning Objectives
By the end of these two lectures you should be able to:
Design a normalized relational schema—
tables, PostgreSQL column types, primary keys, foreign keys, and NOT NULL, UNIQUE, CHECK constraints— and, for any two entities, name the relationship type and state which table holds the foreign key. Write the DDL that creates a schema (CREATE/ALTER/DROP TABLE) and the DML that populates and edits it (INSERT, UPDATE, DELETE, SELECT with WHERE, ORDER BY, LIMIT, CASE).
Answer multi-table questions with INNER/LEFT JOIN, aggregate functions, GROUP BY/HAVING, and a subquery or CTE.
Run PostgreSQL locally, navigate it with psql, and pick the right PostgreSQL-specific type for a column (text vs. varchar, timestamptz, jsonb, arrays, enums).
Explain ACID and wrap a multi-statement write in a transaction; describe, at a high level, the concurrency anomalies that isolation levels exist to prevent.
Read an EXPLAIN ANALYZE plan, decide whether an index will help a slow query, and create the right kind of index.
Query PostgreSQL safely from Python with parameterized queries, and explain—
with a live exploit— why building SQL with string formatting is a security hole. Describe how migrations, backups, and routine maintenance keep a production database alive, and say where pgvector fits once the data becomes embeddings.
7.2 Motivation: Files Don’t Scale, and TerpTasks Wants a Memory
7.2.1 Three things files don’t give you
Every TerpTasks feature so far has lived in a Python dict or a JSON
file next to the code. That collapses the moment three things
happen at once: concurrent access (two requests write at the
same time—
A database is a program whose entire job is answering those
three problems: keep data correct and durable while many clients
read and write it concurrently, and let you ask new questions of old
data without writing a new program each time. A DBMS
(database management system) is that program; a relational
DBMS (RDBMS) is one built on the relational model—
7.2.2 The stack, and where these two lectures fit
REST/FastAPI lecture TODAY (Lecture 1) TODAY (Lecture 2) |
+------------------+ +-------------------+ +--------------------+ |
| HTTP layer |------>| the relational |---->| PostgreSQL itself | |
| (endpoints) | | model + SQL | | types, txns, | |
+------------------+ | (design on paper, | | indexes, psycopg, | |
| then in any RDBMS) | | ops, pgvector | |
+-------------------+ +--------------------+ |
Lecture 1 is engine-agnostic: tables, keys, constraints, and SQL are
the same idea on PostgreSQL, MySQL, or SQLite. Lecture 2 is where
PostgreSQL specifically earns its keep—
7.2.3 Running example: TerpTasks gets a document library
TerpTasks is adding a feature that will matter again the moment this
course reaches retrieval—
Users upload documents. → a table, one-to-many
Each document belongs to exactly one user, and is split into ordered chunks for later retrieval. → one-to-many, twice
Documents can carry tags (“syllabus”, “midterm”); a tag applies to many documents. → many-to-many, junction table
A document starts unprocessed and becomes processed once it has been chunked. → NULL as “not yet known”
This is the schema both lectures build, piece by piece, and the one
your later RAG work will literally reuse—
+------------+ 1 N +---------------+ |
| users |----------| documents | (1-N: FK on documents) |
+------------+ +---------------+ |
| id PK | | id PK | |
| email UQ | | owner_id FK | |
| name | | title | |
+------------+ | processed_at | (nullable: NULL = not yet |
+-------+-------+ chunked) |
1 | |
| split into |
N | |
+-------+-------+ |
| chunks | |
+---------------+ |
| id PK | |
| document_id FK| |
| chunk_index | |
| content | |
+---------------+ |
|
+------------+ N M +-----------+ |
| documents |----------| tags | (M-N: via document_tags |
+------------+ +-----------+ (document_id, tag_id)) |
7.3 Lecture 1 — The Relational Model and SQL
7.3.1 The Relational Model
Formal definition. A table is a named set of rows;
every row has the same columns, and every column has a
data type the database enforces on write. A schema
(the design sense, not PostgreSQL’s namespace sense—
In plain English: a table is a spreadsheet the database
refuses to let you fill in wrong. A spreadsheet suggests; a
database, given the right schema, enforces—
Choosing types. The PostgreSQL types you’ll reach for constantly in Lecture 1 (Lecture 2 adds the PostgreSQL-specific ones):
Type | Use for | Running example |
INTEGER | counts, ids | chunks.chunk_index |
GENERATED ... AS IDENTITY | auto-assigned surrogate ids | every table's PK |
TEXT | strings, any length | documents.title |
BOOLEAN | true/false | --- |
TIMESTAMPTZ | a moment in time, timezone-aware | documents.created_at |
Misconception. “I’ll just make everything TEXT and sort it out in the app.” A column’s type is the first and cheapest integrity check you get: chunk_index typed as INTEGER makes ’first’ unrepresentable before any application code runs. Push checks as early as the type system allows.
7.3.1.1 NULL, and why = NULL doesn’t work
Formal definition. NULL means unknown, not
“empty string” and not “zero.” SQL comparisons involving
NULL don’t return true or false—
Worked example—
-- Looks reasonable. Returns zero rows, always, on every database.
SELECT * FROM documents WHERE processed_at = NULL;
-- Correct: NULL is a state you test for, not a value you compare to.
SELECT * FROM documents WHERE processed_at IS NULL;processed_at = NULL asks “is this unknown value equal to this
other unknown value?”—
7.3.1.2 Primary keys
Formal definition. A primary key (PK) is a column (or set of columns) whose value uniquely identifies each row: unique and non-null, one per table.
Intuition—
CREATE TABLE users (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE, -- natural uniqueness: constraint, not PK
name TEXT NOT NULL
);7.3.1.3 Foreign keys and referential integrity
Formal definition. A foreign key (FK) is a column
constrained to contain only values that exist as a primary key in
another (or the same) table. The database rejects any write that
would break the reference—
CREATE TABLE documents (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (length(title) > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ -- NULL until chunked
);Worked example—
INSERT INTO documents (owner_id, title) VALUES (99, ’Syllabus’) with no user 99 → rejected: FK violation. An orphan document cannot exist even for a millisecond.
DELETE FROM users WHERE id = 3, where user 3 owns 12 documents → allowed, and the 12 documents vanish with her: that’s our ON DELETE CASCADE choice, made in the schema, not in application code that might forget.
INSERT INTO documents (owner_id, title) VALUES (1, ”) → rejected by the CHECK: empty titles are unrepresentable.
RESTRICT (refuse the delete while documents exist) and
SET NULL (documents become ownerless—
7.3.2 Schema Design in Practice
7.3.2.1 The three relationship shapes
The single most useful design question: for one row over
here, how many rows over there? The answer—
One-to-many (the workhorse). One user owns many documents;
each document has one owner. The FK lives on the “many”
side (documents.owner_id)—
CREATE TABLE chunks (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL CHECK (length(content) > 0),
UNIQUE (document_id, chunk_index) -- one chunk 0, one chunk 1, ... per document
);Many-to-many (the junction). A document has many tags; a tag
marks many documents. Now neither side can hold the FK—
CREATE TABLE tags (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE document_tags (
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (document_id, tag_id) -- composite PK: each pairing once
);Worked example. Document 1 (the syllabus) is tagged “syllabus” (tag 1); document 2 (a lecture) is tagged “lecture” (tag 2) and also “midterm” (tag 3):
document_tags: (1, 1) (2, 2) (2, 3) |
Tags on document 2?—
One-to-one, briefly. Structurally a one-to-many strangled by a uniqueness constraint (put the FK on the child and make it unique, or make it the child’s PK). It doesn’t appear in this schema on purpose: it’s genuinely the rarest shape, reached for only when extra columns are optional-and-large, need different access permissions, or are a real optional subtype. Otherwise those columns belong in the parent table.
7.3.2.2 ER diagrams: sketch before you type CREATE TABLE
The diagram in the motivation section is the design step:
boxes for entities, lines for relationships, cardinality
(1/N/M) labeled on each end, and a note on which side gets the FK.
Draw it before opening a SQL file—
7.3.2.3 Normalization, by intuition
The pathology, live. Here’s the schema an eager intern
proposed—
doc_id | title | owner_email | owner_name | tags |
1 | Syllabus | alice@umd.edu | Alice Wu | syllabus |
2 | Lecture 7 | alice@umd.edu | Alice Wu | lecture,midterm |
3 | Notes | bob@umd.edu | Bob Lee | reading |
Each stored fact should live in exactly one place; here, “Alice’s
name is Alice Wu” is stored twice. That redundancy breeds three
named anomalies: update (Alice changes her name; miss
one row and the database now asserts two contradictory names, with
no way to know which is true), insert (Carol signs up but has
no documents yet—
Formal definition (informal ladder). Normalization is
decomposing tables so every fact is stored exactly once. Stated as
three rules rather than formal theory: (1) every cell holds
one atomic value—
Normalize the intern’s table and out fall the tables already built above: users (name stored once), documents (FK to owner), tags + document_tags (atomic, constrainable). Re-run the anomalies: rename Alice → one UPDATE users; Carol signs up → one INSERT INTO users, no fake document; Bob deletes his document → Bob remains.
Common design mistakes worth naming explicitly, because
they’re the two the exercises below will make you commit and then
fix: comma-separated values in a column (breaks rule 1—
How far to go? Course rule: normalize until every fact
has one home; denormalize only later, deliberately, for a measured
performance reason—
7.3.3 Defining Tables: DDL
Formal definition. DDL (data definition language) is
the subset of SQL that creates and changes the shape of the
database—
The full running schema, DDL only:
CREATE TABLE users (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL
);
CREATE TABLE documents (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (length(title) > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
CREATE TABLE chunks (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL CHECK (length(content) > 0),
UNIQUE (document_id, chunk_index)
);
CREATE TABLE tags (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE document_tags (
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (document_id, tag_id)
);GENERATED ALWAYS AS IDENTITY is PostgreSQL’s modern auto-incrementing primary key: every row gets the next integer automatically, and (unlike the older SERIAL) you cannot accidentally INSERT an explicit value into it and desynchronize the counter.
Schemas change after they ship, which is exactly what ALTER and DROP are for:
ALTER TABLE documents ADD COLUMN page_count INTEGER; -- new, nullable column
ALTER TABLE documents DROP COLUMN page_count; -- reverse it
DROP TABLE document_tags; -- careful: gone, with the table's dataMisconception. “DROP TABLE and recreate whenever
the schema needs to change.” Fine on a laptop with no real data;
the instant a table holds rows someone cares about, dropping it
destroys them. Evolving a live schema safely is a big enough
problem to get its own subsection in Lecture 2
(Operating a Real Database)—
7.3.3.1 Reading and writing rows: DML
-- INSERT: one row, or many in one statement
INSERT INTO users (email, name) VALUES ('alice@umd.edu', 'Alice Wu');
INSERT INTO users (email, name) VALUES
('bob@umd.edu', 'Bob Lee'),
('carol@umd.edu', 'Carol Diaz');
-- UPDATE and DELETE: the WHERE clause is not optional
UPDATE documents SET processed_at = now() WHERE id = 1;
DELETE FROM documents WHERE id = 1;The WHERE clause you forgot. Run UPDATE documents SET
processed_at = now(); with no WHERE, and PostgreSQL does
exactly what you asked: every document, in every user’s
library, gets marked processed. DELETE FROM documents; with no
WHERE empties the table. Neither is an error—
SELECT, piece by piece:
SELECT title, created_at
FROM documents
WHERE owner_id = 1 AND processed_at IS NOT NULL
ORDER BY created_at DESC
LIMIT 5 OFFSET 0;
SELECT DISTINCT owner_id FROM documents; -- unique values only
-- comparison / logical operators, IN, BETWEEN, LIKE, IS NULL
SELECT * FROM documents WHERE owner_id IN (1, 2);
SELECT * FROM chunks WHERE chunk_index BETWEEN 0 AND 3;
SELECT * FROM documents WHERE title LIKE 'Lecture%'; -- % = any characters
SELECT * FROM documents WHERE processed_at IS NULL; -- the NULL trap, correctly
-- aliases and expressions
SELECT title AS document_title, length(title) AS title_len
FROM documents;
-- CASE: a conditional expression, usable anywhere a value is usable
SELECT title,
CASE WHEN processed_at IS NULL THEN 'pending'
ELSE 'processed' END AS status
FROM documents;Worked example—
7.3.4 Joins and Aggregation
Why joins exist. Normalization split one fat table into five narrow ones specifically so each fact lives once. A join is the payoff: it stitches those tables back together for one query, without ever storing the redundant copy. You get the integrity benefits of normalization and the convenience of the flat view, on demand.
Formal definition. INNER JOIN returns rows that have a
match in both tables; unmatched rows on either side are
dropped. LEFT JOIN returns every row from the left table, with
NULLs filled in where the right table has no match—
-- INNER JOIN: only documents that have an owner (all of them, by our FK,
-- but the FK is exactly why this is always safe)
SELECT documents.title, users.name AS owner
FROM documents
INNER JOIN users ON documents.owner_id = users.id;
-- LEFT JOIN: every document, even ones with zero chunks so far
SELECT documents.title, chunks.chunk_index
FROM documents
LEFT JOIN chunks ON chunks.document_id = documents.id
ORDER BY documents.title, chunks.chunk_index;Worked example—
Joining three tables—
SELECT documents.title, tags.name AS tag
FROM documents
JOIN document_tags ON document_tags.document_id = documents.id
JOIN tags ON tags.id = document_tags.tag_id
ORDER BY documents.title;Aggregation: COUNT, SUM, AVG, MIN, MAX collapse many rows into one number, and GROUP BY does that per group instead of over the whole table.
-- chunk count and average chunk length, per document
SELECT documents.title,
COUNT(chunks.id) AS chunk_count,
AVG(length(chunks.content)) AS avg_chunk_len
FROM documents
LEFT JOIN chunks ON chunks.document_id = documents.id
GROUP BY documents.id, documents.title;
-- HAVING filters *groups*, after aggregation (WHERE filters rows, before)
SELECT owner_id, COUNT(*) AS doc_count
FROM documents
GROUP BY owner_id
HAVING COUNT(*) > 1;
-- CASE inside an aggregate: a conditional count, per user
SELECT owner_id,
COUNT(*) AS total_docs,
COUNT(*) FILTER (WHERE processed_at IS NOT NULL) AS processed_docs
FROM documents
GROUP BY owner_id;Misconception. “WHERE COUNT(*) > 1 should
work—
Subqueries and a first CTE. A query can nest inside another query’s WHERE, or be named up front with WITH (a common table expression) and reused as if it were a table:
-- subquery: users who own at least one unprocessed document
SELECT name FROM users
WHERE id IN (SELECT owner_id FROM documents WHERE processed_at IS NULL);
-- the same idea as a CTE: named, and easier to read as the query grows
WITH unprocessed AS (
SELECT owner_id, COUNT(*) AS pending_count
FROM documents
WHERE processed_at IS NULL
GROUP BY owner_id
)
SELECT users.name, unprocessed.pending_count
FROM unprocessed
JOIN users ON users.id = unprocessed.owner_id
WHERE unprocessed.pending_count > 0;A CTE and its equivalent subquery return identical results here; the CTE just gives the intermediate result a name, which pays off the moment a query needs that intermediate result more than once, or needs to be read by someone other than the person who wrote it.
Checkpoint—
7.4 Lecture 2 — PostgreSQL in Practice
7.4.1 Getting PostgreSQL Running
Formal definition. A running PostgreSQL server
(one process, one port) hosts one or more databases; each
database has one or more schemas (a namespace for grouping
tables—
Two ways to get that server: install PostgreSQL natively, or run it in Docker without installing anything on the host:
docker run --name terptasks-db \
-e POSTGRES_PASSWORD=devpassword \
-e POSTGRES_DB=terptasks \
-p 5432:5432 \
-d postgres:17psql essentials—
psql -h localhost -U postgres -d terptasks
\l -- list databases
\c terptasks -- connect to a database
\dt -- list tables in the current schema
\d documents -- describe one table: columns, types, constraints, indexes
\q -- quitRoles and privileges, briefly. PostgreSQL calls both users and groups roles; a role can log in (or not), and holds privileges (SELECT, INSERT, ...) on specific objects. For this course: one role per application (never share the postgres superuser’s credentials with your API server), created with CREATE ROLE app_user LOGIN PASSWORD ’...’; and granted only what it needs (GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;). Fine-grained access control beyond this is a topic of its own, out of scope here.
7.4.2 PostgreSQL Specifics Worth Knowing
text vs.\ varchar. In PostgreSQL they perform identically; varchar(n) adds a length cap PostgreSQL enforces on write, text has none. Default to text, and only cap length with varchar(n) (or a CHECK (length(...) <= n)) when the cap is a real business rule, not a guess.
Numeric types and money. INTEGER/BIGINT for
counts; never store money in FLOAT—
timestamptz, and why not timestamp. timestamp
stores a date and time with no timezone information—
uuid, arrays, and enums—
-- uuid: an alternative surrogate key, useful when an id must be
-- guessable-resistant (e.g. exposed in a public share link) rather
-- than a small sequential integer. Not a schema change here---just
-- know it exists:
-- id UUID PRIMARY KEY DEFAULT gen_random_uuid()
-- array: for a genuinely atomic multi-valued fact with no shared
-- identity of its own (contrast with tags, which needed a junction
-- table because tags ARE shared, queryable entities)
ALTER TABLE documents ADD COLUMN authors TEXT[] NOT NULL DEFAULT '{}';
UPDATE documents SET authors = ARRAY['A. Wu', 'B. Lee'] WHERE id = 2;
SELECT title FROM documents WHERE 'A. Wu' = ANY(authors);
-- enum: a small, fixed, closed set of categories -- safer than a
-- free-text column because an invalid value is a write-time error,
-- not a typo discovered three months later
CREATE TYPE source_kind_t AS ENUM ('upload', 'url', 'pasted_text');
ALTER TABLE documents ADD COLUMN source_kind source_kind_t
NOT NULL DEFAULT 'upload';Misconception. “Arrays solve the tags problem without
a junction table.” A tags TEXT[] column still can’t enforce
UNIQUE on tag names, can’t be efficiently joined to “every
document with this tag,” and needs an app-level rename fixup
everywhere the tag name is copied. Reach for an array only when the
values have no identity beyond being members of that one row’s
list—
JSONB—
ALTER TABLE documents ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}';
UPDATE documents
SET metadata = '{"course": "CMSC389A", "week": 7, "pages": 12}'
WHERE id = 2;
-- ->> extracts a JSON field as text; index it like any other column
SELECT title FROM documents WHERE metadata ->> 'course' = 'CMSC389A';
CREATE INDEX ON documents USING GIN (metadata);JSONB (the binary, indexable form—
Type casting. :: converts between types inline: ’42’::INTEGER, now()::DATE (drops the time-of-day), metadata ->> ’week’::INTEGER (a JSONB text field, cast back to a number for comparison).
RETURNING. Skip a round trip: get the row (or just the generated id) back from the same statement that wrote it.
INSERT INTO tags (name) VALUES ('syllabus') RETURNING id;Upsert: INSERT ... ON CONFLICT. “Get or create” in one statement instead of a select-then-maybe-insert race:
-- get-or-create a tag by name; do nothing if it already exists
INSERT INTO tags (name) VALUES ('syllabus')
ON CONFLICT (name) DO NOTHING
RETURNING id;
-- upsert that updates on conflict instead: bump a use counter
ALTER TABLE tags ADD COLUMN use_count INTEGER NOT NULL DEFAULT 0;
INSERT INTO tags (name, use_count) VALUES ('syllabus', 1)
ON CONFLICT (name) DO UPDATE SET use_count = tags.use_count + 1
RETURNING id, use_count;Without ON CONFLICT, “check if the tag exists, insert if not”
is two statements with a race condition between them—
7.4.3 Transactions
Motivation. Move $100 from account A to account B: debit A,
credit B—
Formal definition (ACID). Atomicity—
BEGIN;
INSERT INTO documents (owner_id, title) VALUES (1, 'New Upload')
RETURNING id; -- suppose this returns id = 4
INSERT INTO chunks (document_id, chunk_index, content) VALUES
(4, 0, 'first chunk of text...'),
(4, 1, 'second chunk of text...');
COMMIT; -- or ROLLBACK to undo everything above, as if it never ranWorked example—
What goes wrong without isolation—
Anomaly | What happens |
lost update | two transactions read the same row, both write based on the stale read; one write silently overwrites the other |
dirty read | a transaction reads another transaction's uncommitted change, which then rolls back---you read data that never existed |
non-repeatable read | a transaction reads the same row twice and gets two different answers, because another transaction committed in between |
phantom read | a transaction re-runs the same WHERE query and a new row appears, inserted by another transaction in between |
MVCC, at a high level. PostgreSQL prevents most of these not
by locking readers out, but with MVCC (multiversion
concurrency control): every transaction sees a consistent
snapshot of the data as of when it started, while writers
create new row versions rather than overwriting in place. Readers
never block writers and writers never block readers—
Deadlocks. Transaction A locks row 1 then waits for row 2;
transaction B has already locked row 2 and is waiting for row 1—
7.4.4 Indexes and Query Performance
Formal definition. An index is a separate, ordered data structure PostgreSQL maintains alongside a table, letting it find matching rows without scanning every one. The default and by far most common kind is a B-tree: a balanced tree keyed on one or more columns, giving O(log n) lookups instead of O(n).
Intuition. A phone book sorted by last name lets you find
“Wu” by bisecting; an unsorted pile makes you read every card. An
index is the sorted structure; without one, PostgreSQL reads every
row (a sequential scan)—
CREATE INDEX ON documents (owner_id); -- single column
CREATE INDEX ON chunks (document_id, chunk_index); -- composite
CREATE UNIQUE INDEX ON users (email); -- UNIQUE already
-- creates one of theseThe trade-off. An index makes matching SELECTs faster by making every INSERT/UPDATE/DELETE slightly slower (the index must be kept in sync) and uses extra disk. Index the columns your WHERE, JOIN, and ORDER BY clauses actually use; indexing everything is not free.
EXPLAIN and EXPLAIN ANALYZE. EXPLAIN shows the plan PostgreSQL would use, without running the query; EXPLAIN ANALYZE actually runs it and reports real timing alongside the plan.
Worked example—
EXPLAIN ANALYZE SELECT * FROM documents WHERE owner_id = 42;Seq Scan on documents (cost=0.00..4322.00 rows=8 width=64) |
(actual time=0.031..18.402 rows=8 loops=1) |
Filter: (owner_id = 42) |
Rows Removed by Filter: 199992 |
Planning Time: 0.087 ms |
Execution Time: 18.431 ms |
Every one of 200,000 rows was checked to find 8 matches. Now:
CREATE INDEX ON documents (owner_id);
EXPLAIN ANALYZE SELECT * FROM documents WHERE owner_id = 42;Index Scan using documents_owner_id_idx on documents |
(cost=0.29..8.31 rows=8 width=64) |
(actual time=0.019..0.024 rows=8 loops=1) |
Index Cond: (owner_id = 42) |
Planning Time: 0.112 ms |
Execution Time: 0.041 ms |
Same query, same result, Seq Scan → Index Scan, roughly
450× faster on this shape of data—
The N+1 query problem. A different performance bug, with no
index fix: fetch every document, then loop in application code
fetching that document’s chunks one query at a time. Ten
documents means 1 query plus 10 more—
-- the fix: one query, not N+1
SELECT * FROM chunks WHERE document_id = ANY(ARRAY[1,2,3,4,5,6,7,8,9,10]);
-- or the LEFT JOIN from Lecture 1, which returns everything in one round tripN+1 doesn’t show up in EXPLAIN—
7.4.5 Talking to PostgreSQL from Python
The driver. psycopg (v3) is PostgreSQL’s standard Python driver: connect, open a cursor, execute, fetch.
import psycopg
with psycopg.connect("dbname=terptasks user=app_user") as conn:
with conn.cursor() as cur:
cur.execute("SELECT id, title FROM documents WHERE owner_id = %s", (1,))
for row in cur.fetchall():
print(row)Parameterized queries—
Worked example—
# VULNERABLE: never do this
def search_documents_unsafe(cur, title_query: str):
cur.execute(f"SELECT * FROM documents WHERE title ILIKE '%{title_query}%'")
return cur.fetchall()A normal search for Lecture produces the expected SQL. But title_query = "x’ OR ’1’=’1" produces:
SELECT * FROM documents WHERE title ILIKE '%x' OR '1'='1'%'’1’=’1’ is always true, so this returns every document
in the database, regardless of who owns it—
# SAFE: the value is a parameter, never SQL text
def search_documents(cur, title_query: str):
cur.execute("SELECT * FROM documents WHERE title ILIKE %s", (f"%{title_query}%",))
return cur.fetchall()Note precisely what changed: the % wildcards are still built
into the Python string, but the user-controlled value is
never spliced into the SQL text—
Transactions from application code mirror BEGIN/COMMIT
directly—
with psycopg.connect("dbname=terptasks user=app_user") as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO documents (owner_id, title) VALUES (%s, %s) RETURNING id",
(1, "New Upload"),
)
doc_id = cur.fetchone()[0]
cur.execute(
"INSERT INTO chunks (document_id, chunk_index, content) VALUES (%s, %s, %s)",
(doc_id, 0, "first chunk..."),
)
# both INSERTs commit together here; an exception above rolls both backORM vs.\ raw SQL, in one table. An ORM (object-relational mapper, e.g.\ SQLAlchemy) maps rows to Python objects and writes the SQL for you; this course teaches raw SQL first because the ORM’s SQL is everything above, one layer down, and every mistake in this lecture (missing index, N+1, an unparameterized filter) still happens through an ORM if you don’t understand what it’s generating.
Raw SQL / psycopg | ORM | |
you write | SQL strings + parameters | Python classes/queries |
best for | full control, complex reporting queries | typical CRUD, fast iteration |
risk | you must parameterize by hand | can hide N+1 behind a clean-looking loop |
Connection pooling. Opening a PostgreSQL connection is relatively expensive (a new process on the server, authentication, setup); a web app handling many short-lived requests should not open one per request. A connection pool (e.g.\ psycopg_pool, or PgBouncer in front of the database) keeps a set of connections open and hands them out and back, so a request borrows one instead of paying setup cost every time.
7.4.6 Operating a Real Database
Schema migrations. Section Defining Tables: DDL warned
against DROP-and-recreate; the tool that makes evolving a
live schema safe is a migration: a small,
version-controlled script with an upgrade and a downgrade,
applied in order, with the database remembering which one it’s on—
Seed data and test databases. Tests should run against a
real, disposable PostgreSQL—
pg_dump / pg_restore. A full backup and its inverse:
pg_dump -h localhost -U postgres terptasks > backup.sql
psql -h localhost -U postgres terptasks < backup.sql # restore (plain SQL dump)VACUUM, ANALYZE, and autovacuum, one sentence each. VACUUM reclaims space left behind by MVCC’s old row versions once nothing needs them anymore; ANALYZE refreshes the statistics the query planner uses to decide seq-scan-vs-index-scan; autovacuum runs both automatically in the background, and for a course project you will essentially never touch either command directly.
7.4.7 Bridge to the Rest of the Course
chunks was named that from the very first diagram in this lecture for a reason: it is precisely the table an embedding pipeline chunks documents into. Add one column and this schema is a vector store:
CREATE EXTENSION vector;
ALTER TABLE chunks ADD COLUMN embedding vector(384);
-- nearest-neighbor search: <=> is cosine distance, ascending = most similar
SELECT content FROM chunks ORDER BY embedding <=> $query_vector LIMIT 5;That’s pgvector—
And the whole schema built across these two lectures is exactly what
sits under the REST API layer: a POST /documents endpoint runs
the INSERT from Section Defining Tables: DDL, a GET
/documents/{id}/chunks runs the LEFT JOIN
from Section Joins and Aggregation, each wrapped in exactly the
parameterized, transactional psycopg calls from Section
Talking to PostgreSQL from Python. Nothing about swapping in a real database
changes what the API layer promises—
Checkpoint—
7.5 Hands-On Exercises
7.5.1 Basic
B1. ER diagram to schema. Sketch the ER diagram for the running example (users, documents, chunks, tags, document_tags), labeling cardinality on every line. Then write the CREATE TABLE statements from scratch, with every constraint from Lecture 1, and load 3 users, 5 documents, and a handful of chunks and tags with INSERT.
B2. Name the anomalies. Given the intern’s flat table (document/title/owner_email/owner_name/tags), give one concrete sequence of operations for each anomaly—
update, insert, delete— using Alice, Bob, and a new user Dana. B3. Break it on purpose. Against your schema from B1, deliberately violate a NOT NULL, a UNIQUE, a foreign key, and a CHECK constraint (one INSERT/UPDATE each). Paste each PostgreSQL error message and explain, in one sentence per error, exactly which rule stopped the write.
B4. Five queries. Using your seeded data, write five queries: one JOIN, one GROUP BY, one query using CASE, one using LIKE or IN, and one that correctly tests a nullable column with IS NULL.
B5. The WHERE you forgot. On a scratch copy of your database, run an UPDATE and a DELETE with no WHERE clause and observe the damage. Then restore from a pg_dump backup you took beforehand. State, in one sentence, the habit that would have prevented needing the backup at all.
7.5.2 Intermediate
I1. Three-table report. Write one query that returns, per user: name, total documents, and documents still unprocessed (processed_at IS NULL)—
joining users and documents and using GROUP BY with a conditional aggregate. I2. A CTE, and the NULL trap named. Write a WITH query that finds every user with more than two unprocessed documents. In a comment above it, explain in one sentence why WHERE processed_at = NULL would silently return nothing even if such users exist.
I3. Index a slow query. Seed documents with at least 100,000 rows (a small script generating fake titles/owners is fine). Run EXPLAIN ANALYZE on a query filtering by owner_id, then create the appropriate index and run it again. Report both plans and the before/after timing.
I4. psycopg, RETURNING, and upsert. Write a Python script that inserts a new tag using a parameterized query and RETURNING to capture the generated id, then re-runs the same insert as an ON CONFLICT upsert and shows it does not create a duplicate row.
I5. Transaction, forced rollback. Write a psycopg script that inserts a document and its first two chunks inside one transaction, then deliberately raises an exception before the block exits. Query the database afterward and show that neither the document nor the chunks exist—
the transaction rolled back as a unit.
7.5.3 Advanced
A1. Exploit it, then fix it. Against a scratch database (never one with real data), implement the vulnerable f-string search from Section Talking to PostgreSQL from Python, demonstrate a payload that returns rows it shouldn’t (or drops a table on a disposable copy), then replace it with a parameterized version and show the same payload is now treated as a harmless literal string. Deliverable: both versions of the function, the exploit payload, and the query PostgreSQL actually received in each case (via cur.query or query logging).
A2. Reproduce a deadlock. Open two psql sessions. In session A, BEGIN and update document 1, then (without committing) try to update document 2. In session B, BEGIN and update document 2, then try to update document 1. Capture the deadlock detected error, explain which session PostgreSQL killed and why, then fix the ordering so it can’t recur.
A3. JSONB in anger. Add the metadata JSONB column from Lecture 2, populate it with differently-shaped metadata across at least three documents (a PDF upload’s page count, a URL source’s fetch timestamp, a pasted-text source’s word count), write a query filtering on one nested field with ->>, create a GIN index on the column, and use EXPLAIN ANALYZE to confirm the index is used.
7.6 Summary
7.6.1 Key takeaways
A schema makes wrong states unrepresentable. Types, PKs, FKs, UNIQUE, CHECK, and cascade rules bind every writer, not just the one you remembered to check in application code.
Three relationship shapes, three FK placements: 1-N puts the FK on the many side; M-N requires a junction table whose rows are facts and can carry columns of their own. Normalize until every fact has one home.
NULL is unknown, not empty. Test it with IS [NOT] NULL, never =.
Joins are the payoff for normalization; INNER drops unmatched rows, LEFT keeps them with NULL filled in. WHERE filters rows, HAVING filters groups.
A transaction is all-or-nothing. ACID is what that buys you; MVCC is how PostgreSQL gives most of it away for free, without readers and writers blocking each other.
An index trades write speed and storage for read speed. EXPLAIN ANALYZE is how you find out whether that trade is even happening—
read the node type (Seq Scan vs.\ Index Scan) first. Parameterize every query built from user input. SQL injection is not a rare edge case; it is the default outcome of string-formatting SQL, and the fix is one API call away.
The database is one more thing you version, seed, back up, and maintain—
migrations, test databases, pg_dump, and autovacuum are what keep a schema evolving safely instead of by accident.
7.6.2 Terminology
Term | Meaning |
DBMS / RDBMS | the program managing the data / one built on the relational model |
primary key (PK) | unique, non-null row identifier; one per table |
surrogate key | meaningless auto-assigned PK (vs. a natural key) |
foreign key (FK) | column constrained to reference an existing PK |
referential integrity | no write may create a dangling reference |
junction table | table whose rows represent an M-N relationship |
normalization / 3NF | every non-key column depends on the key alone |
DDL / DML | statements that define the schema's shape / that read and write rows |
transaction | a group of statements that commit or roll back together |
ACID | atomicity, consistency, isolation, durability |
MVCC | concurrency via row versions and per-statement snapshots, not locks |
index / B-tree | an ordered structure enabling O(log n) lookups instead of a full scan |
EXPLAIN ANALYZE | runs a query and reports its real plan and timing |
N+1 problem | one query per row from a naive loop, instead of one query total |
parameterized query | SQL sent with values kept separate from the query text |
SQL injection | user input executed as SQL because it was concatenated, not parameterized |
migration | versioned, ordered, reversible schema change script |
pgvector | PostgreSQL extension adding a vector column type and nearest-neighbor search |
7.6.3 Common mistakes
Comparing to NULL with = instead of IS NULL.
Running UPDATE/DELETE without checking the WHERE clause first.
Comma-separated values in a column instead of a junction table—
and reaching for an array type to do the same thing with a different syntax. Skipping FK constraints “because the app checks”—
the app is one writer among several. FLOAT for money; timestamp without a timezone.
Building SQL with an f-string instead of a parameterized query.
Adding an index without ever running EXPLAIN ANALYZE to confirm it’s used—
or never adding one and blaming “the database” for a slow, un-indexed WHERE clause. Looping in application code to fetch related rows one at a time (N+1) instead of one join or one WHERE ... = ANY(...).
DROP TABLE and recreate as a migration strategy, once real data exists.
7.6.4 Connections
Backward: this is the persistence layer under the REST APIs and FastAPI: Designing and Building Web Interfaces lecture; constraint-thinking continues the make-invalid-unrepresentable philosophy from Pydantic; the migration/backup discipline echoes the Git lecture’s version-control habits applied to a schema instead of code.
Forward: pgvector and the chunks table are the literal data layer under this course’s retrieval-augmented-generation material; parameterized queries are the first concrete defense against the OWASP injection class of vulnerabilities; and when an AI agent maintains your schema or writes your queries, the review habits from the (part "Elephant-Goldfish") lecture—
read the diff, read the generated SQL, don’t ship an autogenerated migration or a string-built query unread— are exactly where the human judgment concentrates.
7.7 Instructor Notes
Where students struggle.
Where does the FK go? The single most common design error (they put document_id on users). Drill B1 in class with the “say the sentence both directions” trick before the lab.
The NULL trap. Nearly every student writes = NULL at least once; make them predict the (wrong, empty) result before running it, not after.
WHERE vs.\ HAVING. Students reach for WHERE COUNT(*) > 1 reflexively; tie the rule to when aggregation actually happens, not to memorized syntax.
Reading an EXPLAIN ANALYZE plan. They skim the timings and skip the node type; make them say “Seq Scan” or “Index Scan” out loud before looking at any number.
“It’s just a search box, who’d attack it.” The injection demo (Section Talking to PostgreSQL from Python) exists to make the answer visceral; run it live, on a scratch database, every time.
Live demonstrations.
In psql: violate the FK, the UNIQUE, and the CHECK live; let the class watch PostgreSQL refuse. Then DELETE a user and SELECT the cascaded documents—
gone, tags survive. The EXPLAIN ANALYZE before/after: seed 200K rows live (or pre-seed and show the seeding script), run the un-indexed query, CREATE INDEX, run it again. Let the class watch the plan change, not just the timing.
The SQL injection exploit, live, on a disposable database: the vulnerable search function, the payload, the leaked rows; then the one-line parameterized fix and the same payload doing nothing.
Two terminal windows, two psql sessions, one deliberate deadlock: watch PostgreSQL kill one transaction and print deadlock detected.
Quiz seeds. (1) For each relationship type, where does the
FK live? (2) Why does WHERE processed_at = NULL return zero
rows even when unprocessed documents exist—
Homework ideas. B1–B5 as the standard basic set (schema,
five queries, break-it, restore-from-backup). I3 (index a slow
query) and A1 (the injection exploit) as the two exercises worth
making mandatory—
PostgreSQL’s own documentation (postgresql.org/docs) is unusually well-written and is the canonical reference for everything this lecture had to compress; psycopg’s documentation covers connection pooling and advanced usage beyond what’s shown here.