What Actually Goes Wrong When You Write SQL

I spent three years dealing with production outages that all traced back to the same root cause: code that assumed the database would behave politely. It doesn't. SQL Server will execute whatever you throw at it, and if your queries aren't written defensively, you end up with locked tables, phantom reads, and performance hits that show up only under real traffic. Defensive database programming isn't a single tool or framework. It's a mindset applied to how you write stored procedures, manage transactions, handle errors, and structure your schema. The goal is straightforward. Your code should survive bad input, unexpected concurrency, and partial failures without taking down the entire system.

Defensive Database Programming With Sql Server

The practice itself involves writing T-SQL code that anticipates failure modes. You set explicit transaction isolation levels instead of relying on defaults. You wrap operations in TRY...CATCH blocks that actually log useful information. You validate inputs before they touch your data. You avoid SELECT * because you never know what columns will change when someone alters the table structure. These are basic things, but most developers I see writing SQL haven't bothered with any of them. Here is a practical example of how this looks in a stored procedure:

CREATE PROCEDURE dbo.UpdateOrderStatus
    @OrderId INT,
    @NewStatus NVARCHAR(50)
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    IF @NewStatus NOT IN ('Pending', 'Processing', 'Shipped', 'Delivered', 'Cancelled')
    BEGIN
        RAISERROR('Invalid status value.', 16, 1);
        RETURN;
    END

    BEGIN TRY
        BEGIN TRANSACTION;

        UPDATE dbo.Orders
        SET Status = @NewStatus,
            LastModified = SYSUTCDATETIME()
        WHERE OrderId = @OrderId
          AND Status <> @NewStatus;

        IF @@ROWCOUNT = 0
        BEGIN
            RAISERROR('Order not found or status already set to the requested value.', 16, 1);
            ROLLBACK TRANSACTION;
            RETURN;
        END

        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF @@TRANCOUNT > 0
            ROLLBACK TRANSACTION;

        DECLARE @ErrMsg NVARCHAR(4000) = ERROR_MESSAGE();
        DECLARE @ErrSeverity INT = ERROR_SEVERITY();
        DECLARE @ErrState INT = ERROR_STATE();

        INSERT INTO dbo.ErrorLog (ErrorMessage, ErrorSeverity, ErrorState, OccurredAt)
        VALUES (@ErrMsg, @ErrSeverity, @ErrState, SYSUTCDATETIME());

        RAISERROR(@ErrMsg, @ErrSeverity, @ErrState);
    END CATCH
END

The procedure above does several defensive things at once. XACT_ABORT ON ensures that if any statement inside the transaction fails, the entire transaction rolls back automatically. That single setting prevented at least two major data integrity issues in my experience. The input validation on @NewStatus catches invalid values before they ever reach the database engine. Checking @@ROWCOUNT prevents silently accepting requests for orders that don't exist. And the error handling logs to a dedicated table so you actually have a record of what went wrong instead of losing the information when the connection drops. I remember one specific incident where a developer had written a bulk update procedure that looked perfectly fine in testing. It ran against a staging database with five thousand rows and finished in under two seconds. When we promoted it to production, the table had grown to eighteen million rows. The query held locks for forty-seven minutes, blocking every other operation on that table. We lost about two hours of order processing while it crawled through. The fix was adding OPTION (FAST 100) to return rows quickly, breaking the update into batches of ten thousand using a loop, and adding appropriate index coverage. But the real lesson was that the procedure had no defensive batching logic at all. It assumed the data volume would stay predictable. It never would.

Get the Full Details

Defensive Database Programming with SQL Server - Free Computer, Programming, Mathematics ...
Defensive Database Programming with SQL Server - Free Computer, Programming, Mathematics ...

Key Patterns and What They Prevent

There are several core patterns that make up defensive SQL Server development. Understanding what each one prevents is more useful than memorizing syntax. SET XACT_ABORT ON changes how SQL Server handles runtime errors inside a transaction. Without it, some errors leave the transaction in an open state while allowing execution to continue. That means you can end up committing partial data. With it set, any error aborts the entire transaction immediately. This is non-negotiable for any procedure that modifies data. Explicit transaction management using BEGIN TRANSACTION, COMMIT, and ROLLBACK gives you control over when changes become permanent. The default behavior in SQL Server is autocommit mode, where every individual statement is its own transaction. That sounds safe until you need two related changes to succeed or fail together. A transfer between accounts is the classic example. You debit one account and credit another. If the credit fails after the debit succeeds, money disappears from the system. Explicit transactions prevent this by grouping the operations.

Error handling with TRY...CATCH is the standard mechanism in T-SQL. It captures errors at the procedure level and lets you respond gracefully instead of letting the error propagate unpredictably. The functions ERROR_NUMBER(), ERROR_MESSAGE(), ERROR_SEVERITY(), and ERROR_STATE() give you detailed diagnostic information. I always recommend logging these to a table rather than just printing them. Production errors disappear when the client disconnects, and you lose your only record of what happened. Optimistic concurrency control using rowversion or timestamp columns prevents lost updates. When two users fetch the same row, modify it, and then try to save, the second writer should detect that the row changed since they read it. A simple UPDATE with a WHERE clause checking the rowversion value handles this:

UPDATE dbo.Products
SET Price = @NewPrice,
    RowVersion = ROWVERSION_COLUMN + 1
WHERE ProductId = @ProductId
  AND RowVersion = @OriginalRowVersion;

If @@ROWCOUNT equals zero, someone else modified the row between read and write. You can then retry, alert the user, or merge the changes depending on your business logic. This pattern eliminates a whole class of silent data corruption bugs. Parameter sniffing mitigation is one of those issues that catches experienced developers off guard. SQL Server caches execution plans based on the first set of parameters it sees. If your procedure is first called with a parameter that returns millions of rows, the cached plan might use a table scan. Subsequent calls with parameters that should return a single row will still use that inefficient plan. Solutions include using OPTION (RECOMPILE) on the problematic query, local variable assignment to obscure the parameter value from the optimizer, or OPTIMIZE FOR UNKNOWN hint. I found that for my reporting procedures with highly variable parameter distributions, creating separate optimized versions for common versus edge-case parameter combinations cut average query time from several seconds down to under 200 milliseconds.

Book Review: Defensive Database Programming With SQL Server | Simple Talk
Book Review: Defensive Database Programming With SQL Server | Simple Talk

Indexing as a Defensive Measure

Indexes are usually discussed in terms of performance, but they are also a defensive mechanism. Missing indexes lead to full table scans under load, which escalate into lock contention and blocking chains that affect unrelated queries. A properly indexed query finishes quickly and releases locks promptly. An unindexed one holds them for minutes. The Database Engine Tuning Advisor can suggest indexes based on actual workload, but I have found its recommendations unreliable in practice. It tends to suggest too many indexes, especially on write-heavy tables. Every index slows down INSERT, UPDATE, and DELETE operations. The better approach is monitoring your actual query patterns using DMVs like sys.dm_exec_query_stats and sys.dm_exec_sql_text. Look for queries with high logical reads and low execution counts. Those are your worst offenders. Then create targeted indexes for those specific queries rather than chasing every suggestion the tool makes. Covering indexes are particularly useful defensively. A covering index includes all columns referenced in a query, so SQL Server never needs to look up the base table. This eliminates key lookups entirely. For frequently executed reporting queries, the performance improvement is dramatic and the locking impact is minimal because the entire operation happens on the index structure alone.

Schema Design Choices That Prevent Problems

Nullable columns are a common source of bugs that are extremely difficult to trace. NULL behaves differently than you might expect in comparisons, aggregations, and joins. A WHERE clause like WHERE ColumnName = NULL never returns any rows. It must be written as WHERE ColumnName IS NULL. This alone causes countless incorrect result sets that pass testing because test data rarely includes nulls in the relevant columns. My recommendation is to design columns as NOT NULL whenever possible, using sensible defaults instead of relying on NULL as a placeholder for missing information. If you need to represent an unknown value, use a sentinel value or a separate flag column. The extra explicitness pays for itself in reduced debugging time. Foreign key constraints are another defensive measure that gets routinely skipped. Developers sometimes disable them during bulk loads or disable them permanently for perceived performance reasons. Disabled foreign keys mean orphaned records, referential integrity violations, and application code that has to manually enforce relationships that the database was designed to handle. I have seen databases where foreign keys were disabled because a migration script failed to re-enable them after a deployment. The data quality degradation was gradual and went unnoticed for months.

Unique constraints prevent duplicate entries without requiring application-level validation. They are enforced at the database level, which means they catch duplicates even when multiple clients insert simultaneously. This is something application code cannot reliably do without proper locking, and locking introduces its own problems. Database-level constraints are the right tool for this job.

Securing SQL Server: DBAs Defending the Database [Book]
Securing SQL Server: DBAs Defending the Database [Book]

Testing Strategy

Unit testing SQL procedures is not widely practiced, but it is one of the most impactful defensive measures you can take. The tSQLt framework is the standard tool for this purpose. It allows you to write tests that verify procedure behavior under normal conditions, edge cases, and failure scenarios. A well-written test suite for a critical financial procedure might cover valid inputs, boundary values, concurrent access, rollback behavior on error, and performance under load. I once discovered a race condition in a procedure that allocated reservation codes by selecting the maximum existing code and incrementing it. In isolation, the procedure worked correctly. Under concurrent load from ten simultaneous connections, roughly one in twenty requests generated a duplicate code. The fix was replacing the max-then-increment pattern with an IDENTITY column backed by a dedicated sequence object. Testing caught the issue during staging because I ran a parallel execution test that simulated the production load profile. Integration testing with realistic data volumes is equally important. Procedures that perform adequately on small datasets often fail catastrophically when the data grows. Statistics become stale, query plans regress, and memory grants shrink relative to actual requirements. I set up a monthly job that refreshes my test database from production with anonymized data and runs my full test suite against it. This has caught three significant performance regressions that would have otherwise reached production.

Monitoring and Observability

Defensive programming also means knowing when something goes wrong. SQL Server provides several built-in tools for this purpose. Extended Events replaced SQL Server Profiler as the preferred tracing technology because it has significantly lower performance overhead. You can capture slow queries, deadlock events, blocking chains, and plan cache evictions without degrading the system you are monitoring. Query Store is another essential tool. It retains historical query performance data and lets you identify queries whose plans have degraded over time. Plan regression is one of the most insidious performance problems because it happens gradually and affects all users of the affected query. With Query Store, you can see exactly when a query started performing poorly and what changed in its execution plan. Basic alerting on error log entries, blocked process reports, and resource waits should be part of any production environment. I configure alerts for deadlock occurrences, long-running queries exceeding threshold durations, and failed login attempts that spike beyond normal baselines. These alerts catch issues before they escalate into user-facing problems.

When Defensive Programming Is Not Enough

No amount of defensive coding fixes a fundamentally poor database design. I have seen applications where the underlying schema made correct behavior impossible to guarantee regardless of how carefully the queries were written. Many-to-many relationships implemented with repeated denormalized columns, date fields stored as strings, and primary keys that were actually business identifiers instead of surrogate keys. These structural problems compound every other issue you try to solve defensively. If you are working with a legacy system that has deep structural problems, the most practical defensive approach is wrapping the existing schema with a clean layer of stored procedures and views. This isolates your application code from the underlying mess and gives you a controlled interface where you can enforce validation, logging, and consistency rules. It is not a permanent solution, but it is often the only viable path when a full redesign is not feasible. Another scenario where defensive programming falls short is when the threat model includes malicious SQL injection. No amount of careful parameterization and validation can compensate for a system that constructs SQL by concatenating user input. The fix is always the same: use parameterized queries exclusively and validate input at the application boundary before it ever reaches the database. I have encountered systems where the only SQL injection vulnerability was in a single legacy stored procedure that accepted a raw string parameter. One malformed input could execute arbitrary commands. The patch took approximately four minutes once we identified the procedure.

Sql Server Database Engine Services – JRYE
Sql Server Database Engine Services – JRYE

A Realistic Checklist

For any new stored procedure you write, run through this list before considering it production-ready: SET NOCOUNT ON is present to reduce network traffic from row count messages. SET XACT_ABORT ON is set for any procedure that modifies data. Input parameters are validated for type, range, and format before being used in any query. All string concatenation is eliminated in favor of parameterized queries. Error handling captures and logs complete error information. Transactions are explicitly managed with proper commit and rollback paths. Queries use explicit column lists instead of SELECT *. Indexes support the query's filter and join conditions. Concurrency is handled through appropriate isolation levels or optimistic locking mechanisms. The procedure is tested with empty input, boundary values, and invalid data. Performance is verified against a dataset that approximates production volume. Following this checklist consistently will catch the vast majority of issues that cause problems in production. It will not catch everything, and no amount of defensiveness replaces thorough testing and monitoring. But the gap between a fragile procedure and a robust one is almost entirely defined by whether these practices are applied systematically.