powershelldba.de · Uwe Janke

Nested Transactions in SQL Server: The Feature That Isn't

SQL Server accepts BEGIN TRANSACTION inside an open transaction, and that acceptance is the whole problem. It looks like nesting. It behaves like a counter. The difference is the gap most transaction bugs fall into.

Every developer who writes a stored procedure that calls another stored procedure eventually asks the same question: what happens if both of them open a transaction? The natural assumption is that the inner one is a smaller unit inside the outer one, that committing it saves its work, and that rolling it back undoes only its own changes while the outer transaction carries on.

None of that is true. SQL Server parses nested BEGIN TRANSACTION statements, accepts them without complaint, and then does something completely different with them. There is only ever one transaction per session. Everything after the first BEGIN TRANSACTION is bookkeeping.

One Transaction, One Counter

The only thing a second BEGIN TRANSACTION does is increment @@TRANCOUNT. No new transaction is started, no separate scope is created, no isolation boundary appears. The engine still has a single transaction with a single log stream, a single transaction ID and a single set of locks.

Figure 1 · Syntax versus engine
What the syntax suggests What the engine keeps BEGIN TRAN A BEGIN TRAN B BEGIN TRAN C own commit, own rollback Three units of work that can succeed or fail independently of each other. ONE transaction one log stream, one lock set, one XID @@TRANCOUNT 1 2 3 ← current depth Two of the three BEGIN statements did nothing except add 1 to a number.
Nesting exists in your source code. It does not exist in the engine. Everything the second and third BEGIN TRANSACTION produced is the number 3.

You can watch the counter directly, and it is the single most useful diagnostic in this whole topic:

BEGIN TRANSACTION;
    SELECT @@TRANCOUNT AS depth;           -- 1
    BEGIN TRANSACTION;
        SELECT @@TRANCOUNT AS depth;       -- 2
        BEGIN TRANSACTION Inner3;
            SELECT @@TRANCOUNT AS depth;   -- 3

Commits Count Down, Rollbacks Go Straight to Zero

This is the asymmetry that breaks code. COMMIT TRANSACTION and ROLLBACK TRANSACTION are not opposites here. They do not even operate on the same thing.

Figure 2 · The @@TRANCOUNT ladder
Three BEGIN, three COMMIT 3 2 1 0 BEGIN BEGIN BEGIN COMMIT COMMIT COMMIT counter only counter only durable Three BEGIN, one ROLLBACK 3 2 1 0 BEGIN BEGIN BEGIN ROLLBACK every change in this area is undone
Going up costs one statement per level. Coming down the safe way costs one statement per level too. Coming down the other way costs one statement in total, and takes the whole transaction with it.

Put those two rules together and the classic bug writes itself. A procedure that opens its own transaction, hits an error and issues a bare ROLLBACK TRANSACTION does not clean up after itself. It destroys the transaction of whoever called it, including work that had nothing to do with it, and it does so silently until the caller tries to commit.

Naming the Inner Transaction Changes Nothing

Because you can write BEGIN TRANSACTION Inner3, it is tempting to believe that ROLLBACK TRANSACTION Inner3 will undo only that part. It will not. The name on an inner BEGIN is accepted and then ignored. Only the outermost transaction name is remembered, so only that name can be rolled back:

BEGIN TRANSACTION OuterTran;
    BEGIN TRANSACTION InnerTran;
        ROLLBACK TRANSACTION InnerTran;

/* Msg 6401, Level 16, State 1
   Cannot roll back InnerTran. No transaction or savepoint of that name was found. */

The same holds for COMMIT TRANSACTION SomeName. The name is parsed, checked for syntax and discarded. It has no influence on which level is committed.

The Four Errors You Will Actually See

When transaction depth gets out of step, SQL Server reports it with a small and very recognisable set of messages. Learning them by number saves a lot of debugging:

MsgTextWhat actually happened
266 Transaction count after EXECUTE indicates a mismatching number of BEGIN and COMMIT statements. A procedure returned with a different @@TRANCOUNT than it was called with. Almost always: it rolled back a transaction it did not start.
3902 The COMMIT TRANSACTION request has no corresponding BEGIN TRANSACTION. You committed at depth 0. Something below you already rolled everything back, or you have one COMMIT too many.
3903 The ROLLBACK TRANSACTION request has no corresponding BEGIN TRANSACTION. The same situation seen from the rollback side. Typical in a CATCH block that rolls back without checking @@TRANCOUNT first.
6401 Cannot roll back name. No transaction or savepoint of that name was found. You tried to roll back an inner transaction by name, or a savepoint that was never created or was already passed.
Error 266 is a report, not a cause By the time you see it the damage is done. The rollback already happened inside the procedure, the caller's changes are already gone, and 266 is only SQL Server pointing out that the counter no longer adds up. Treat it as evidence, then go and find the bare ROLLBACK that caused it.

Savepoints: The Partial Rollback That Really Exists

What people want when they reach for nested transactions is a partial undo. SQL Server has that feature, it is just not spelled BEGIN TRANSACTION. It is spelled SAVE TRANSACTION.

A savepoint is a named marker inside the current transaction. ROLLBACK TRANSACTION savepoint_name undoes everything done after that marker and leaves the rest of the transaction alive and still open. Crucially, a savepoint does not touch @@TRANCOUNT, neither when it is created nor when it is rolled back to.

Figure 3 · What a savepoint rollback actually undoes
BEGIN TRAN SAVE TRAN A SAVE TRAN B error work 1 (kept) work 2 (kept) work 3 (undone) ROLLBACK TRAN B @@TRANCOUNT 1 1 1 unchanged throughout
The transaction never ends and never splits. A savepoint rollback rewinds the data changes back to a marker, and the transaction continues from there, still open, still holding every lock it has taken since BEGIN.

In code the difference is immediately visible, because the counter never moves:

BEGIN TRANSACTION;                      -- @@TRANCOUNT = 1
    INSERT dbo.Orders (CustomerId) VALUES (17);
    DECLARE @OrderId INT = SCOPE_IDENTITY();

    SAVE TRANSACTION BeforeLines;       -- @@TRANCOUNT = 1, still

    INSERT dbo.OrderLine (OrderId, Sku, Qty) VALUES (@OrderId, 'X-1', 5);

    ROLLBACK TRANSACTION BeforeLines;   -- the line is gone, the order is not
                                        -- @@TRANCOUNT = 1, still

COMMIT TRANSACTION;                     -- the order is committed, without its line

Three limits are worth knowing before you build on this:

Doomed Transactions and XACT_STATE()

Some errors do not merely fail a statement, they poison the transaction. After such an error the transaction is still open but can no longer be committed and can no longer be rolled back to a savepoint. The only legal move left is a full rollback. SQL Server calls this state uncommittable, everyone else calls it doomed.

XACT_STATE() is how you ask:

ValueMeaningWhat you may do
1An open transaction that is still healthyCommit, full rollback, or rollback to a savepoint
0No open transaction on this sessionNothing. A COMMIT here raises 3902, a ROLLBACK raises 3903
-1Open but uncommittable (doomed)Full ROLLBACK TRANSACTION only. A savepoint rollback raises 3931

Note what @@TRANCOUNT does not tell you here. It reports depth, not health. A doomed transaction at depth 3 still shows @@TRANCOUNT = 3, which looks entirely normal right up to the moment the commit fails.

The XACT_ABORT trade-off nobody mentions SET XACT_ABORT ON is good advice in general, and it is the direct enemy of savepoint-based error handling. With it on, most run-time errors doom the transaction, so by the time your CATCH block runs XACT_STATE() is -1 and ROLLBACK TRANSACTION savepoint_name fails with Msg 3931. You can have automatic, predictable abort behaviour, or you can have partial rollback. Choose per procedure, deliberately, and write down which one you chose.

The Procedure Pattern That Survives Both Cases

A stored procedure that changes data cannot know whether it was called standalone or from inside someone else's transaction. That is the entire problem, and it has one well-established answer: read @@TRANCOUNT on entry and behave according to what you find.

If the counter is 0, you own the transaction: begin it and commit it. If the counter is greater than 0, the caller owns it: do not begin, do not commit, set a savepoint instead, and on error undo only back to that savepoint. The procedure must return with exactly the @@TRANCOUNT it was called with, otherwise error 266 is waiting on the way out.

CREATE OR ALTER PROCEDURE dbo.usp_TransferFunds
    @FromAccount INT,
    @ToAccount   INT,
    @Amount      DECIMAL(19,4)
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @OuterTran INT = @@TRANCOUNT;

    IF @OuterTran > 0
        SAVE TRANSACTION TransferFunds;   -- the caller owns the transaction
    ELSE
        BEGIN TRANSACTION;                -- we own it

    BEGIN TRY

        UPDATE dbo.Account
           SET Balance = Balance - @Amount
         WHERE AccountId = @FromAccount;

        IF @@ROWCOUNT <> 1
            THROW 50001, 'Source account not found.', 1;

        UPDATE dbo.Account
           SET Balance = Balance + @Amount
         WHERE AccountId = @ToAccount;

        IF @@ROWCOUNT <> 1
            THROW 50002, 'Target account not found.', 1;

        IF @OuterTran = 0
            COMMIT TRANSACTION;           -- commit only what we started

    END TRY
    BEGIN CATCH

        IF XACT_STATE() = -1
            ROLLBACK TRANSACTION;                 -- doomed, nothing else is legal
        ELSE IF XACT_STATE() = 1 AND @OuterTran = 0
            ROLLBACK TRANSACTION;                 -- our transaction, ours to end
        ELSE IF XACT_STATE() = 1 AND @OuterTran > 0
            ROLLBACK TRANSACTION TransferFunds;   -- the caller's transaction, undo our part only

        THROW;   -- the caller decides what happens next

    END CATCH
END

Read that CATCH block as a decision, not as a sequence. There are exactly four situations it can be in, and only one of them allows the savepoint:

Figure 4 · What a CATCH block is allowed to do
error lands in CATCH XACT_STATE() -1 0 1 doomed ROLLBACK TRANSACTION a savepoint here raises 3931 nothing left open do nothing a rollback here raises 3903 still committable who started it? @@TRANCOUNT read on entry entry count was 0 we own it ROLLBACK TRANSACTION entry count > 0 the caller owns it ROLLBACK TRAN <save> THROW
Every path ends in THROW. A procedure that swallows the error after rolling back is how a caller ends up committing a transaction whose middle third quietly disappeared.
One design rule worth more than the whole pattern Decide, per call chain, who owns the transaction, and let only that layer begin and commit it. Procedures in the middle keep their own consistency with savepoints and report failures upward. Transaction control belongs at the top of the stack, close to the business operation, not scattered across every helper procedure that happens to write a row.

Triggers Are Always Nested

Inside a trigger, @@TRANCOUNT is never 0. Every DML statement runs in a transaction, either the explicit one you opened or the implicit one SQL Server created for the statement, so trigger code is by definition running at depth 1 or deeper. That makes ROLLBACK TRANSACTION inside a trigger the most destructive statement in the whole topic:

CREATE OR ALTER TRIGGER dbo.trg_Account_NoNegative
ON dbo.Account
AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;

    IF EXISTS (SELECT 1 FROM inserted WHERE Balance < 0)
    BEGIN
        ROLLBACK TRANSACTION;   -- takes the caller's entire transaction with it
        THROW 50010, 'Balance would go negative.', 1;
    END
END

That rollback does not undo just the offending UPDATE. It ends the whole transaction and aborts the rest of the batch, so statements queued after the UPDATE never run. A trigger that only rolls back reports it as Msg 3609, The transaction ended in the trigger. The batch has been aborted. Add a THROW after the rollback, as above, and the caller sees your error number instead, which hides the fact that a transaction was destroyed behind a message about a negative balance.

In almost every case the better trigger raises the error and lets the caller decide:

    IF EXISTS (SELECT 1 FROM inserted WHERE Balance < 0)
        THROW 50010, 'Balance would go negative.', 1;

With SET XACT_ABORT ON in the calling code, or a CATCH block that handles the failure, the outcome is the same rollback, but it happens where someone is in a position to log it, compensate for it and report it. And if the rule really is "this column may never go negative", a CHECK constraint enforces it for a fraction of the cost and none of this complexity.

What an Open Transaction Costs While You Are Nesting

Because the inner commits are bookkeeping, the transaction stays open the whole time. Everything that depends on transaction lifetime therefore depends on the outermost commit, not on the inner ones:

The first point is easy to see for yourself. Count the locks a session holds before and after an inner commit:

BEGIN TRANSACTION;
    UPDATE dbo.Account SET Balance = 123 WHERE AccountId = 2;
    BEGIN TRANSACTION;
        UPDATE dbo.Account SET Balance = 124 WHERE AccountId = 1;

        SELECT COUNT(*) FROM sys.dm_tran_locks
         WHERE request_session_id = @@SPID
           AND resource_type <> 'DATABASE';        -- 4

    COMMIT TRANSACTION;                             -- the "inner" commit

        SELECT COUNT(*) FROM sys.dm_tran_locks
         WHERE request_session_id = @@SPID
           AND resource_type <> 'DATABASE';        -- 4, unchanged

COMMIT TRANSACTION;

        SELECT COUNT(*) FROM sys.dm_tran_locks
         WHERE request_session_id = @@SPID
           AND resource_type <> 'DATABASE';        -- 0

Four locks before the inner commit, four after it, zero after the outer one. The inner COMMIT did not release a single lock, because there was nothing there to release.

The distinctive failure mode here is an application that sent a BEGIN TRANSACTION, then a second one from a nested method, and then died or wandered off. The session sits idle, @@TRANCOUNT is 2, nothing is executing and nothing is being released. From the outside it looks like a healthy sleeping connection.

This query finds those sessions, including their transaction depth, which is the outside view of another session's @@TRANCOUNT:

SELECT  s.session_id,
        s.login_name,
        s.host_name,
        s.program_name,
        s.open_transaction_count,
        s.status                                AS session_status,
        t.transaction_begin_time,
        DATEDIFF(SECOND, t.transaction_begin_time,
                 SYSDATETIME())                 AS tran_age_sec,
        c.last_read,
        c.last_write
FROM        sys.dm_tran_session_transactions st
JOIN        sys.dm_tran_active_transactions   t  ON t.transaction_id = st.transaction_id
JOIN        sys.dm_exec_sessions              s  ON s.session_id     = st.session_id
LEFT JOIN   sys.dm_exec_connections           c  ON c.session_id     = st.session_id
WHERE       s.is_user_process = 1
ORDER BY    t.transaction_begin_time;

Two numbers decide what you are looking at. An open_transaction_count above 1 tells you the session nested its transactions. A tran_age_sec in the thousands next to a last_read that is just as old tells you nobody is coming back to finish it. DBCC OPENTRAN gives the short version for a single database, but it will not show you the depth.

What the Client Side Does With All This

Application frameworks do not rescue you from any of it, they mostly refuse to play along. In ADO.NET, calling BeginTransaction() a second time on the same SqlConnection does not nest anything, it throws: SqlConnection does not support parallel transactions. TransactionScope with the default Required option does not create an inner transaction either, it enlists into the ambient one that already exists, which is the correct behaviour and also the reason a rollback in an inner scope aborts the entire outer operation.

The practical consequence is the same rule as on the T-SQL side, expressed one layer up. One logical operation, one transaction, opened and closed at one place in the code. Everything below that layer participates, nothing below it commits.

Q&A

Q: If nested transactions do nothing, why does SQL Server allow the syntax at all? So that procedures written independently can be composed without every one of them failing at the BEGIN. The counter makes the statement legal. It was never meant as a promise that the inner transaction is a real one, and the ANSI standard does not require nesting either.
Q: My procedure committed, the caller rolled back, and my data is gone. Is that a bug? That is the documented behaviour. Your COMMIT ran at a depth above 1, so it only decremented the counter. Nothing became durable until the caller's outermost commit, and the caller chose not to issue one.
Q: Can I just put IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION in every CATCH block? It prevents error 3903, and it is exactly the destructive rollback described above whenever your procedure was called from inside someone else's transaction. Check the entry count as well, then decide between a full rollback and a savepoint rollback.
Q: Is there any way to make an inner block commit independently? Not within one session. That requires a genuinely separate transaction, which means a separate connection: a loopback call, a second connection from the application, or a queued message via Service Broker. That is an architectural decision with its own failure modes, not a substitute for BEGIN TRANSACTION.
Q: How deep can @@TRANCOUNT go? Far enough that the limit is never your problem. A depth beyond two or three is a design smell long before it is a technical limit: it means no layer in the call chain knows who owns the transaction.

The Bottom Line

SQL Server has one transaction per session and a counter that tracks how many times you asked for one. Commits walk that counter down and only the last one means anything. A rollback ignores the counter entirely and ends everything. Savepoints are the only real partial undo, they do not release locks, and they stop working the moment the transaction is doomed.

Everything else follows from those four facts. Read @@TRANCOUNT when your procedure starts, never issue a bare ROLLBACK for a transaction you did not open, keep transaction control at the top of the call chain, and treat error 266 as a message that something below you already broke the rules.

Related reading Transactions: Implicit vs. Explicit for how transactions get started in the first place. SET IMPLICIT_TRANSACTIONS ON: Why This Setting is a Bad Idea for the setting that opens transactions you never asked for. The Real Fix for an Orphaned Transaction That Fills TempDB for what a forgotten open transaction costs. THROW and the Semicolon for the way error handling fails silently. SQL Server Isolation Levels Explained for what those held locks are actually protecting. Log Truncation vs. Log Shrinking for why the log will not shrink while one is open.