<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. SQL
  3. Master level
All lessons in this course

Lesson 48 of 54 18 minMaster

Transactions and ACID

Make several changes succeed or fail together: autocommit, BEGIN TRANSACTION, COMMIT and ROLLBACK, why an error does not end a transaction, SET XACT_ABORT ON, the TRY…CATCH pattern, nested transactions, savepoints and what ACID means.

Placing an order at BuildMart takes several statements: add a row to Orders, add its lines to OrderItems and take the items out of stock. If the server stops after the first two, the shop has an order whose goods were never taken out of stock. A transaction groups statements so that they all happen or none of them do.

Every statement is a transaction

You have used transactions in every lesson without knowing it. By default SQL Server runs in autocommit mode: each INSERT, UPDATE or DELETE is a transaction of its own. A statement that changes 3 rows changes all 3 or none. Here a CHECK constraint stops stock going below zero, and one of the three tools has only 12 in stock:

Query.sql
ALTER TABLE dbo.Products ADD CONSTRAINT CK_Products_Stock CHECK (Stock >= 0);GOUPDATE dbo.Products SET Stock = Stock - 15 WHERE CategoryId = 1; SELECT Name, Stock FROM dbo.Products WHERE CategoryId = 1;
Output
Msg 547, Level 16, State 0, Line 1
The UPDATE statement conflicted with the CHECK constraint "CK_Products_Stock". The conflict occurred in database "BuildMart", table "dbo.Products", column 'Stock'.
The statement has been terminated.

Name              Stock
----------------  -----
Claw hammer          40
Cordless drill       12
Tape measure 8 m     65

The hammer and the tape measure had enough stock, but they are unchanged too. The UPDATE failed as a whole, so SQL Server undid the rows it had already changed. You never see a statement half done.

BEGIN TRANSACTION and COMMIT

To make several statements one unit, start a transaction yourself. BEGIN TRANSACTION starts it; COMMIT makes every change since then permanent:

Query.sql
BEGIN TRANSACTION; INSERT INTO dbo.Orders (CustomerId, OrderDate, Status)VALUES (3, '2024-06-20', 'Pending'); DECLARE @OrderId int = SCOPE_IDENTITY(); INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice)VALUES (@OrderId, 2, 1, 89.00),       (@OrderId, 8, 3, 11.90); UPDATE dbo.ProductsSET Stock = Stock - CASE ProductId WHEN 2 THEN 1 ELSE 3 ENDWHERE ProductId IN (2, 8); COMMIT; SELECT i.OrderId, p.Name, i.Quantity, p.StockFROM dbo.OrderItems AS iJOIN dbo.Products AS p ON p.ProductId = i.ProductIdWHERE i.OrderId = 11;
Output
OrderId  Name               Quantity  Stock
-------  -----------------  --------  -----
     11  Cordless drill            1     11
     11  Wood screws (200)         3    147

Until COMMIT runs, the changes are provisional. Other sessions cannot see them (the next lesson shows what they see instead), and nothing is permanent yet.

ROLLBACK undoes the lot

ROLLBACK undoes every change since BEGIN TRANSACTION. @@TRANCOUNT tells you whether a transaction is open: 0 means none.

Query.sql
BEGIN TRANSACTION; DELETE FROM dbo.OrderItems; SELECT @@TRANCOUNT AS OpenTransactions, COUNT(*) AS OrderLinesFROM dbo.OrderItems; ROLLBACK; SELECT @@TRANCOUNT AS OpenTransactions, COUNT(*) AS OrderLinesFROM dbo.OrderItems;
Output
OpenTransactions  OrderLines
----------------  ----------
               1           0

OpenTransactions  OrderLines
----------------  ----------
               0          18

Inside the transaction the session sees its own deletes: 0 lines. After ROLLBACK all 18 lines are back. This is a safe way to try a risky UPDATE or DELETE: run it inside a transaction, check the result, then COMMIT or ROLLBACK.

The trap: an error does not end the transaction

This surprises almost everyone. Most errors stop only the statement that failed. The transaction stays open and the batch carries on to the next statement, COMMIT included. Here the second statement refers to an order that does not exist:

Query.sql
BEGIN TRANSACTION; UPDATE dbo.Products SET Stock = Stock - 1 WHERE ProductId = 2; INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice)VALUES (99, 2, 1, 89.00);          -- there is no order 99 COMMIT; SELECT Name, Stock, @@TRANCOUNT AS OpenTransactionsFROM dbo.Products WHERE ProductId = 2;
Output
Msg 547, Level 16, State 0, Line 5
The INSERT statement conflicted with the FOREIGN KEY constraint "FK__OrderItem__Order__412EB0B6". The conflict occurred in database "BuildMart", table "dbo.Orders", column 'OrderId'.
The statement has been terminated.

Name            Stock  OpenTransactions
--------------  -----  ----------------
Cordless drill     11                 0

The INSERT failed, but COMMIT still ran, so the drill’s stock went down from 12 to 11 for an order line that was never written. That is exactly the half-done change a transaction was meant to prevent.

SET XACT_ABORT ON

With SET XACT_ABORT ON, any run-time error rolls back the whole transaction and stops the batch. Put it at the top of every script and stored procedure that uses transactions:

Query.sql
SET XACT_ABORT ON; BEGIN TRANSACTION; UPDATE dbo.Products SET Stock = Stock - 1 WHERE ProductId = 2; INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice)VALUES (99, 2, 1, 89.00); COMMIT;GOSELECT Name, Stock, @@TRANCOUNT AS OpenTransactionsFROM dbo.Products WHERE ProductId = 2;
Output
Msg 547, Level 16, State 0, Line 7
The INSERT statement conflicted with the FOREIGN KEY constraint "FK__OrderItem__Order__412EB0B6". The conflict occurred in database "BuildMart", table "dbo.Orders", column 'OrderId'.

Name            Stock  OpenTransactions
--------------  -----  ----------------
Cordless drill     12                 0

The stock is still 12 and no transaction is open. The COMMIT never ran, because the batch stopped at the error. The SELECT is in a new batch (after GO), so it still runs.

Interactive

Inside a transaction

  1. BEGIN TRANSACTION;
  2. INSERT INTO dbo.Orders … VALUES (3, '2024-06-20', 'Pending');
  3. INSERT INTO dbo.OrderItems … (@OrderId, 2, 1, 89.00), (@OrderId, 8, 3, 11.90);
  4. UPDATE dbo.Products SET Stock = Stock - … WHERE ProductId IN (2, 8);
  5. COMMIT;
@@TRANCOUNT 0
This session seesCommitted (other sessions)
Drill stock1212
Screws stock150150
Order 11nonenone
Press “Run next statement”. Highlighted cells are changes this session can see but that are not committed.

The full pattern: XACT_ABORT with TRY…CATCH

XACT_ABORT makes sure nothing is half done. TRY…CATCH (from the error-handling lesson) lets you react: log the error, tidy up, and pass the error on. Together they give the standard shape for a unit of work:

Query.sql
SET XACT_ABORT ON; BEGIN TRY    BEGIN TRANSACTION;     UPDATE dbo.Products SET Stock = Stock - 1 WHERE ProductId = 2;     INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice)    VALUES (99, 2, 1, 89.00);     COMMIT;END TRYBEGIN CATCH    IF @@TRANCOUNT > 0 ROLLBACK;     PRINT CONCAT('Rolled back: ', ERROR_MESSAGE());    THROW;                                  -- pass the error on to the callerEND CATCH;
Output
Rolled back: The INSERT statement conflicted with the FOREIGN KEY constraint "FK__OrderItem__Order__412EB0B6". The conflict occurred in database "BuildMart", table "dbo.Orders", column 'OrderId'.
Msg 547, Level 16, State 0, Line 8
The INSERT statement conflicted with the FOREIGN KEY constraint "FK__OrderItem__Order__412EB0B6". The conflict occurred in database "BuildMart", table "dbo.Orders", column 'OrderId'.

In the CATCH block, test before you roll back: if the error happened before BEGIN TRANSACTION, there is nothing to roll back, and ROLLBACK with no open transaction is itself an error. XACT_STATE() tells you more than @@TRANCOUNT:

XACT_STATE()MeaningWhat you can do
1A transaction is open and can be committedCOMMIT or ROLLBACK
0No transaction is openNothing to end
-1A transaction is open but doomed: an error means it can only be rolled backROLLBACK only; a COMMIT fails

With XACT_ABORT ON, every error inside a TRY block dooms the transaction, so XACT_STATE() is -1 in the CATCH block:

Query.sql
SET XACT_ABORT ON; BEGIN TRY    BEGIN TRANSACTION;    UPDATE dbo.Products SET Stock = Stock - 1 WHERE ProductId = 2;    INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice)    VALUES (99, 2, 1, 89.00);END TRYBEGIN CATCH    SELECT XACT_STATE() AS XactState, @@TRANCOUNT AS OpenTransactions,           ERROR_NUMBER() AS ErrorNumber;    IF XACT_STATE() <> 0 ROLLBACK;END CATCH; SELECT XACT_STATE() AS XactState, @@TRANCOUNT AS OpenTransactions;
Output
XactState  OpenTransactions  ErrorNumber
---------  ----------------  -----------
       -1                 1          547

XactState  OpenTransactions
---------  ----------------
        0                 0

Nested transactions are not really nested

You can run BEGIN TRANSACTION inside a transaction, but that does not start a second, independent transaction. It only adds 1 to @@TRANCOUNT. COMMIT takes 1 off, and only the COMMIT that brings it to 0 really commits. ROLLBACK always undoes everything back to the outermost BEGIN:

Query.sql
BEGIN TRANSACTION;                                     -- @@TRANCOUNT 1    UPDATE dbo.Products SET Price = 20.00 WHERE ProductId = 1;     BEGIN TRANSACTION;                                 -- @@TRANCOUNT 2        UPDATE dbo.Products SET Price = 10.00 WHERE ProductId = 3;    COMMIT;                                            -- back to 1: nothing is committed yet     SELECT @@TRANCOUNT AS OpenTransactions;ROLLBACK;                                              -- undoes both updates SELECT ProductId, Name, Price FROM dbo.Products WHERE ProductId IN (1, 3);
Output
OpenTransactions
----------------
               1

ProductId  Name              Price
---------  ----------------  -----
        1  Claw hammer       18.50
        3  Tape measure 8 m   9.75

The inner COMMIT did not save the tape measure’s new price. This matters when a stored procedure that uses a transaction is called by code that already has one open: the procedure’s COMMIT commits nothing, and its ROLLBACK undoes the caller’s work too.

Savepoints: undo part of a transaction

To undo only the last few changes, mark a point with SAVE TRANSACTION name and later run ROLLBACK TRANSACTION name. The changes before the savepoint stay, and the transaction stays open:

Query.sql
BEGIN TRANSACTION; INSERT INTO dbo.Orders (CustomerId, OrderDate, Status)VALUES (5, '2024-06-21', 'Pending');                   -- order 11 SAVE TRANSACTION BeforeLines; INSERT INTO dbo.OrderItems (OrderId, ProductId, Quantity, UnitPrice)VALUES (11, 12, 3, 6.80); ROLLBACK TRANSACTION BeforeLines;                      -- undo the line, keep the order SELECT @@TRANCOUNT AS OpenTransactions;COMMIT; SELECT o.OrderId, o.Status,       (SELECT COUNT(*) FROM dbo.OrderItems AS i WHERE i.OrderId = o.OrderId) AS LinesFROM dbo.Orders AS oWHERE o.OrderId = 11;
Output
OpenTransactions
----------------
               1

OrderId  Status   Lines
-------  -------  -----
     11  Pending      0

ACID

Database people sum up what a transaction promises with four letters:

LetterPromiseHow SQL Server keeps it
AtomicityAll the changes happen, or none doEvery change is written to the transaction log first; ROLLBACK and crash recovery use the log to undo
ConsistencyA transaction takes the data from one valid state to anotherConstraints (keys, foreign keys, CHECK) are checked; a change that breaks one fails
IsolationTransactions running at the same time do not see each other’s half-done workLocks or row versions; the next two lessons
DurabilityOnce COMMIT returns, the change survives a crash or power cutCOMMIT waits until the log records are on disk; the data pages are written later

Durability works through write-ahead logging. A change is made to the page in memory and described in the log. COMMIT returns as soon as the log is safely on disk. The changed data pages are written to the data file later, at a checkpoint. If the server crashes in between, SQL Server reads the log when it starts again: it redoes committed changes that had not reached the data file and undoes changes from transactions that never committed.

Quick check

Without SET XACT_ABORT ON, a transaction runs an UPDATE (it works), then an INSERT that breaks a foreign key, then COMMIT. What is saved?

Quick check

@@TRANCOUNT is 2 after two BEGIN TRANSACTION statements. You run COMMIT, then ROLLBACK. What happens to the changes made after the second BEGIN?

Practice

Order 9 (two boxes of wall plugs short) is being cancelled. In one transaction, set its Status to 'Cancelled' and put its items back into stock: add each line’s Quantity to the product’s Stock. Use SET XACT_ABORT ON and TRY…CATCH, then show order 9’s status and the wall plugs’ stock.

Show a hint

One UPDATE with a join adds the quantities back: UPDATE p SET Stock = p.Stock + i.Quantity FROM dbo.Products AS p JOIN dbo.OrderItems AS i ON ….

Show the solution
Solution.sql
SET XACT_ABORT ON; BEGIN TRY    BEGIN TRANSACTION;     UPDATE dbo.Orders SET Status = 'Cancelled' WHERE OrderId = 9;     UPDATE p    SET Stock = p.Stock + i.Quantity    FROM dbo.Products AS p    JOIN dbo.OrderItems AS i ON i.ProductId = p.ProductId    WHERE i.OrderId = 9;     COMMIT;END TRYBEGIN CATCH    IF @@TRANCOUNT > 0 ROLLBACK;    THROW;END CATCH; SELECT o.OrderId, o.Status, p.Name, p.StockFROM dbo.Orders AS oJOIN dbo.OrderItems AS i ON i.OrderId = o.OrderIdJOIN dbo.Products AS p ON p.ProductId = i.ProductIdWHERE o.OrderId = 9;
Output
OrderId  Status     Name              Stock
-------  ---------  ----------------  -----
      9  Cancelled  Wall plugs (100)      4

Remember

  • Each statement is its own transaction (autocommit): it changes all its rows or none.
  • BEGIN TRANSACTION … COMMIT makes several statements one unit; ROLLBACK undoes them.
  • Most errors end only the statement, not the transaction: the batch goes on and COMMIT commits the rest.
  • SET XACT_ABORT ON makes any error roll back the whole transaction and stop the batch.
  • The standard shape: SET XACT_ABORT ON; BEGIN TRY … COMMIT END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK; THROW; END CATCH.
  • XACT_STATE(): 1 open, 0 none, -1 doomed (roll back only).
  • Nested BEGIN TRANSACTION only counts; ROLLBACK undoes everything. SAVE TRANSACTION allows a partial undo.
  • ACID: atomic, consistent, isolated, durable. Durability comes from the transaction log being on disk before COMMIT returns.
0 of 54 lessons completed