3

Summary

I just discovered a surprising behavior of SQL Server: if a statement is executed outside of a TRY block, and it violates a constraint1, the execution will continue to the next statement.

I would have expected the execution to stop, like for an exception, but apparently constraint violations do not (always) behave like exceptions. However, if a statement is executed inside a TRY block, a constraint violation behaves like exception and stops the flow of execution before it reaches the next statement.

Test

Here is my test setup...

CREATE TABLE T (
    T_ID int,
    CONSTRAINT T_PK PRIMARY KEY (T_ID)
);

INSERT INTO T VALUES (1);

Below, the INSERT has violated the primary key, but the next statement is executed nonetheless ('next reached' is printed):

INSERT INTO T VALUES(1);
PRINT 'next reached';

Msg 2627, Level 14, State 1, Line 10
Violation of PRIMARY KEY constraint 'T_PK'. Cannot insert duplicate key in object 'dbo.T'. The duplicate key value is (1).
The statement has been terminated.
next reached

This is different from the "normal" exception ('next reached' is not printed):

THROW 50000, 'Some exception.', 0;
PRINT 'next reached';

Msg 50000, Level 16, State 0, Line 15
Some exception.

Curiously, if I put the INSERT into a TRY block, the constraint violation starts behaving like an exception (note how neither 'next reached' nor 'end reached' are printed):

BEGIN TRY
    INSERT INTO T VALUES(1);
    PRINT 'next reached';
END TRY
BEGIN CATCH
    THROW;
END CATCH
PRINT 'end reached'

(0 rows affected)
Msg 2627, Level 14, State 1, Line 21
Violation of PRIMARY KEY constraint 'T_PK'. Cannot insert duplicate key in object 'dbo.T'. The duplicate key value is (1).

Question

Is there a way to make the constraint violations behave like exceptions even when not enclosed within a TRY block?

Motivation: The above behavior is different from all programming languages supporting exceptions that I know of. When implementing complex business logic in stored procedures, using TRY everywhere would make the code less readable. More importantly, it would make it fragile: forgetting it somewhere, or relying on a caller to have it, and then somebody independently removing it from the caller, could easily make the stored procedure continue execution unexpectedly and potentially even corrupt the data.


1 I have tested PRIMARY KEY, UNIQUE, FOREIGN KEY and CHECK constraints, and they all seem to behave the same.

--- EDIT ---

For what it's worth, I have created a suggestion for Microsoft, but I'm not holding my breath that it will be implemented soon (or ever):

Consistent flow of execution with arithmetic errors and constraint violations

Branko Dimitrijevic
  • 50,809
  • 10
  • 93
  • 167
  • 1
    Likely you want `SET XACT_ABORT ON`, which'll abort and rollback the entire transaction on an error. – Thom A Feb 11 '22 at 12:10
  • Can it be done without `SET XACT_ABORT ON`? We want our clients to receive the exception and then decide whether to rollback or retry. We have many tests which setup the data once and then probe various exceptions (to make sure our business logic is protecting the database properly), all within the same transaction. – Branko Dimitrijevic Feb 11 '22 at 12:15
  • Yes, use `TRY...CATCH` and handle the error as you see fit; as you have done. – Thom A Feb 11 '22 at 12:16
  • *"The above behavior is different from all programming languages"* As a side note, SQL isn't a programming language, so you should not expect it to behave like one. – Thom A Feb 11 '22 at 12:19
  • Always use `SET XACT_ABORT ON;` there is almost no reason not to. See fiddle https://dbfiddle.uk/?rdbms=sqlserver_2019&fiddle=63225b9f63bdfc0aa807e460ff6025f3 – Charlieface Feb 11 '22 at 12:28
  • @Larnu So in other words, the `insert into` statement has been terminated by the violation, and the violation raised an exception, but then happily continued with the next statement, the print ? – GuidoG Feb 11 '22 at 12:29
  • 3
    @GuidoG Correct, it is *statement-aborting* not *batch-aborting* unless you use `XACT_ABORT` see https://www.sommarskog.se/error_handling/Part2.html#statementtermination – Charlieface Feb 11 '22 at 12:29
  • @Charlieface And is XACT_ABORT defined per scope or database-wide ? – GuidoG Feb 11 '22 at 12:31
  • 2
    @Larnu C'mon, this is not helpful. Besides, T-SQL (not SQL) is a procedural language and it has "exceptions" that are similar to (but not quite the same as) the exceptions in other languages. – Branko Dimitrijevic Feb 11 '22 at 12:32
  • @GuidoG Both: see https://stackoverflow.com/questions/43192298/why-does-sql-server-default-xact-abort-to-off-can-it-be-set-to-on-globally – Charlieface Feb 11 '22 at 12:33
  • Then don't state that the language doesn't act like other programming languages, @BrankoDimitrijevic . The fact you do implies you think it *is* a programming language and therefore expect it to act like one. Therefore if your misunderstanding is that you expect it to act like one it is helpful. If you know it isn't one, then saying it doesn't act like a programming language isn't relevant; you know it won't. – Thom A Feb 11 '22 at 12:40
  • @Lamu I said that T-SQL is a programming language. – Branko Dimitrijevic Feb 11 '22 at 12:53

1 Answers1

0

Addressing your "Motivation" part, I strongly disagree. There are many versions of what is called try-transaction block, but sooner or later one realizes it should be included in almost every procedure. It's not more than 20 lines of code, so not really that pain to read. Here's what I use:

set xact_abort on -- rollsback when try-catch doesn't work, eg on table not found errors
begin try
    begin transaction    -- start the transaction
    ...........................
    commit
end try
begin catch
    if @@trancount > 0 rollback
    declare @rethrow nvarchar(4000)='Error >> num '+convert(nvarchar(max),error_number())+', line '+convert(nvarchar(max),error_line())+', message: '+error_message()
    raiserror(@rethrow, 16, 0)
end catch

As always, I recommend the corresponding Erland Sommarskog's article, inside of which you will find a table detailing the exact behaviour for the various combinations of error types, xact_abort on/off, and using or not of try/catch.

You also mention you want to let your clients choose whether to "rollback or retry". I am unfamiliar with this, but it has an ominous feeling. What is "retry"? If you plan to do some manual change and then retry, don't forget your procedure will still be probably holding locks if its transaction is still open, leading to bad situations. Best way would be to make a loop, where the transaction will be rolled back, and retry means to just fire it up from the beginning.

George Menoutis
  • 6,894
  • 3
  • 19
  • 43
  • 2
    Why `raiserror` and not `THROW;`? `raiserror` will have the wrong error number. And if all you are doing is rolling back and re-throwing, why bother with a `TRY` `CATCH` at all, why not just use `SET XACT_ABORT ON;` which does exactly the same? – Charlieface Feb 11 '22 at 12:30
  • @Charlieface The combination of the hidden semicolon requirement of `throw` along with its limited print capabilities makes me prefer raiserror. I tried it for a time, and hated it. Also, this structure is more catch-all. Sometimes I don't rethrow. Sometimes I insert into a log table instead of rethrowing. Also, as seen in the table I mentioned, there are types of errors which will not rollback with only xact_abort, but will do so with also try-catch on. – George Menoutis Feb 11 '22 at 12:37
  • 1
    The documentation states "The RAISERROR statement does not honor SET XACT_ABORT. New applications should use THROW instead of RAISERROR." and " New applications should use THROW instead." – SMor Feb 11 '22 at 12:38
  • But `RAISERROR` doesn't support `XACT_ABORT` @GeorgeMenoutis , so why are you using the combination of both `RAISERROR` **and** `XACT_ABORT` when they don't work together? – Thom A Feb 11 '22 at 12:42
  • @SMor It might sound risky, but I trust more a multiple-page article of an MS MVP with solid gold articles rather than a 20-word bolded sentence, with no further explanation at all. My guess for this nonexplained incompatibility is they do not work well when `try-catch` is not used. When both `xact_abort` and `try-catch` is used, then after the `catch`, the die is cast. What possible incompatibility might remain? Maybe the `raiserror` will return an empty `error_message()`? Not sure, but I never had problems with that. – George Menoutis Feb 11 '22 at 12:47
  • @GeorgeMenoutis I have explained one use that we have for `XACT ABORT OFF` in the question. There are others. I agree this can be "ominous" if you don't trust the client, but our clients are trusted in a sense that there are clear expectations about transaction processing (the "client" is our own middle tier) and locks are really an issue for writes only (we use SNAPSHOT isolation). There are pros and cons, obviously, but we feel the pros outweigh the cons. Also, feel that savepoints are far better fit for "inner" error handling that transactions, since transactions cannot truly be nested. – Branko Dimitrijevic Feb 11 '22 at 12:47
  • @BrankoDimitrijevic I admit I haven't worked with savepoints much. I have seen `try-transaction` blocks with savepoints around. – George Menoutis Feb 11 '22 at 12:50
  • 1
    @GeorgeMenoutis Also I'm familiar with the Erland's blog and I still feel we made the right choice with regards to not using `XACT ABORT ON` and using savepoints. Everything really works quite well, except when we forget the enclose the procedure with TRY block, and I simply want to see if we can get the correct behavior even when we do. – Branko Dimitrijevic Feb 11 '22 at 12:50