I run scheduled jobs on several systems that clean out data past its retention window. Anything older than ninety days, or a year, depending on the system, gets removed overnight. On the quiet tables that is nothing to think about. On the busy ones, the logging and event tables especially, "older than ninety days" can mean millions of rows, and that is where a delete stops being a one liner and starts being something you have to plan.
The obvious version is a single statement:
DELETE FROM dbo.EventLog
WHERE CreatedUtc < @Cutoff;
It runs fine in development against a few thousand rows. Against a few million in production, it can take the whole table down with it.
The reason is that this is one transaction. SQL Server acquires locks as it works through the rows, and once a single statement holds about five thousand locks on one table, it escalates to a lock on the entire table. Every other query that needs those rows now waits behind your cleanup. The transaction log grows the whole time, because nothing the delete touches can be cleared from the log until the statement commits; delete ten million rows and the log has to carry all ten million rows of change before it can release any of it. And if anything interrupts the statement partway through, SQL Server rolls the entire thing back, which costs about as much as the delete did in the first place.
The fix is to stop doing it all at once. Delete in small batches, let each batch commit on its own, and loop until nothing is left:
DECLARE @BatchSize INT = 4500;
DECLARE @Cutoff DATETIME2 = DATEADD(DAY, -90, SYSUTCDATETIME());
DECLARE @Rows INT = 1;
WHILE @Rows > 0
BEGIN
DELETE TOP (@BatchSize)
FROM dbo.EventLog
WHERE CreatedUtc < @Cutoff;
SET @Rows = @@ROWCOUNT;
WAITFOR DELAY '00:00:01';
END
A batch of a few thousand keeps each statement under that five thousand lock threshold, so the table stays available to everyone else in the gaps between batches. @@ROWCOUNT tells the loop when the last batch came back empty, which is how it knows to stop. The one second WAITFOR is deliberate: it gives the disk and the log a moment to catch up, and on a busy system it leaves room for other queries instead of pounding the table in a tight loop.
One temptation to resist: do not wrap the whole loop in a single BEGIN TRANSACTION and COMMIT to make it tidy. That folds every batch back into one enormous transaction, and you have written the slow version with extra steps. Each DELETE statement is already atomic by itself. Let it commit by itself.
There is one more piece that trips people up, and it is the reason a batched delete sometimes still fills the log. Under the simple recovery model, SQL Server reclaims the log space after each batch checkpoints, so the log stays roughly flat no matter how many rows you remove. Under the full recovery model, that space is not reclaimed until a log backup runs; however, that means your log backup frequency, not your batch size, becomes the real limit. If your log backups run hourly and your delete runs for two hours, the log holds two hours of deletions no matter how neatly you batched them. On the systems where I run the big cleanups, I make sure the batch cadence and the log backup schedule actually agree with each other.
None of this helps if the column in your WHERE clause is not indexed. Each pass through the loop has to find its next few thousand rows to delete, and without an index on the filter column it scans the table to do that, every single time. An index on the date column turns each of those scans into a quick seek:
CREATE NONCLUSTERED INDEX IX_EventLog_CreatedUtc
ON dbo.EventLog (CreatedUtc);
For a job that runs unattended at two in the morning, being well behaved counts for more than being fast. A batched delete does take longer on the clock than one big statement, and that is the trade you are making on purpose: it gives up some speed so the rest of the server keeps serving while the cleanup works. If you have a nightly job that clears more than a few thousand rows at a time, it is worth rewriting the delete before the table it cleans grows into the one that wakes somebody up.