Runbook

Identifying Hash-to-Nested Loop Join Shifts Automatically

This runbook shows how to detect the specific regression where the optimizer replaces a Hash Join with a Nested Loop on a structurally identical plan node, and how to remediate it before it breaches a latency SLO.

A hash-to-nested-loop shift is the single most damaging join-strategy regression a Database SRE encounters, because its cost curve is non-linear: a nested loop that is cheap at 200 inner rows becomes catastrophic at 200,000, scanning O(N×M) instead of building one hash table. This page is a focused procedure for the one transition — hashnestedloophash \to nested_{loop} — that sits underneath the broader join-type shift detector. It assumes plans are already captured and fingerprinted upstream; here we identify the shift, attribute its root cause, and reverse it safely.

Symptom Identification and Production Thresholds

A hash-to-nested-loop shift rarely fires a single clean alarm. It surfaces as correlated degradation across latency, CPU, and memory. Treat the shift as confirmed when two or more of the following breach conditions hold for the same query_fingerprint inside one 5-minute window:

  1. p95 execution latency ≥ 3.0× the anchored baseline median. The baseline median is the value stored by your regression pipeline, not a rolling in-request average.
  2. CPU wait ≥ 60% of total query duration. Row-by-row inner-side probing shows up as CPU-bound time, not I/O wait.
  3. Actual-to-estimated row ratio ≥ 10.0 on the inner input. The optimizer chose a nested loop because it estimated a tiny inner set; the actual scan is an order of magnitude larger.
  4. Plan-cache churn ≥ 15% new plan generations per hour for one plan hash — a signature of parameter-sensitive replans flip-flopping between strategies.
  5. work_mem / hash_area_size spill rate ≥ 5% of executions. Memory pressure that suppresses the hash build is both a symptom and a cause.

The operational priority the instant these align is to capture the offending plan before cache eviction. Extract the raw tree with EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) on PostgreSQL, or sys.dm_exec_query_plan filtered by query_hash on SQL Server, then hand it to the deterministic comparison keyed by the plan fingerprint.

Hash Join baseline versus Nested Loop candidate on one aligned nodeTwo panels for the same aligned plan node. The baseline Hash Join builds a single hash table and has a near-flat cost curve; the candidate Nested Loop re-probes the inner input per outer row and its cost curve grows super-linearly with inner rows.Same aligned nodenode_id qb1:join:2  ·  predicate_hash a1b2c3Baseline · Hash JoinHash JoinHash builddim_region · onceProbefact_orderscostinner rows →near-flatbuild once, then O(N) probeCandidate · Nested LoopNested LoopOuter scanfact_orders · N rowsInner index scanre-run per outer rowcostinner rows →O(N × M)catastrophic as inner rows grow

Root Cause Analysis

Before automating any reversal, attribute the shift to one of four failure domains. Each has a distinct diagnostic and a distinct fix; forcing a plan without knowing which one is active masks the real defect.

1. Stale statistics. The optimizer underestimated inner cardinality because the histogram predates a bulk load or a distribution change. This is the most common trigger. Diagnose on PostgreSQL:

SQL
SELECT relname, last_analyze, last_autoanalyze, n_mod_since_analyze
FROM pg_stat_user_tables
WHERE relname = 'fact_orders';

A large n_mod_since_analyze relative to the table size, or an last_analyze older than the last load, confirms drift.

2. Parameter sniffing. A plan compiled for a selective bind value (WHERE region = 'EU') is reused for a non-selective one (WHERE region = 'US'), where the nested loop is disastrous. Diagnose by comparing the compiled estimate against runtime reality:

SQL
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM fact_orders f
JOIN dim_region r ON f.region_id = r.id
WHERE r.code = 'US';
-- Look for: "rows=..." (estimate) vs "actual rows=..." on the inner scan.

3. Memory pressure. Global work_mem exhaustion makes the optimizer avoid the hash build entirely and default to a loop. Correlate the shift timeline against pg_stat_activity states and configured work_mem; if the shift coincides with concurrency spikes rather than a data change, this domain — not statistics — is the culprit.

4. Dropped or invalidated index. An index the baseline plan relied on was dropped or rebuilt INVALID, and the optimizer’s fallback path happens to prefer a loop. Cross-check with the index-usage regression signal:

SQL
SELECT indexrelname, idx_scan, indisvalid
FROM pg_stat_user_indexes i
JOIN pg_index x ON i.indexrelid = x.indexrelid
WHERE relname = 'fact_orders';

Step-by-Step Remediation

The detection stage below is a production-grade evaluator: it consumes an already-normalized operator tree, aligns the candidate node against its baseline, and emits a directive only when the hashnestedloophash \to nested_{loop} transition is real. It follows the site’s async/structlog/OpenTelemetry conventions so it drops directly into the regression worker pool.

PYTHON
from __future__ import annotations

import asyncio
from dataclasses import dataclass
from enum import Enum
from typing import Optional

import structlog
from opentelemetry import metrics, trace

log = structlog.get_logger("hash_to_nl_detector")
tracer = trace.get_tracer("regression.hash_to_nl")
meter = metrics.get_meter("regression.hash_to_nl")

shift_total = meter.create_counter(
    "hash_to_nl_shift_total", unit="1",
    description="Confirmed hash-to-nested-loop shifts, labelled by directive",
)

# Exact thresholds — never 'configure as needed'.
INNER_ROW_RATIO_LIMIT = 10.0   # actual / estimated on the inner input
LATENCY_MULTIPLIER_LIMIT = 3.0  # candidate p95 / baseline median


class JoinType(Enum):
    HASH = "hash"
    NESTED_LOOP = "nested_loop"
    MERGE = "merge"
    UNKNOWN = "unknown"


@dataclass(frozen=True)
class AlignedNode:
    node_id: str
    predicate_hash: str
    baseline_type: JoinType
    candidate_type: JoinType
    estimated_inner_rows: float
    actual_inner_rows: float
    candidate_p95_ms: float
    baseline_median_ms: float


def _row_ratio(node: AlignedNode) -> float:
    est = max(node.estimated_inner_rows, 1.0)
    return node.actual_inner_rows / est


async def evaluate_node(node: AlignedNode) -> Optional[str]:
    """Return a remediation directive when a real hash->NL shift is proven."""
    with tracer.start_as_current_span("evaluate_node") as span:
        span.set_attribute("db.plan.node_id", node.node_id)

        is_shift = (
            node.baseline_type is JoinType.HASH
            and node.candidate_type is JoinType.NESTED_LOOP
            and node.predicate_hash  # identical predicate => structurally aligned
        )
        if not is_shift:
            return None

        ratio = _row_ratio(node)
        latency_mult = node.candidate_p95_ms / max(node.baseline_median_ms, 1.0)

        if ratio >= INNER_ROW_RATIO_LIMIT:
            directive = "REFRESH_STATISTICS"
        elif latency_mult >= LATENCY_MULTIPLIER_LIMIT:
            directive = "FORCE_HASH_JOIN"
        else:
            log.info("shift_observed_below_threshold",
                     node_id=node.node_id, ratio=round(ratio, 2))
            return None

        shift_total.add(1, {"directive": directive})
        log.warning("hash_to_nl_shift_confirmed",
                    node_id=node.node_id, directive=directive,
                    row_ratio=round(ratio, 2), latency_mult=round(latency_mult, 2))
        return directive


async def main() -> None:
    node = AlignedNode(
        node_id="qb1:join:2", predicate_hash="a1b2c3",
        baseline_type=JoinType.HASH, candidate_type=JoinType.NESTED_LOOP,
        estimated_inner_rows=180.0, actual_inner_rows=214_000.0,
        candidate_p95_ms=4200.0, baseline_median_ms=310.0,
    )
    directive = await evaluate_node(node)
    print(f"directive={directive}")


if __name__ == "__main__":
    asyncio.run(main())

Running it against the sample node prints the expected directive:

TEXT
directive=REFRESH_STATISTICS

With the directive in hand, apply the escalation chain in strict order of operational safety. Never skip to a forced plan.

Escalation ladder from confirmed shift to rollbackA top-down ladder of four remediation steps of increasing operational risk: refresh statistics, targeted join hint, plan pinning, then rollback and circuit break. Each downward arrow is labelled with the condition that escalates to the next step.escalating operational riskShift confirmeddirective emitted · never skip a rungstill Nested Loop after ANALYZEhint can't be embedded in callerpinned plan spills / deadlocks1 · Refresh statisticsANALYZE / UPDATE STATISTICS — re-sample the histogramLOW2 · Targeted join hintpg_hint_plan / INNER HASH JOIN — one statement onlyMED3 · Plan pinningQuery Store force plan / SPM — native plan managementHIGH4 · Rollback + circuit breakdrop the guide, revert to defaults — p99 > 5s auto-disablesSTOP

Step 1 — Refresh statistics (low risk). For the REFRESH_STATISTICS directive, re-sample the histogram, then re-run the query and confirm the optimizer returns to a hash join.

SQL
-- PostgreSQL
ANALYZE VERBOSE fact_orders;
-- SQL Server
UPDATE STATISTICS dbo.fact_orders WITH FULLSCAN;

Expected result: EXPLAIN now reports Hash Join at the aligned node and the inner estimate matches actual within the 10× band.

Step 2 — Targeted join hint (medium risk). If statistics are already current (the FORCE_HASH_JOIN directive), pin the strategy for this statement only.

SQL
-- PostgreSQL (pg_hint_plan)
/*+ HashJoin(f r) */
SELECT * FROM fact_orders f JOIN dim_region r ON f.region_id = r.id;
-- SQL Server
SELECT * FROM fact_orders f INNER HASH JOIN dim_region r ON f.region_id = r.id;

Verify work_mem (PostgreSQL) or max_grant_percent (SQL Server) headroom first — a hash build that spills to disk trades one regression for another.

Step 3 — Plan pinning (high risk). Only when a hint cannot be embedded in the calling code, pin the plan through native plan management: sp_query_store_force_plan on SQL Server, pg_hint_plan with session scope on PostgreSQL, or DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE on Oracle.

Step 4 — Rollback and circuit break. If a pinned plan causes memory exhaustion or lock contention, drop the guide and revert to optimizer defaults. Auto-disable pinning when p99 latency > 5s or deadlock_count > 0 inside any 60-second window. Feed the reversal outcome back into the regression thresholds so a recurrence escalates faster.

Verification Checklist

Run every check after remediation; a green run means the shift is reversed and the baseline is trustworthy again.

  • [ ] EXPLAIN (ANALYZE, BUFFERS) shows Hash Join at the previously drifted node.
  • [ ] Inner actual-to-estimated row ratio is below 10.0×.
  • [ ] p95 execution latency is back within 3.0× of the baseline median.
  • [ ] CPU wait share has dropped below 60% of query duration.
  • [ ] hash_to_nl_shift_total has stopped incrementing for the fingerprint over a full 15-minute window.
  • [ ] The reversal was logged and the baseline re-anchored (or the exception documented) in the cost-delta record.
  • [ ] Any temporary hint or pinned plan has a removal ticket with an expiry date.

Compatibility and Engine-Specific Notes

The detection logic is engine-agnostic once plans are normalized, but the operator names, memory knobs, and pinning primitives differ. Normalize operator strings through the shared mapping used by the cross-engine normalization stage before comparison, and translate cost units through the cost-estimation mapping.

ConcernPostgreSQLMySQL / InnoDBSQL ServerDistributed SQL (CockroachDB / Yugabyte)
Nested-loop operator labelNested LoopBlock Nested Loop / hash join (8.0.18+)Nested Loopslookup join
Hash operator labelHash Joinhash joinHash Matchhash join
Memory knob suppressing hashwork_memjoin_buffer_sizequery memory grantdistsql.temp_storage
Refresh statisticsANALYZEANALYZE TABLEUPDATE STATISTICSCREATE STATISTICS
Force / pin strategypg_hint_planoptimizer hints (/*+ ... */)Query Store force planHASH JOIN hint

MySQL before 8.0.18 has no hash-join executor at all, so a “shift” there is really a block-nested-loop cost change; weight its thresholds against the MySQL cost model rather than the PostgreSQL one. Distributed engines add a network-cost term to the inner probe, so calibrate the latency multiplier per cluster before trusting the 3.0× default.