Back to Performance Tuning
Q2Question 2 of 8

Why min/max and dictionary filtering fail on high-cardinality unsorted columns, and how bloom filters help

Key Concepts
  • Min/max statistics fail on high-cardinality unsorted columns because every block's range spans the entire domain, so no block can be skipped.
  • Dictionary filtering fails because high cardinality means the dictionary is too large or Parquet drops dictionary encoding entirely.
  • Bloom filters store a compact bitset per block; testing a value gives 'definitely not present' (safe skip) or 'probably present' (must read).
  • Bloom filters never produce false negatives, which makes skipping safe, and they are compact regardless of value count.
  • Enable bloom filters per column at write time only for high-cardinality equality lookups like transaction_id or customer_id.
  • Bloom filters help equality and IN predicates only, not range queries, because hashing destroys order.

Definition

On high-cardinality unsorted columns, min/max ranges span everything and dictionaries explode, so only a bloom filter's 'definitely not present' answer lets Parquet skip blocks.

Three data-skipping mechanisms and their limitations.

  • Min/max statistics – Record the smallest and largest value per block. Effective only when data is sorted so ranges are tight. Useless on high-cardinality unsorted columns where every block spans the full domain.
  • Dictionary filtering – Checks if the filter value exists in the block's dictionary. Fails when the dictionary grows too large or Parquet drops dictionary encoding.
  • Bloom filters – A compact fixed-size bitset per block built by hashing each value. Testing gives 'definitely not present' (safe skip) or 'probably present' (must read). Works for billions of distinct unsorted values.

Bloom filters are the purpose-built solution for high-cardinality equality lookups on unsorted columns.

Suppose the National Bank of India fills archive boxes randomly with records from every account. The label 'smallest and largest account ID in this box' becomes meaningless because every box, filled randomly, spans from the first account to the last. Listing every account ID in the box (dictionary) is hopeless with millions of distinct IDs. A bloom filter is a tiny fingerprint card on each box: you scan your account's fingerprint against it and it can firmly say 'not in this box,' letting you skip, even though the box holds millions of IDs.

Understanding the Concepts

Why do min/max statistics fail on unsorted columns?

Min/max statistics record the smallest and largest value in each Parquet row group. Their power comes from tight, non-overlapping ranges, which you only get when data is sorted on that column. For a high-cardinality unsorted column, each block receives a random sample of the whole value space.

DIAGRAM
Sorted by account_id:
  Block 1: min=ACC-0001, max=ACC-1000  → tight range
  Block 2: min=ACC-1001, max=ACC-2000  → tight range
  Query: WHERE account_id = 'ACC-1500' → skip Block 1 ✓

Unsorted (random distribution):
  Block 1: min=ACC-0003, max=ACC-9997  → spans everything
  Block 2: min=ACC-0001, max=ACC-9999  → spans everything
  Query: WHERE account_id = 'ACC-1500' → cannot skip any block ✗
PYTHON
# Demonstrate: min/max skipping on sorted vs unsorted data
txns_sorted = spark.read.parquet("/data/bank/transactions_sorted_by_txn_id")
txns_unsorted = spark.read.parquet("/data/bank/transactions")

# Sorted: Spark reads 1 row group (min/max skipping works)
txns_sorted.filter("transaction_id = 'TXN-500000'").explain()

# Unsorted: Spark reads ALL row groups (min/max useless)
txns_unsorted.filter("transaction_id = 'TXN-500000'").explain()

Why does dictionary filtering fail on high cardinality?

Dictionary filtering checks whether the filter value exists in a block's dictionary of distinct values. For a high-cardinality column, two things go wrong.

DIAGRAM
Low cardinality (branch_code, 50 values):
  Dictionary per block: [MUMBAI-01, DELHI-02, ..., KOLKATA-50]
  Size: ~2 KB
  Membership check: fast and selective ✓

High cardinality (transaction_id, 10M values):
  Dictionary per block: [TXN-000001, TXN-000002, ..., TXN-100000]
  Size: ~5 MB per block → exceeds Parquet threshold
  Parquet drops dictionary encoding → falls back to plain encoding
  No dictionary to check → filtering impossible ✗
PYTHON
# Check Parquet encoding for a column
import pyarrow.parquet as pq

parquet_file = pq.ParquetFile("/data/bank/transactions/part-00000.parquet")
for rg in range(parquet_file.metadata.num_row_groups):
    col_meta = parquet_file.metadata.row_group(rg).column(0)  # transaction_id
    print(f"Row group {rg}: encoding={col_meta.encodings}")
    # High cardinality: shows PLAIN instead of PLAIN_DICTIONARY

How do bloom filters work?

A bloom filter is a fixed-size array of bits. When writing, each value is hashed by several hash functions, and the corresponding bits are set. When reading, you hash the lookup value and check those bits.

DIAGRAM
Bloom filter for Block 1 (simplified, 16 bits):

Write 'TXN-1001':  hash1=3, hash2=7, hash3=12  → set bits 3, 7, 12
Write 'TXN-2045':  hash1=1, hash2=7, hash3=15  → set bits 1, 7, 15

Bitset: [0,1,0,1,0,0,0,1,0,0,0,0,1,0,0,1]
         0 1 2 3 4 5 6 7 8 9 ...   12    15

Lookup 'TXN-5000': hash1=3, hash2=9, hash3=12
  Bit 3=1 ✓  Bit 9=0 ✗  → DEFINITELY NOT PRESENT → skip block

Lookup 'TXN-1001': hash1=3, hash2=7, hash3=12
  Bit 3=1 ✓  Bit 7=1 ✓  Bit 12=1 ✓  → PROBABLY PRESENT → read block

How do you enable bloom filters in Parquet?

Bloom filters are not built by default. You enable them per column at write time.

PYTHON
# Enable bloom filter on transaction_id column
txns.write \
    .option("parquet.bloom.filter.enabled#transaction_id", "true") \
    .option("parquet.bloom.filter.expected.ndv#transaction_id", "10000000") \
    .parquet("/data/bank/transactions_with_bloom")
SQL
-- Delta Lake: enable bloom filter index
CREATE BLOOMFILTER INDEX ON TABLE banking_prod.transactions
FOR COLUMNS (transaction_id OPTIONS (fpp = 0.01, numItems = 10000000));

What are the trade-offs?

Bloom filters add bytes to the file and are only useful for equality and IN predicates.

DIAGRAM
Pros:
  - Compact: a few bits per value regardless of value size
  - No false negatives: safe to skip
  - Works on billions of distinct unsorted values

Cons:
  - False positives: "probably present" may be wrong → reads unnecessary blocks
  - Storage overhead: typically 1-2% of column data size
  - Only equality and IN: hashing destroys order, so range queries get no help
  - Must be enabled per column at write time
PYTHON
# Bloom filters HELP:
txns.filter("transaction_id = 'TXN-500000'")  # equality ✓
txns.filter("transaction_id IN ('TXN-1', 'TXN-2', 'TXN-3')")  # IN ✓

# Bloom filters DO NOT HELP:
txns.filter("transaction_id > 'TXN-500000'")  # range ✗
txns.filter("transaction_id LIKE 'TXN-5%'")  # prefix ✗

When do you use each skipping mechanism?

| Mechanism | Best For | Fails When |
|---|---|---|
| Min/max | Sorted or low-cardinality columns | Unsorted high-cardinality |
| Dictionary | Low-cardinality equality filters | High cardinality (dictionary dropped) |
| Bloom filter | High-cardinality equality on unsorted columns | Range queries |
| ZORDER | Multi-column filter skipping | Too many ZORDER columns |

How do you combine all three mechanisms in a banking table?

At the National Bank of India, the transactions table uses all three skipping mechanisms together: sorting for date range queries, bloom filters for transaction ID point lookups, and dictionary filtering for low-cardinality branch codes.

PYTHON
# Combined strategy for the bank transactions table

# 1. Sort or ZORDER by txn_date (range filters use min/max)
# 2. Bloom filter on transaction_id (equality lookups)
# 3. Dictionary filtering on branch_code (low cardinality, automatic)

# Write with bloom filter on transaction_id
txns.write \
    .option("parquet.bloom.filter.enabled#transaction_id", "true") \
    .option("parquet.bloom.filter.expected.ndv#transaction_id", "10000000") \
    .format("delta") \
    .save("/data/bank/transactions")

# Then ZORDER for date range queries
spark.sql("OPTIMIZE banking_prod.transactions ZORDER BY (txn_date)")

# Result:
# WHERE txn_date = '2025-06-15'     → min/max skipping (ZORDER)
# WHERE transaction_id = 'TXN-5000' → bloom filter skipping
# WHERE branch_code = 'MUMBAI-01'   → dictionary filtering

What should a senior engineer know?

  • Sort or ZORDER on the columns you range-filter on most; add bloom filters on the high-cardinality columns you equality-filter on.
  • You cannot sort by many columns at once, so bloom filters cover the lookups sorting cannot.
  • The false-positive rate is tunable: a larger filter means fewer false positives but more storage.
  • At the National Bank of India, the transactions table was sorted by date (date filters used min/max), but point lookups by transaction_id skipped nothing until we added a bloom filter on that column.
PYTHON
# Combined strategy:
# 1. Sort or ZORDER by txn_date (range filters use min/max)
# 2. Bloom filter on transaction_id (equality lookups)
# 3. Dictionary filtering on branch_code (low cardinality, automatic)

Visual Overview

DIAGRAM
  Data Skipping Mechanisms:

  Query: WHERE transaction_id = 'TXN-500000'

  ┌─────────────────────────────────────────────────────┐
  │ Block 1                                              │
  │                                                      │
  │ Min/Max: [TXN-000003, TXN-999997]                   │
  │   → Range includes TXN-500000 → CANNOT SKIP         │
  │                                                      │
  │ Dictionary: DROPPED (too many distinct values)       │
  │   → No membership check → CANNOT SKIP               │
  │                                                      │
  │ Bloom Filter: hash(TXN-500000) → bits [3,9,12]      │
  │   → Bit 9 = 0 → DEFINITELY NOT PRESENT → SKIP ✓    │
  └─────────────────────────────────────────────────────┘

  ┌─────────────────────────────────────────────────────┐
  │ Block 2                                              │
  │                                                      │
  │ Bloom Filter: hash(TXN-500000) → bits [3,9,12]      │
  │   → All bits = 1 → PROBABLY PRESENT → READ          │
  └─────────────────────────────────────────────────────┘

  Result: read 1 block instead of all blocks

Key Takeaway

Min/max statistics and dictionary filtering both break down on high-cardinality unsorted columns, and bloom filters are the fix. Min/max records the smallest and largest value per block, but on an unsorted column each block spans nearly the entire domain, so no block can be skipped. Dictionary filtering checks membership in the block's dictionary, but a high-cardinality column exceeds Parquet's dictionary threshold and the encoding is dropped.

Bloom filters are built exactly for this case. A bloom filter is a compact fixed-size bitset per block: at write time each value is hashed, and at read time the lookup value is tested against those hashes. If any required bit is zero, the value was definitely never inserted and the block is safely skipped. If all bits are set, it is probably present and must be read. The two properties that matter are no false negatives (skipping is safe) and compactness (a tiny structure represents millions of IDs).

At the National Bank of India, the transactions table was sorted by date so date filters used min/max effectively, but point lookups by transaction_id, which was extremely high cardinality and not the sort key, skipped nothing until we added a bloom filter on that column. The rule is that bloom filters cost space and are off by default, so enable them per column only for high-cardinality equality lookups. They help equality and IN predicates, not ranges.

Remember one sentence: on high-cardinality unsorted columns, only a bloom filter's 'definitely not present' answer lets you skip blocks, because min/max ranges span everything and dictionaries are too large.

---

Share
Use ← → arrow keys to navigate