TRY/CATCH Error Handling in SQL Server: THROW vs RAISERROR
Production T-SQL needs to fail gracefully, not just fail. An unhandled error in the middle of a multi-step operation can leave data in a half-finished state — TRY/CATCH, paired with transactions (covered in full in Chapter 9), is how you prevent that.
The Pattern
BEGIN TRY
UPDATE dbo.Product SET stock_qty = stock_qty - 10 WHERE product_id = 2;
IF (SELECT stock_qty FROM dbo.Product WHERE product_id = 2) < 0
THROW 51000, 'Stock quantity cannot go negative.', 1;
END TRY
BEGIN CATCH
PRINT 'Error caught: ' + ERROR_MESSAGE();
PRINT 'Error number: ' + CAST(ERROR_NUMBER() AS VARCHAR(10));
PRINT 'Error line: ' + CAST(ERROR_LINE() AS VARCHAR(10));
END CATCH;
Code inside BEGIN TRY ... END TRY runs normally. The instant any statement in that block raises an error, execution jumps immediately to BEGIN CATCH ... END CATCH — the rest of the TRY block is skipped entirely, similar to try/catch in C#, Java, or Python, but with SQL-Server-specific error inspection functions.
THROW vs RAISERROR
THROW is preferred — it's simpler, and with no arguments inside a CATCH block, it correctly preserves the original error's number, severity, and state when re-thrown. Reach for RAISERROR only when you need its legacy formatting (%s/%d placeholders) or must support very old SQL Server versions.
-- Re-throwing the ORIGINAL caught error, unchanged, after logging it:
BEGIN TRY
SELECT 1/0; -- deliberate divide-by-zero to trigger an error
END TRY
BEGIN CATCH
PRINT 'Logged: ' + ERROR_MESSAGE();
THROW; -- bare THROW re-raises the exact original error
END CATCH;
THROW with your own custom message/number when you meant to just re-raise the original error for the caller to see. A bare THROW; (no arguments) inside CATCH re-throws exactly what was caught — THROW 50000, 'Something failed', 1; replaces it with a brand-new, less specific error that loses the original diagnostic detail.The Error Functions Toolkit
| Function | Returns |
|---|---|
ERROR_NUMBER() |
The error's numeric code |
ERROR_MESSAGE() |
The human-readable error text |
ERROR_LINE() |
Line number where the error occurred |
ERROR_PROCEDURE() |
Procedure/function name, NULL if ad-hoc |
ERROR_SEVERITY() |
Severity level (11-19 typical for handleable errors) |
ERROR_STATE() |
A custom state number you can use to distinguish similar errors |
All six are only valid inside a CATCH block — called anywhere else, they simply return NULL, since there's no error context to describe.
Nesting: A TRY/CATCH Inside a CATCH
BEGIN TRY
UPDATE dbo.Product SET stock_qty = stock_qty - 10 WHERE product_id = 2;
END TRY
BEGIN CATCH
BEGIN TRY
INSERT INTO dbo.ErrorLog (error_message, logged_at) VALUES (ERROR_MESSAGE(), SYSDATETIME());
END TRY
BEGIN CATCH
PRINT 'Even the error logging failed — this is genuinely bad, escalate.';
END CATCH;
THROW;
END CATCH;
This is a real, defensible pattern in production code: log the error to a table for later diagnosis, but wrap the logging itself in its own TRY/CATCH — you don't want a failure in your error-logging code to mask or replace the original error.
Enjoyed this?
Subscribe to get every new SQL Server lesson as soon as it's published, and share it with a developer who'd find it useful.
📡 Subscribe via RSS |
Share on X |
Share on LinkedIn |
Share on Facebook
Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.