Trino explain plan review
Skill ivanshamaev/de-agent-skills/group_skills/trino_group_skills/trino_explain_plan_review
Профессиональные Data Engineering Agent Skills для разработки AI Agentic Data Platform
npx -y skills add ivanshamaev/de-agent-skills --skill trino_explain_plan_reviewAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 13 stars13 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Trino EXPLAIN and EXPLAIN ANALYZE plan reading — DISTRIBUTED/LOGICAL/IO/VALIDATE formats, fragment types (SINGLE/HASH/ROUND_ROBIN/BROADCAST/SOURCE), exchange node analysis, stage bottleneck detection, data skew identification via task row-count variance, spill detection, operator-level metrics (ScanFilterProject/HashJoin/Aggregation/LocalExchange), cost estimate vs actual row disparity, slow plan patterns and fixes
SKILL.md
10.1 KB, as published. Nobody here has run it
Trino EXPLAIN Plan Review
When to Use
- A query is unexpectedly slow and you need to find the bottleneck
- Verifying that predicate/partition pushdown is actually happening
- Detecting data skew across tasks
- Comparing plan cost estimates against actual runtime metrics
- Reviewing query plan before deploying to production
EXPLAIN Syntax Reference
-- Default: DISTRIBUTED plan (most useful for production diagnosis)
EXPLAIN SELECT ...;
-- Distributed plan (explicit)
EXPLAIN (TYPE DISTRIBUTED) SELECT ...;
-- Logical plan (high-level operators)
EXPLAIN (TYPE LOGICAL) SELECT ...;
-- IO plan (what tables/columns are read, with constraints)
EXPLAIN (TYPE IO) SELECT ...;
-- Validate syntax only — returns TRUE/FALSE
EXPLAIN (TYPE VALIDATE) SELECT ...;
-- Output as JSON (useful for programmatic analysis)
EXPLAIN (FORMAT JSON) SELECT ...;
-- Runtime metrics — execute the query and report actual operators
EXPLAIN ANALYZE SELECT ...;
-- Full verbose metrics: percentile distributions, driver counts
EXPLAIN ANALYZE VERBOSE SELECT ...;
DISTRIBUTED Plan: Fragment Types
A distributed plan splits into numbered Fragments separated by exchange operators. Each fragment runs on specific nodes.
| Fragment Type | Description | Runs On |
|---|---|---|
SINGLE | Final result aggregation | Coordinator |
SOURCE | Reads data from connector splits | Where splits are located |
HASH | Partitioned by hash key (joins, GROUP BY) | All workers, fixed partition |
ROUND_ROBIN | Round-robin distribution | All workers |
BROADCAST | Build side replicated to all workers | All workers |
Fragment 0 [SINGLE] ← coordinator collects final output
Output[order_id, total]
└── RemoteStreamingExchange[GATHER]
└── Fragment 1 [HASH] ← join stage, repartitioned by join key
HashJoin[order_id]
├── RemoteExchange[REPARTITION] ← probe side from Fragment 2
└── LocalExchange[BROADCAST] ← build side from Fragment 3
Fragment 1 ...
Fragment 2 [SOURCE] ← scans fact_orders splits
ScanFilterProject[table=fact_orders, ...]
Fragment 3 [SOURCE] ← scans dim_customer splits
ScanFilterProject[table=dim_customer, ...]
Key insight: Every RemoteExchange = network shuffle. Minimize them.
Reading an EXPLAIN DISTRIBUTED Plan
EXPLAIN
SELECT o.customer_id, SUM(o.amount) AS total
FROM iceberg.silver.orders o
JOIN iceberg.gold.dim_customer c ON o.customer_id = c.customer_id
WHERE o.order_date >= DATE '2024-01-01'
GROUP BY o.customer_id;
Look for:
| What to check | Where to look | Good sign | Bad sign |
|---|---|---|---|
| Partition pruning | ScanFilterProject constraints | partitions=42 (pruned) | partitions=3650 (full scan) |
| Predicate pushdown | Presence of Filter above scan | No Filter node | Filter[...] above TableScan |
| Join strategy | HashJoin type | BROADCAST (small dim) | PARTITIONED for tiny table |
| Exchange count | Number of RemoteExchange nodes | ≤ 3 for simple join | > 5 suggests over-shuffling |
| Broadcast size | LocalExchange[BROADCAST] | Small row count | Millions of rows → OOM risk |
EXPLAIN ANALYZE: Runtime Metrics
EXPLAIN ANALYZE runs the query and annotates the plan with actual metrics.
EXPLAIN ANALYZE VERBOSE
SELECT customer_id, SUM(amount)
FROM iceberg.silver.orders
WHERE order_date = DATE '2024-06-01'
GROUP BY customer_id;
Sample output interpretation:
Fragment 1 [HASH]
CPU: 45.23s, Scheduled: 62.10s, Blocked: 3.21s (I/O: 1.5s, Overall: 1.7s)
Output: 1234567 rows (98.5MB), Filtered: 78.3%
Input avg.: 15.2M rows, Input std.dev.: 234.5% ← HIGH SKEW
- ScanFilterProject[table=silver.orders] {
Input: 15.2M avg (range: 1K–45M) ← SKEWED splits
CPU fraction: 35%
}
Metrics to Flag
| Metric | Threshold | Meaning | Fix |
|---|---|---|---|
Input std.dev. | > 50% | Data skew across tasks | Add bucket/salt column, rewrite partition |
Blocked: I/O | > 10% of Scheduled | Slow object storage reads | Check S3/MinIO throughput, increase workers |
Blocked: Overall | Persistent | Memory pressure / slow consumers | Increase query.max-memory-per-node |
| CPU fraction | < 20% on scan | Most time in network | Reduce exchanges, increase partition pushdown |
| Estimated rows >> Actual | 100x difference | Stale statistics | Run ANALYZE table |
Common Plan Patterns and Fixes
Pattern 1: Missing Partition Pruning
-- BAD PLAN: full scan despite date filter
ScanFilterProject[table=silver.orders]
Estimates: {rows: 3650000000, cpu: 3.65T, ...}
Filter: (YEAR(order_date) = 2024) ← function wrapping prevents pushdown
-- FIX: use range predicate
WHERE order_date >= DATE '2024-01-01' AND order_date < DATE '2025-01-01'
-- GOOD PLAN: only matching partitions read
ScanFilterProject[table=silver.orders]
Estimates: {rows: 365000000, cpu: 365B, ...}
Constraint: order_date in [2024-01-01, 2024-12-31]
Pattern 2: Accidental Cross Join
-- BAD: missing join condition creates cross join
Fragment 1 [BROADCAST]
CrossJoin
├── Source: fact (1B rows)
└── Broadcast: dim (100 rows) → 100B rows output
-- FIX: add ON clause with correct column
Pattern 3: Data Skew in Hash Join
-- SYMPTOM: one task processes 90% of rows
Fragment 2 [HASH]
Input avg.: 5M rows, Input std.dev.: 890% ← extreme skew
HashJoin[customer_id]
-- CAUSE: customer_id distribution is highly skewed (many NULLs or one hot key)
-- FIX 1: filter NULLs before join
-- FIX 2: salt the hot key
SELECT CONCAT(CAST(customer_id AS VARCHAR), '_', CAST(MOD(ABS(order_id), 8) AS VARCHAR))
Pattern 4: Unnecessary Remote Exchange
-- BAD: intermediate aggregation forces repartition
Fragment 1 [HASH]
RemoteExchange[REPARTITION] ← extra shuffle
Aggregate[partial]
ScanFilterProject
-- GOOD when: pre-aggregation on SOURCE fragment reduces data 100x before shuffle
-- BAD when: cardinality is unchanged (COUNT DISTINCT of unique key)
Pattern 5: Small Files → Too Many Splits
-- SYMPTOM: thousands of splits, each tiny
ScanFilterProject[table=bronze.events]
Splits: 45000 × 1MB ← 45K tiny files
-- FIX: run optimize to compact
ALTER TABLE iceberg.bronze.events EXECUTE optimize(file_size_threshold => '128MB');
IO Plan: Verify Table Access
EXPLAIN (TYPE IO, FORMAT JSON)
SELECT order_id FROM iceberg.silver.orders
WHERE order_date = DATE '2024-06-01';
{
"inputTableColumnInfos": [
{
"table": {"catalog": "iceberg", "schemaTable": {"schema": "silver", "table": "orders"}},
"columns": ["order_id", "order_date"], -- columns actually read
"constraint": {
"columnConstraints": [
{
"columnName": "order_date",
"nullability": "NOT_NULL",
"domain": {"ranges": [{"low": "2024-06-01", "high": "2024-06-01"}]}
}
]
},
"estimate": {"outputRowCount": 1234567.0, "outputSizeInBytes": 9876543.0}
}
]
}
Use IO plan to verify:
- Only necessary columns are read (
columnslist) - Partition constraint is applied (
domain.ranges) - Estimated row count is reasonable (not full table)
EXPLAIN ANALYZE Verbose: Skew Detection
EXPLAIN ANALYZE VERBOSE
SELECT customer_id, COUNT(*)
FROM iceberg.silver.orders
GROUP BY customer_id;
In verbose output, look for:
Input rows distribution:
p25: 1.2M, p50: 3.5M, p75: 8.1M, p99: 45.2M ← p99 >> p50 = SKEW
Active drivers: 48/64 (25% idle) ← some tasks starved
High p99/p50 ratio (> 5x) indicates hot partitions requiring data redistribution.
Quick EXPLAIN Checklist
-- Run EXPLAIN first:
EXPLAIN (TYPE DISTRIBUTED, FORMAT TEXT) <your query>;
-- Check:
-- [ ] Number of fragments ≤ 5 for simple queries
-- [ ] ScanFilterProject has partition constraints (not full scan)
-- [ ] No Filter node above TableScan (predicate pushed down)
-- [ ] JOIN uses BROADCAST for small build side (< 100MB)
-- [ ] No unexpected CrossJoin nodes
-- [ ] Estimated rows are reasonable (not billions for small result)
-- Then run EXPLAIN ANALYZE to get actuals:
EXPLAIN ANALYZE VERBOSE <your query>;
-- Check actuals:
-- [ ] Input std.dev. < 50% (no skew)
-- [ ] Blocked time < 5% of scheduled time
-- [ ] Estimated rows ≈ actual rows (CBO working)
-- [ ] CPU fraction > 30% of scheduled time
Anti-Patterns
- Only running
EXPLAINwithoutANALYZE— estimated costs are based on statistics that may be stale; always runEXPLAIN ANALYZEto see actual row counts. - Ignoring
Input std.dev.— high deviation (> 200%) is the primary skew signal; it means a few tasks do most of the work while others sit idle. - Missing the difference between
SINGLEandHASHfragments —SINGLEmeans one node does all the work; aSINGLEfragment handling billions of rows is a bottleneck. - Not checking
Filternode placement — aFilteraboveTableScanmeans the predicate wasn't pushed into the connector; rewrite the filter to enable pushdown. - Reading
TEXTformat for automated analysis — useFORMAT JSONfor programmatic processing;TEXTis for human reading only.
References
- EXPLAIN syntax:
trino.io/docs/current/sql/explain.html - EXPLAIN ANALYZE:
trino.io/docs/current/sql/explain-analyze.html - Pushdown verification:
trino.io/docs/current/optimizer/pushdown.html - Related skills:
[[trino-query-optimization]],[[trino-memory-and-spill-tuning]],[[trino-iceberg-best-practices]]