

- Description
- Panneau « Speed limit 5 » sur le muret d'un parking en hauteur : une limite fixée, mais qui a sa raison d'être.
- Source
- MarkBuckawicki · original page
- Licence
- CC0 Public domain
When procedural is legitimate
Judgment before dogma: where set-based stops, and what looks like it without being it.
In brief
- Legitimate: looping over objects, batches, external effects, irreducible sequential logic.
- To avoid: scalar functions, multi-statement TVFs, correlated subqueries on large volumes, row-by-row triggers.
- Test: "what does the loop iterate over?"
Set-based is not a religion: it is a tool. This page marks its limits, to avoid the opposite mistake (forcing a set-based solution where it makes no sense) and to recognize fake set-based code.
When procedural is legitimate#
- Looping over objects, not over data: rebuilding the indexes of each table, applying an operation to each database, walking through a list of parameters. Each iteration drives a set-based operation.
- Batch processing: splitting a large delete or update into batches to control the log, locks and duration.
- External effects: sending a message, calling a service, writing a file: the engine cannot group them.
- Irreducible sequential logic: some computations where each step depends on a state computed by the previous one, with no reasonable windowed or recursive formulation.
- Very small volumes: when readability matters most and the volume is bounded for certain.
But remember: an excellent block invoked inside a cursor loses its gains (see the approach, §5 and the measured cases).
-- Loop over OBJECTS: legitimate (dynamic SQL, one object per iteration)
DECLARE @sql nvarchar(max) =
(SELECT STRING_AGG(CAST(N'ALTER INDEX ALL ON ' + QUOTENAME(s.name) + N'.' + QUOTENAME(t.name) + N' REBUILD;' AS nvarchar(max)), CHAR(10))
FROM sys.tables AS t
JOIN sys.schemas AS s ON s.schema_id = t.schema_id);
-- PRINT @sql; -- review before executing
EXEC sys.sp_executesql @sql;
Rule of thumb
A loop that drives sets is orchestration. A loop that processes the rows of a set is, almost always, a design flaw.
Fake set-based code#
A query can look set-based and behave like a hidden loop:
- Scalar functions called in the
SELECTor theWHERE: executed per row, without parallelism, with a faulty cost estimate (partially fixed by inlining since SQL Server 2019, under conditions). - Multi-statement table-valued functions: limited row estimates and systematic materialization.
- Correlated subqueries executed for each row of a large set, when a join or a window would suffice.
- Recursive CTE used as a counter to generate series: recursion level by level, bounded by
MAXRECURSION. - Disguised cursor: a
WHILEloop that reads "the next row" withSELECT TOP (1)on each iteration. - Stored procedure calls per row, with a scalar parameter, over thousands of rows.
- Triggers written as if only one row were processed.
-- Fake set-based: the scalar function is called for each row
SELECT CommandeId, dbo.fn_TauxRemise(ClientId) AS Taux FROM dbo.Commandes;
-- Set-based: inline table-valued function + APPLY, or a direct join
SELECT o.CommandeId, r.Taux
FROM dbo.Commandes AS o
CROSS APPLY dbo.itvf_TauxRemise(o.ClientId) AS r;
-- Trigger written for one row: BUG as soon as a statement touches several rows
CREATE TRIGGER dbo.trg_Mauvais ON dbo.Commandes AFTER INSERT AS
BEGIN
DECLARE @id int = (SELECT CommandeId FROM inserted); -- fails or ignores rows if inserted contains several
-- rest of the processing, based on @id
END;
GO
-- Correct version: treat inserted as a set
CREATE TRIGGER dbo.trg_Correct ON dbo.Commandes AFTER INSERT AS
BEGIN
INSERT INTO dbo.Audit (CommandeId, DateAudit)
SELECT CommandeId, SYSUTCDATETIME() FROM inserted;
END;
Safeguards#
- For each loop, ask yourself: what does it iterate over? Over objects or batches: probably legitimate. Over rows of data: needs justification.
- Write the set-based version first if it seems possible; only go back to the loop with a documented reason.
- Measure on realistic volumes (see Measuring seriously): neither intuition nor dogma replaces a measurement.
- Isolate the sequential part, and keep the rest set-based.
- Verify that the results are equal between the old and the new version before replacing.
Sources and verification#
- Microsoft Learn — Scalar UDF Inlining — why scalar functions are slow, inlining conditions
- Microsoft Learn — STRING_AGG — string aggregation used in the example