XACT_STATE() returns -1), then the code tried to write or commit anyway, usually an error-logging INSERT in a CATCH block. Find the first error, then fix the CATCH: save ERROR_NUMBER() and ERROR_MESSAGE(), ROLLBACK when XACT_STATE() <> 0, log after the rollback, and THROW.A procedure or a trigger fails with Msg 3930 and the application log shows nothing else. The message tells you to roll back, but it does not tell you what went wrong, because the error that caused it was caught and swallowed a few lines earlier.
What Error 3930 Means
An error inside a transaction can leave it in one of two states. Some errors only fail the statement and the transaction carries on. Others doom it: the transaction stays open, holds its locks, and can do nothing except roll back. Any statement that writes to the log after that point, including COMMIT, gets Msg 3930. XACT_STATE() tells you which state you are in:
| XACT_STATE() | What it means |
|---|---|
| 1 | Open and committable. The failed statement was undone, the rest of the transaction is intact, and you can still write, commit or roll back. |
| 0 | No transaction open. Nothing to commit or roll back. |
| -1 | Open and doomed. Reads still work; any write or COMMIT raises 3930. The only way out is ROLLBACK. |
@@TRANCOUNT cannot tell 1 from -1; it returns the same nonzero count for both (1 in this repro, more inside nested transactions), which is why code that checks IF @@TRANCOUNT > 0 walks straight into 3930. This is the smallest repro: XACT_ABORT on, a foreign key violation, and a CATCH block that logs the error to a table.
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
INSERT dbo.Orders (CustomerId, Amount) VALUES (99, 10); -- customer 99 does not exist
COMMIT;
END TRY
BEGIN CATCH
SELECT XACT_STATE() AS xact_state, @@TRANCOUNT AS trancount, ERROR_NUMBER() AS error_number;
INSERT dbo.ErrorLog (ErrNo, ErrMsg) VALUES (ERROR_NUMBER(), ERROR_MESSAGE());
END CATCH;
PRINT 'after catch';
Three things in that output. The real error was 547, and the client never saw it. The log row was never written, so the error table is empty too. And after catch never printed, because with XACT_ABORT on the 3930 ended the batch, which rolled the transaction back.
Which Errors Doom a Transaction
The same foreign key violation does not always doom the transaction. It depends on XACT_ABORT and on the error. Captured on the lab:
| The first error | XACT_STATE() in the CATCH block |
|---|---|
547 foreign key, XACT_ABORT ON | -1, doomed. With XACT_ABORT on, nearly every error dooms the transaction. |
547 foreign key, XACT_ABORT OFF | 1, committable. Only the statement failed; the CATCH could still write. |
245 conversion, XACT_ABORT OFF | -1, doomed anyway. Conversion failures (Error 245) end the transaction whatever the setting, so inside TRY the error is caught and the transaction is left doomed. |
THROW in an AFTER trigger, XACT_ABORT OFF in the caller | -1, doomed. Triggers run with XACT_ABORT on by default, so an error in a trigger dooms the caller’s transaction. |
| 1205 deadlock victim | -1, doomed, with @@TRANCOUNT still 1. The victim’s CATCH caught the 1205 with XACT_STATE() at -1 whether its XACT_ABORT was on or off, so the transaction is still open and can only be rolled back. Roll back before you retry. See Reading and Fixing a SQL Server Deadlock. |
That is the common production shape: someone adds a validation trigger, and a procedure that has worked for years starts failing with 3930 instead of the trigger’s message. MS Docs lists the rules in SET XACT_ABORT.
Find the Error That Was Swallowed
Start with the cheap places. In SSMS, scroll the Messages tab above the 3930; if the CATCH selected or printed anything, the real error number is there. In the procedure, read the CATCH block: a write to a log table before the rollback is the usual culprit. If the error is intermittent or comes from an application, catch it as it happens with an Extended Events session on error_reported, filtered to the database:
CREATE EVENT SESSION find_first_error ON SERVER
ADD EVENT sqlserver.error_reported (
ACTION (sqlserver.session_id, sqlserver.sql_text, sqlserver.database_name)
WHERE severity >= 11 AND sqlserver.database_name = N'YourDb')
ADD TARGET package0.ring_buffer;
ALTER EVENT SESSION find_first_error ON SERVER STATE = START;
-- reproduce the failure, then read the buffer:
SELECT
x.e.value('(@timestamp)[1]', 'datetime2(0)') AS event_time,
x.e.value('(data[@name="error_number"]/value)[1]', 'int') AS error_number,
x.e.value('(data[@name="message"]/value)[1]', 'nvarchar(300)') AS message
FROM (SELECT CAST(t.target_data AS xml) AS td
FROM sys.dm_xe_session_targets t
JOIN sys.dm_xe_sessions s ON s.address = t.event_session_address
WHERE s.name = 'find_first_error' AND t.target_name = 'ring_buffer') r
CROSS APPLY r.td.nodes('RingBufferTarget/event') x(e)
ORDER BY event_time;
-- when you have it:
ALTER EVENT SESSION find_first_error ON SERVER STATE = STOP;
DROP EVENT SESSION find_first_error ON SERVER;
The row before the 3930 is the one you want. Two things that caught this on the lab: the XML query needs QUOTED_IDENTIFIER ON or it fails with Error 1934 (sqlcmd defaults it off; add -I), and on a server short of memory the XML shredding can sit waiting for a memory grant. Stop and drop the session when you are done. Extended Events Tracing has more sessions in the same style.
The CATCH Block That Causes It
This is the pattern behind most 3930 tickets. It looks careful: it logs the error, rolls back, and re-raises.
CREATE OR ALTER PROCEDURE dbo.AddOrder_Bad @CustomerId int, @Amount int
AS
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
INSERT dbo.Orders (CustomerId, Amount) VALUES (@CustomerId, @Amount);
COMMIT;
END TRY
BEGIN CATCH
INSERT dbo.ErrorLog (ErrNo, ErrMsg) VALUES (ERROR_NUMBER(), ERROR_MESSAGE()); -- write while doomed
IF @@TRANCOUNT > 0 ROLLBACK;
THROW;
END CATCH;
The INSERT into the log table raised 3930, which ended the batch, so the ROLLBACK and the THROW never ran. The caller got 3930 instead of 547, and the error table that was meant to record the failure has nothing in it. The ordering is the whole bug.
The CATCH Pattern That Works
Save the error details into variables first, roll back if there is anything to roll back, write the log row outside the transaction, then re-raise the original:
CREATE OR ALTER PROCEDURE dbo.AddOrder_Good @CustomerId int, @Amount int
AS
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
INSERT dbo.Orders (CustomerId, Amount) VALUES (@CustomerId, @Amount);
COMMIT;
END TRY
BEGIN CATCH
DECLARE @ErrNo int = ERROR_NUMBER(), @ErrMsg nvarchar(4000) = ERROR_MESSAGE();
IF XACT_STATE() <> 0 ROLLBACK; -- covers both 1 and -1
INSERT dbo.ErrorLog (ErrNo, ErrMsg) VALUES (@ErrNo, @ErrMsg); -- now outside the transaction
THROW; -- the original error, number and line
END CATCH;
The caller now gets the real error with the line that raised it, the log row survives, and no transaction is left open. Two details matter:
- Log after the rollback, not before. Even when the transaction is still committable (
XACT_STATE() = 1), a log row written before theROLLBACKis rolled back with it. On the lab, theXACT_ABORT OFFversion logged the 547 and then rolled back, and the error table came out with 0 rows. - Use
THROW, notRAISERRORwith a new message. A bareTHROW;in theCATCHre-raises the original error number, so monitoring and the application see 547, not a generic 50000.
If you deliberately keep partial work when the state is 1, test XACT_STATE() = 1 before the COMMIT and roll back on -1; never commit on @@TRANCOUNT alone. MS Docs: XACT_STATE and TRY…CATCH.
Errors 3998 and 266 That Come With It
Two other messages travel with 3930 and point at the same problem.
3998 is raised when a batch ends with a doomed transaction still open, and SQL Server rolls it back for you. A CATCH that swallows the error and carries on produces 3998 with no 3930 at all:
SET XACT_ABORT OFF;
BEGIN TRY
BEGIN TRANSACTION;
INSERT dbo.Orders (CustomerId, Amount) VALUES (1, CAST('ten' AS int));
COMMIT;
END TRY
BEGIN CATCH
PRINT 'swallowed';
END CATCH;
SELECT 1 AS still_running;
Everything the batch did after the swallowed error ran inside a transaction that was always going to roll back. That is the same family as the problems in Silent Failures.
266 is raised when a procedure returns with a different transaction count from the one it started with, typically a nested procedure that rolls back the caller’s transaction and returns quietly:
0x10A is 266 in hex; SSMS shows it as Msg 266 (documented, not reproduced here). A ROLLBACK anywhere rolls back the outermost transaction, so the caller’s COMMIT then fails with 3902. The fix is the same pattern: the procedure that rolls back should THROW, so the caller’s own CATCH deals with it, rather than return quietly.
What Not To Do
- Do not take the message literally and add a ROLLBACK after the line that failed. The ROLLBACK has to come before any write in the
CATCH, or the batch ends before it is reached. - Do not check
@@TRANCOUNTto decide whether to commit. It is still nonzero for a doomed transaction. - Do not turn
XACT_ABORToff to make 3930 go away. It only changes which errors doom the transaction. Conversion and trigger errors still do, and with it off a statement can fail while the rest of the transaction commits. Keep it on and write theCATCHproperly. - Do not swallow errors in a
CATCH. If you are not going to handle it,THROWit.
Frequently Asked Questions
Is my data half written after a 3930?
XACT_ABORT was off, and with no extra message when it was on. Check @@TRANCOUNT in that session if you need to be sure nothing is still open.Does a doomed transaction still hold locks?
Why does it only happen sometimes?
Can I read data while XACT_STATE() is -1?
SELECT XACT_STATE() in the repro ran. Writes do not, so copy what you need into variables and write it after the rollback.Should SET XACT_ABORT be ON or OFF?
CATCH pattern above handles the rest.3930 is the symptom; these are the errors that usually cause it and the checks around it:
- The DELETE Statement Conflicted With a FOREIGN KEY Constraint (Error 547), the first error in the repro here.
- Conversion Failed When Converting the varchar Value to Data Type int (Error 245), the error that dooms a transaction even with XACT_ABORT off.
- Violation of PRIMARY KEY Constraint (Errors 2627 and 2601), another common first error behind 3930.
- Reading and Fixing a SQL Server Deadlock, when the first error is 1205.
- SQL Server Error Severities Explained, what Level 16 means and what TRY…CATCH can catch.
- Get Open Transactions, sessions still holding a transaction open.
Leave a Reply