SQL Server Error Handling: TRY...CATCH, THROW and Transactions

Error handling in T-SQL decides whether a failure leaves your data clean or half-updated, and whether the application gets a clear error or a mysterious timeout from a transaction left open. A small, consistent pattern covers almost every stored procedure.

SQL Server error handling with TRY CATCH and THROW

The template

CREATE OR ALTER PROCEDURE dbo.PostPayrollRun
    @PayrollRunId INT
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    BEGIN TRY
        BEGIN TRANSACTION;

        UPDATE dbo.PayrollRun
        SET Status = 'Posted'
        WHERE PayrollRunId = @PayrollRunId;

        INSERT INTO dbo.PayrollAudit (PayrollRunId, Action, CreatedAt)
        VALUES (@PayrollRunId, 'Posted', SYSUTCDATETIME());

        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF XACT_STATE() <> 0
            ROLLBACK TRANSACTION;

        THROW;   -- re-raise the original error to the caller
    END CATCH
END;

Each piece has a job. Here is why.

SET XACT_ABORT ON

By default, some errors (like many constraint violations) abort only the failing statement, and the transaction carries on. That is how you get half-finished work. With XACT_ABORT ON, most run-time errors roll back the entire transaction. It also matters when a client times out and cancels a call: without it, the transaction can stay open on the server, holding locks, until the connection is reused or closed.

XACT_STATE() before ROLLBACK

  • 1: there is an active, committable transaction.
  • -1: there is a transaction, but it is doomed; it can only be rolled back.
  • 0: no transaction is open.

Checking XACT_STATE() <> 0 means you roll back whenever there is something to roll back, and never call ROLLBACK when there is no transaction (which would raise a second error).

THROW instead of RAISERROR

THROW; with no arguments re-raises the original error with its number, message and line, which is exactly what the caller and your logs need. RAISERROR is older and does not re-raise the original error as cleanly. To raise your own business error, use THROW with a number of 50000 or higher:

IF NOT EXISTS (SELECT 1 FROM dbo.PayrollRun WHERE PayrollRunId = @PayrollRunId)
    THROW 50001, 'Payroll run not found.', 1;

One gotcha: the statement before THROW must end with a semicolon, or SQL Server misreads it.

Log the details

Inside CATCH, these functions describe the error. Log them to a table after the rollback so the log entry is not rolled back too:

BEGIN CATCH
    IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;

    INSERT INTO dbo.ErrorLog (ErrorNumber, ErrorMessage, ErrorProcedure, ErrorLine, LoggedAt)
    VALUES (ERROR_NUMBER(), ERROR_MESSAGE(), ERROR_PROCEDURE(), ERROR_LINE(), SYSUTCDATETIME());

    THROW;
END CATCH

What CATCH does not catch

  • Compile errors in the same batch, such as syntax errors or a missing table at compile time, are not caught by that batch's TRY. They are caught by a TRY...CATCH in an outer scope that calls the code (for example, a calling procedure).
  • Client timeouts and cancellations stop the batch from the client side, so the CATCH block never runs. This is exactly why XACT_ABORT ON matters.
  • Errors with low severity (10 or lower) are informational messages, not errors that jump to CATCH.

Handling deadlocks

A deadlock victim gets error 1205, which CATCH does see. Rather than retrying inside the procedure, it is usually cleaner to let the error reach the application and retry the whole operation there.

Quick checklist

  • SET XACT_ABORT ON at the top of every procedure that changes data.
  • Wrap the transaction in TRY...CATCH.
  • Roll back when XACT_STATE() <> 0, then log, then THROW;.
  • Use THROW 50000+ for business rule errors.
  • Handle retries for deadlocks and transient errors in the application.

Related reading:

Comments

Popular posts from this blog

How to Read a SQL Server Execution Plan (Beginner's Guide)

SARGable Queries in SQL Server: Why Functions in WHERE Slow You Down

SQL Server Statistics Explained: Why Your Query Plans Go Wrong