
A MERGE INTO on an Iceberg table is slow for one reason more than any other: it touches far more of the table than the change it applies. An upsert of a few thousand keys can scan a whole partition, shuffle and sort it across the cluster, rewrite every data file that holds a single matching row, and then lose its commit to a compaction job that started a minute earlier. None of that shows up in the SQL. The statement looks like a small update; the execution is a large rewrite.
The other reason is time. The merge that ran in minutes when the table was new runs for an hour months later, against the same volume of changes. The statement did not change; the table did — more small files, more delete files from earlier merges, more manifests, and a layout that no longer matches the merge key. Upsert performance is a property of the statement and of the table, and most tuning guides only cover the first half.
If you need the short answer, slow Iceberg merges almost always come down to some combination of these:
- The target scan is not pruned — the
ONclause gives Iceberg nothing to skip files with. - Merge keys are scattered across files — every file overlaps the incoming keys, so every file is read and, in copy-on-write, rewritten.
- The wrong write mode for the table — copy-on-write rewriting large files for sparse changes, or merge-on-read piling up delete files nobody compacts.
- An oversized or duplicated source — full snapshots instead of changes, or several changes per key.
- Shuffles and sorts on the large side of the join that the table layout could have avoided.
- Commit conflicts with other writers and with maintenance.
- Table state — delete files, small files, and metadata that grow with every merge and are never brought back down.
The first six are statement and pipeline fixes. The last is different: delete files, small files, and key layout drift continuously, on every upserted table, at each table's own pace. That work belongs to a lakehouse control plane — the operational layer the open lakehouse does not ship as one system. LakeOps is built as that layer for Apache Iceberg:
- What it is and where it sits — a system beside your data path, above the catalogs (Glue, S3 Tables, Polaris, Nessie, any REST catalog), the Iceberg tables in your object storage, and the engines that run your merges and queries. It replaces none of them and connects through standard catalog and Iceberg metadata APIs, so data is not copied, pipelines do not change, and tables stay plain Iceberg in your account.
- What it does — runs one loop on every table: read metadata and query telemetry, score table health, decide which maintenance and layout each table needs, execute in dependency order, then measure the result and feed it into the next decision.
- Why scripts cannot do it — a cron job or DAG applies one rule to every table on a clock. Upserted tables each degrade at their own pace, so the right moment to apply deletes, re-sort, or expire snapshots differs per table and keeps moving. That takes a system that watches every table, ranks them by severity, works around live writers, and keeps one record of what it did.
- What you get for merge workloads — delete files applied during compaction instead of accumulating, files clustered on the keys your merges and queries actually use, maintenance that runs when a table's state calls for it, and an audit trail of every operation and what it changed.
The capabilities that matter most for upserts map directly onto that loop: Smart Compaction applies deletes and re-sorts files for how each table is actually queried; Table Maintenance expires snapshots, rewrites manifests, and cleans orphans when table health degrades; Observability scores every table and shows where delete files concentrate; Change Simulations test layout changes on an Iceberg branch before production; and Governance sets table defaults and policies from the organization down to a single table. The longer definition is in what a data lakehouse control plane is.

The rest of this guide follows a merge through its five stages, with the levers at each one, then turns to the table state every merge inherits. Examples use Spark SQL, where most Iceberg upserts run, with notes for Trino and Flink.
How Iceberg executes MERGE INTO
Iceberg never updates a row in place. A merge is a read of the target, a join with the source, and a write of new files that replace or mask the old ones, committed as one new snapshot. In Spark, with the Iceberg SQL extensions enabled, that happens in five stages:
- 1.Plan the target scan. Spark and Iceberg decide which target files the join must read. Every predicate that can be pushed into this scan — from the
ONclause, from the table's partitioning, from runtime filters — shrinks it. - 2.Join source and target. The
ONcondition joins incoming rows to target rows, usually with a shuffle and a sort on both sides. - 3.Classify rows. Each joined row is routed to a
WHEN MATCHED,WHEN NOT MATCHED, orWHEN NOT MATCHED BY SOURCEclause — the first matching clause wins — which decides whether it is updated, deleted, inserted, or left alone. - 4.Write. In copy-on-write mode, every data file containing at least one changed row is rewritten in full, unchanged rows included. In merge-on-read mode, matched rows are masked with delete files and new versions are written as new data files.
- 5.Commit. Iceberg validates the new snapshot against anything committed since the merge started, according to the table's isolation level, then swaps the table's metadata pointer.
Each stage scales with something different. The scan and the copy-on-write rewrite scale with target files touched, not rows changed. The join scales with data shuffled and sorted, the commit with contention. The merge-on-read write is cheap now and expensive later, because readers apply its delete files until compaction removes them. So for any slow merge, ask which stage is slow — and whether the statement or the table made it so.
How to diagnose a slow MERGE INTO
Start with the snapshots the merge produced. Iceberg records what each commit added and removed, and recent Iceberg releases add MERGE-specific counters from Spark to the snapshot summary — including the number of target rows copied unchanged:
1SELECT2 committed_at,3 summary['spark.merge-into.num-target-rows-updated'] AS rows_updated,4 summary['spark.merge-into.num-target-rows-inserted'] AS rows_inserted,5 summary['spark.merge-into.num-target-rows-deleted'] AS rows_deleted,6 summary['spark.merge-into.num-target-rows-copied'] AS rows_copied,7 summary['deleted-data-files'] AS data_files_replaced,8 summary['added-data-files'] AS data_files_written,9 summary['added-delete-files'] AS delete_files_written10FROM lake.sales.orders.snapshots11WHERE operation = 'overwrite'12ORDER BY committed_at DESC13LIMIT 20;Two ratios come out of this. Rows copied per row changed is copy-on-write's write amplification: if it is large, the merge touches files of mostly unrelated rows, and the fix is layout and pruning, not more executors. Delete files written per merge is the debt each merge-on-read merge leaves for readers and compaction; if it climbs while changed rows stay flat, merges are spreading across more files.
Then read the Spark UI. A scan of a whole partition to replace a handful of files means pruning failed. An Exchange and a Sort on the target side of the join are what Lever 4 removes. A long gap before the commit is usually metadata — many manifests to plan, or a busy table to validate against.
Finally, check the table itself. Delete-file burden and file sizes per partition explain most of the slowdown that appears over time:
1-- Delete files and small data files per partition2SELECT3 partition,4 sum(CASE WHEN content = 0 THEN 1 ELSE 0 END) AS data_files,5 sum(CASE WHEN content = 0 AND file_size_in_bytes < 64 * 1024 * 1024 THEN 1 ELSE 0 END) AS small_data_files,6 sum(CASE WHEN content = 1 THEN 1 ELSE 0 END) AS position_delete_files,7 sum(CASE WHEN content = 2 THEN 1 ELSE 0 END) AS equality_delete_files8FROM lake.sales.orders.files9GROUP BY partition10ORDER BY position_delete_files + equality_delete_files DESC11LIMIT 20;Set the small-file threshold relative to your target file size rather than copying this one, and read the output per partition: the partitions merges hit most usually carry the most delete files and fragments, and table-level averages hide them.
Nobody keeps running these queries for every upserted table, every day. LakeOps computes the same signals across catalogs — file count and size distribution, delete-file ratio, snapshot depth, manifest growth, partition skew, and sort-order alignment with real query patterns — and rolls them into a health status per table, so the upserted tables that are degrading surface on their own.

The other half of the diagnosis is history: what touched the table between the fast merge and the slow one. A lake-wide event log answers that without digging through job logs.

Lever 1: Touch fewer target files
Every other optimization is secondary to this one: the scan, the shuffle, and the copy-on-write rewrite all scale with the target files a merge touches.
Put the pruning in the ON clause
Iceberg can only skip target files using predicates it can see, and a join on the key alone gives it none. When incoming changes are bounded in time or by partition, say so in the ON clause, as a predicate on the target's own columns:
1MERGE INTO lake.sales.orders t2USING (3 SELECT * FROM staging.order_changes4 WHERE batch_id = '2026-10-22T10'5) s6ON t.order_id = s.order_id7AND t.order_date >= DATE '2026-10-01' -- prunes the target scan8AND t.order_date = s.order_date -- keeps the join partition-aligned9WHEN MATCHED AND s.op = 'D' THEN DELETE10WHEN MATCHED THEN UPDATE SET *11WHEN NOT MATCHED AND s.op <> 'D' THEN INSERT *;The literal bound prunes partitions and files before the join; the partition-column equality keeps the join aligned with the layout (Lever 4). Derive the bound from the batch rather than hard-coding it: the minimum event date in the source, minus the lateness your pipeline tolerates. If a small fraction of changes arrives very late, route those rows to a separate, less frequent merge with a wider bound instead of widening it for every run.
Spark 3.4 and later also filter copy-on-write merges at runtime: before rewriting, Spark evaluates which target files actually contain matches — projecting only the columns needed for the merge condition — and Iceberg rewrites only those files. That reduces the rewrite, not the initial scan planning, which is why the static predicate still matters.
Partitioning decides which files exist; clustering decides which ones the merge touches
Pruning a merge works in the same layers as pruning a query. Partition pruning is coarse: it decides which groups of files the scan may enter, and only helps when the ON clause constrains the partition column. File pruning uses each file's min/max statistics on the join key, and only works when rows are clustered so those ranges are narrow. Row-group pruning repeats the test inside each Parquet file.
The middle layer is where most slow merges are lost. If the merge key is a UUID or any value unrelated to write order, every file in a partition spans nearly the whole key range, so a batch of random keys overlaps every file — and copy-on-write rewrites all of them. Against a table sorted by the merge key within each partition, the same batch overlaps only the files whose ranges contain those keys.
| Key pattern | Layout that keeps merges narrow | Why |
|---|---|---|
| Monotonic or time-correlated keys (order IDs, sequence numbers) | Partition by date, sort by key within partitions | New changes land in recent partitions and narrow key ranges |
| Random keys (UUIDs, hashes) with changes spread across history | bucket(N, key) in the partition spec, sorted by key | Each key maps to one bucket, so a batch touches only its buckets — and enables storage-partitioned joins |
| Merges keyed on one column, queries filtering on others | Linear sort on the merge key first, or Z-order across the key and the main filter columns | A linear sort favors its leading column; Z-order shares pruning across two to four columns and dilutes with each one added |
1-- Random keys: bucket on the merge key so each batch touches few buckets2ALTER TABLE lake.crm.accounts ADD PARTITION FIELD bucket(32, account_id);3 4-- Cluster rows on the merge key within each partition5ALTER TABLE lake.crm.accounts WRITE ORDERED BY account_id;Two cautions. A declared sort order applies to new writes and sort compaction; existing files keep their layout until rewritten. And sorting purely for the merge can hurt dashboards that filter on other columns, so choose layouts from merge keys and reader filters together, and test before rewriting production files. LakeOps does this with layout simulations: candidate sort and partition strategies run on an isolated Iceberg branch and are compared against the real query mix before anything touches the main branch.
Lever 2: Copy-on-write vs merge-on-read for MERGE
Iceberg sets the write mode separately for each row-level operation: write.merge.mode, write.update.mode, and write.delete.mode, each copy-on-write (the default) or merge-on-read. The choice moves cost between the merge and everything that reads the table after it.
Copy-on-write makes merges slower and readers fast: no deletes to apply. Merge-on-read writes only what changed — delete files masking old rows, new files with new versions — so merges are small, and readers apply the deletes until compaction does.
| Table | Merge mode | Reasoning |
|---|---|---|
| Frequent small upserts (CDC, streaming, hourly batches), moderate reads | merge-on-read | Merge latency matters most; compaction applies deletes off the critical path |
| Infrequent large merges, heavy dashboard or BI reads | copy-on-write | Readers never pay for deletes; the rewrite happens once per batch |
| Changes dense within the touched files | copy-on-write | Most rows in each touched file change anyway, so rewriting costs little extra |
| Changes sparse across many files | merge-on-read | Copy-on-write would rewrite large files to change a few rows in each |
| GDPR-style deletes on otherwise append-only tables | merge-on-read for deletes, copy-on-write for merges | Deletes stay cheap without making every upsert a delete-file writer |
1ALTER TABLE lake.sales.orders SET TBLPROPERTIES (2 'format-version' = '2',3 'write.merge.mode' = 'merge-on-read',4 'write.update.mode' = 'merge-on-read',5 'write.delete.mode' = 'merge-on-read',6 'write.delete.granularity' = 'file'7);File-scoped delete granularity makes each position delete file target a single data file, which is cheaper for readers to apply and lets compaction discard deletes as soon as their data file is rewritten. On format v3 tables, position deletes become deletion vectors: at most one per data file, stored in Puffin and merged on write, which keeps delete files bounded no matter how many merges hit the same file. Moving merge-heavy tables to v3 is the biggest structural improvement for merge-on-read, provided every engine that reads the table supports v3. The guide to Iceberg delete files covers position, equality, and deletion-vector mechanics in depth.
The catch: merge-on-read is a promise to compact. A table whose deletes are never applied gets slower to read every day, and eventually slower to merge, because the target scan applies its own delete files before it can join. Merge-on-read without delete-aware compaction moves the problem rather than solving it.
Lever 3: Shrink and shape the source
- Deduplicate before merging. A merge fails if more than one source row matches the same target row. CDC batches routinely carry several changes per key; keep the latest per key, ordered by the source's sequence or log position, not by arrival time.
- Merge only what changed. A full snapshot of the source makes the join and classification process every key, every run. Compute changed rows upstream — from a CDC stream, an incremental read of the source's snapshots, or a change timestamp.
- Carry the partition values. The pruning and partition-aligned join in Lever 1 need source rows to carry the target's partition columns. Add them in staging if the change feed does not.
- Avoid
WHEN NOT MATCHED BY SOURCEunless you mean it. The clause applies to every unmatched target row, so the merge cannot prune the target: it scans every file in scope and, in copy-on-write, rewrites every file containing a row it touches. It is right for a full reconciliation; for deletes, an operation flag in the change feed is far cheaper.
1WITH ranked AS (2 SELECT *,3 row_number() OVER (PARTITION BY order_id ORDER BY change_seq DESC) AS rn4 FROM staging.order_changes5 WHERE batch_id = '2026-10-22T10'6)7MERGE INTO lake.sales.orders t8USING (SELECT * FROM ranked WHERE rn = 1) s9ON t.order_id = s.order_id10AND t.order_date = s.order_date11AND t.order_date >= DATE '2026-10-01'12WHEN MATCHED AND s.op = 'D' THEN DELETE13WHEN MATCHED AND s.change_seq > t.change_seq THEN UPDATE SET *14WHEN NOT MATCHED AND s.op <> 'D' THEN INSERT *;The change_seq comparison makes the merge idempotent: replaying a batch, or applying an older change after a newer one, cannot overwrite newer data. The CDC pipeline guide covers ordering and replay in more depth.
Lever 4: Remove the shuffle and the sort
A merge in Spark typically shuffles twice — once to join source and target on the key, once before the write so each task writes few partitions — and the default sort-merge join sorts both sides. When the target is large and the batch is small, nearly all of that work is on the target side, and most of it can be avoided.
Storage-partitioned joins
If the source is itself an Iceberg table partitioned compatibly with the target — the same transforms on the join columns, such as the same bucket(N, key) — Spark can join partition by partition without an exchange. Stage incoming changes into an Iceberg table with the target's spec, include the partition columns in the ON clause, and enable the settings Spark and Iceberg require:
1SET spark.sql.sources.v2.bucketing.enabled = true;2SET spark.sql.sources.v2.bucketing.pushPartValues.enabled = true;3SET spark.sql.iceberg.planning.preserve-data-grouping = true;4SET spark.sql.requireAllClusterKeysForCoPartition = false;5SET spark.sql.sources.v2.bucketing.partiallyClusteredDistribution.enabled = true;Then check the plan: with a storage-partitioned join, there is no Exchange before the join. If one remains, the usual causes are a staging spec that differs from the target's, an ON clause missing a partition column, or partition evolution that left the target with files under more than one spec. Newer Spark releases can also shuffle only the source to match a bucketed target (spark.sql.sources.v2.bucketing.shuffle.enabled), so the large side stays in place even when the source is not an Iceberg table.
Join strategy
Once the target is not shuffled, the next cost is the sort. A sort-merge join sorts every target bucket it reads; a shuffle hash join builds a hash table from the small source side and streams the target past it unsorted. A SHUFFLE_HASH hint on the source asks for it, and spark.sql.join.preferSortMergeJoin=false makes it the default preference. Spark still decides: if it estimates the build side will not fit in memory, it silently falls back to sort-merge, so check the physical plan on the runs that matter, not just the first one.
Broadcasting the source avoids the target shuffle entirely, but only fits merges whose join lets Spark build on the source side — typically matched-only merges with no WHEN NOT MATCHED clause. When the merge inserts unmatched source rows, every source row must be preserved through the join, and a shuffle hash join on the source is the practical choice.
Write distribution
write.merge.distribution-mode controls how rows reach the writers: hash clusters them by partition and suits most merges, range also sorts by the table's sort order at the cost of a sampling pass, and none skips the shuffle and relies on data already being clustered. Size write tasks with spark.sql.iceberg.advisory-partition-size; shuffle data compresses less than Parquet, so set it above the target file size. Too small and every merge writes small files; too large and a skewed partition becomes one long task.
Lever 5: Avoid commit conflicts
A merge that fails to commit costs the whole run. Iceberg commits optimistically, validating at commit time that nothing committed since the merge started conflicts with it.
Isolation level decides what counts as a conflict. Under serializable, the default for write.merge.isolation-level, the merge fails if another writer added data that could match its condition. Under snapshot, only concurrent deletes of rows it read are conflicts, so a merge can commit alongside streaming appends to the same partitions. Whether that is safe depends on whether a concurrently appended row could need the merge's update — a question about your pipeline, not about Iceberg.
Commit retries (commit.retry.num-retries and related settings) cover the benign case, where another writer moved the table forward without conflicting. A real validation failure fails the merge outright.
The conflicts that hurt most are with maintenance, not with other merges. Compaction and copy-on-write merges both replace files; when they replace the same ones, one of them loses. On merge-on-read tables, compaction that rewrites a data file conflicts with a merge that wrote new deletes against it. The fixes are sequencing and scope:
- Keep maintenance off the partitions merges are writing. Compact partitions that have stopped changing, and reach hot partitions in quieter windows.
- Commit compaction in pieces.
partial-progress.enabledlets a rewrite commit file groups independently, so a conflict costs one group instead of the whole run. - Schedule around the merge cadence, not the clock. A compaction that starts while a large merge runs is the most predictable conflict in the lake.
A control plane handles this by construction, treating a conflict as a reason to retry later rather than an incident. LakeOps' compaction commit detects conflicts through Iceberg's metadata version check, and its conflict handling retries the affected partitions on the next run — the worst case is a delayed compaction cycle, never a failed merge.
The table state every merge inherits
Statement tuning sets the starting point. Table state decides how quickly it decays. An upserted table degrades in four ways, and each one feeds back into merge time:
- Delete files. Every merge-on-read merge adds deletes, which readers and the next merge's target scan both apply. On v2 tables with partition-scoped deletes, one delete file can apply to every data file in its partition.
- Small files. Merges write files sized by the batch, not the table; frequent small merges fragment a table as surely as streaming appends.
- Key-layout drift. Updates and inserts land in new files in arrival order, so a table sorted on the merge key gradually stops being sorted and the file pruning from Lever 1 erodes.
- Metadata growth. Every merge is a snapshot, a manifest list, and new manifests, which slow planning for the merge and for every query.
For a merge-heavy table, maintenance is a sequence, and the order matters:
1-- 1. Release history the table no longer needs2CALL lake.system.expire_snapshots(3 table => 'sales.orders',4 older_than => TIMESTAMP '2026-10-15 00:00:00',5 retain_last => 206);7 8-- 2. Apply deletes and re-cluster the partitions merges have settled in9CALL lake.system.rewrite_data_files(10 table => 'sales.orders',11 strategy => 'sort',12 sort_order => 'order_id ASC',13 where => 'order_date >= "2026-09-01" AND order_date < "2026-10-20"',14 options => map(15 'delete-file-threshold', '1',16 'partial-progress.enabled', 'true'17 )18);19 20-- 3. Collapse leftover position deletes the rewrite did not reach21CALL lake.system.rewrite_position_delete_files(22 table => 'sales.orders',23 options => map('rewrite-all', 'true')24);25 26-- 4. Rebuild manifests to match the new layout27CALL lake.system.rewrite_manifests('sales.orders');By default, compaction does not pick up a file just because deletes are attached to it. delete-file-threshold changes that, so deletes are physically applied rather than carried forward. The sort strategy restores the key clustering merges erode, and the date window keeps compaction out of partitions merges are writing. Each choice — window, thresholds, sort column, cadence — is right for one table at one write rate and wrong for the next.
That is where hand-built maintenance stops scaling. A nightly job is late for a CDC table, wasteful for a dimension merged weekly, and blind to the partition a backfill just fragmented. LakeOps runs autonomous table maintenance instead: each table gets its own readiness per operation, derived from how quickly it accumulates delete files, small files, snapshots, and manifests, so a merge-heavy table is maintained as often as its merges degrade it and an idle one is left alone.

Within a table, the partitions view shows where merges concentrate their damage — file counts, bytes, and delete files per partition key — so compaction reaches the hot partitions first instead of rewriting the whole table.

Engine notes: Spark, Trino, and Flink
- Spark honors the table's
write.merge.mode, distribution settings, and isolation level, and is the engine with storage-partitioned joins for merges.MERGE INTOrequires the Iceberg SQL extensions; Spark 4 also exposes merges through the DataFrame API. - Trino, in released versions, applies
MERGE,UPDATE, andDELETEon Iceberg as merge-on-read, writing position deletes even when the table is configured for copy-on-write (copy-on-write support has been proposed upstream). A table merged from Trino needs delete-aware compaction like any merge-on-read table. - Flink upserts through its Iceberg sink in upsert mode (
write.upsert.enabledor theupsert-enabledwrite option), keyed by the table's identifier fields, and writes equality deletes on every changed key. They are cheap to write and expensive to read, because readers compare every row in the affected files against them. Flink-upserted tables need compaction that applies equality deletes promptly.
Mixed-engine tables inherit the worst of each writer. The read side differs by engine too: between compactions, a merge-on-read table costs more to scan on some engines than others. LakeOps routes each query by cost or latency behind one SQL endpoint, and can route queries away from tables with fragmented manifests or high delete-file counts to engines that handle degraded state better. Routing is powered by QueryFlux, an open-source Rust SQL proxy — clients keep their protocol and dialect, so moving a workload between engines is a routing change. The source is on GitHub.
What breaks at 20, 200, and 2,000 upserted tables
At twenty tables, all of the above is a runbook. At two hundred, the runbook stops being followed: write modes depend on whoever created the table, some merge-on-read tables have delete-aware compaction and some do not, and schedules were tuned for last year's merge cadence. The first signal of a degrading table is a downstream job missing its window.
At two thousand, the problem is knowing which tables need what. That takes what scripts do not provide: a severity-ranked view of degrading tables, defaults that apply to every new table, and maintenance that decides per table rather than per schedule.

How LakeOps keeps MERGE fast across the lake
Everything above can be built by hand for a handful of tables. With LakeOps, the levers that depend on table state stop being per-table projects and become the job of one loop running against every table.
Deletes are applied during compaction, not after. LakeOps' Smart Compaction physically applies position and equality deletes in the same pass that merges small files and re-sorts data, on a Rust engine built on Apache DataFusion rather than a Spark cluster — so CDC and Flink-upserted tables stop paying the merge-on-read tax on every read. Dedicated policies for rewriting position and equality delete files cover tables where deletes need their own cadence.
Clustering follows the keys merges and queries actually use. LakeOps reads the filters, joins, and groupings engines issue against each table, chooses sort and clustering from that traffic, and tests candidates on a branch before rewriting production files. Re-sorting is part of compaction, so the key-layout drift merges cause is repaired continuously.

Write-mode defaults are policy, not tribal knowledge. Configuration policies define default Iceberg table properties — merge, update, and delete modes, delete granularity, format version, target file size — and apply them by catalog, namespace, or table, so a new CDC table starts with the right settings instead of its creator's guess. Maintenance policies cover expiry, orphan cleanup, compaction, manifest rewrites, and delete-file rewrites with the same scoping, on a schedule or triggered by table state.

Every operation is on the record, per table. Each compaction, expiry, and manifest rewrite lands in the table's event history with its duration and impact, so "what changed between the fast merge and the slow one" has an answer.

Table health, delete-file hotspots, and event history come from LakeOps' observability layer, which reads the same metadata the queries earlier in this guide read, for every table in every connected catalog. The platform overview shows how observe, maintain, compact, route, and govern fit together.

Adoption can follow the order of this guide: start in Manual mode, where every operation waits for approval, and review what health scores say about your upserted tables; then hand over delete-file compaction and re-clustering one policy at a time.
MERGE INTO performance checklist
- 1.Measure before tuning: rows copied per row changed, data files replaced, and delete files written per merge, from the snapshot summary.
- 2.Bound the target in the ON clause with a literal predicate on the partition column, derived from the batch.
- 3.Cluster on the merge key — date partitioning plus key sort for monotonic keys,
bucket(N, key)plus sort for random keys — and check it against the table's read patterns. - 4.Choose the merge mode per table: merge-on-read for frequent small upserts, copy-on-write for infrequent merges on read-heavy tables.
- 5.Use file-scoped deletes, and v3 deletion vectors where every engine supports them.
- 6.Deduplicate the source to one row per key, merge only changed rows, and make merges idempotent with a sequence column.
- 7.Reserve
WHEN NOT MATCHED BY SOURCEfor full reconciliations. - 8.Keep the target in place: storage-partitioned joins, a hash join on the source side, and a plan check on every important run.
- 9.Size write tasks with the advisory partition size so merges do not write small files.
- 10.Pick the isolation level deliberately, and keep compaction off partitions merges are writing.
- 11.Compact with deletes applied and keys re-sorted, on each table's own cadence, in dependency order.
- 12.Watch every upserted table, not just the one that paged you — health, delete files, and events, ranked by severity.
A slow MERGE INTO is rarely one bad setting. It is a statement that touches more of the table than its change, running against a table whose deletes, fragments, and layout drift a little further with every run. Fix the statement once, then keep the table in the state your merges were tuned for. The first part is a pipeline change. The second is continuous, per-table work across every upserted table in the lake — the work LakeOps takes over, table by table.



