

- Description
- Un rapporteur, une équerre et un compas posés sur d'anciens plans dessinés.
- Source
- Sergey Zolkin (szolkin) · original page
- Licence
- CC0 Public domain
Principles in detail
Nine ideas for understanding why and how to think in sets.
In brief
- A relation is a set, with no order.
- Operations are closed and composable.
- You describe, the optimizer decides.
- The row-by-row loop costs something on every iteration.
- Define the grain first.
- Watch out: order, NULL, determinism.
On this page
1. The relational model#
SQL is based on the relational model proposed by E. F. Codd in 1970: a relation is a set of tuples (rows) characterized by the same attributes (columns). The operations of relational algebra have a decisive property: they are closed, meaning they take relations and return a relation. They can therefore be chained at will.
| Relational operation | SQL equivalent | Note |
|---|---|---|
| Selection (σ) | WHERE | Filters rows according to a property |
| Projection (π) | column list of the SELECT | With DISTINCT to remove duplicates |
| Join (⋈) | JOIN | INNER, LEFT, RIGHT, FULL, CROSS |
| Union (∪) | UNION / UNION ALL | UNION ALL keeps duplicates |
| Intersection (∩) | INTERSECT | |
| Difference (−) | EXCEPT | Useful for comparing two results |
| Cartesian product (×) | CROSS JOIN | Basis of numbers tables and calendars |
| Aggregation (γ) | GROUP BY, aggregate functions | Extension of basic algebra |
SQL departs from the model on three points you need to know: tables can contain duplicates (multisets), the NULL value introduces three-valued logic, and order only exists if you ask for it.
2. A declarative language and an optimizer#
A SQL query describes what you want. The engine turns it into an execution plan in several steps: parsing, object resolution, then cost-based optimization. The optimizer relies on statistics (value distribution) to estimate the number of rows at each step, and chooses:
- the join order and the algorithm (nested loops, hash join, merge join);
- the access method (index seek, index or table scan);
- parallelization and possible use of batch mode.
The more complete the statement, the more latitude the optimizer has. A loop deprives it of this overall view: it only sees isolated statements, one per iteration.
3. Why the row-by-row loop is expensive#
Row-by-row processing is often called RBAR (Row By Agonizing Row, an expression popularized by Jeff Moden). Its costs add up:
- Per-iteration overhead: fetching the row, assigning variables, launching a statement.
- Transaction log: without an explicit transaction, each statement is committed separately; the engine waits for the log write on every row.
- Locks: acquired and released on each iteration, with a risk of prolonged waits under load.
- No overall view for the optimizer: no global choice of algorithm, limited parallelism.
- Repeated calls if the loop lives on the client side: one network round trip per row.
A loop is not always disastrous: on a few dozen rows, the difference is invisible. The problem appears with volume, and above all with a doubling of volume that doubles (or worse) the processing time.
4. Thinking in sets: a method#
- Define the grain of the result: one row = what? (a sale, a customer per month, a date range).
- List the input relations: which tables supply the columns?
- Express the links with joins, choosing INNER or LEFT depending on whether the absence of a match should exclude or keep the row.
- Express the filters as properties (
WHERE), not as tests inside a loop. - Express the calculations by grouping (
GROUP BY) or by window (OVER). - Check determinism, NULLs and duplicates before validating.
5. Order does not exist (unless you ask for it)#
- Without
ORDER BYin the final query, no order is guaranteed, even if it seems stable in testing. TOPwithoutORDER BYreturns arbitrary rows; with a non-unique ordering, ties are broken arbitrarily too: add a unique key to the ordering.- In
OVER (ORDER BY …), the order is logical: it defines the calculation window, not the display order.
6. NULL and three-valued logic#
A comparison with NULL is neither true nor false: it is unknown, and WHERE only keeps rows whose predicate is true.
COUNT(*)counts rows;COUNT(column)ignores NULLs.- A join on a column containing NULL never matches two NULLs.
NOT INwith a subquery that may contain NULL returns no rows.
-- Classic pitfall: NOT IN and NULL
DECLARE @A TABLE (v int NULL);
DECLARE @B TABLE (v int NULL);
INSERT INTO @A VALUES (1), (2), (3);
INSERT INTO @B VALUES (1), (NULL);
SELECT v FROM @A WHERE v NOT IN (SELECT v FROM @B); -- 0 rows! (because of the NULL)
SELECT v FROM @A AS a
WHERE NOT EXISTS (SELECT 1 FROM @B AS b WHERE b.v = a.v); -- 2 and 3, as expected
To compare two values treating two NULLs as equal:
-- WHERE clause excerpts — "null-safe" comparison (valid before SQL Server 2022)
WHERE EXISTS (SELECT c.Valeur EXCEPT SELECT r.Valeur) -- true if the two values differ, NULL included
-- SQL Server 2022 and Azure SQL: dedicated operator
WHERE c.Valeur IS DISTINCT FROM r.Valeur
7. Determinism#
A result must depend on the data, not on the plan. Risky cases: an update with a join where several source rows match the same target row, a TOP without a unique ordering, a ROW_NUMBER with ties. Determinism is also an object property that the engine exploits: see Determinism and deterministic objects.
-- Non-deterministic: several rows of B for one row of A
UPDATE a
SET a.Valeur = b.Valeur
FROM dbo.A AS a
JOIN dbo.B AS b ON b.Cle = a.Cle; -- if B is not unique on Cle, the value retained is arbitrary
-- Make the result deterministic: guarantee the uniqueness of the source
-- (primary key / UNIQUE constraint on B.Cle, or prior aggregation / ROW_NUMBER)
8. Atomicity and transactions#
A statement is atomic: it succeeds entirely or fails entirely. A loop of statements is not: an error at row 40,000 leaves 39,999 rows already modified, unless an explicit transaction was opened around the loop (with its own locking and logging costs).
9. Common mistakes#
- Replacing a cursor with a WHILE loop that does the same thing: it is the same row-by-row processing.
- Calling a scalar function in a result column over large volumes (see fake set-based code).
- Writing a trigger assuming that only one row is inserted: a statement can modify thousands.
- Believing that a result is sorted because it was in testing.
- Using
NOT INon a nullable column. - Multiplying rows with a join on a non-unique key, then "fixing" it with
DISTINCT.
Sources and verification#
- Codd (1970), CACM 13(6), 377–387 — relational model
- Microsoft Learn — Scalar UDF Inlining — iterative execution of scalar functions
- Microsoft Learn — OVER clause — logical order in window functions