Relational Databases
You’ve heard of them. My absolute favorite is the humble SQLite, if only for its license ❤️
The relational part doesn’t come from the fact that tables in a database ‘relate’ to each other. It comes from the Math that was specified about these by EF Codd at IBM in 1970. This Mathamatical formalism is the reason why SQL is so elegant and powerful and will be around a long, long while1. Having an overview is helpful. Codd’s is pretty heavy paper. I am going to try and distill a few things from it.
Relational Algebra
A relation is a set that looks like this: .
- It’s a set of tuples.
- is the cardinality — Number of rows.
- is the arity — Number of columns.
- Each is an attribute, a named column.
- Each column has a domain, a type, and every entry in the column is drawn from it.
E.g.ageis an integer for every row. - A header is the list of attribute names, e.g. .
Names are unique within a relation, which is how and refer to columns (see below)
Note that a relation is a set: there are no duplicate rows and no row order2. SQL tables relax both of these!
The Patient table below has arity 3, cardinality 3, header , and contains tuples like .
- Relational algebra is a small set of operators that map relations relations.
- Because outputs are relations, operators compose
This is why SQL queries nest and why a database can rewrite them. - SQL is basically a friendlier syntax over this.
The Query Planner turns your SQL back into an algebra tree!
Cheatsheet
Those are the Primitives: Everything else can be built from those (joins, intersection) or is an extension added for practicality (outer joins, aggregation, NULL).
| Operator | Name | SQL-Equivalent (Kinda) |
|---|---|---|
| Selection | WHERE | |
| Projection | SELECT col, ... | |
| Rename | AS | |
| Cartesian product | CROSS JOIN / FROM A, B | |
| Join | JOIN ... ON | |
| , , | Set ops | UNION, EXCEPT, INTERSECT |
| ⟕, ⟖, ⟗ | Outer joins | LEFT / RIGHT / FULL JOIN |
| , | Semi/anti join | EXISTS, NOT EXISTS |
| Aggregation | GROUP BY + COUNT, SUM, … |
Toy Example
Consider these two tables:
Selection — Pick Rows
SELECT * FROM Patient WHERE age > 50;
THis naming is confusing since algebra’s selection is SQL’s WHERE, not SQL’s SELECT. Conditions combine with = AND, OR, NOT.
Selection is what indexes speed up. A B-tree on age lets the engine find matching rows without scanning the table.
Projection — Pick Columns
SELECT dx FROM Visit; -- flu, copd, flu
SELECT DISTINCT dx FROM Visit; -- flu, copd
This is important: Algebra works on sets, so duplicates vanish automatically. SQL works on bags (multisets) and keeps duplicates unless you say DISTINCT. Deduping costs a sort or hash, so SQL makes it optional. This set vs bag gap is where SQL diverges from theory!
Rename with
SELECT * FROM Patient AS p;
SELECT name AS patient_name FROM Patient;
Seems silly, but it’s needed whenever a table meets itself (self join), since the two copies have identical column names otherwise.
Cartesian product — Every Row With Every Other Row
has rows, most of them nonsense (Ana paired with Cy’s visits).
SELECT * FROM Patient CROSS JOIN Visit;
SELECT * FROM Patient, Visit; -- same thing, old syntax
Rarely what you want by itself, but it’s the foundation of joins. Forgetting a join condition in the old comma syntax gives you this accidentally, which is the classic “why did my query return 40 million rows” bug.
Join — Product then Filter
A join is defined as a product followed by a selection:
| pid | name | age | vid | dx |
|---|---|---|---|---|
| 1 | Ana | 34 | 10 | flu |
| 3 | Cy | 71 | 11 | copd |
| 3 | Cy | 71 | 12 | flu |
SELECT * FROM Patient JOIN Visit ON Patient.pid = Visit.pid;
SELECT * FROM Patient NATURAL JOIN Visit; -- joins on all same-named cols
Variants: theta join (any condition), equijoin (condition is =), natural join (equijoin on same-named columns, shared column kept once).
In a database: the definition says “build the product, then filter,” but no engine actually does that! SQL is declarative so it’s about expressing the meaning/what, not the algorithm/how.
Real engines use nested loop, hash join, or sort-merge join, all of which produce the same relation without materializing 9 or 9 billion rows.
Set Operations — , ,
Patients with no visits:
SELECT pid FROM Patient
EXCEPT
SELECT pid FROM Visit;
Operands must be union-compatible (same columns). In SQL that means same number of columns with compatible types3.
UNION dedupes via set semantics. UNION ALL keeps duplicates via bag semantics and is faster since it skips the dedupe.
Note that isn’t a primitive:
Outer Joins ⟕ — Keep Unmatched Rows
Patient ⟕ Visit:
| pid | name | age | vid | dx |
|---|---|---|---|---|
| 1 | Ana | 34 | 10 | flu |
| 2 | Bo | 58 | NULL | NULL |
| 3 | Cy | 71 | 11 | copd |
| 3 | Cy | 71 | 12 | flu |
SELECT * FROM Patient LEFT JOIN Visit ON Patient.pid = Visit.pid;
Not part of Codd’s classical algebra, because filling gaps needs a NULL value, and pure relations have no such thing. This is where all of SQL’s three-valued logic weirdness (NULL = NULL is not true, IS NULL, etc.) enters!
A Full Outer Join is ⟗ = ( ⟕ ) ( ⟖ ).
Semijoin and antijoin
- Semijoin: rows of the left that have some match, returning only left columns.
- Antijoin: rows with no match.
SELECT * FROM Patient p WHERE EXISTS (SELECT 1 FROM Visit v WHERE v.pid = p.pid);
SELECT * FROM Patient p WHERE NOT EXISTS (SELECT 1 FROM Visit v WHERE v.pid = p.pid);
Note Cy appears once in the semijoin even though Cy has two visits. A regular JOIN would duplicate Cy. In EXPLAIN output you’ll literally see “Semi Join” / “Anti Join” nodes, since planners rewrite EXISTS / IN / NOT EXISTS into these.
Aggregation — Grouping
SELECT dx, COUNT(*) FROM Visit GROUP BY dx;
Also an extension and not in the original algebra. Grouping columns go on the left of , aggregate functions on the right. Same rule as SQL: anything in SELECT must be grouped or aggregated.
Queries are Trees 🌳
Let’s try this: “Names of patients over 50 diagnosed with flu”. Expressed in Relational Algebra,
SELECT name
FROM Patient JOIN Visit ON Patient.pid = Visit.pid -- ⋈
WHERE age > 50 AND dx = 'flu'; -- σ
-- SELECT name -- π
Read the algebra inside-out and you get SQL’s logical execution order: FROM/JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. This is why you can’t use a SELECT alias in WHERE: projection happens after selection.
Query Optimization
Because operators have algebraic laws, the planner can rewrite a tree into an equivalent, cheaper one. The big one is selection pushdown: filter as early as possible so joins see fewer rows.
Left side joins 3 × 3 then filters. Right side filters to 2 patients and 2 visits first, then joins. Same answer, less work. In Will-It-Scale-Bro scenarios, this can be the difference between seconds and minutes.
Other tricks the planner uses becuase of the lovely Math:
- Joins are commutative and associative, so can run in any order. Picking the order (using table statistics) is most of what a cost-based optimizer does.
- , so compound
WHEREclauses can be split and pushed to different tables. - Projection can be pushed down too (only carry needed columns), which is what columnar stores like DuckDB and Parquet readers exploit.
Best way to learn this stuff is to run EXPLAIN on any query to see the actual operator tree the database chose.