Last modified: January 24, 2026
This article is written in: 🇺🇸
The Halloween Problem is a notorious pitfall in relational‐database execution plans. First observed by IBM System R researchers on October 31, 1976—hence the spooky nickname—the phenomenon occurs when an UPDATE statement unwittingly revisits and modifies the same row more than once. Each extra pass can inflate numeric columns, duplicate work, or otherwise corrupt results, so modern optimizers build in explicit defenses. Nevertheless, the problem remains a classic illustration of how row ordering and side effects can interact inside a seemingly simple set operation.
The crux of the issue is state-dependent scanning: if the executor reads rows through an index whose key contains the column being updated, the mutation can reposition that row inside the index, making it visible again later in the scan. Without extra safeguards, the engine has no memory that the tuple was already processed.
A textbook example is granting a 10 % raise to employees earning under \$50 000:
UPDATE employees
SET salary = salary * 1.10
WHERE salary < 50000;
An employee starting at \$45 000 climbs to \$49 500 after the first pass—still under the threshold—so the cursor notices the row again and applies yet another 10 % bump. In a naïve plan, the loop continues until the row finally exits the qualifying range (or, in degenerate cases, forever).
This classic remedy freezes the qualifying set in an auxiliary structure, turning the original scan into a read-only operation:
WHERE predicate into a worktable (e.g., a spool, temporary table, or materialized CTE).UPDATE solely to that worktable, guaranteeing each row changes exactly once.Original Table Worktable Original Table
+-------------+ +-------------+ +-------------+
| Row 1 | 1) Extract ---> | Row 1 | | Row 1* |
| Row 2 | | Row 3 | 2) Update --> | Row 2 |
| Row 3 | | Row 5 | | Row 3* |
| Row 4 | +-------------+ | Row 4 |
| Row 5 | | Row 5* |
+-------------+ +-------------+
^ |
|___________________ 3) Merge / Insert _________________________|
The extra I/O can be significant, so many engines insert this spool only when the optimizer detects the risk automatically.
Selecting an access path orthogonal to the updated column breaks the feedback loop:
Base Table (heap) Non-Clustered Index
+----------------------+ +------------------+
| RID 101 Data A | <----+ | Key A -> RID 101 |
| RID 102 Data B | | | Key B -> RID 102 |
| RID 103 Data C | Update | Key C -> RID 103 |
| RID 104 Data D | -------+ | Key D -> RID 104 |
| RID 105 Data E | | Key E -> RID 105 |
+----------------------+ +------------------+
^ ^ ^
| |_____________________________|
| Lookup
| (Each RID visited exactly once)
Extra non-clustered indexes speed reads but slow writes, so DBAs must balance mitigation against maintenance cost.
ANSI isolation levels can also shield a statement from revisiting modified rows:
Transaction 1 (Serializable) Transaction 2
+-----------------------------+ +-------------------------+
| 1. Read Row 1 | | 1. Blocked on Row 1 |
| 2. Update Row 1 | | |
| 3. Commit | | 2. Read Row 1 |
+-----------------------------+ | 3. Update Row 1 |
| 4. Commit |
+-------------------------+
^ ^
|_____________ strict snapshot ________|
High isolation removes the glitch but can throttle throughput on busy, write-heavy tables.
Modern DBMSs implement several automatic defenses:
Because each vendor differs, always test critical updates under production-like loads.
Running the update in a stable key order eliminates back-tracking:
ORDER BY <primary_key> forces the executor to process rows in ascending (or descending) primary-key sequence, which by definition never changes within the statement.| Table: employees |
+----------------------------+
| PK 1 salary 45000 |
| PK 2 salary 47000 | --+
| PK 3 salary 48000 | | Cursor direction
| PK 4 salary 49000 | | (ascending PK)
| PK 5 salary 49500 | |
+----------------------------+ v
| +----------------------------------+
| UPDATE ... | After pass: |
| ORDER BY PK +----------------------------------+
| | 1 49500 (was 45000) |
| | 2 51700 (was 47000) |
v | 3 52800 (was 48000) |
+----------------------------+ | 4 53900 (was 49000) |
| Executor scans once only | | 5 54450 (was 49500) |
+----------------------------+ +----------------------------------+
A supporting index on the key avoids a full-table scan and external sort, keeping the remedy performant.
Finally, explicit pessimistic locks guarantee a row is updated exactly once in a transaction:
SELECT ... FOR UPDATE (or equivalent) takes an exclusive latch on each qualifying row.+--------------------------+ +----------------------+
| Txn A (holds Lock) | | Txn B (waiting) |
+--------------------------+ +----------------------+
| 1. SELECT ... FOR UPDATE | --> | 1. Blocks on same row|
| 2. UPDATE Row | | |
| 3. COMMIT (release) | | 2. Gains lock |
+--------------------------+ | 3. UPDATE Row |
| 4. COMMIT |
+----------------------+
Locks are easy to reason about but can throttle high-throughput workloads, so use them judiciously alongside the other, less intrusive techniques.