Eliminating Legacy Cursors: An AI-Assisted Deep Dive

The companion satellite post covers straightforward cursor-to-set-based conversions — the cases where a CASE expression or a single UPDATE...FROM replaces the cursor cleanly. This post covers the harder cases: the legacy production cursors that have been running for years, are genuinely complex, and can’t be solved with a single set-based statement.

These are the cursors with nested loops, conditional branching that calls other procedures mid-iteration, and side effects like audit logging, email notifications, or external API calls that must happen per-row. They’re the ones where a junior developer looks at it and says “just rewrite it as a set-based query” — and a senior DBA says “it’s not that simple.”

AI agents are genuinely useful here, but the value isn’t a magic one-click conversion. It’s the decomposition: breaking a monolithic cursor into components, identifying which parts can go set-based and which parts must stay row-by-row.

The Problem: A Legacy Order Fulfillment Procedure

Here’s a simplified version of a pattern I’ve seen in multiple production systems. An order fulfillment procedure that processes each order individually with multiple conditional paths:

This cursor does at least six distinct things per row: checks inventory, reserves stock, generates tracking numbers, logs events, sends emails, and writes audit records. You can’t replace this with a single UPDATE...FROM.

The Prompt

What the Agent Produced

The agent broke the procedure into three phases, which is exactly the right decomposition.

Phase 1 — Set-Based Classification: Determine which orders can be fulfilled and which are backordered, in a single query, storing results in a temp table:

Phase 2 — Bulk Set-Based Operations: All the inserts and updates that don’t require per-row procedure calls:

Phase 3 — Row-by-Row for Side Effects: A reduced cursor that only loops through fulfilled orders and only handles the operations that require per-row processing:

What I Validated and Changed

The decomposition was correct, but I caught several issues:

  1. Inventory race condition. The original cursor checked inventory and reserved it row by row. The rewrite checks inventory in Phase 1 but reserves it in Phase 2. Between those two operations, another concurrent batch could consume the same inventory. Worse, even within a single batch, if two orders compete for the same product at the same warehouse, Phase 1 can classify both as Fulfilled based on pre-update stock while the original cursor would fulfill one and backorder the other. I wrapped Phases 1 and 2 in an explicit transaction with UPDLOCK, HOLDLOCK hints on the inventory check in Phase 1 — or, for truly contended inventory, kept the reservation logic in the Phase 3 cursor and only moved the non-inventory operations to set-based. The agent didn’t flag this because it wasn’t thinking about concurrency.

  2. The usp_NotifyPurchasing call for backorders was dropped. The agent moved the backorder logging to a bulk insert (correct) but forgot the procedure call that notifies purchasing. I added a second cursor for backordered items that calls usp_NotifyPurchasing per row — or, if that procedure just sends email, consolidated it into a single notification with all backordered order IDs.

  3. GETDATE() consistency. In the original cursor, each row gets its own timestamp. In the set-based phases, all rows get the same GETDATE() value. I captured GETDATE() into a @BatchTimestamp variable at the top of the procedure and used it throughout for consistency.

  4. Email volume. The cursor sends one email per fulfilled order. For a batch of 5,000 orders, that’s 5,000 individual sp_send_dbmail calls. I flagged this as a separate optimization opportunity — batch the emails into a summary notification, or queue them through a Service Broker queue instead of blocking the fulfillment process. The agent’s rewrite preserved the original behavior (one email per order), which is correct as a first pass, but it’s worth questioning whether the original behavior is desirable.

When Cursors Can’t Be Eliminated

Let me be direct about this. Some row-by-row operations genuinely can’t go set-based:

  • sp_send_dbmail — one call per message, no set-based alternative
  • Stored procedure calls with OUTPUT parameters — the tracking number generation here must be called per row because each call returns a different value
  • External API calls via CLR or xp_cmdshell — inherently sequential
  • Operations where row N depends on row N-1 — running balance calculations where each row’s result feeds the next

The agent recognized all of these correctly. The goal isn’t zero cursors — it’s minimal cursors. Move everything you can to set-based operations, then loop only for the operations that require it. The Phase 1/2/3 pattern the agent produced is the right architectural approach.

In the example above, the original cursor executed roughly 7 operations per row (inventory check, reserve, generate tracking, two log inserts, status update, email). The rewritten version executes 3 operations per row (generate tracking, status update, email) and handles the rest in bulk. For a batch of 1,000 orders, that’s 4,000 fewer individual SQL statements — with the heaviest operations (inventory updates, audit inserts) now running as efficient set-based bulk operations.

Try This Yourself

Find a complex cursor in your environment — the kind with multiple IF branches and side effects. Feed it to the agent with this prompt:

Compare the rewrite’s behavior against the original on a test system. Pay close attention to concurrency — the set-based version may have different locking behavior than the row-by-row version, and that difference can introduce blocking or deadlocks under load.

For the simpler cursor-to-set-based conversions, see the companion post on cursor elimination. For the full legacy code reverse-engineering workflow, see Reverse-Engineering Legacy Stored Procedures. For T-SQL generation and refactoring patterns, see Writing T-SQL with an AI Partner.


Part of the ALTER DBA ADD AGENT series.