01 · Micro-Partitions & Pruning¶
Snowflake has no indexes. The single biggest factor in how much data a query reads, and therefore how long it runs and what it costs, is pruning: skipping micro-partitions that can't contain matching rows, using the minimum and maximum values Snowflake records for every column of every micro-partition. Pruning is automatic, but whether it works depends on something you control: how rows are grouped into micro-partitions, which mostly means the order the data arrived in.
What Snowflake records per micro-partition¶
From Snowflake's documentation, each micro-partition holds 50–500 MB of uncompressed data, stored by column, and its metadata includes:
- the range of values (min and max) of each column,
- the number of distinct values,
- other properties used for optimization.
When a query has a filter like WHERE ordered_at BETWEEN '2026-03-15' AND '2026-03-16', the cloud services layer compares
the filter with every micro-partition's ordered_at range before a warehouse reads anything, and drops those whose
range can't overlap. Only the survivors are scanned, and of those, only the referenced columns.
A real demonstration with Parquet¶
Parquet files work the same way: rows are grouped into row groups, and each row group stores min/max statistics per column. That makes them a faithful, runnable stand-in for micro-partitions. This experiment (DuckDB 1.5.6 and Python, locally, not Snowflake) writes the 200,000 orders from Level 1 twice: once in time order, as they'd arrive from an application, and once shuffled, as they might after an unordered bulk load or many scattered updates:
import duckdb
con = duckdb.connect("../data/sales.duckdb")
ROWS_PER_GROUP = 2000 # 100 "micro-partitions"
con.sql(f"COPY (SELECT * FROM orders ORDER BY ordered_at) TO 'orders_by_time.parquet' (ROW_GROUP_SIZE {ROWS_PER_GROUP})")
con.sql(f"COPY (SELECT * FROM orders ORDER BY hash(order_id)) TO 'orders_shuffled.parquet' (ROW_GROUP_SIZE {ROWS_PER_GROUP})")
DuckDB rounded the row-group size to its internal vector size, giving 98 row groups of up to 2,048 rows. Their
ordered_at ranges look like this:
orders_by_time.parquet orders_shuffled.parquet
row_group │ stats_min │ stats_max row_group │ stats_min │ stats_max
0 │ 2026-01-01 00:02:42 │ 2026-01-03 20:07:00 0 │ 2026-01-01 00:43:58 │ 2026-09-27 21:06:39
1 │ 2026-01-03 20:08:02 │ 2026-01-06 16:31:03 1 │ 2026-01-01 07:26:32 │ 2026-09-27 22:42:14
2 │ 2026-01-06 16:33:40 │ 2026-01-09 12:51:36 2 │ 2026-01-01 01:30:33 │ 2026-09-27 19:16:20
In time order, each row group covers about three days. Shuffled, every row group covers the entire nine months. Now
apply the pruning rule (skip a group if its [min, max] can't overlap the filter) to three filters:
def would_scan(path, col, lo, hi):
conv = int if col == "customer_id" else str # compare numbers as numbers, ISO timestamps as text
groups = [(g, conv(mn), conv(mx)) for g, mn, mx in stats(path, col)]
lo, hi = conv(lo), conv(hi)
keep = [g for g in groups if not (g[2] < lo or g[1] > hi)] # skip if [min,max] misses [lo,hi]
return len(keep), len(groups)
orders_by_time.parquet
one day (ordered_at on 2026-03-15) scans 1 of 98 row groups
one month (ordered_at in 2026-06) scans 12 of 98 row groups
one customer (customer_id = 4242) scans 98 of 98 row groups
orders_shuffled.parquet
one day (ordered_at on 2026-03-15) scans 98 of 98 row groups
one month (ordered_at in 2026-06) scans 98 of 98 row groups
one customer (customer_id = 4242) scans 98 of 98 row groups
The same data, the same query: one day reads 1% of the table in time order and 100% shuffled. And no layout helps every column. Customer 4242's orders are spread across the whole year, so a customer filter scans everything in both layouts.
(An earlier version of this script compared customer_id statistics as text, and "pruned" groups where '4242'
sorted outside a string range like '1003'…'4999'. It reported 84 of 98 for one layout, which was wrong. Pruning on
numbers has to compare numbers. The engine does this correctly. Hand-written analyses have to as well.)
Measuring it: overlap and depth¶
Snowflake describes how well a table is laid out for a column with two numbers, both computed from micro-partition ranges:
- overlaps: how many other micro-partitions a micro-partition's range overlaps;
- depth: how many micro-partitions contain a given value. A depth of 1 means a value lives in exactly one micro-partition.
Computing these from our row groups' ordered_at ranges (averaged over every partition, and over every boundary value
for depth, our approximation of Snowflake's metric):
orders_by_time.parquet partitions 98 average_overlaps 0.00 average_depth 1.00
orders_shuffled.parquet partitions 98 average_overlaps 97.00 average_depth 49.50
In Snowflake you get these numbers, for real micro-partitions, from:
SELECT SYSTEM$CLUSTERING_INFORMATION('learn_db.raw.orders', '(ordered_at)');
SELECT SYSTEM$CLUSTERING_DEPTH('learn_db.raw.orders', '(ordered_at)');
The first returns JSON with the total partition count, average_overlaps, average_depth and a histogram of
depths. Snowflake's docs note that depth isn't an absolute score: query performance is the real test. The direction
is clear, though: closer to 1 is better for filters on that column.
Seeing pruning in Snowflake¶
For any query, the Query Profile in Snowsight (Level 2 · 03) shows, on the TableScan operator, Partitions
scanned and Partitions total. The same numbers are in the query history:
SELECT query_id, query_text, partitions_scanned, partitions_total,
ROUND(100 * partitions_scanned / NULLIF(partitions_total, 0), 1) AS pct_scanned,
bytes_scanned, total_elapsed_time
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
WHERE warehouse_name = 'LEARN_WH'
AND start_time > DATEADD('day', -1, CURRENT_TIMESTAMP())
ORDER BY bytes_scanned DESC
LIMIT 20;
A frequently run query with a selective filter and pct_scanned near 100 is the clearest sign that the table's layout
doesn't match how it's queried.
Writing filters that can prune¶
Pruning needs a predicate that can be compared with the column's min/max:
-- prunes: plain range on the column
SELECT SUM(quantity * unit_price) FROM learn_db.raw.orders
WHERE ordered_at >= '2026-03-15' AND ordered_at < '2026-03-16';
-- may not prune: the column is hidden inside an arbitrary expression
SELECT SUM(quantity * unit_price) FROM learn_db.raw.orders
WHERE TO_CHAR(ordered_at, 'YYYY-MM-DD') = '2026-03-15';
-- doesn't prune (per Snowflake's docs): the bound comes from a subquery, even if it returns a constant
SELECT SUM(quantity * unit_price) FROM learn_db.raw.orders
WHERE ordered_at >= (SELECT MAX(report_date) FROM learn_db.analytics.report_runs);
Snowflake can prune through some simple expressions (casts, date truncation), but not arbitrary ones, and it can't use a bound it doesn't know at compile time. The reliable habits are to filter the raw column with range predicates, and when the bound comes from data, look it up first and pass it in as a literal or a session variable.
How It Actually Works¶
Pruning happens at compile time, in the cloud services layer, using metadata only. For each table in the query, the optimizer walks the micro-partition list (which can hold millions of entries for large tables) and evaluates each predicate against each partition's min/max statistics. A partition is kept if the predicate might be true for some value in its range, and dropped if it's provably false for all of them. The warehouse receives only the surviving file list. That's why pruning reduces both I/O and the number of files to fetch, and why it works the same on a cold or warm warehouse.
Snowflake can also prune at runtime in joins: after building a hash table from the smaller side of a join, it can derive a range of join-key values and skip probe-side partitions outside it. But compile-time pruning on filters is the part you can design for.
Micro-partitions are formed in the order rows are written. Rows loaded together land together, so a table loaded
daily, in time order, is naturally well clustered by time, exactly like orders_by_time.parquet. What destroys that
layout is loading data in random order, or rewriting it with operations that reshuffle rows. The next lesson covers how
to restore or enforce a layout with clustering keys.
Common mistakes¶
- Assuming an index exists. There are none. Layout plus pruning is the mechanism.
- Wrapping the filter column in functions (
TO_CHAR(ts) = …,YEAR(ts) = 2026on hot paths). Filter on the column with ranges. - Filter bounds from subqueries, which don't prune.
- Judging a table by row count. Look at
partitions_scanned / partitions_totalfor the real queries. - Expecting good pruning on every column. Only the column(s) the data is ordered by (or naturally correlated with) prune well.
- Loading historical backfills in random order, then wondering why recent-day queries scan everything.
Exercise¶
- Reproduce the Parquet experiment with row groups of 500 and 8,000 rows. How do the "one day" and "one month" scan counts change, and why?
- Write a third layout ordered by
customer_idand rerun the three filters. Which query improves, and which gets worse? - In Snowflake, load the orders once in file order and once shuffled (
INSERT INTO … SELECT … ORDER BY RANDOM()), at a larger scale (SNOWFLAKE_SAMPLE_DATA.TPCH_SF10.ORDERSworks). CompareSYSTEM$CLUSTERING_INFORMATIONandpartitions_scannedfor a one-day filter. - Rewrite the subquery-bound query so it prunes, using
SET max_date = (SELECT …);followed byWHERE ordered_at >= $max_date.