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.
| Category | Description | Examples |
|---|---|---|
| System errors | Raised by SQL Server itself | Constraint violations, deadlocks, timeouts, type-conversion failures |
| User-defined errors | Errors you raise from your own code | Custom 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:
| Severity | Meaning | CATCH catches it? |
|---|---|---|
| 0–10 | Informational — warnings, status messages | No — doesn't enter CATCH |
| 11–16 | User-correctable errors (constraint violations, type mismatches) | Yes |
| 17–19 | Resource / state errors (out of locks, transaction lost) | Yes |
| 20–25 | Fatal — connection terminates | Caller's CATCH only — not the same scope |
| Mechanism | Purpose | Modern recommendation |
|---|---|---|
TRY ... CATCH | Structured error handling | ✓ Always use for any non-trivial code |
@@ERROR | Pre-2005 error checking | ✗ Legacy — replace with TRY/CATCH |
RAISERROR | Raise 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:
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.
| Function | Returns |
|---|---|
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) |
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;
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.CREATE TABLE TestTable (ID INT PRIMARY KEY);
INSERT INTO TestTable (ID) VALUES (1);
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
@@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 ('A custom error occurred', 16, 1);
RAISERROR also supports formatted messages with printf-style placeholders:
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.
| Element | Rule |
|---|---|
error_number | Must be ≥ 50000 (user-defined range) |
message | The error text — string literal or variable |
state | 0–255 (typically 1) |
| Severity | Always 16 — not configurable |
| Statement before | Must end with a semicolon — the statement before THROW needs ; |
THROW 50000, 'A custom error message', 1;
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.
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;
THROW is required. Forgetting it gives a confusing parse error. This is the most common THROW bug — always end the previous statement with ;.| Aspect | RAISERROR | THROW |
|---|---|---|
| Available since | SQL Server 7.0 | SQL Server 2012 |
| Severity | You set it (1–25) | Always 16 (fixed) |
| Error number | Reuses sys.messages or 50000 | Must be ≥ 50000 (user errors only) |
printf-style formatting | Yes — %s, %d, etc. | No — concatenate strings yourself, or use FORMATMESSAGE() |
| Re-throw inside CATCH | Awkward — loses original error number | Clean — THROW; with no args |
| Recommended for | Legacy code only | All new code |
The canonical structure for any procedure that does multi-statement DML and needs to be safe under failure.
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()
);
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
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 legacyRAISERROR(@ErrorMessage, ...)approach would have lost the error number and line.
Test it with a valid call and an invalid one:
EXEC InsertData @ID = 1, @Name = 'John Doe', @Age = 30;
EXEC InsertData @ID = 2, @Name = 'Alice', @Age = -5;
And inspect the log:
SELECT ErrorID, ErrorNumber, ErrorSeverity, ErrorLine, ErrorProcedure, ErrorMessage
FROM ErrorLog
ORDER BY ErrorID;
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.
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)
);
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;
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:
| Type | Why CATCH misses it |
|---|---|
| Compile-time syntax errors | The 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 scope | Connection-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 severity | Severity 0–10 don't enter CATCH regardless |
- Use TRY/CATCH for any non-trivial T-SQL. The legacy
@@ERRORpattern is fragile — replace it. - Use
THROW, notRAISERROR, for new code — especially the bareTHROW;in CATCH blocks for clean re-throws. - Always
SET XACT_ABORT ONin procedures that modify data — it makes some otherwise-trappable errors automatically roll back the transaction. - Use
XACT_STATE()in CATCH blocks to test transaction state before COMMIT or ROLLBACK. - Log errors before re-throwing. The caller may not log; persist what you know in your own table.
- Don't swallow errors silently. An empty CATCH block is almost always a bug — at minimum, log and re-throw.
- Pick a sensible severity. Use 16 for normal user-correctable errors; 11 is the minimum that reaches CATCH; below that is informational only.
- Always end the statement before
THROWwith 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), andTHROW(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), notRAISEERROR. THROW>RAISERRORfor new code: simpler, preserves error context on re-throw via the bareTHROW;form.- For data-modifying procedures, the canonical pattern is:
SET XACT_ABORT ON,BEGIN TRY ... BEGIN TRANSACTION ... COMMIT ... END TRY,BEGIN CATCHwithIF XACT_STATE() <> 0 ROLLBACK, log, thenTHROW.