The 30-Second Check
Before you touch any code, run this on the connection that's throwing the error:
SELECT @@TRANCOUNT;If it returns 0, you're not inside a transaction at all. That means something is trying to commit or rollback a transaction that doesn't exist. If it returns a number greater than 1, you've got nested transactions – and that's where the real trouble starts.
What's actually happening here is that SQL Server tracks transaction state per session. The error 0X00001A2E (which maps to SQL Server error 3930) fires when you call COMMIT or ROLLBACK on a transaction that's already been finalized. The most common trigger I see in the wild: a stored procedure that does a COMMIT, then later tries to do another COMMIT because the caller didn't know the SP already committed.
If you spot it right here, the quick fix is to comment out the second COMMIT or ROLLBACK. But don't stop there – you need to understand why it happened, or it'll come back.
The 5-Minute Fix: Check for COMMIT/ROLLBACK in Triggers
Triggers are the usual suspects. Here's the scenario: you have an AFTER INSERT trigger that does a ROLLBACK when validation fails. That's fine. But if the trigger also does a COMMIT – or if it doesn't explicitly rollback but the outer transaction gets rolled back by a nested trigger – you end up with a state where the transaction is already gone.
Consider this:
CREATE TRIGGER trg_CheckOrder
ON Orders
AFTER INSERT
AS
BEGIN
IF EXISTS (SELECT 1 FROM inserted WHERE Total < 0)
BEGIN
ROLLBACK;
RETURN;
END
END;If this trigger fires inside an explicit transaction, the ROLLBACK undoes the whole transaction, but the outer code still thinks it's in one. The next COMMIT in the outer SP throws 0X00001A2E.
Fix: Remove the ROLLBACK from the trigger. Use THROW to raise an error instead, and let the caller decide whether to rollback. Or, if you must rollback, use a savepoint and rollback to that savepoint, not the whole transaction. Here's how:
CREATE TRIGGER trg_CheckOrder
ON Orders
AFTER INSERT
AS
BEGIN
IF EXISTS (SELECT 1 FROM inserted WHERE Total < 0)
BEGIN
THROW 51000, 'Order total cannot be negative.', 1;
END
END;Then the caller's TRY/CATCH handles the rollback. That's cleaner and avoids the state mismatch.
Another quick win: search your codebase for COMMIT or ROLLBACK that aren't inside a TRY/CATCH. In a TRY block, if an error occurs, the transaction state gets messed up if you don't check XACT_STATE() before committing. Here's the pattern you should be using:
BEGIN TRANSACTION;
BEGIN TRY
-- do work
COMMIT;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0
ROLLBACK;
THROW;
END CATCH;Notice the XACT_STATE() check. That's the difference between a robust SP and one that randomly throws 0X00001A2E when an error occurs.
The 15-Minute Fix: Restructure Nested Transactions
If the quick checks didn't solve it, you're dealing with nested transactions. SQL Server doesn't really support nested transactions – it just counts them. Every BEGIN TRANSACTION increments @@TRANCOUNT, every COMMIT decrements it. The real COMMIT only happens when @@TRANCOUNT hits 0. ROLLBACK, however, rolls back everything and sets @@TRANCOUNT to 0.
The problem: if an inner SP does a ROLLBACK, it wipes out the outer transaction too. The outer code then tries to COMMIT or ROLLBACK again, and boom – invalid transaction object.
Here's a typical failing pattern:
CREATE PROCEDURE OuterSP
AS
BEGIN
BEGIN TRANSACTION;
EXEC InnerSP;
COMMIT; -- throws if InnerSP rolled back
END;
CREATE PROCEDURE InnerSP
AS
BEGIN
BEGIN TRANSACTION;
-- something bad happens
ROLLBACK;
END;The fix is to make the inner SP transaction-aware. Check if there's already a transaction before starting a new one:
CREATE PROCEDURE InnerSP
AS
BEGIN
DECLARE @wasInTransaction BIT = CASE WHEN @@TRANCOUNT > 0 THEN 1 ELSE 0 END;
IF @wasInTransaction = 0
BEGIN TRANSACTION;
BEGIN TRY
-- do work
IF @wasInTransaction = 0
COMMIT;
END TRY
BEGIN CATCH
IF @wasInTransaction = 0
ROLLBACK;
THROW;
END CATCH;
END;This way, the inner SP doesn't mess with an outer transaction. If there's an error, it throws and lets the outer SP handle the rollback. If there's no outer transaction, it manages its own.
But what if the inner SP absolutely needs to rollback on error? In that case, don't use ROLLBACK – use SAVE TRANSACTION with a named savepoint:
CREATE PROCEDURE InnerSP
AS
BEGIN
DECLARE @savepointName NVARCHAR(32) = 'SP_' + CAST(@@NESTLEVEL AS NVARCHAR);
SAVE TRANSACTION @savepointName;
BEGIN TRY
-- do work that might fail
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION @savepointName;
THROW;
END CATCH;
END;This rolls back only the inner work, not the outer transaction. The outer SP can continue and commit normally.
One more thing to check: application code. If you're using ADO.NET or JDBC and you manage transactions at the app level, make sure you're not committing or rolling back the same SqlTransaction object twice. In .NET, that's a common cause – you call Commit() and then the Dispose() method tries to rollback. A lot of developers miss that.
Let's Be Opinionated
Skip the fancy ORM transaction wrappers that hide state. They're a nightmare when they leak. Directly control your transactions with the patterns above, and test with a script that deliberately triggers errors to see how your code behaves.
Also, don't ignore XACT_STATE(). I've seen plenty of code that checks @@TRANCOUNT before committing, but that's not enough. Only XACT_STATE() tells you if the transaction is committable. If it returns -1, the transaction is doomed – you must rollback.
Finally, log the actual error details. Add a CATCH block that captures ERROR_NUMBER(), ERROR_STATE(), and ERROR_LINE(). That'll tell you exactly where the invalid operation happens, saving you the guesswork next time.
The root cause is almost always a mismatch between what your code thinks the transaction state is and what SQL Server actually has. Get the state right, and 0X00001A2E becomes a thing of the past.