# You're Updating 1M Rows. Why Scan 100 Billion to Find Them?

> A JOIN or MERGE has to find the target rows that match your source keys. Spark tries to prune the target with dynamic partition pruning, but when updates land randomly across a large fact table it barely helps, so it falls back to scanning and shuffling most of the table. Quanton asks an index instead, mapping each key to the exact file and row position so the engine reads only the row groups that actually match.

Published 2026-07-15 by Vinish Reddy Pannala (Quanton, by Onehouse).
Canonical: https://quanton.dev/blog/index-aware-merge

---
## Overview

Every `MERGE INTO`, and every join that feeds one, has to answer the same question: *which rows in the target table match my source keys?* You're applying a CDC batch, upserting late events, correcting a few records. In most production pipelines, streaming or a daily batch, the source is much smaller than the target, and the matching rows are a tiny fraction of a large table.

Spark isn't naive about this. It tries **dynamic partition pruning (DPP)**, using the source keys to skip target partitions that can't match. That works well when your updates cluster into a few partitions. But in a large fact table, late-arriving data lands more or less randomly across the table, so the source keys can touch hundreds of partitions, or every partition in the worst case. DPP prunes almost nothing, and Spark falls back to scanning and shuffling most of the target to find the rows. The cost ends up scaling with the size of the **table**, not the size of your **change**.

We covered one half of that pain in [MERGE INTO Updates a Slice of Your Table — So Why Shuffle All of It?](/blog/low-shuffle-merge). This post is about the other half. Even a low-shuffle merge still has to *locate* the matching rows.

There's a better answer than scanning everything. Keep an **index** that already knows where each key lives, and read only the files that hold matches.

## The problem — finding rows is a full-table operation

Say you're updating 1 million keys in a **10 TB** table of ~100 billion rows, spread across roughly **20,000 data files of 512 MB each**. Because the incoming source rows can sit in any data file, DPP can't shrink the target by much for most real-world merges, so the plan has to consider all of them:

- **Scan** ~20,000 files to expose their keys.
- **Shuffle** the target so rows with equal keys co-locate with the source.
- **Join**, discover which files actually held matching rows, and discard the rest.

You paid to read and move 100 billion rows to touch a million. Predicate pushdown and DPP help *only* when your filter lines up with partitioning that happens to isolate the change set. On an arbitrary join key with randomly-distributed updates, they don't fire, and you're back to reading most of the table.

## The idea — let an index point straight at the rows

An index turns "find the rows" from a scan into a lookup. Quanton maintains two kinds, both backed by the Hudi metadata table and exposed on Iceberg through XTable.

| Index | Maps | Use it when the join key is |
|---|---|---|
| **Record-Level Index (RLI)** | record key → file + row position | the table's record key |
| **Secondary Index (SI)** | any column value → record key | a non-key column you join or merge on |

The two chain together. An SI on its own doesn't reveal a file location — it resolves your join column to a record key, and the RLI then turns that record key into the exact file and row position.

For table formats that don't support record keys, the index skips that hop and maps the column value straight to the `_file` and `_pos` that hold it.

Either way, the lookup returns the **exact physical location** of every matching row, not a probability and not a partition, the file, the row position within that file, and the partition it belongs to.

```text
key           _file                          _pos    _partition
--------      ---------------------------    -----   ----------
1000234   →   data/part-00017-....parquet    4821    region=us
1000238   →   data/part-00017-....parquet    9110    region=us
1000512   →   data/part-00311-....parquet    277     region=eu
```

With that in hand, the engine skips straight to the files that actually hold matching rows and ignores the rest. How many files that is depends on your data. When your changes cluster into a few files, it's a tiny fraction of the table; even when a million updates scatter across many files, the engine goes to them directly instead of scanning and shuffling all 20,000 to find them. Either way, that's the theoretical minimum I/O to *locate* the change.

The rows still have to be written, and that's where the [low-shuffle columnar MERGE path](/blog/low-shuffle-merge) takes over. It updates the matched files in place, columnar, without shuffling any file's data across the network. That's what matters most. The win isn't that Quanton touches *fewer* files; it's that it shuffles *nothing*, however many files the matches land in. The index finds the rows, the columnar merge writes them with no shuffle, and together they give an optimal merge path no matter how the matches are distributed.

<figure>
  <img src="/blog/index-aware-merge.svg" alt="Two lanes. Top (OSS Spark, Gluten, Comet and Databricks Photon): 1M update keys flow through SCAN target (all ~20,000 files), an all-to-all SHUFFLE across the network, then JOIN to find the matches; the target-files grid shows ~75% of files touched. Bottom (Quanton): the same 1M keys flow into an INDEX LOOKUP (Secondary / Record-Level Index on the join key) that returns EXACT LOCATIONS (_file, _pos, _partition), then READ + MERGE runs a low-shuffle columnar merge on only the matched files; the grid shows the same ~75% of files touched, but with zero shuffle. A footer notes Quanton maintains Record-Level and Secondary indexes dynamically on both Hudi and Iceberg tables, plus expression and vector indexes for computed columns and unstructured data. A footnote adds that Databricks Photon runs a low-shuffle MERGE too but has no record-level index, so it still scans the target to locate matching rows, and that Photon writes Delta natively while exposing Iceberg only as a read-only UniForm sync tied to Unity Catalog." />
  <figcaption>Both engines touch the same files. Here the matches land in an illustrative ~75% of the table either way. The difference is the shuffle — OSS Spark, Gluten, Comet and Databricks Photon move the whole table across the network to find them, while Quanton's index goes straight to the matching files and the low-shuffle columnar merge writes them with zero shuffle.</figcaption>
</figure>

## How Quanton wires it into the plan

This isn't partition pruning. The index lookup returns the actual parquet files and row positions that match, and Quanton hands those straight to the Arrow-based parquet reader, which reads only those files and skips to the relevant row groups inside them. It's a targeted read, not a filter that hopes to skip partitions.

The bigger idea is **Dynamic Indexes**. The columns that hurt most are the ones you repeatedly join and merge on, exactly the keys that force Spark to shuffle enormous amounts of data. Quanton maintains an index on those columns and applies it automatically at plan time, so a query that used to shuffle the whole table now resolves its keys through the index instead. The optimization happens dynamically, per query, with no change to your SQL.

<div class="phases">
  <div class="phase">
    <span class="phase-step">Phase 01</span>
    <h4 class="phase-title">Maintain the index</h4>
    <code class="phase-code">secondary_index_{column}</code>
    <p>Quanton keeps a Record-Level or Secondary Index on the frequently-joined columns in the Hudi metadata table, refreshed on write. It lives alongside the table, so it's there for every future merge — no rebuild per query.</p>
  </div>
  <div class="phase">
    <span class="phase-step">Phase 02</span>
    <h4 class="phase-title">Look up the source keys</h4>
    <code class="phase-code">index.lookup(source_keys)</code>
    <p>At merge time the source-side key values are looked up in the index. The result is the exact list of files, row positions, and partitions that can match — computed before the target scan runs.</p>
  </div>
  <div class="phase">
    <span class="phase-step">Phase 03</span>
    <h4 class="phase-title">Read only what matches</h4>
    <code class="phase-code">read matched files + row groups</code>
    <p>The file and row-position list goes to the Arrow parquet reader. It reads only the matching files, and only the row groups that hold matches — and because rows never fan out to find their partner, the whole-table shuffle is gone.</p>
  </div>
</div>

You don't change the query. Turn the index-aware path on once, and Quanton applies it whenever a usable index is present:

```sql
MERGE INTO customers t
USING source_updates s
ON t.id = s.id
WHEN MATCHED THEN UPDATE SET t.amount = s.amount
WHEN NOT MATCHED THEN INSERT *
```

```properties
spark.quanton.sql.optimizer.dynamicPruning.enabled=true
```

The important part is that the decision is made **per query, at plan time**. Quanton doesn't blindly use the index. It reads the column statistics for the join key to estimate how many target files the source keys could actually touch, and then picks the cheaper plan:

- If plain DPP already narrows the target to very few files, the index earns nothing, so Quanton just lets DPP run.
- When the keys are spread across most of the table and DPP would prune almost nothing — the common case for late-arriving updates — Quanton switches to the index lookup.

Same SQL either way. The plan adapts to the data in front of it, run by run, instead of committing to one strategy up front.

## Full scan vs. index lookup

Updating ~1M keys scattered randomly across a 10 TB table (~20,000 files of 512 MB):

<div class="versus">
  <div class="vs-card">
    <span class="vs-label">Scan + shuffle</span>
    <span class="vs-num">100<small>% shuffled</small></span>
    <span class="vs-sub">DPP prunes almost nothing on random keys, so the whole target is scanned and shuffled just to locate the matches</span>
  </div>
  <div class="vs-card vs-card--win">
    <span class="vs-label vs-label--accent">Index-aware</span>
    <span class="vs-num vs-num--fast">0<small>shuffle</small></span>
    <span class="vs-sub">the low-shuffle columnar merge writes the matched files in place — zero data shuffled, however many files match</span>
  </div>
</div>

The work now scales with the size of your change, not the size of your table. That's what `MERGE` was supposed to do all along.

## Beyond keys — expression and vector indexes

A secondary index is just one shape of "map a value to where it lives." The same machinery covers more:

- **Expression indexes** index a *computed* value like `lower(email)`, `date(ts)`, or `substr(sku, 1, 4)`, so a merge or filter on that expression prunes the same way a raw-column index does.
- **Vector indexes** index embeddings of unstructured data like text, images, and audio, so you can find the nearest rows to a query vector without scanning every record. It's the same lookup pattern, now inside the same table.

Different index, same principle — don't scan to find something you can look up.

## Takeaway

A join or a merge is, underneath, a search for the target rows that match your source. Scanning and shuffling the entire table to run that search is the expensive way to do it. An index answers the same question by pointing at the exact file and row position, so Quanton reads only the files that match, only the row groups inside them that hold matches, and skips the target shuffle. The cost of changing your data finally scales with how much you're changing, not with how much you've stored.