Runbook

Normalizing Parameterized Queries for Consistent Plan Tracking

Normalizing parameterized queries for consistent plan tracking is the ingestion-time canonicalization pattern that strips runtime literals, collapses syntactic noise, and emits one stable digest per logical statement so that a single query never fragments into dozens of untrackable plan signatures.

Parameterized execution is foundational to every relational and analytical engine, yet it introduces a persistent tracking problem: identical logical statements produce divergent execution plans depending on runtime parameter values. Without deterministic normalization at the door, baseline systems shatter one query into many signatures, masking real regressions and inflating plan-cache pressure. This pattern is the input contract for the normalization engine for cross-engine comparison, which owns structural plan flattening — this page owns the SQL-text canonicalization that must happen before a plan is ever hashed. Together they feed the wider Automated EXPLAIN Capture & Storage Workflows pipeline with deterministic query digests instead of volatile raw text.

Ingestion-time query canonicalization pipelineRaw SQL text enters a Query Canonicalizer that applies four ordered, deterministic transforms: strip comments, collapse whitespace and upper-case, replace string and numeric literals with a placeholder, and sort IN-list values. The canonicalizer emits a stable normalized_query_hash, which flows downstream into the Plan Normalizer and then the Baseline Store. If canonicalize() raises, a dashed error branch degrades to a raw-text SHA-256 fallback digest that is still written to the Baseline Store so no statement is dropped.Raw SQL textvolatile literalsQuery Canonicalizerpure · deterministic · idempotent1 · strip comments-- line /* block */ → removed2 · collapse whitespace · UPPER-CASE"\s+" → " " select → SELECT3 · literals → placeholder'active', 25 → ?4 · sort IN-list valuesIN ('US','EU') = IN ('EU','US') → IN (?, ?)emitnormalized_query_hashone digest per logical statementPlan Normalizerflattens plan structureBaseline Storestable plan trackingon errorcanonicalize() raises → raw-text fallbackraw:sha256[:16] · row never dropped

Symptom identification and production thresholds

Degradation from unnormalized tracking surfaces through three measurable signals. Wire these as recording rules evaluated over a rolling 15-minute window and alert when any breach condition holds:

  1. Signature fragmentation ratio breach: COUNT(DISTINCT plan_hash) / COUNT(DISTINCT normalized_query_hash) > 0.15. A ratio above 0.15 means a single logical query is spawning multiple execution paths from parameter sniffing, implicit type coercion, or inconsistent ORM binding.
  2. Latency divergence breach: p95_execution_time_ms > 1.8 * baseline_p95_ms for any group sharing a normalized_query_hash. The 1.8 multiplier filters transient jitter while catching cardinality-estimation failures and plan-cache evictions.
  3. Plan-cache bloat breach: total_plan_cache_bytes > 0.12 * available_buffer_pool_bytes. When normalization is absent, literal-heavy queries consume disproportionate cache space, evicting reusable plans and raising compilation overhead above 0.12 of the pool.

Every one of these conditions must be evaluated against the normalized_query_hash, never raw SQL text. Raw matching fails the instant whitespace, casing, or IN-list ordering shifts, producing false-negative regression alerts. Capturing the inputs requires instrumenting your proxy or application layer with OpenTelemetry DB semantic conventions so db.query.text and a db.query.plan.hash attribute land on every span before ingestion.

Root cause analysis

Fragmentation traces to four named failure domains. Each has a direct diagnostic you can run against a live engine.

1. Parameter sniffing (cardinality skew). The optimizer samples the current bind values at compile time. SELECT * FROM orders WHERE region = ? may seek on a clustered index for region = 'US' and switch to a parallel scan for region = 'INTL'. Diagnose the reuse in PostgreSQL:

SQL
SELECT queryid, calls, mean_exec_time, stddev_exec_time
FROM pg_stat_statements
WHERE query LIKE 'SELECT%FROM orders WHERE region%'
ORDER BY stddev_exec_time DESC LIMIT 5;

A stddev_exec_time several times larger than mean_exec_time for one queryid confirms a single generic plan serving skewed binds.

2. Implicit type coercion. Driver-level coercion (VARCHAR vs NVARCHAR, INT vs BIGINT) mutates the query text before it reaches the engine, so one logical statement yields several signatures. Inspect the coercion in MySQL immediately after a run:

SQL
EXPLAIN SELECT id FROM users WHERE status = 'active'\G
SHOW WARNINGS;

A Warning | 1739 | Cannot use ref access ... because of a collation/type mismatch line flags the implicit cast.

3. Unparameterized literal injection. Dynamic SQL concatenation bypasses binding entirely, so every distinct literal becomes a distinct plan. Detect the top offenders from the shell:

BASH
psql -Atc "SELECT left(query,60), calls FROM pg_stat_statements \
  WHERE query ~ '= ''[^'']+''' ORDER BY calls DESC LIMIT 10;"

Rows with high calls and embedded quoted literals are queries that never got parameterized.

4. IN-list permutation drift. ... IN ('US','EU') and ... IN ('EU','US') are logically identical but textually distinct, so unsorted lists inflate signature counts. Confirm with a grouped count over pg_stat_statements filtered to the offending table; a group of near-identical queryids differing only in list order is the signature.

Step-by-step remediation

The fix is a deterministic, stateless canonicalizer that runs in the query router or APM agent before any plan is hashed. Idempotency is the hard requirement: identical raw inputs must yield byte-identical digests on every worker.

Step 1 — Implement the canonicalizer as a pure function. Precompile the patterns; strip comments, normalize whitespace and case, replace literals with ?, and sort IN-list values so permutation drift collapses.

PYTHON
import hashlib
import re

RE_COMMENTS = re.compile(r"(--[^\n]*$|/\*.*?\*/)", re.MULTILINE | re.DOTALL)
RE_WHITESPACE = re.compile(r"\s+")
RE_STRING_LITERALS = re.compile(r"'(?:[^'\\]|\\.)*'")
RE_NUMERIC_LITERALS = re.compile(r"\b\d+(?:\.\d+)?\b")
RE_IN_CLAUSE = re.compile(r"\bIN\s*\(([^)]+)\)", re.IGNORECASE)


def canonicalize(raw_sql: str) -> tuple[str, str]:
    """Return (normalized_sql, 16-char sha256 prefix). Pure and deterministic."""
    sql = RE_COMMENTS.sub("", raw_sql)
    sql = RE_WHITESPACE.sub(" ", sql).strip().upper()
    sql = RE_STRING_LITERALS.sub("?", sql)
    sql = RE_NUMERIC_LITERALS.sub("?", sql)

    def _sort_in(match: re.Match) -> str:
        values = sorted(v.strip() for v in match.group(1).split(","))
        return f"IN ({', '.join(values)})"

    sql = RE_IN_CLAUSE.sub(_sort_in, sql)
    digest = hashlib.sha256(sql.encode("utf-8")).hexdigest()[:16]
    return sql, digest

Expected output for SELECT id FROM users WHERE status = 'active' AND region IN ('US','EU') AND age > 25:

SELECT ID FROM USERS WHERE STATUS = ? AND REGION IN (?, ?) AND AGE > ?

Step 2 — Wrap it in an instrumented async worker. Production ingestion runs under load, so the canonicalizer belongs inside an asyncio consumer with structlog context and OpenTelemetry spans. A per-signature cache in Redis (24-hour TTL) skips redundant work; a failed canonicalization degrades to raw-text hashing rather than dropping the statement.

PYTHON
import asyncio
import hashlib

import structlog
from opentelemetry import trace

log = structlog.get_logger("query_normalizer")
tracer = trace.get_tracer("query_normalizer")


async def normalize_worker(raw_sql: str, redis, errors_counter) -> str:
    """Emit a stable normalized_query_hash, degrading safely on failure."""
    with tracer.start_as_current_span("normalize_query") as span:
        cache_key = "norm:" + hashlib.sha256(raw_sql.encode()).hexdigest()[:16]
        if cached := await redis.get(cache_key):
            span.set_attribute("normalize.cache_hit", True)
            return cached.decode()
        try:
            _, digest = await asyncio.to_thread(canonicalize, raw_sql)
            span.set_attribute("db.query.plan.hash", digest)
        except Exception as exc:  # never block ingestion
            errors_counter.add(1)
            span.record_exception(exc)
            digest = "raw:" + hashlib.sha256(raw_sql.encode()).hexdigest()[:16]
            await log.awarning("normalize_fallback", error=str(exc))
        await redis.set(cache_key, digest, ex=86400)
        return digest

Step 3 — Force parameterization at the engine for literal-heavy paths. For queries you cannot fix in the application, push canonicalization down to the engine:

SQL
-- SQL Server: collapse literals into templates automatically
ALTER DATABASE current SET PARAMETERIZATION FORCED;

-- PostgreSQL: pin a generic plan for a skewed prepared statement
SET plan_cache_mode = force_generic_plan;

Step 4 — Route breaches through a tiered alert chain. Emit plan_signature_fragmentation_ratio as a gauge and gate the response so observability degradation never blocks queries:

YAML
groups:
  - name: query-normalization
    rules:
      - alert: SignatureFragmentationWarning
        expr: plan_signature_fragmentation_ratio > 0.15
        for: 15m
        labels: { severity: P3 }
      - alert: SignatureFragmentationCritical
        expr: plan_signature_fragmentation_ratio > 0.25
          and latency_divergence_ratio > 1.8
        for: 15m
        labels: { severity: P2 }
      - alert: NormalizationPipelineFailure
        expr: rate(query_normalization_errors_total[5m]) > 0.05
        for: 5m
        labels: { severity: P1 }

At P1 the pipeline falls back to raw-text hashing and queues affected statements; once the canonicalizer stabilizes, replay the queue to reconcile historical baselines.

Verification checklist

  • [ ] canonicalize() returns byte-identical output for the same statement across two separate worker processes (idempotency proven).
  • [ ] IN ('US','EU') and IN ('EU','US') collapse to a single normalized_query_hash.
  • [ ] plan_signature_fragmentation_ratio sits at or below 0.15 for the top 20 queries by call volume.
  • [ ] query_normalization_duration_seconds p95 stays under 0.005 at peak ingestion rate.
  • [ ] A deliberately malformed statement triggers the raw: fallback and increments query_normalization_errors_total without dropping the row.
  • [ ] SHOW WARNINGS (MySQL) / pg_stat_statements (PostgreSQL) shows no implicit casts on the top 10 hottest queries.
  • [ ] Redis cache hit ratio for norm:* keys exceeds 0.9 under steady-state traffic.

Compatibility and engine-specific notes

Canonicalization is portable, but the parameterization and diagnostic surfaces differ per engine. Confirm the right lever before you ship a fix.

ConcernPostgreSQLMySQLDistributed SQL (CockroachDB / Spanner)
Forced parameterizationNo global switch; use PREPARE/EXECUTE or plan_cache_modeServer-side prepared statements onlyAutomatic literal collapsing into placeholders
Generic-plan controlplan_cache_mode = force_generic_plan (12+)OPTIMIZE FOR UNKNOWN not available; use hintsOptimizer re-plans per fingerprint automatically
Statement stats sourcepg_stat_statementsperformance_schema.events_statements_summary_by_digestcrdb_internal.node_statement_statistics / query stats API
Native digest availableNo (normalize in-pipeline)Yes (DIGEST / DIGEST_TEXT)Yes (fingerprint per statement)

Where an engine exposes a native digest (MySQL DIGEST, distributed-SQL fingerprints), reconcile it against your normalized_query_hash rather than replacing it — native digests vary by server version and will not match cross-engine, which is exactly the drift the cross-engine plan normalizer exists to absorb before regression thresholds are applied.