Understanding the Error

The error message:

Transaction count after EXECUTE indicates a mismatching number of
BEGIN and COMMIT statements. Previous count = 0, current count = 1.

This error occurs when SQL Server detects that the transaction nesting level (@@TRANCOUNT) changed as a result of executing a stored procedure. Before the EXECUTE call, @@TRANCOUNT was 0 (no active transaction). After the call, it was 1, meaning the stored procedure opened a transaction but never committed or rolled it back.

SQL Server tracks the transaction nesting level and expects it to be the same before and after a stored procedure call. When the levels do not match, it raises this error to warn you that there is an orphaned transaction that could cause locks, data inconsistency, or connection pool issues.

What Causes This

The Fundamental Problem

The root cause is a stored procedure that contains a BEGIN TRANSACTION without a corresponding COMMIT or ROLLBACK on every possible execution path.

Example: Missing ROLLBACK in Error Handling

-- BAD: This stored procedure will cause the transaction count mismatch
CREATE PROCEDURE dbo.InsertOrder
    @CustomerId INT,
    @ProductId INT,
    @Quantity INT
AS
BEGIN
    BEGIN TRANSACTION

    INSERT INTO Orders (CustomerId, ProductId, Quantity, OrderDate)
    VALUES (@CustomerId, @ProductId, @Quantity, GETDATE())

    -- If this UPDATE fails, the CATCH block does not roll back
    UPDATE Inventory
    SET StockCount = StockCount - @Quantity
    WHERE ProductId = @ProductId

    COMMIT TRANSACTION
END

If the UPDATE statement fails (for example, a check constraint violation), the transaction is left open because there is no error handling.

Example: TRY/CATCH Without ROLLBACK

-- BAD: CATCH block does not roll back the transaction
CREATE PROCEDURE dbo.TransferFunds
    @FromAccount INT,
    @ToAccount INT,
    @Amount DECIMAL(18,2)
AS
BEGIN
    BEGIN TRY
        BEGIN TRANSACTION

        UPDATE Accounts SET Balance = Balance - @Amount WHERE AccountId = @FromAccount
        UPDATE Accounts SET Balance = Balance + @Amount WHERE AccountId = @ToAccount

        COMMIT TRANSACTION
    END TRY
    BEGIN CATCH
        -- ERROR: No ROLLBACK here!
        -- The transaction is left open
        DECLARE @ErrorMessage NVARCHAR(4000) = ERROR_MESSAGE()
        RAISERROR(@ErrorMessage, 16, 1)
    END CATCH
END

When the calling .NET code executes this procedure and an error occurs in the TRY block, the CATCH block raises the error but does not roll back the transaction. The caller then sees the transaction count mismatch.

The Correct Pattern for Transactions in Stored Procedures

Pattern 1: Basic TRY/CATCH with XACT_ABORT

This is the simplest and most reliable pattern:

CREATE PROCEDURE dbo.InsertOrder
    @CustomerId INT,
    @ProductId INT,
    @Quantity INT
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;  -- Auto-rollback on errors

    BEGIN TRY
        BEGIN TRANSACTION

        INSERT INTO Orders (CustomerId, ProductId, Quantity, OrderDate)
        VALUES (@CustomerId, @ProductId, @Quantity, GETDATE())

        UPDATE Inventory
        SET StockCount = StockCount - @Quantity
        WHERE ProductId = @ProductId

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

        -- Re-throw the error
        DECLARE @ErrorMessage NVARCHAR(4000) = ERROR_MESSAGE();
        DECLARE @ErrorSeverity INT = ERROR_SEVERITY();
        DECLARE @ErrorState INT = ERROR_STATE();
        RAISERROR(@ErrorMessage, @ErrorSeverity, @ErrorState);
    END CATCH
END

Key points:

  • SET XACT_ABORT ON ensures the transaction is rolled back even if an error bypasses the CATCH block
  • The CATCH block checks @@TRANCOUNT > 0 before rolling back to avoid errors from double-rollback
  • The error is re-raised so the calling application knows something went wrong

Pattern 2: Nested Transaction-Safe Pattern

This pattern works correctly whether the procedure is called standalone or from within an existing transaction:

CREATE PROCEDURE dbo.TransferFunds
    @FromAccount INT,
    @ToAccount INT,
    @Amount DECIMAL(18,2)
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    DECLARE @TranStarted BIT = 0;

    BEGIN TRY
        -- Only start a transaction if one is not already active
        IF @@TRANCOUNT = 0
        BEGIN
            BEGIN TRANSACTION;
            SET @TranStarted = 1;
        END

        UPDATE Accounts SET Balance = Balance - @Amount WHERE AccountId = @FromAccount;

        IF @@ROWCOUNT = 0
            RAISERROR('Source account not found.', 16, 1);

        UPDATE Accounts SET Balance = Balance + @Amount WHERE AccountId = @ToAccount;

        IF @@ROWCOUNT = 0
            RAISERROR('Destination account not found.', 16, 1);

        -- Only commit if we started the transaction
        IF @TranStarted = 1
            COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        -- Only rollback if we started the transaction
        IF @TranStarted = 1 AND @@TRANCOUNT > 0
            ROLLBACK TRANSACTION;

        DECLARE @ErrorMessage NVARCHAR(4000) = ERROR_MESSAGE();
        DECLARE @ErrorSeverity INT = ERROR_SEVERITY();
        DECLARE @ErrorState INT = ERROR_STATE();
        RAISERROR(@ErrorMessage, @ErrorSeverity, @ErrorState);
    END CATCH
END

Pattern 3: Using SAVE TRANSACTION for Nested Calls

If you need partial rollback support within nested procedure calls:

CREATE PROCEDURE dbo.ProcessOrderItem
    @OrderId INT,
    @ProductId INT,
    @Quantity INT
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    DECLARE @SavePoint NVARCHAR(32) = 'ProcessOrderItem';
    DECLARE @TranStarted BIT = 0;

    BEGIN TRY
        IF @@TRANCOUNT = 0
        BEGIN
            BEGIN TRANSACTION;
            SET @TranStarted = 1;
        END
        ELSE
        BEGIN
            SAVE TRANSACTION @SavePoint;
        END

        -- Do work here
        INSERT INTO OrderItems (OrderId, ProductId, Quantity)
        VALUES (@OrderId, @ProductId, @Quantity);

        UPDATE Inventory SET StockCount = StockCount - @Quantity
        WHERE ProductId = @ProductId;

        IF @TranStarted = 1
            COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF @TranStarted = 1 AND @@TRANCOUNT > 0
            ROLLBACK TRANSACTION;
        ELSE IF @@TRANCOUNT > 0
            ROLLBACK TRANSACTION @SavePoint;

        DECLARE @ErrorMessage NVARCHAR(4000) = ERROR_MESSAGE();
        DECLARE @ErrorSeverity INT = ERROR_SEVERITY();
        DECLARE @ErrorState INT = ERROR_STATE();
        RAISERROR(@ErrorMessage, @ErrorSeverity, @ErrorState);
    END CATCH
END

Debugging with @@TRANCOUNT

When diagnosing this error, @@TRANCOUNT is your primary tool. Add diagnostic output to identify where the transaction level goes out of sync:

-- Debug version to find the mismatch
CREATE PROCEDURE dbo.DebugTransaction
AS
BEGIN
    PRINT 'Entry @@TRANCOUNT: ' + CAST(@@TRANCOUNT AS VARCHAR(10))

    BEGIN TRANSACTION
    PRINT 'After BEGIN TRAN: ' + CAST(@@TRANCOUNT AS VARCHAR(10))

    -- Simulate work
    SELECT 1

    -- Uncomment one to test:
    -- COMMIT TRANSACTION
    -- ROLLBACK TRANSACTION

    PRINT 'Exit @@TRANCOUNT: ' + CAST(@@TRANCOUNT AS VARCHAR(10))
END

In your C# code, you can also check the transaction count:

using (SqlConnection conn = new SqlConnection(connectionString))
{
    conn.Open();

    // Check @@TRANCOUNT before
    using (SqlCommand checkCmd = new SqlCommand("SELECT @@TRANCOUNT", conn))
    {
        int tranCountBefore = (int)checkCmd.ExecuteScalar();
        Console.WriteLine($"@@TRANCOUNT before: {tranCountBefore}");
    }

    // Execute the stored procedure
    using (SqlCommand cmd = new SqlCommand("dbo.InsertOrder", conn))
    {
        cmd.CommandType = CommandType.StoredProcedure;
        cmd.Parameters.AddWithValue("@CustomerId", 1);
        cmd.Parameters.AddWithValue("@ProductId", 100);
        cmd.Parameters.AddWithValue("@Quantity", 5);

        try
        {
            cmd.ExecuteNonQuery();
        }
        catch (SqlException ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
        }
    }

    // Check @@TRANCOUNT after
    using (SqlCommand checkCmd = new SqlCommand("SELECT @@TRANCOUNT", conn))
    {
        int tranCountAfter = (int)checkCmd.ExecuteScalar();
        Console.WriteLine($"@@TRANCOUNT after: {tranCountAfter}");
    }
}

Understanding XACT_ABORT

SET XACT_ABORT ON is a critical setting for transaction safety.

Without XACT_ABORT (Default OFF)

When XACT_ABORT is OFF (the default), most errors only abort the individual statement that failed, leaving the transaction open. This means:

  • The transaction remains active after the error
  • Subsequent statements in the batch continue to execute within the open transaction
  • If the error jumps to a CATCH block, you must explicitly ROLLBACK

With XACT_ABORT ON

When XACT_ABORT is ON:

  • Any runtime error automatically rolls back the entire transaction
  • The batch is aborted (execution jumps to the CATCH block)
  • The transaction is guaranteed to be in a rolled-back state in the CATCH block
-- Demonstration of XACT_ABORT behavior
SET XACT_ABORT ON;

BEGIN TRY
    BEGIN TRANSACTION;

    INSERT INTO Table1 (Col1) VALUES ('A');  -- Succeeds
    INSERT INTO Table1 (Col1) VALUES (NULL); -- Fails (NOT NULL constraint)
    INSERT INTO Table1 (Col1) VALUES ('B');  -- Never reached

    COMMIT TRANSACTION;
END TRY
BEGIN CATCH
    -- With XACT_ABORT ON, the transaction is already rolled back
    -- but @@TRANCOUNT may be > 0, so check before rolling back
    IF @@TRANCOUNT > 0
        ROLLBACK TRANSACTION;

    PRINT 'Error: ' + ERROR_MESSAGE();
END CATCH

Handling Transactions in C# / .NET

Pattern: Let the Stored Procedure Handle Transactions

The cleanest approach is to let the stored procedure manage its own transactions:

public void InsertOrder(int customerId, int productId, int quantity)
{
    using (SqlConnection conn = new SqlConnection(_connectionString))
    {
        conn.Open();
        using (SqlCommand cmd = new SqlCommand("dbo.InsertOrder", conn))
        {
            cmd.CommandType = CommandType.StoredProcedure;
            cmd.Parameters.AddWithValue("@CustomerId", customerId);
            cmd.Parameters.AddWithValue("@ProductId", productId);
            cmd.Parameters.AddWithValue("@Quantity", quantity);

            cmd.ExecuteNonQuery();  // Procedure handles its own transaction
        }
    }
}

Pattern: C# TransactionScope with Stored Procedures

If you need to coordinate multiple procedure calls in a single transaction:

public void ProcessOrder(int customerId, List<OrderItem> items)
{
    using (TransactionScope scope = new TransactionScope())
    using (SqlConnection conn = new SqlConnection(_connectionString))
    {
        conn.Open();

        foreach (var item in items)
        {
            using (SqlCommand cmd = new SqlCommand("dbo.InsertOrderItem", conn))
            {
                cmd.CommandType = CommandType.StoredProcedure;
                cmd.Parameters.AddWithValue("@CustomerId", customerId);
                cmd.Parameters.AddWithValue("@ProductId", item.ProductId);
                cmd.Parameters.AddWithValue("@Quantity", item.Quantity);
                cmd.ExecuteNonQuery();
            }
        }

        scope.Complete();  // Commits only if all succeed
    }
    // If scope.Complete() was not called, everything is rolled back
}

Important: When using TransactionScope in C#, the stored procedure should not start its own transaction with BEGIN TRANSACTION, as this creates a nested transaction. Use the nested-safe pattern (Pattern 2 above) or remove transaction management from the procedure.

Common Mistakes to Avoid

  1. Missing ROLLBACK in CATCH — Always roll back in the CATCH block
  2. Not checking @@TRANCOUNT before ROLLBACK — Avoid errors from rolling back a non-existent transaction
  3. Forgetting SET XACT_ABORT ON — Without it, some errors do not reach the CATCH block
  4. Double-managing transactions — Do not use both C# TransactionScope and T-SQL BEGIN TRANSACTION unless the procedure is designed for it
  5. Using RETURN without ROLLBACK — A RETURN statement exits the procedure but does not roll back an open transaction

Summary

The “Transaction count after EXECUTE” error means a stored procedure opened a transaction but did not close it on all code paths. Fix it by using SET XACT_ABORT ON in every stored procedure that uses transactions, always including a ROLLBACK in the CATCH block, and checking @@TRANCOUNT > 0 before committing or rolling back. For procedures that may be called from within existing transactions, use the nested-safe pattern that only begins a transaction when @@TRANCOUNT is 0.