powershelldba.de

Batch Deletion of Large Data Volumes in SQL Server

Delete 100 million rows with a single DELETE statement, and you'll lock the table for hours, grow the log to terabytes, and crash recovery when something goes wrong. Batch deletion is the art of removing massive data volumes without collapsing your database.

The Problem: Why Large Deletes Fail

Scenario: Delete Old Audit Data

-- You have 500 million rows of old audit logs (pre-2020)
-- Disk is 95% full. You need to reclaim space.

DELETE FROM AuditLog WHERE AuditDate < '2020-01-01';
-- ✗ This starts a single transaction
-- ✗ Locks the entire table (or partition)
-- ✗ Generates 500M row deletions in the transaction log
-- ✗ Log fills → Database goes into EMERGENCY mode
-- ✗ Other queries timeout (table locked)
-- ✗ If power fails, recovery takes HOURS re-playing the log
-- ✗ If query times out mid-delete, ROLLBACK takes even longer

Root Causes

Strategy 1: DELETE in Batches (ROWCOUNT)

The Concept

Instead of one massive transaction, delete N rows at a time in a loop. After each batch, commit and start fresh.

Basic Implementation

-- Delete in batches of 10,000 rows
DECLARE @BatchSize INT = 10000;
DECLARE @RowsDeleted BIGINT = 0;

WHILE 1 = 1
BEGIN
  DELETE TOP (@BatchSize)
  FROM dbo.AuditLog
  WHERE AuditDate < '2020-01-01';

  SET @RowsDeleted = @@ROWCOUNT;

  IF @RowsDeleted = 0
    BREAK;  -- No more rows to delete

  COMMIT TRANSACTION;  -- Explicit commit after each batch
  WAITFOR DELAY '00:00:01';  -- Optional: pause between batches to let other queries run
END;

-- Result:
-- ✓ Each transaction deletes 10K rows (small, quick)
-- ✓ Table unlocked between batches (other queries can run)
-- ✓ Log grows linearly, not exponentially
-- ✓ If something fails, only the current batch is lost
Benefits of batching:
• Smaller transactions = less memory
• Table remains accessible (except during 10K-row locks)
• Log truncation happens between batches
• Application queries can interleave
• Easier to cancel (lose only current batch)

Tuning Batch Size

-- BATCH_SIZE = number of rows to delete at once
-- Too small (1,000): Many batches, slower overall
-- Too large (1,000,000): Lock table for longer, log grows fast
-- Optimal: 5,000 - 50,000 depending on row size and disk I/O

-- For DELETE on a table with clustered index:
-- Test with 10,000 first. If it takes < 1 sec, increase to 50,000.
-- If > 5 sec, reduce to 5,000.

-- For DELETE with complex WHERE clause or FK checks:
-- Reduce to 5,000-10,000 (more overhead per deletion)

-- Monitor:
-- Check Transaction Log size between batches
-- If not shrinking, your batches are too large

Strategy 2: Partitioning & DROP PARTITION

The Concept

If your table is already partitioned by date, drop the entire partition instead of deleting rows. This is instant (no transaction log overhead).

Setup (Partitioned Table)

-- Create partition scheme (by month)
CREATE PARTITION FUNCTION pfAuditLogDate (DATETIME)
AS RANGE LEFT
FOR VALUES (
  '2020-01-01', '2020-02-01', '2020-03-01',
  -- ... monthly intervals ...
  '2024-01-01', '2024-02-01'
);

CREATE PARTITION SCHEME psAuditLogDate
AS PARTITION pfAuditLogDate
TO (fg2020q1, fg2020q2, fg2020q3, fg2020q4, fgCurrent);

-- Create table on partitioned scheme
CREATE TABLE dbo.AuditLog (
  AuditID BIGINT PRIMARY KEY CLUSTERED,
  AuditDate DATETIME NOT NULL,
  UserID INT,
  Action NVARCHAR(MAX)
) ON psAuditLogDate(AuditDate);

Delete Entire Partition

-- To delete all data from 2020:
ALTER TABLE dbo.AuditLog
DROP PARTITION 1;  -- Partition 1 = '2020-01-01' to '2020-02-01' data

-- Result:
-- ✓ Instantaneous (no row-by-row deletion)
-- ✓ No transaction log bloat
-- ✓ Entire partition freed to filegroup
-- ✗ Requires table to be partitioned upfront
-- ✗ Can't use if you need to keep some 2020 data
Partitioning is best for:
• Time-series data (audit logs, events, metrics)
• Data with clear cutoff dates
• Retention policies (e.g., "keep only last 2 years")
• Massive tables (100M+ rows)

Strategy 3: TRUNCATE (Nuclear Option)

The Concept

TRUNCATE removes all data instantly with minimal log overhead. No WHERE clause—entire table gone.

When to Use

-- Truncate entire table (no WHERE clause)
TRUNCATE TABLE dbo.AuditLog;

-- ✓ Instantaneous
-- ✓ Minimal log (deallocate pages, not individual row deletes)
-- ✓ Reset identity counter
-- ✗ ALL data deleted (no selectivity)
-- ✗ Cannot use with FK constraints (must disable first)
-- ✗ Cannot use with WHERE clause (use DELETE for that)

-- Disable FK constraints
DISABLE TRIGGER ALL ON dbo.AuditLog;
ALTER TABLE dbo.Orders NOCHECK CONSTRAINT ALL;

TRUNCATE TABLE dbo.AuditLog;

-- Re-enable FK constraints
ENABLE TRIGGER ALL ON dbo.AuditLog;
ALTER TABLE dbo.Orders CHECK CONSTRAINT ALL;
⚠ TRUNCATE is dangerous: It deletes ALL rows. If you need to keep some data, use DELETE with batching or partitioning instead.

Strategy 4: CREATE TABLE AS SELECT (CTAS) Technique

The Concept

Instead of deleting rows, create a new table with only the data you want to keep. Then drop the old table.

Implementation

-- Create new table with only rows you want to keep
CREATE TABLE dbo.AuditLog_New
WITH (PAD_INDEX = ON, STATISTICS_NORECOMPUTE = ON)
AS
SELECT *
FROM dbo.AuditLog
WHERE AuditDate >= '2020-01-01';  -- Keep only 2020 onwards

-- This runs as a bulk insert (faster than row-by-row delete)

-- Then drop old table
DROP TABLE dbo.AuditLog;

-- Rename new table
EXEC sp_rename 'dbo.AuditLog_New', 'AuditLog';

-- Recreate indexes, constraints, triggers on new table

-- Result:
-- ✓ Much faster than DELETE (bulk insert vs. row-by-row)
-- ✓ Minimal log overhead
-- ✓ Clean defragmentation (new table is well-organized)
-- ✗ Requires downtime (table unavailable during rename)
-- ✗ Must recreate all indexes/constraints
// ✗ Requires double disk space temporarily
CTAS is best for:
• Off-hours maintenance (downtime acceptable)
• Tables with many indexes (faster to rebuild than DELETE)
• Massive data removals (>50% of table)
• Cleanup combined with defragmentation

Comparison of Strategies

Strategy Speed Lock Time Log Growth Downtime Best For
Batch DELETE Medium Brief (per batch) Small None Production (24/7 availability)
DROP PARTITION Instant None None None Partitioned tables, time-series data
TRUNCATE Instant Brief Minimal None Clearing entire table (no WHERE)
CTAS Fast During rename Minimal Maintenance window Off-hours, large removals, defrag

Production Example: Batch Delete with Error Handling

-- Safe, production-grade batch deletion
CREATE PROCEDURE usp_DeleteOldAuditLogs
  @CutoffDate DATETIME,
  @BatchSize INT = 10000,
  @MaxBatches INT = NULL  -- NULL = unlimited
AS
BEGIN
  SET NOCOUNT ON;

  DECLARE @RowsDeleted BIGINT = 0;
  DECLARE @TotalDeleted BIGINT = 0;
  DECLARE @BatchCount INT = 0;

  WHILE 1 = 1
  BEGIN
    BEGIN TRY
      BEGIN TRANSACTION;

      DELETE TOP (@BatchSize)
      FROM dbo.AuditLog
      WHERE AuditDate < @CutoffDate;

      SET @RowsDeleted = @@ROWCOUNT;
      SET @TotalDeleted = @TotalDeleted + @RowsDeleted;
      SET @BatchCount = @BatchCount + 1;

      COMMIT TRANSACTION;

      -- Log progress
      RAISERROR ('Batch %d: Deleted %d rows (Total: %d)',
        0, 0, @BatchCount, @RowsDeleted, @TotalDeleted) WITH NOWAIT;

      IF @RowsDeleted = 0
      BEGIN
        RAISERROR ('Deletion complete. Total: %d rows', 0, 0, @TotalDeleted)
          WITH NOWAIT;
        BREAK;
      END;

      -- Stop if max batches reached
      IF @MaxBatches IS NOT NULL AND @BatchCount >= @MaxBatches
      BEGIN
        RAISERROR ('Max batches (%d) reached. Total: %d rows deleted',
          0, 0, @MaxBatches, @TotalDeleted) WITH NOWAIT;
        BREAK;
      END;

      -- Brief pause to allow other queries
      WAITFOR DELAY '00:00:00.500';

    END TRY
    BEGIN CATCH
      IF @@TRANCOUNT > 0
        ROLLBACK TRANSACTION;

      RAISERROR ('Error in batch %d: %s',
        11, 0, @BatchCount, ERROR_MESSAGE());
      BREAK;
    END CATCH;
  END;
END;

-- Usage:
EXEC usp_DeleteOldAuditLogs
  @CutoffDate = '2020-01-01',
  @BatchSize = 50000,
  @MaxBatches = NULL;  -- Delete all matching rows

Monitoring & Troubleshooting

Monitor Transaction Log Growth

-- Check log file size during deletion
SELECT
  db_name(recovery_model_desc) AS db_name,
  recovery_model_desc,
  size / 128.0 AS size_mb,
  used / 128.0 AS used_mb,
  used * 100.0 / size AS percent_used
FROM sys.database_files
WHERE type_desc = 'LOG';

-- If log keeps growing even with batches:
-- 1. Batches too large
-- 2. Long-running queries preventing log truncation
-- 3. Replication subscriptions not caught up

Check Active Locks During Deletion

-- See what's locked while deletion runs
SELECT
  db_name(tl.database_id) AS database_name,
  object_name(tl.resource_associated_entity_id) AS table_name,
  tl.request_mode,
  tl.request_status,
  es.session_id,
  es.start_time
FROM sys.dm_tran_locks tl
INNER JOIN sys.dm_exec_sessions es
  ON tl.request_session_id = es.session_id
WHERE tl.resource_type = 'OBJECT'
ORDER BY es.session_id;

Best Practices Checklist

The Bottom Line

Deleting large data volumes is like surgery—a single mistake can be catastrophic. Batch deletion (small, repeated transactions) is the safest approach for production systems. Partitioning and DROP PARTITION are elegant if your data structure supports it. CTAS is powerful for off-hours cleanup. TRUNCATE is fast but dangerous—only use if you're deleting everything.

Always test your deletion strategy in a staging environment first. Measure time, log growth, and lock contention. Then schedule it for off-peak hours or implement batching logic that plays nicely with your production workload.

← Back to Blog