Oracle PL/SQL Error Handling T-SQL · Error Handling

T-SQL Error Handling

Master T-SQL error handling — TRY/CATCH blocks, the ERROR_*() functions, severity levels, RAISERROR vs THROW (with the THROW recommendation for modern code), nested TRY/CATCH, the XACT_ABORT and XACT_STATE() patterns for transactions, error logging procedures, and what TRY/CATCH cannot catch.

Error handling in T-SQL is the practice of catching errors at runtime — division by zero, primary-key violations, missing tables, deadlocks — and responding to them in a controlled way: rolling back transactions, logging details, raising friendlier messages to the caller. Without it, an unhandled error stops execution mid-batch and can leave your data half-modified.

Modern T-SQL (SQL Server 2005+) provides a structured exception model based on TRY ... CATCH blocks, plus a set of ERROR_*() functions that expose details about what went wrong. SQL Server 2012 added the THROW statement which made re-throwing far cleaner than the legacy RAISERROR.

CategoryDescriptionExamples
System errorsRaised by SQL Server itselfConstraint violations, deadlocks, timeouts, type-conversion failures
User-defined errorsErrors you raise from your own codeCustom business rule failures via THROW or RAISERROR

Every error has a number, severity, state, and message. Numbers below 50000 are reserved by SQL Server; 50000 and above are available for user-defined errors.

Severity controls whether an error even reaches your CATCH block:

SeverityMeaningCATCH catches it?
0–10Informational — warnings, status messagesNo — doesn't enter CATCH
11–16User-correctable errors (constraint violations, type mismatches)Yes
17–19Resource / state errors (out of locks, transaction lost)Yes
20–25Fatal — connection terminatesCaller's CATCH only — not the same scope
📌 Default severity is 16 — most user-defined errors use this. Anything below 11 won't trigger TRY/CATCH at all; anything 20+ kills the connection and bypasses local CATCH blocks.
MechanismPurposeModern recommendation
TRY ... CATCHStructured error handling✓ Always use for any non-trivial code
@@ERRORPre-2005 error checking✗ Legacy — replace with TRY/CATCH
RAISERRORRaise custom errors~ Legacy; use THROW for new code
THROW (2012+)Raise or re-throw errors✓ Modern preferred way

The structured way to catch and respond to errors. Code that might fail goes inside BEGIN TRY ... END TRY; the response goes inside the matching BEGIN CATCH ... END CATCH:

BEGIN TRY -- code that might raise an error END TRY BEGIN CATCH -- runs only if the TRY block raised an error -- ERROR_MESSAGE(), ERROR_NUMBER(), etc. are populated here END CATCH;

The ERROR_*() Functions

These functions return details about the error currently being handled. They only return values inside a CATCH block — outside CATCH, every one of them returns NULL.

FunctionReturns
ERROR_NUMBER()The error's numeric ID (e.g. 2627 for unique-key violation)
ERROR_MESSAGE()Full text of the error message
ERROR_SEVERITY()Severity level (1–25)
ERROR_STATE()State number — disambiguates same error number from different lines
ERROR_LINE()Line where the error occurred
ERROR_PROCEDURE()Procedure or trigger name (NULL if outside one)
Example 1 — Catch a Division-by-Zero Error
T-SQL — Classic TRY/CATCH
BEGIN TRY
    SELECT 1 / 0;
END TRY
BEGIN CATCH
    SELECT
        ERROR_NUMBER()    AS ErrorNumber,
        ERROR_SEVERITY()  AS ErrorSeverity,
        ERROR_STATE()     AS ErrorState,
        ERROR_LINE()      AS ErrorLine,
        ERROR_PROCEDURE() AS ErrorProcedure,
        ERROR_MESSAGE()   AS ErrorMessage;
END CATCH;
OUTPUT
Results
(1 row affected)
ErrorNumber ErrorSeverity ErrorState ErrorLine ErrorProcedure ErrorMessage
─────────── ───────────── ────────── ───────── ────────────── ─────────────────────────────
8134 16 1 2 NULL Divide by zero error encountered.

The TRY block raised error 8134; control jumped to CATCH where the ERROR_*() functions filled in the details. Without TRY/CATCH, the same query would have terminated the batch with a red error message in SSMS.

Before SQL Server 2005, error handling meant checking @@ERROR after every statement. The function returns the error number of the last executed statement — but resets immediately on the next statement, including successful ones.

⚠️ @@ERROR resets after every statement. Even a successful PRINT resets it to 0. You must capture it into a variable on the very next line, or you'll lose it.
Example 2 — @@ERROR Pattern
SQL — Setup
CREATE TABLE TestTable (ID INT PRIMARY KEY);
INSERT INTO TestTable (ID) VALUES (1);
T-SQL — @@ERROR check
DECLARE @ErrorNumber INT;

-- This will fail — duplicate primary key
INSERT INTO TestTable (ID) VALUES (1);

-- Capture @@ERROR IMMEDIATELY — the next statement resets it
SET @ErrorNumber = @@ERROR;

IF @ErrorNumber <> 0
BEGIN
    PRINT 'An error occurred. Error number: ' +
          CAST(@ErrorNumber AS NVARCHAR(10));
END
OUTPUT
Messages
Msg 2627, Level 14, State 1, Line 4
Msg 2627, Level 14, State 1, Line 4
Violation of PRIMARY KEY constraint 'PK__TestTabl__...'. Cannot insert duplicate key in object 'dbo.TestTable'. The duplicate key value is (1).
An error occurred. Error number: 2627
💡 Use TRY/CATCH for new code. The @@ERROR pattern still works, but it's verbose, fragile, and easy to get wrong. TRY/CATCH wins on every dimension.

Note the spelling: RAISERROR (one E in the middle, not two). It's a quirk of T-SQL history.

RAISERROR ( { message_id | message_string } , severity , state [, argument [, ...n] ] ) [ WITH option [, ...n] ] ;
Example 3 — RAISERROR with a Custom Message
T-SQL — Raise a custom error
RAISERROR ('A custom error occurred', 16, 1);
OUTPUT
Messages
Msg 50000, Level 16, State 1, Line 1
Msg 50000, Level 16, State 1, Line 1
A custom error occurred

RAISERROR also supports formatted messages with printf-style placeholders:

T-SQL — Formatted message
DECLARE @account NVARCHAR(20) = 'AC-1042', @balance MONEY = -50;

RAISERROR ('Account %s has negative balance %d', 16, 1, @account, @balance);

Introduced in SQL Server 2012, THROW is the recommended replacement for RAISERROR — simpler syntax, doesn't lose the original error context when re-throwing.

-- Form 1: Re-throw the current error from inside a CATCH THROW ; -- Form 2: Raise a new error THROW error_number , message , state ;
ElementRule
error_numberMust be ≥ 50000 (user-defined range)
messageThe error text — string literal or variable
state0–255 (typically 1)
SeverityAlways 16 — not configurable
Statement beforeMust end with a semicolon — the statement before THROW needs ;
Example 4 — Raise a New Error with THROW
T-SQL — Custom error
THROW 50000, 'A custom error message', 1;
OUTPUT
Messages
Msg 50000, Level 16, State 1, Line 1
Msg 50000, Level 16, State 1, Line 1
A custom error message
Example 5 — Re-throw the Original Error

The killer feature of THROW — re-throwing inside a CATCH block preserves the original error number, message, and line. This is far better than the old RAISERROR(@msg, ...) pattern, which loses the original error number.

T-SQL — Log + re-throw pattern
BEGIN TRY
    INSERT INTO TestTable (ID) VALUES (1);    -- duplicate key
END TRY
BEGIN CATCH
    -- log the error somewhere first
    PRINT 'Error caught: ' + ERROR_MESSAGE();

    -- re-throw with full fidelity — original error 2627 is preserved
    THROW;
END CATCH;
⚠️ The semicolon before THROW is required. Forgetting it gives a confusing parse error. This is the most common THROW bug — always end the previous statement with ;.
AspectRAISERRORTHROW
Available sinceSQL Server 7.0SQL Server 2012
SeverityYou set it (1–25)Always 16 (fixed)
Error numberReuses sys.messages or 50000Must be ≥ 50000 (user errors only)
printf-style formattingYes — %s, %d, etc.No — concatenate strings yourself, or use FORMATMESSAGE()
Re-throw inside CATCHAwkward — loses original error numberClean — THROW; with no args
Recommended forLegacy code onlyAll new code
💡 Default to THROW for new code. The only time RAISERROR wins is when you genuinely need printf-style formatting or a non-16 severity for an informational message.

The canonical structure for any procedure that does multi-statement DML and needs to be safe under failure.

Example 6 — Atomic Transaction with Logging
SQL — Setup
CREATE TABLE SampleTable (
    ID    INT PRIMARY KEY,
    Name  NVARCHAR(50) NOT NULL,
    Age   INT CHECK (Age > 0)
);

CREATE TABLE ErrorLog (
    ErrorID         INT IDENTITY(1,1) PRIMARY KEY,
    ErrorNumber     INT,
    ErrorMessage    NVARCHAR(4000),
    ErrorSeverity   INT,
    ErrorState      INT,
    ErrorLine       INT,
    ErrorProcedure  NVARCHAR(200),
    ErrorDateTime   DATETIME2 DEFAULT SYSDATETIME()
);
T-SQL — Procedure with full error handling
CREATE OR ALTER PROCEDURE InsertData
    @ID    INT,
    @Name  NVARCHAR(50),
    @Age   INT
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;     -- ensures any error rolls back the transaction

    BEGIN TRY
        BEGIN TRANSACTION;

        INSERT INTO SampleTable (ID, Name, Age)
        VALUES (@ID, @Name, @Age);

        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        -- Roll back if a transaction is still active
        IF XACT_STATE() <> 0
            ROLLBACK TRANSACTION;

        -- Log the error
        INSERT INTO ErrorLog (ErrorNumber, ErrorMessage, ErrorSeverity,
                              ErrorState, ErrorLine, ErrorProcedure)
        VALUES (ERROR_NUMBER(),    ERROR_MESSAGE(),  ERROR_SEVERITY(),
                ERROR_STATE(),     ERROR_LINE(),     ERROR_PROCEDURE());

        -- Re-throw so the caller knows something went wrong
        THROW;
    END CATCH
END;
GO
📌 What's new vs the typical online example:
  • SET XACT_ABORT ON — without this, some errors leave the transaction in a "doomed" state where neither COMMIT nor more work is allowed.
  • XACT_STATE() — more reliable than @@TRANCOUNT > 0; tells you if the transaction is committable, doomed, or already gone.
  • THROW; with no arguments — preserves the original error for the caller. The legacy RAISERROR(@ErrorMessage, ...) approach would have lost the error number and line.

Test it with a valid call and an invalid one:

T-SQL — Valid call
EXEC InsertData @ID = 1, @Name = 'John Doe', @Age = 30;
OUTPUT
Messages
Commands completed successfully.
(1 row affected)
T-SQL — Invalid call (negative age)
EXEC InsertData @ID = 2, @Name = 'Alice', @Age = -5;
OUTPUT
Messages
Msg 547, Level 16, State 0, Procedure InsertData, Line 11
Msg 547, Level 16, State 0, Procedure InsertData, Line 11
The INSERT statement conflicted with the CHECK constraint "CK__SampleTab__Age__...". The conflict occurred in database 'tempdb', table 'dbo.SampleTable', column 'Age'.

And inspect the log:

T-SQL — View the log
SELECT ErrorID, ErrorNumber, ErrorSeverity, ErrorLine, ErrorProcedure, ErrorMessage
FROM   ErrorLog
ORDER BY ErrorID;
OUTPUT
Results
(1 row affected)
ErrorID ErrorNumber ErrorSeverity ErrorLine ErrorProcedure ErrorMessage
─────── ─────────── ───────────── ───────── ────────────── ────────────────────────────────────────────
1 547 16 11 InsertData The INSERT statement conflicted with...

You can nest TRY/CATCH blocks to handle errors at multiple levels. The inner CATCH gets first crack; if it doesn't re-throw, the outer code continues normally. If it does THROW, control jumps to the outer CATCH.

Example 7 — Parent / Child Insert with Inner CATCH
SQL — Tables
CREATE TABLE ParentTable (
    ParentID    INT IDENTITY(1,1) PRIMARY KEY,
    ParentData  NVARCHAR(50)
);

CREATE TABLE ChildTable (
    ChildID    INT IDENTITY(1,1) PRIMARY KEY,
    ParentID   INT,
    ChildData  NVARCHAR(50) NOT NULL,
    CONSTRAINT FK_Parent FOREIGN KEY (ParentID) REFERENCES ParentTable(ParentID)
);
T-SQL — Procedure with nested error handling
CREATE OR ALTER PROCEDURE InsertParentAndChild
    @ParentData  NVARCHAR(50),
    @ChildData   NVARCHAR(50)
AS
BEGIN
    SET NOCOUNT ON;

    BEGIN TRY
        INSERT INTO ParentTable (ParentData) VALUES (@ParentData);

        DECLARE @ParentID INT = SCOPE_IDENTITY();

        -- Inner TRY/CATCH — keep parent insert even if child fails
        BEGIN TRY
            INSERT INTO ChildTable (ParentID, ChildData)
            VALUES (@ParentID, @ChildData);
        END TRY
        BEGIN CATCH
            -- Log child failure but don't re-throw — parent stays
            INSERT INTO ErrorLog (ErrorNumber, ErrorMessage)
            VALUES (ERROR_NUMBER(), ERROR_MESSAGE());
        END CATCH;

    END TRY
    BEGIN CATCH
        -- Outer CATCH — parent insert itself failed
        INSERT INTO ErrorLog (ErrorNumber, ErrorMessage)
        VALUES (ERROR_NUMBER(), ERROR_MESSAGE());

        THROW;        -- bubble up to caller
    END CATCH;
END;
GO

-- Valid call
EXEC InsertParentAndChild 'Sankar', 'Dhileep Kumar';

-- Child fails (NOT NULL violation), but parent still inserts
EXEC InsertParentAndChild 'Sankar', NULL;

SELECT * FROM ParentTable;
OUTPUT
Results
(2 rows affected)
ParentID ParentData
──────── ──────────
1 Sankar
2 Sankar

Both calls created a parent row. The second call's child insert failed with a NOT NULL violation, the inner CATCH logged it, but the parent stayed because the inner CATCH didn't re-throw.

TRY/CATCH is powerful but not universal. These errors bypass it entirely:

TypeWhy CATCH misses it
Compile-time syntax errorsThe batch never starts — there's no TRY block running yet
Object-name resolution errors (deferred until run)If an object doesn't exist when the batch first compiles a statement, the batch can fail outside CATCH
Severity 20–25 errors in the same scopeConnection-terminating; CATCH in the calling batch may catch them, but not the local CATCH
Attention events (client cancelled query)Not exceptions — the engine aborts
Errors from RAISERROR WITH NOWAIT at low severitySeverity 0–10 don't enter CATCH regardless
  1. Use TRY/CATCH for any non-trivial T-SQL. The legacy @@ERROR pattern is fragile — replace it.
  2. Use THROW, not RAISERROR, for new code — especially the bare THROW; in CATCH blocks for clean re-throws.
  3. Always SET XACT_ABORT ON in procedures that modify data — it makes some otherwise-trappable errors automatically roll back the transaction.
  4. Use XACT_STATE() in CATCH blocks to test transaction state before COMMIT or ROLLBACK.
  5. Log errors before re-throwing. The caller may not log; persist what you know in your own table.
  6. Don't swallow errors silently. An empty CATCH block is almost always a bug — at minimum, log and re-throw.
  7. Pick a sensible severity. Use 16 for normal user-correctable errors; 11 is the minimum that reaches CATCH; below that is informational only.
  8. Always end the statement before THROW with a semicolon. The single most common THROW bug.
  • T-SQL has four error-handling tools: TRY ... CATCH (modern, structured), @@ERROR (legacy), RAISERROR (legacy raise/format), and THROW (modern raise/re-throw).
  • Severity 11+ enters CATCH; 0–10 are informational; 20+ kill the connection and bypass the local CATCH.
  • ERROR_NUMBER(), ERROR_MESSAGE(), ERROR_SEVERITY(), ERROR_STATE(), ERROR_LINE(), ERROR_PROCEDURE() populate inside CATCH — all return NULL outside.
  • Note the spelling: RAISERROR (one E in the middle), not RAISEERROR.
  • THROW > RAISERROR for new code: simpler, preserves error context on re-throw via the bare THROW; form.
  • For data-modifying procedures, the canonical pattern is: SET XACT_ABORT ON, BEGIN TRY ... BEGIN TRANSACTION ... COMMIT ... END TRY, BEGIN CATCH with IF XACT_STATE() <> 0 ROLLBACK, log, then THROW.