PostgreSQL vs MySQL EXPLAIN Output Differences
PostgreSQL and MySQL both expose a JSON execution plan, but the two documents share almost no field names, use incompatible cost units, and disagree on where actual-versus-estimated numbers live — so any baseline tracker that stores them raw will compare noise. This runbook enumerates the exact structural differences between EXPLAIN (FORMAT JSON) in PostgreSQL and EXPLAIN FORMAT=JSON in MySQL, then gives a deterministic normalization that folds both into one canonical plan node you can hash, diff, and threshold. It is the engine-mapping layer that feeds Normalizing Query Plans for Cross-Engine Comparison and it depends on the cost-axis alignment defined in Cost Estimation Mapping Across PostgreSQL and MySQL.
Symptom Identification and Production Thresholds
Storing engine-native plans and comparing them directly produces silent false negatives: the fields you diff on are either absent or measured on a different axis. Treat each of the following as a hard breach condition, not a guideline.
- Buffer fields present in only one engine. PostgreSQL under
BUFFERSemitsShared Hit Blocks,Shared Read Blocks, andTemp Written Blocks; MySQLEXPLAIN FORMAT=JSONcarries zero buffer-accounting fields. A canonical schema that marksshared_hit_blocksas required will null out on 100% of MySQL captures. Any capture whose required-field null rate exceeds 0% must be quarantined, never joined to a baseline. - Cost-unit magnitude mismatch. An identical full scan of a 1,000,000-row table yields a PostgreSQL
Total Costnear18,334(rows plus pages, atseq_page_cost = 1.0) versus a MySQLquery_costnear103,000(row_evaluate_cost = 0.1per row plus block reads) — a scale gap. Comparing raw cost numbers cross-engine with any tolerance under460%is meaningless. - Cardinality fields do not align. PostgreSQL exposes one estimate (
Plan Rows) and, underANALYZE, one actual (Actual Rows). MySQL FORMAT=JSON exposesrows_examined_per_scan(pre-filter) androws_produced_per_join(post-filter) but no actual at all. Any estimated-versus-actual ratio built by pairing PostgreSQLActual Rowsagainst MySQLrows_produced_per_joincompares an actual to an estimate and will misfire above the divergence alarm. - Actual-time double counting. PostgreSQL
Actual Total Timeis per-node cumulative milliseconds already normalized per loop; MySQLEXPLAIN ANALYZEemits a text tree withactual time=0.05..12.4 rows=840 loops=6, where time is per-iteration. Summing the two the same way over-counts the MySQL inner side by itsloopsfactor, inflating totals by – on nested access. - Operator-vocabulary collision. PostgreSQL has a dedicated
Merge Joinnode; MySQL 8 has none, offering only nested-loop and (since 8.0.18) hash join. A plan hash keyed on raw operator names collides across engines on more than30%of join-bearing queries.
When two or more of these fire on the same fingerprint, stop comparing raw plans and route both captures through the normalizer below before any threshold evaluation.
Root Cause Analysis
The divergence is not cosmetic — it stems from two optimizers with unrelated cost models and unrelated serializers. PostgreSQL descends from a page-fetch cost model where 1.0 unit equals one sequential 8 KB page read; every other constant (random_page_cost = 4.0, cpu_tuple_cost = 0.01) is expressed relative to that. MySQL’s optimizer costs in its own units anchored on io_block_read_cost = 1.0 and row_evaluate_cost = 0.1, and it accumulates cost as read_cost + eval_cost per table rather than as startup/total pairs per node. The two numbers are not the same currency, and no fixed exchange rate is exact — but a workload-calibrated scaling constant of roughly 5.6 maps a MySQL full-scan cost onto the PostgreSQL axis closely enough to threshold.
A PostgreSQL node is a recursive object rooted at plan[0]["Plan"], with children under Plans:
[
{
"Plan": {
"Node Type": "Hash Join",
"Total Cost": 18334.50,
"Plan Rows": 42000,
"Actual Rows": 41180,
"Shared Hit Blocks": 9021,
"Plans": [
{"Node Type": "Seq Scan", "Relation Name": "orders",
"Total Cost": 12010.00, "Plan Rows": 500000, "Actual Rows": 499213}
]
}
}
]MySQL nests the same logical join under query_block, with per-table cost inside cost_info and joins expressed as a nested_loop array rather than typed nodes:
{
"query_block": {
"cost_info": {"query_cost": "103182.40"},
"nested_loop": [
{"table": {"table_name": "orders", "access_type": "ALL",
"rows_examined_per_scan": 500000, "rows_produced_per_join": 500000,
"filtered": "100.00",
"cost_info": {"read_cost": "51420.00", "eval_cost": "50000.00"}}}
]
}
}The generative fault lines are: cost currency (page-relative versus block-and-row), plan shape (typed recursive nodes versus a nested_loop/table array), cardinality semantics (single estimate/actual pair versus pre- and post-filter estimates), and instrumentation surface (rich BUFFERS I/O accounting versus none). You can align a query to a stable identity across these using Plan Hashing Algorithms for SQL Engines, but only after the fields are canonical.
Step-by-Step Remediation
Normalize at capture time so the baseline store only ever holds canonical nodes. The reference implementation captures the PostgreSQL plan over asyncpg, walks both engine shapes, remaps operators, rescales the MySQL cost onto the PostgreSQL axis, and rejects any node missing a required field.
import json
from dataclasses import dataclass
from enum import Enum
from typing import Optional
import asyncpg
import structlog
from opentelemetry import trace
log = structlog.get_logger("normalize.explain")
tracer = trace.get_tracer("normalize.explain")
# PG Total Cost is in seq_page_cost units; a MySQL query_cost for an equivalent
# full scan runs ~5.6x larger. Divide MySQL cost to land on the PostgreSQL axis.
MYSQL_COST_SCALE = 1.0 / 5.6
REQUIRED_FIELDS = ("relation", "operator", "est_rows", "est_cost")
class CanonicalOp(str, Enum):
SEQ_SCAN = "seq_scan"
INDEX_SCAN = "index_scan"
NESTED_LOOP = "nested_loop"
HASH_JOIN = "hash_join"
MERGE_JOIN = "merge_join"
AGGREGATE = "aggregate"
OTHER = "other"
PG_OP_MAP = {
"Seq Scan": CanonicalOp.SEQ_SCAN, "Index Scan": CanonicalOp.INDEX_SCAN,
"Index Only Scan": CanonicalOp.INDEX_SCAN, "Bitmap Heap Scan": CanonicalOp.INDEX_SCAN,
"Nested Loop": CanonicalOp.NESTED_LOOP, "Hash Join": CanonicalOp.HASH_JOIN,
"Merge Join": CanonicalOp.MERGE_JOIN, "Aggregate": CanonicalOp.AGGREGATE,
}
MYSQL_ACCESS_MAP = {
"ALL": CanonicalOp.SEQ_SCAN, "index": CanonicalOp.INDEX_SCAN,
"range": CanonicalOp.INDEX_SCAN, "ref": CanonicalOp.INDEX_SCAN,
"eq_ref": CanonicalOp.INDEX_SCAN, "const": CanonicalOp.INDEX_SCAN,
}
@dataclass(frozen=True)
class CanonicalNode:
engine: str
relation: Optional[str]
operator: CanonicalOp
est_rows: int
est_cost: float # normalized onto the PostgreSQL axis
actual_rows: Optional[int] = None
shared_hit_blocks: Optional[int] = None # PostgreSQL-only, None elsewhere
def normalize_pg(plan_json: str) -> list[CanonicalNode]:
root = json.loads(plan_json)[0]["Plan"]
out: list[CanonicalNode] = []
def walk(n: dict) -> None:
out.append(CanonicalNode(
engine="postgresql",
relation=n.get("Relation Name"),
operator=PG_OP_MAP.get(n["Node Type"], CanonicalOp.OTHER),
est_rows=int(n.get("Plan Rows", 0)),
est_cost=float(n.get("Total Cost", 0.0)),
actual_rows=int(n["Actual Rows"]) if "Actual Rows" in n else None,
shared_hit_blocks=n.get("Shared Hit Blocks"),
))
for child in n.get("Plans", []):
walk(child)
walk(root)
return out
def normalize_mysql(plan_json: str) -> list[CanonicalNode]:
root = json.loads(plan_json)["query_block"]
out: list[CanonicalNode] = []
def emit(tbl: dict) -> None:
ci = tbl.get("cost_info", {})
raw = float(ci.get("read_cost", 0.0)) + float(ci.get("eval_cost", 0.0))
out.append(CanonicalNode(
engine="mysql",
relation=tbl.get("table_name"),
operator=MYSQL_ACCESS_MAP.get(tbl.get("access_type", ""), CanonicalOp.OTHER),
est_rows=int(tbl.get("rows_examined_per_scan", 0)),
est_cost=raw * MYSQL_COST_SCALE, # rescale onto the PostgreSQL axis
))
def walk(block: dict) -> None:
if "table" in block:
emit(block["table"])
for item in block.get("nested_loop", []):
walk(item)
for key in ("ordering_operation", "grouping_operation",
"duplicates_removal", "query_block"):
if key in block:
walk(block[key])
walk(root)
return out
def reject_incomplete(nodes: list[CanonicalNode]) -> list[CanonicalNode]:
for n in nodes:
missing = [f for f in REQUIRED_FIELDS if getattr(n, f) in (None, "")]
if missing:
log.error("quarantine_node", engine=n.engine, relation=n.relation,
missing=missing)
raise ValueError(f"canonical node missing {missing}")
return nodes
async def capture_pg(pool: asyncpg.Pool, sql: str) -> list[CanonicalNode]:
with tracer.start_as_current_span("capture_pg") as span:
async with pool.acquire() as conn:
await conn.execute("SET LOCAL statement_timeout = 800")
raw = await conn.fetchval(f"EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) {sql}")
span.set_attribute("db.system", "postgresql")
nodes = reject_incomplete(normalize_pg(raw))
log.info("captured", engine="postgresql", node_count=len(nodes))
return nodesThe mapping is deterministic: the same plan text always yields the same canonical nodes, so the output is safe to hash and store. The field correspondences the mapper enforces are:
| Canonical field | PostgreSQL source | MySQL source | Notes |
|---|---|---|---|
relation | Relation Name | table.table_name | Null on pure compute nodes in both engines |
operator | Node Type via PG_OP_MAP | access_type via MYSQL_ACCESS_MAP | MySQL has no Merge Join; folds to other/hash_join |
est_rows | Plan Rows | rows_examined_per_scan | MySQL rows_produced_per_join is post-filter; do not mix |
est_cost | Total Cost | Rescale before any cross-engine threshold | |
actual_rows | Actual Rows (under ANALYZE) | none (EXPLAIN ANALYZE tree only) | Leave None for MySQL FORMAT=JSON |
shared_hit_blocks | Shared Hit Blocks (under BUFFERS) | none | Optional; never required cross-engine |
Running the capture against a two-table PostgreSQL join emits the canonical count and lets a downstream diff operate on uniform nodes:
2026-07-18T09:41:07Z [info] captured engine=postgresql node_count=3
2026-07-18T09:41:07Z [info] canonical_diff fingerprint=7c2b…e4 est_cost_pg=18334.5 est_cost_norm_mysql=18425.8 delta_pct=0.005A normalized delta near 0.005 confirms the two engines agree once cost is on a common axis — the same query is not regressing, it is merely described in two dialects.
Verification Checklist
- [ ] The MySQL cost scaling constant (
1/5.6) has been recalibrated against your own full-scan benchmark on the current storage tier, not assumed. - [ ] Every canonical node passes
reject_incomplete; the quarantine rate for both engines is0%over the last 100 captures. - [ ]
est_rowsis sourced fromPlan Rowsandrows_examined_per_scanonly — never fromrows_produced_per_join. - [ ]
actual_rowsisNonefor all MySQL FORMAT=JSON captures, and populated for PostgreSQL captures taken withANALYZE. - [ ] Operator maps cover every
Node Typeandaccess_typeobserved in the last week; unmapped values fall tootherand are logged, not silently dropped. - [ ] The canonical delta for a known-stable cross-engine query stays under
0.05after normalization. - [ ]
shared_hit_blocksis marked optional in the baseline schema so MySQL rows are not rejected for lacking buffers. - [ ] A round-trip test re-normalizes a stored plan and reproduces an identical canonical node set (determinism holds).
Compatibility and Engine-Specific Notes
The canonical shape is engine-agnostic, but the source fields and their caveats differ sharply. Distributed SQL engines add a third dialect again — CockroachDB reports a unitless optimizer cost and per-node estimated row counts, while TiDB splits work across task tiers (root versus cop) with estRows/actRows columns.
| Concern | PostgreSQL | MySQL 8.x | Distributed SQL (CockroachDB / TiDB) |
|---|---|---|---|
| Plan entry point | plan[0]["Plan"], recursive Plans | query_block, nested_loop/table array | EXPLAIN (VERBOSE) tree / EXPLAIN ANALYZE operator rows |
| Cost field | Total Cost (page-relative) | cost_info.query_cost (block-and-row) | CockroachDB unitless cost; TiDB estCost with FORMAT='verbose' |
| Row estimate | Plan Rows | rows_examined_per_scan | estimated row count / estRows |
| Actual rows | Actual Rows under ANALYZE | tree-only under EXPLAIN ANALYZE | CockroachDB actual row count; TiDB actRows |
| Buffer / I/O accounting | Shared Hit Blocks + read/temp blocks | none | KV-batch and network stats, not page buffers |
| Merge join node | Merge Join present | absent (nested-loop / hash only) | TiDB MergeJoin; CockroachDB merge join |
| Cost calibration | random_page_cost, seq_page_cost | not disk-time calibrated; scale manually | per-range placement makes cost node-dependent |
Fold MySQL’s missing Merge Join into the hash-join operator class rather than inventing a node the optimizer never chose. On distributed engines, a single logical relation can span ranges on different nodes, so treat any cost scaling as per-range rather than per-table. Anchor the exchange rate itself in Cost Estimation Mapping Across PostgreSQL and MySQL, and hash the normalized output with Plan Hashing Algorithms for SQL Engines so a canonical node set has one stable identity regardless of source engine.
Related
- ← Back to Normalizing Query Plans for Cross-Engine Comparison (parent topic)
- Align the cost currency: Cost Estimation Mapping Across PostgreSQL and MySQL
- Give the canonical node a stable identity: Plan Hashing Algorithms for SQL Engines
- Wider context: Automated EXPLAIN Capture & Storage Workflows
← Back to Normalizing Query Plans for Cross-Engine Comparison