Transactions & Isolation Levels
A transaction is a unit of work — either every statement commits together or none of them do. Isolation levels control how concurrent transactions see each other's uncommitted changes.
BEGIN / COMMIT / ROLLBACK
BEGIN TRAN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- (or COMMIT TRAN / COMMIT TRANSACTION — all synonyms)
-- Roll back on error
BEGIN TRAN;
BEGIN TRY
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK;
THROW;
END CATCH;
@@TRANCOUNT and Nested Transactions
T-SQL transactions can be named but they don't truly nest — BEGIN TRAN increments @@TRANCOUNT, COMMIT decrements, but only the outermost COMMIT actually commits. A ROLLBACK always rolls back to the outermost transaction (unless you use savepoints).
PRINT @@TRANCOUNT; -- 0
BEGIN TRAN;
PRINT @@TRANCOUNT; -- 1
BEGIN TRAN;
PRINT @@TRANCOUNT; -- 2
COMMIT; -- decrements to 1, but does NOT really commit yet
PRINT @@TRANCOUNT; -- 1
COMMIT; -- this is the real commit
Savepoints
Use a savepoint to roll back part of a transaction:
BEGIN TRAN;
INSERT INTO orders (...) VALUES (...);
SAVE TRAN line_items;
INSERT INTO order_items (...) VALUES (...);
IF @@ERROR <> 0
ROLLBACK TRAN line_items; -- undo only the items insert
COMMIT;
XACT_ABORT
Without XACT_ABORT ON, many runtime errors leave the transaction open and partially committed. Most production code starts with:
SET XACT_ABORT ON;
SET NOCOUNT ON;
XACT_ABORT ON causes any runtime error to roll back the entire transaction automatically — the safer default.
Isolation Levels
Set per-session with SET TRANSACTION ISOLATION LEVEL .... Each level trades concurrency for consistency:
| Level | Dirty reads | Non-repeatable | Phantom | Notes |
|---|---|---|---|---|
READ UNCOMMITTED |
Yes | Yes | Yes | Same as WITH (NOLOCK) |
READ COMMITTED (default) |
No | Yes | Yes | Locking by default |
REPEATABLE READ |
No | No | Yes | Holds shared locks until commit |
SERIALIZABLE |
No | No | No | Strictest; uses range locks |
SNAPSHOT |
No | No | No | Row versions; requires ALLOW_SNAPSHOT_ISOLATION |
READ_COMMITTED_SNAPSHOT |
No | Yes | Yes | Like READ COMMITTED but uses row versions instead of shared locks |
-- Switch this session to SNAPSHOT for the duration
ALTER DATABASE HR SET ALLOW_SNAPSHOT_ISOLATION ON;
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRAN;
-- Sees a consistent snapshot of the DB at transaction start
SELECT * FROM employees;
-- ...
COMMIT;
READ_COMMITTED_SNAPSHOT vs SNAPSHOT
| Feature | READ_COMMITTED_SNAPSHOT |
SNAPSHOT |
|---|---|---|
| Setting scope | Database-wide setting | Per-transaction setting |
| Snapshot scope | Per statement (each statement sees committed-as-of-start-of-statement) | Per transaction (whole txn sees a single snapshot) |
| Update conflicts | Same as READ COMMITTED | 3960 update-conflict error if two txns modify the same row |
| Repeatable reads | No — different statements may see different data | Yes — entire txn is consistent |
| Common use | Replaces shared-lock READ COMMITTED for most OLTP | Long-running reports needing consistency |
-- Enable RCSI on a database — fix for blocking under READ COMMITTED
ALTER DATABASE HR SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
Locking Hints
Per-statement locking overrides:
| Hint | Meaning |
|---|---|
WITH (NOLOCK) |
Read uncommitted — fast, dangerous, allows torn reads |
WITH (READPAST) |
Skip locked rows (queue patterns) |
WITH (UPDLOCK) |
Take an update lock now — prevents lost updates |
WITH (HOLDLOCK) |
= SERIALIZABLE for the duration of the statement |
WITH (XLOCK) |
Take an exclusive lock |
WITH (TABLOCK) / (TABLOCKX) |
Lock the whole table |
-- Read-mostly query that prefers stale data over blocking (use sparingly!)
SELECT * FROM orders WITH (NOLOCK) WHERE order_date >= '2024-01-01';
-- Safe UPSERT pattern: lock the row before deciding insert vs update
BEGIN TRAN;
UPDATE config WITH (UPDLOCK, HOLDLOCK)
SET v = @v
WHERE k = @k;
IF @@ROWCOUNT = 0
INSERT INTO config (k, v) VALUES (@k, @v);
COMMIT;
-- Queue dispatcher that skips rows other workers are processing
DELETE TOP (1) FROM job_queue WITH (READPAST, ROWLOCK)
OUTPUT deleted.*
WHERE status = 'PENDING';
WITH (NOLOCK) can return rows that were never committed, miss rows entirely, or even read the same row twice. Treat it as a last resort, not a default. Modern apps should use READ_COMMITTED_SNAPSHOT instead.
Deadlocks
A deadlock occurs when two transactions hold locks the other needs. SQL Server detects them and kills the deadlock victim (lowest-priority transaction) with error 1205.
BEGIN TRY
BEGIN TRAN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK;
IF ERROR_NUMBER() = 1205 -- deadlock victim
-- retry once
;
ELSE THROW;
END CATCH;
Avoid deadlocks by accessing tables in a consistent order in every transaction, keeping transactions short, and using READ_COMMITTED_SNAPSHOT to remove read-write blocking entirely.
Best Practices
- Start most procedures with
SET XACT_ABORT ON; SET NOCOUNT ON;. - Keep transactions short — never include user interaction or long network calls.
- Enable
READ_COMMITTED_SNAPSHOTon most OLTP databases — eliminates a huge class of blocking. - Use lock hints sparingly and document why each one is there.
- Always check
@@TRANCOUNTbeforeROLLBACKinCATCHto avoid "no active transaction" errors.
Summary
BEGIN TRAN/COMMIT/ROLLBACKdefine units of work;@@TRANCOUNTtracks nesting.- Savepoints (
SAVE TRAN) allow partial rollback;XACT_ABORT ONrolls back the whole txn on any runtime error. - Isolation levels range from
READ UNCOMMITTED(fast, unsafe) toSERIALIZABLE(strict, slow). SNAPSHOTgives transaction-wide consistency;READ_COMMITTED_SNAPSHOTis the default-friendly version for OLTP.- Avoid
NOLOCK; useUPDLOCK+HOLDLOCKfor safe upserts.