09 · Query Engines Over the Lake (Presto/Trino/Athena)¶
Once data lands as Parquet files in a catalog, you need something to answer SQL queries against it without loading everything into a single machine's memory. Presto/Trino (open-source, run-anywhere) and AWS Athena (the same engine, managed and serverless) are the dominant "SQL over object storage" query engines. This module builds a simplified query planner in Python to make the engine's actual work — metadata lookup, partition pruning, distributed scan, aggregation — visible end to end.
What actually ran
This module was reasoned through step by step against real pandas and
pyarrow APIs but not executed in a live interpreter for this lesson —
the outputs shown match documented behavior precisely. It reuses the
catalog pattern from Module 03.
The mental model: engine, not storage¶
import sqlite3
import pandas as pd
import pyarrow.parquet as pq
from pathlib import Path
# Catalog (Module 03's minimal version) tells the engine where partitions live
catalog = sqlite3.connect(":memory:")
catalog.execute("""
CREATE TABLE partitions (region TEXT, order_date TEXT, location TEXT, row_count INTEGER)
""")
base = Path("lake/silver/orders")
regions = ["us", "eu"]
dates = ["2026-08-01", "2026-08-02"]
import random
random.seed(1)
for region in regions:
for date in dates:
out_dir = base / f"region={region}" / f"order_date={date}"
out_dir.mkdir(parents=True, exist_ok=True)
rows = pd.DataFrame({
"order_id": range(100),
"amount": [round(random.uniform(5, 300), 2) for _ in range(100)],
})
rows.to_parquet(out_dir / "part-0001.parquet", index=False)
catalog.execute(
"INSERT INTO partitions VALUES (?, ?, ?, ?)",
(region, date, str(out_dir), 100),
)
catalog.commit()
Trino/Athena never scan a bucket by listing every key — a connector consults the catalog (Hive Metastore, Glue Catalog) to learn exactly which partition directories a query needs.
Step 1 of query execution: partition pruning from the catalog¶
def plan_scan(catalog, region_filter=None, date_filter=None) -> pd.DataFrame:
query = "SELECT region, order_date, location, row_count FROM partitions WHERE 1=1"
params = []
if region_filter:
query += " AND region = ?"
params.append(region_filter)
if date_filter:
query += " AND order_date = ?"
params.append(date_filter)
return pd.read_sql(query, catalog, params=params)
plan = plan_scan(catalog, region_filter="us", date_filter="2026-08-02")
print(plan)
region order_date location row_count
0 us 2026-08-02 lake/silver/orders/region=us/order_date=2026-08-02 100
A query like SELECT ... WHERE region='us' AND order_date='2026-08-02'
plans to touch exactly one partition (100 rows) instead of the full table
(400 rows) — this planning step happens before any data file is opened.
Step 2: distributed scan, simulated as parallel workers¶
from concurrent.futures import ThreadPoolExecutor
def scan_partition(location: str) -> pd.DataFrame:
files = list(Path(location).glob("*.parquet"))
return pd.concat([pq.read_table(f).to_pandas() for f in files], ignore_index=True)
def distributed_scan(plan: pd.DataFrame) -> pd.DataFrame:
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(scan_partition, plan["location"]))
return pd.concat(results, ignore_index=True)
all_partitions_plan = plan_scan(catalog) # no filter — full table
scanned = distributed_scan(all_partitions_plan)
print("Total rows scanned across all partitions:", len(scanned))
Each partition's file(s) get read independently and in parallel — Trino does this across worker nodes in a cluster; the same idea holds at any scale, just with more workers and network shuffles for cross-partition aggregations.
Step 3: pushdown — filtering as early as possible¶
def scan_partition_with_pushdown(location: str, amount_min: float) -> pd.DataFrame:
files = list(Path(location).glob("*.parquet"))
tables = [pq.read_table(f, filters=[("amount", ">=", amount_min)]) for f in files]
return pd.concat([t.to_pandas() for t in tables], ignore_index=True) if tables else pd.DataFrame()
pushed = scan_partition_with_pushdown(str(base / "region=us" / "order_date=2026-08-02"), amount_min=250)
print(len(pushed), "rows after pushdown filter (amount >= 250)")
Parquet's row-group statistics let the reader skip row groups whose max
amount is below 250 without ever decompressing them — pushdown means the
filter is applied by the storage-reading layer, not after loading
everything into memory.
Step 4: aggregation after the scan¶
def run_query(catalog, region_filter, amount_min):
plan = plan_scan(catalog, region_filter=region_filter)
partial_results = [
scan_partition_with_pushdown(loc, amount_min) for loc in plan["location"]
]
combined = pd.concat(partial_results, ignore_index=True)
return combined["amount"].sum(), len(combined)
total, count = run_query(catalog, region_filter="eu", amount_min=100)
print(f"SUM(amount) WHERE region='eu' AND amount >= 100 -> {total:.2f} over {count} rows")
Traps¶
- Expecting a query engine to rewrite a badly laid-out table for you. Presto/Trino/Athena benefit enormously from good partitioning and file sizing (Modules 04 and 06) — they don't fix a poorly organized lake, they just execute against whatever layout exists.
- Ignoring partition filter case/type mismatches. A catalog storing
order_dateas a string but a query comparing against aDATEliteral can silently defeat partition pruning in some engines — check the catalog's declared partition column types. - Assuming Athena has no cost implications. Athena bills per byte scanned — an unpruned, unpartitioned, uncompressed table turns every query into a large, avoidable bill.
Cheat sheet¶
| Stage | What happens |
|---|---|
| Catalog lookup | Determine which partitions could possibly match |
| Partition pruning | Skip directories that can't match filters |
| Distributed scan | Read matching files in parallel across workers |
| Predicate pushdown | Skip row groups using Parquet statistics before decompressing |
| Aggregation | Combine partial results (map-reduce style) into the final answer |
How It Actually Works¶
A query engine's core mechanism is compiling a declarative SQL query into a physical execution plan — a tree of operators (scan, filter, join, aggregate, shuffle) — and then executing that plan across a fleet of distributed workers, with the engine's cost-based optimizer choosing between multiple valid physical plans based on estimated cost.
The planner first produces a logical plan (a relational-algebra tree representing what
the query means, independent of how it will be executed), then transforms it into a
physical plan by choosing concrete algorithms for each operator: a join might be executed
as a broadcast join (replicate the smaller table to every worker, avoid a shuffle entirely —
chosen when the optimizer's row-count/byte-size statistics say one side is small enough to
fit in each worker's memory) or a shuffle/sort-merge join (redistribute both tables by join
key across the network, chosen when both sides are large). This decision is made using
statistics the engine has about the tables involved — row counts, column cardinality, and
for lake tables, the same Parquet footer and catalog-partition statistics from earlier lessons
— which is exactly why running ANALYZE/collecting fresh statistics on a table can change a
query's plan and performance dramatically: stale statistics lead the optimizer to
underestimate a table's size and choose a broadcast join that then runs out of memory.
Execution across a distributed engine (Spark, Presto/Trino) proceeds in stages separated by shuffle boundaries: each stage runs many parallel tasks (one per data partition) that can execute independently without cross-task communication, and a stage only starts once its upstream stage's shuffle output is fully written, because downstream tasks need to read shuffle partitions that may originate from any upstream task. This staged, shuffle-bounded execution model is why a single slow or skewed task (one partition with disproportionately more rows than others — data skew) can stall an entire stage: every downstream task in the next stage waits on the slowest upstream task's shuffle output before it can even start.
Exercise¶
Extend run_query to also accept a group_by column (e.g., "region")
and return a pd.Series of per-group sums instead of a single total,
computed by summing each partition's partial group-by result rather than
concatenating all rows first (a map-side partial aggregation, mirroring how
a real distributed engine minimizes data shuffled between stages). Confirm
the grouped result for region matches what you'd get from summing the
ungrouped result per partition manually.