Back to Portfolio
October 2026 Data Engineering Microsoft Fabric

Delta Table Maintenance in Microsoft Fabric: The Runtime 2.0 Edition

Delta table maintenance in Microsoft Fabric after Runtime 2.0 GA: what's now automatic, what's still your job, and what I found when I tested it.

Accuracy notice: This post was written in October 2026 and reflects the state of Microsoft Fabric Runtime 2.0 at that time. Fabric is a rapidly evolving platform - defaults may change, documentation may be corrected, and documented limitations may be resolved. Before making decisions based on this post, verify current behaviour against the official Microsoft documentation and test it in your own environment. Where this post identifies gaps, contradictions or limitations, check whether they have been addressed in a recent Fabric release.

Earlier this year I published Delta Table Maintenance in Microsoft Fabric: A 2026 Practitioner's Guide, written for Runtime 1.3, the current runtime at the time, with notes on the Runtime 2.0 Preview. I wrote it because Fabric is sold as SaaS, and most teams reasonably assume that table maintenance is taken care of for them. Unfortunately it isn't, and at the time the official documentation was thin, scattered and inconsistent.

Runtime 2.0 went GA on 14 August 2026, and it's now the default runtime for new workspaces. Much of my old advice is now defaults - I'd like to take credit for the good work of Miles Cole and Fabric's Spark team, though I know better, at least it's a good endorsement of my previous article if nothing else! Adaptive file sizing, Fast Optimize, deletion vectors and incremental liquid clustering are all on by default now. The big ticket improvement, incremental liquid clustering, removes the full-table rewrite that made liquid clustering impractical on Runtime 1.3.

For better or worse, in Runtime 2.0 Fabric still doesn't maintain your Delta tables for you. OPTIMIZE doesn't run on a schedule - auto-compact makes up some ground here but is disabled by default - and there's no auto-VACUUM.

If you want to manage your costs and capacity usage effectively, there's still manual tuning required. Microsoft announced on-demand billing and a zero-provisioned F0 SKU at FabCon Europe in September, which should give spiky workloads a middle ground, but pricing isn't published yet, and on-demand billing doesn't make wasted compute free. Poor maintenance inflates capacity usage gradually, small files multiply, deletion vectors pile up, Direct Lake falls back to cold-state transcoding. That creeping usage is hard to diagnose, often teams assume they're just processing more data, it's easy to blame on organic growth. But eventually you'll outgrow your SKU and be forced to upgrade when the real fix might have been reading this article and implementing a few lines of configuration.

My research and testing of Runtime 2.0 revealed this is actually a bigger problem under Runtime 2.0. Deletion Vectors are now enabled by default, so every UPDATE, DELETE and MERGE means they start to pile up.

For this edition I trawled through the documentation and ran a notebook against a Runtime 2.0 workspace (Spark 4.1.1, Delta 4.2.0) and a Runtime 1.3 environment. I checked every default this guide relies on, benchmarked liquid clustering on both runtimes, and tested how Data Factory's Copy activity and Copy job behave against Runtime 2.0 tables. My work revealed many documentation and runtime contradictions, a reminder to trust your environment over any documentation, including this article!

What's changed since my original guide. Some of its advice is now redundant because Runtime 2.0 made it the default. Some was overtaken by Microsoft's revised guidance. And a couple of points were wrong, or are now wrong on Runtime 2.0:

  • The 256 MB Silver/400 MB Gold file-size targets (and 400 MB–1 GB with 8M-row row groups for Direct Lake). These came from Microsoft's own Cross-Workload page, which dropped fixed targets on 26 August in favour of adaptive file sizing, and now gives Direct Lake a 1–16 million row-group range.
  • "Run OPTIMIZE aggressively at Silver and Gold." Also from that page, and also removed. Microsoft's default strategy is now Auto Compaction, with scheduled OPTIMIZE kept for specific cases.
  • The V-Order figures (40–60%/~10%/15–33%) and enabling it for SQL analytics endpoint consumers. The 40–60% figure was on the same page and went in the same revision. Microsoft now says to enable V-Order only when Direct Lake is a primary consumer.
  • "Optimize Write is on by default." Under the default writeHeavy profile it only applies to partitioned tables.
  • "Fast Optimize does not apply to liquid clustering." That's what the Table Compaction page said. On Runtime 2.0 it does apply, and I measured it.
  • No liquid clustering at Bronze. Bronze MERGE targets benefit as much as any table.

Scope: Lakehouse (Spark/Delta) tables only. Fabric Warehouse manages its own layout and is out of scope.


Runtime 2.0 at a Glance

If you only read one section, read this one.

Setting Runtime 1.3 Runtime 2.0 What you do now
Adaptive Target File Size (ATFS)Opt-inOnNothing. Remove it from your utility notebook.
File-Level Compaction TargetOpt-inOnNothing.
Fast OptimizeOpt-inOnNothing, but know when it skips work (see Compaction).
Deletion vectorsOpt-inOn for new tablesKeep them. Schedule OPTIMIZE on update-heavy MERGE targets.
Incremental liquid clusteringNot available (full rewrite under 100 GB)OnUse liquid clustering by default.
Auto CompactionOffOffEnable it, preferably as a table property.
V-OrderOffOffEnable for Direct Lake tables, or use readHeavyForPBI.
Optimize WritePartitioned tables only (writeHeavy)SameEnable per table for streaming/microbatch writes.
Native Execution EngineOpt-inOpt-in (now also accelerates liquid clustering OPTIMIZE)Enable it.
VACUUM LITE, OPTIMIZE FULL, clusteringQuality()Not availableAvailableUse where relevant.

Every "On" in the Runtime 2.0 column is something I confirmed in a fresh session, both from the session config and by testing the behaviour. You can check your own environment with this snippet:

for k in ["spark.microsoft.delta.optimize.fast.enabled",
          "spark.microsoft.delta.optimize.fileLevelTarget.enabled",
          "spark.microsoft.delta.targetFileSize.adaptive.enabled",
          "spark.databricks.delta.autoCompact.enabled",
          "spark.databricks.delta.optimizeWrite.enabled",
          "spark.sql.parquet.vorder.default",
          "spark.microsoft.delta.optimize.clustering.strategy.incremental",
          "spark.databricks.delta.properties.defaults.enableDeletionVectors"]:
    try:
        print(f"{k:70} {spark.conf.get(k)}")
    except Exception:
        print(f"{k:70} <unset>")

My original guide was largely a list of configs to set, Runtime 2.0 now sets most of them for you. Miles Cole's own summary at the August Fabric Spark AMA was that incremental liquid clustering, ATFS, Fast Optimize and the file-level target are on by default with "no reason to disable" them, and that his only two non-default go-tos are the Native Execution Engine and Auto Compaction. All that's left is a short list of decisions that you'll need to make on your own, dependent on your environment and workloads, and the rest of this guide is about those.


Fabric Still Doesn't Maintain Your Tables

What happens automatically:

What doesn't:

The Lakehouse explorer has a right-click Maintenance action for one-off OPTIMIZE and VACUUM on a single table. Pipelines now have a Lakehouse Maintenance activity that went GA in Sep 2026 despite being painful to use, and incompatible with schema-enabled Lakehouses. I didn't get as far as testing whether it clusters liquid clustered tables since I exclusively use schema-enabled Lakehouses, and had already had a gutful trying to get the activity to work (I may also be impatient).

The right maintenance strategy varies by table, e.g. an append-only Bronze table and a Gold table serving hundreds of Direct Lake users need different things, and a single automated policy would either waste capacity on the first or under-serve the second. That said, Runtime 2.0 has narrowed the gap with much better defaults.

Personally, I'd still like an opt-in automatic mode for teams that want one (or can't be bothered tuning their own and are happy with 'good enough'). Databricks has predictive optimisation, which decides per table when to run OPTIMIZE and VACUUM. Fabric has nothing equivalent, and Cole said at the AMA there are no plans for a CLUSTER BY AUTO.


Compaction

How it works

Most practitioners would (should) know that every write creates new Parquet files. Frequent small writes, MERGEs and updates leave behind lots of small files, and every query pays to open, read the footer of, and skip each one. Compaction is the panacea to the small-file problem, it rewrites many small files into fewer, right-sized ones.

There are two ways to compact in Fabric:

Fast Optimize sits in front of OPTIMIZE. Before rewriting a bin of files, it checks whether doing so would meaningfully improve the table, and skips the bin if not. That makes a no-op OPTIMIZE nearly free.

What changed in Runtime 2.0

What to do

Enable Auto Compaction, and do it as a table property. Session configs only apply to the session that sets them, so any writer that doesn't run your utility notebook won't trigger compaction. A table property applies to every writer. In my testing the table property won even when the session config was explicitly false.

ALTER TABLE silver.my_table
SET TBLPROPERTIES ('delta.autoOptimize.autoCompact' = 'true');

Know what Fast Optimize skips. It deliberately ignores a handful of small files. That's the point of it, but it means a trickle of tiny appends can sit uncompacted (and, on liquid clustered tables, unclustered) for a while. If you need to force a compaction, turn it off for that one command:

spark.conf.set("spark.microsoft.delta.optimize.fast.enabled", "false")
spark.sql("OPTIMIZE silver.my_table")
spark.conf.unset("spark.microsoft.delta.optimize.fast.enabled")

Keep a scheduled OPTIMIZE only where it earns its place:

  1. One-off backlog cleanup on tables that have never been maintained.
  2. Update-heavy MERGE targets. Auto Compaction doesn't clear deletion vectors unless small files also trigger it (see Deletion Vectors). This is now the main reason scheduled OPTIMIZE is still required, particularly for tables serving Direct Lake Semantic Models.
  3. Workloads that can't tolerate write latency. Auto Compaction runs in the write path, so for these workloads you'd disable (or not enable) Auto Compaction, and rely on a scheduled OPTIMIZE for compaction and clustering.
  4. Direct Lake tables, timed around framing. Direct Lake models pick up new data by framing the latest Delta version. With automatic updates on (the default), that happens whenever the table changes, so run OPTIMIZE straight after the load that created the deletion vectors. If you've turned automatic updates off to control when data appears, finish OPTIMIZE before your scheduled refresh.

Use OPTIMIZE ... DRY RUN to see what a run would rewrite before committing to it. Auto Compaction runs appear in DESCRIBE HISTORY as OPTIMIZE with auto = true, along with file counts and size percentiles, which is the easiest way to confirm it's working.


File Sizing

How it works

Every compaction, every OPTIMIZE and every Optimize Write aims at a target file size. Too small and you're back to the small-file problem. Too large and you lose parallelism and skipping granularity.

Adaptive Target File Size (ATFS) sets that target from the table's size: a flat 128 MB for any table under 10 GB, scaling linearly to 1 GB at 10 TB. 1 GB is the ceiling: it's the default maxFileSize (configurable between 128 MB and 1 GB), and once a table reaches it ATFS stops re-evaluating (stopAtMaxSize). The evaluated value is stored on the table as the delta.targetFileSize.adaptive property, so you can read it from DESCRIBE DETAIL.

ATFS target file size by table size 0 256 MB 512 MB 768 MB 1 GB 0 2 TB 4 TB 6 TB 8 TB 10 TB table size 10 GB table: ~128 MB target 1 TB table: ~217 MB target 5 TB table: ~576 MB target 10 TB table: ~1024 MB target 128 MB up to 10 GB 1 TB: ~217 MB 5 TB: ~576 MB 1 GB cap at 10 TB
Plotted from the documented rule: 128 MB up to 10 GB, then linear to 1 GB at 10 TB. Even a 1 TB table targets only ~217 MB. Read your own table's value from delta.targetFileSize.adaptive.

Is 1 GB too small for a very large table? I don't think so. Bigger files mean fewer files to list, but every MERGE, UPDATE or deletion-vector purge has to rewrite whole files, so larger files make those more expensive, and fewer files give file skipping less to work with. Microsoft caps it at 1 GB and doesn't let you go higher.

The File-Level Compaction Target tags each compacted file with the target it was written for. When ATFS raises the target as the table grows, files that were at least half the target at the time they were compacted aren't rewritten (e.g. a 100 MB file compacted under a 128 MB target is left alone when the target rises to 256 MB, even though it's well under the new target, whereas a 50 MB file would get compacted). That avoids needless rewrites as tables grow.

These are both runtime defaults, not table settings, so they apply to existing tables as soon as a Runtime 2.0 session touches them. ATFS evaluates a table's target at the start of its next OPTIMIZE (or on CTAS and overwrites), and existing files aren't rewritten just because the setting is now on. In my migration test, the files Runtime 1.3 had written were left alone. The one thing to check is that no Environment or notebook still sets these configs to false.

Christopher Finlan (Microsoft) described the "compaction gap" well: without ATFS, Auto Compaction aims at 128 MB while OPTIMIZE aims at 1 GB, so the two never converge and you end up recompacting forever. With ATFS they share one dynamic target, which is another reason why the scheduled OPTIMIZE has become less necessary.

What changed in Runtime 2.0

What to do


Clustering

This is where Runtime 2.0 changed the most, so it gets the most space.

How it works

Partitioning physically splits a table into a folder per value of a column (e.g. one folder per date). Queries filtering on that column skip whole folders. The trouble is that it bakes a physical structure into the table: a high-cardinality column produces thousands of tiny partitions (the small-file problem again, at a structural level), and choosing the wrong column means rewriting the table. Microsoft's cross-workload guidance now says "Avoid partitioning by default."

Z-Order and liquid clustering both rearrange rows so that similar values end up in the same files. Each file then covers a narrow range of values, and its min/max statistics let queries skip the files that can't contain what they're looking for. With ATFS, a 10 GB table is roughly 80 files of ~128 MB. Unclustered, a selective query on a given column may have to open all of them. Clustered on that column, it may only need one: 128 MB read instead of 10 GB. A table split into N files can skip at most (N−1)/N of them, so clustering only pays off once a table spans several files, and only as well as the clustering quality allows.

Liquid clustering is the better of the two, for four reasons:

Clustering never happens on write. A plain INSERT or MERGE writes unclustered files, even on Runtime 2.0. Miles Cole's reasoning is that clustering on write forces a shuffle that can make writes twice as slow or worse. OPTIMIZE is a guarantee where cluster-on-write would only be optimistic. Clustering only happens when OPTIMIZE runs, either explicitly or through Auto Compaction (which, on a liquid clustered table, clusters as well as compacts). If neither ever runs, CLUSTER BY gives you no benefit at all. The same goes for Data Factory - a Copy activity or Copy job append into a liquid clustered table writes unclustered files.

What changed in Runtime 2.0

Runtime 1.3 rewrote your whole table on every OPTIMIZE. Open-source Delta groups clustered files into Z-Cubes (a Z-Cube is a group of files clustered on the same columns, which keeps growing until it reaches 100 GB or the cluster keys change). Any Z-Cube under 100 GB is "partial" and is rewritten in full whenever there's new data to cluster, until it seals at 100 GB. Most tables are under 100 GB, so most tables were rewritten in full on every OPTIMIZE. Miles Cole, on r/MicrosoftFabric: "Databricks doesn't even work this way, it's really just the OSS LC implementation."

Tables under 100 GB (most tables) fit inside one Z-Cube. Let's say you have a 50 GB table that has recently been compacted and clustered and you do a 100 MB write to this table, which adds a new file outside the Z-Cube. Under Runtime 1.3, when you run OPTIMIZE this entire table 50.1 GB is rewritten. Now with Incremental Liquid Clustering on Runtime 2.0, the existing Z-Cube is left alone, and only the 100 MB of new data (plus any partly filled file it merges with) is re-written into the Z-Cube.

Runtime 2.0 clusters incrementally. A file is only rewritten if any of these are true:

Everything else is left alone.

Runtime 1.3 before: clustered table + 1% append 1 clustered file · 1.45 GB +15 MB OPTIMIZE after Runtime 1.3: 1.47 GB rewritten (101% of the table) 1 file · 1.47 GB · all rewritten Partial Z-Cube (< 100 GB) rewritten in full One file: nothing for queries to skip ~101% of the table per OPTIMIZE Runtime 2.0 before: clustered files + partial file + 1% append Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched part new OPTIMIZE after Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Clustered file, ~145 MB: untouched Runtime 2.0: only the partial file and new append rewritten (1–3% of the table) rewritten └── 10 files · ~145 MB · untouched ──┘ Only the new append and the partly filled file are rewritten 1–3% of the table per OPTIMIZE
What a liquid clustering OPTIMIZE rewrites after a 1% append, from my benchmark. Runtime 1.3 rewrites the whole table into one unskippable file. Runtime 2.0 leaves clustered files alone.

I couldn't find a published Fabric benchmark for the difference, so I ran one. I used a 20-million-row, ~1.45 GB table clustered on two columns, with settings pinned so that only the clustering algorithm differed: Fast Optimize, Optimize Write and Auto Compaction all off. After the initial OPTIMIZE, I ran three rounds of "append 1% new data, then OPTIMIZE":

Round Runtime 1.3 rewrote Runtime 1.3 time Runtime 2.0 rewrote Runtime 2.0 time
01.47 GB (101%)54 s15 MB (1.0%)6 s
11.48 GB (102%)53 s30 MB (2.1%)6 s
21.50 GB (103%)54 s44 MB (3.1%)8 s
Data rewritten per OPTIMIZE (1% append each round) Runtime 1.3 Runtime 2.0 0 500 MB 1 GB 1.5 GB Round 0 Round 0, Runtime 1.3: 1.47 GB rewritten in 54 s 1.47 GB · 54 s Round 0, Runtime 2.0: 15 MB rewritten in 6 s 15 MB · 6 s Round 1 Round 1, Runtime 1.3: 1.48 GB rewritten in 53 s 1.48 GB · 53 s Round 1, Runtime 2.0: 30 MB rewritten in 6 s 30 MB · 6 s Round 2 Round 2, Runtime 1.3: 1.50 GB rewritten in 54 s 1.50 GB · 54 s Round 2, Runtime 2.0: 44 MB rewritten in 8 s 44 MB · 8 s
20M-row, ~1.45 GB table clustered on two columns, with Fast Optimize, Optimize Write and Auto Compaction off. Runtime 1.3's cost tracks the whole table, Runtime 2.0's tracks the new data.

On Runtime 1.3, clustering about 15 MB of new data meant rewriting the entire table every time: roughly 100 times the new data, and 7–9 times slower. The gap only grows with table size, because Runtime 1.3's cost scales with the whole table and Runtime 2.0's scales with the new data. Note that this is just one table and one append pattern, not a universal multiplier.

Two details are worth noticing:

There's a trade-off hidden in those numbers. In a separate test, each newly clustered file covered the full range of both cluster keys, so it overlapped every other file and no query could skip it. Incremental clustering keeps rewrites small by clustering new data only within itself, so a small slice of recent data stays unskippable until Auto Reclustering (or an OPTIMIZE FULL) reorganises it with its neighbours.

The guidance changed accordingly. In February, Christopher Finlan wrote that partitioning "is often the better choice until Runtime 2.0." In May, Miles Cole: "I fully recommend using Liquid Clustering over partitioning and Z-Order, as long as you are using Runtime 2.0."

Migration from 1.3 is automatic. At the AMA, Cole's answer to "is there a migration step?" was "No migration, it's automatic and seamless :)". It's not that I don't trust Miles, or Microsoft, but I tested it. I took the table Runtime 1.3 had clustered, switched to Runtime 2.0, and repeated the same three rounds. They rewrote 1.0%, 2.0% and 3.0%, and the 1.5 GB file from Runtime 1.3 was never touched. There was no one-off full rewrite and no OPTIMIZE FULL required.

OPTIMIZE FULL is still useful, just not required. Migration stops the full-rewrite cost, but data clustered on Runtime 1.3 keeps its old layout, in my case that single 1.5 GB file. One OPTIMIZE FULL on Runtime 2.0 rewrote it into 11 files of 110–162 MB, the same layout a native Runtime 2.0 table gets. That's a single full rewrite, worth paying once on large or heavily queried tables you clustered on 1.3.

Also new in Runtime 2.0:

What to do

Use liquid clustering by default, with one to four cluster keys. Four is Delta's limit, not just guidance. In practice, one or two uncorrelated, selective columns that appear in your filters and joins. The order you list them in CLUSTER BY doesn't matter. Low-cardinality or correlated columns waste a slot.

Cluster keys also need file statistics, and by default Delta only collects statistics on the first 32 columns of a table. If a key sits beyond column 32, move it earlier or name it in delta.dataSkippingStatsColumns. (clusteringQuality() reports no_stats for a key without statistics.)

CREATE TABLE silver.orders (...) CLUSTER BY (customer_id);

-- Existing table: change the policy without rewriting existing data
ALTER TABLE silver.orders CLUSTER BY (customer_id, order_date);

MERGE targets benefit, but not quite how I expected. Cluster the target table on the merge key or, for a composite key, its most selective one or two columns, but never a hash of the key, which scatters neighbouring rows across every file.

It's often said that the MERGE ON clause works like a filter, so a clustered target lets MERGE skip files. I tested that and it's only half true.

A MERGE reads the target twice:

  1. A matching scan to find which files contain the batch's keys, then
  2. A rewrite scan of just those files.

I merged the same batch (50,000 updates to a single block of consecutive order IDs, plus 10,000 new orders) into two identical 1.4 GB tables, one clustered on order_id and one not:

Matching scan Rewrite scan Deletion vectors added
Plain ON, clustered18 files, 1,368 MiB1 file, 127 MiB1
Plain ON, unclustered18 files, 1,368 MiB10 files, 1,360 MiB10
ON + key range, clustered9 files, 396 MiB1 file, 132 MiB1
ON + key range, unclustered16 files, 1,362 MiB10 files, 1,360 MiB10
MiB read by one MERGE batch Matching scan Rewrite scan 0 1,000 2,000 MiB read Plain ON · unclustered Plain ON · unclustered: matching scan 1,368 MiB Plain ON · unclustered: rewrite scan 1,360 MiB 2,728 MiB · 10 DVs Plain ON · clustered Plain ON · clustered: matching scan 1,368 MiB Plain ON · clustered: rewrite scan 127 MiB 1,495 MiB · 1 DV ON + range · unclustered ON + range · unclustered: matching scan 1,362 MiB ON + range · unclustered: rewrite scan 1,360 MiB 2,722 MiB · 10 DVs ON + range · clustered ON + range · clustered: matching scan 396 MiB ON + range · clustered: rewrite scan 132 MiB 528 MiB · 1 DV
Same batch merged into two identical 1.4 GB tables. Clustering alone shrinks the rewrite scan, and adding a key range to ON also lets the matching scan skip files, but only on the clustered table.

<batch min> and <batch max> aren't something Spark works out for you. Compute them from the batch first, then put them into the MERGE as literal values, so Spark can compare them against each file's min/max statistics before it reads anything:

from pyspark.sql import functions as F

lo, hi = spark.table("staging.order_batch").agg(F.min("order_id"), F.max("order_id")).first()

spark.sql(f"""
    MERGE INTO silver.orders t
    USING staging.order_batch s
    ON t.order_id = s.order_id
       AND t.order_id BETWEEN {lo} AND {hi}
    WHEN MATCHED THEN UPDATE SET *
    WHEN NOT MATCHED THEN INSERT *
""")

Two cautions:

  1. The range must cover every key in the batch that could match an existing row, or that row will be treated as new and inserted twice.
  2. And this is one table and one batch shape: if your batches update keys scattered across the whole table, the changed rows sit in most files anyway and the benefit shrinks.

Small tables: clustering a table that fits in one file does nothing, but it doesn't cost anything either, since Fast Optimize turns the OPTIMIZE into a no-op. You do still have to choose keys, so leaving a static lookup table unclustered is perfectly reasonable. I've considered building something custom that decides to cluster a table, or not, based on size or file count, but tables grow and it adds dev effort and complexity for little benefit.

Partition only for concurrent DML on disjoint partitions. My original guide recommended liquid clustering everywhere except Bronze, and didn't say when partitioning is still the right call. The answer is narrower than "multi-source ingestion": blind appends don't conflict under Delta's concurrency control, with or without partitions. The real exception is concurrent writes that update or delete existing rows (UPDATE, DELETE, or a MERGE that does either) on disjoint data. Cole, at the AMA: "Partitioning is really the only way to guarantee that disjoint DML predicates won't conflict."

Before reaching for partitions, see whether you can avoid the concurrency instead: run those writes in sequence, or combine the sources into a single MERGE. Partitioning is for when you genuinely need parallel DML on the same table. If you're in that case:

Use OPTIMIZE FULL after changing cluster keys, or once on Runtime 1.3-era tables, as above:

ALTER TABLE gold.sales CLUSTER BY (store_id, sale_date);
OPTIMIZE gold.sales FULL;

Deletion Vectors

How it works

Without deletion vectors, any command that deletes or updates a row (DELETE, UPDATE, or a MERGE that does either) rewrites the entire Parquet file containing it. With deletion vectors, the affected rows are marked as deleted in a small sidecar file and the Parquet file is left alone. That makes writes much cheaper, at the cost of readers having to apply the deletion vectors until compaction rewrites the file.

Direct Lake is where the cost shows: on cold start it has to load every deletion vector for the table, so accumulated deletion vectors directly slow the first queries your users run. Now that deletion vectors are on by default, every Direct Lake model over new Runtime 2.0 tables is exposed to this, whether or not anyone chose to enable them.

What changed in Runtime 2.0

What to do

Keep them on. Then manage the build-up:

So update-heavy MERGE targets, whether at Silver, Gold or merge-style Bronze, can build up deletion vectors indefinitely under Auto Compaction alone. Schedule an OPTIMIZE on them, and for Direct Lake tables, run it straight after the loads that create the deletion vectors (see the timing note in Compaction).

To spot the build-up, look at DESCRIBE HISTORY:

(Don't read UPDATE cost from numRemovedBytes, as on the deletion-vector path it reports 0.)

REORG TABLE ... APPLY (PURGE) forces deletion vectors out regardless of the 5% threshold. Use it for compliance-driven hard deletes, not routine maintenance.

Compatibility: deletion vectors upgrade the table protocol, and not every reader supports them. In particular, Python notebooks (delta-rs, Polars) can't read them. If you rely on those readers, you now have to turn deletion vectors off explicitly on the new tables they read, because Runtime 2.0 turns them on:

CREATE TABLE silver.my_table (...) TBLPROPERTIES ('delta.enableDeletionVectors' = 'false');

(Or set spark.databricks.delta.properties.defaults.enableDeletionVectors = false in the writing session.) And see the Data Factory section: Copy's merge writer can add deletion vectors to a table even after you've turned them off.


Vacuuming and Retention

How it works

OPTIMIZE, UPDATE, DELETE and MERGE all leave old files behind. That's what makes time travel possible. VACUUM deletes unreferenced files older than the retention window, which defaults to 7 days. OPTIMIZE improves performance, VACUUM is the step that actually reduces your storage bill.

VACUUM gold.my_table DRY RUN;   -- see what would be removed
VACUUM gold.my_table;

What changed in Runtime 2.0

VACUUM LITE finds unreferenced files by reading the Delta log rather than listing the whole directory, which is much cheaper on large tables. It's easy to assume it quietly falls back to a full VACUUM when it can't run, but that would be too convenient! If the log doesn't have enough history, it raises DELTA_CANNOT_VACUUM_LITE, and you need to handle that:

def vacuum(table):
    try:
        spark.sql(f"VACUUM {table} LITE")
    except Exception as e:
        if "DELTA_CANNOT_VACUUM_LITE" in str(e):
            spark.sql(f"VACUUM {table}")      # fall back to a full directory listing
        else:
            raise

For very large tables there's also VACUUM ... USING INVENTORY, where you supply a precomputed file listing instead of having VACUUM list the directory.

What to do

Set retention per table, with the right property. It's important to note that delta.logRetentionDuration is the transaction log's retention (default 30 days), and it doesn't change what VACUUM deletes. VACUUM retention is delta.deletedFileRetentionDuration. I tested both:

Time travel can only go as far back as the minimum of the two (once VACUUM has run), so to keep 30 days of time travel, set both to 30 days:

ALTER TABLE silver.my_table SET TBLPROPERTIES (
  'delta.deletedFileRetentionDuration' = 'interval 30 days',
  'delta.logRetentionDuration'         = 'interval 30 days'
);
Layer Retention Reason
Bronze7 days (default)Raw data, time travel rarely needed
Silver14–30 daysSupports debugging and rollback of transformations
Gold7–14 daysLonger if Change Data Feed consumers are active

If anything reads a table's Change Data Feed, keep retention comfortably longer than your slowest consumer's polling interval, or it will miss changes.

Run VACUUM on its own schedule, after OPTIMIZE: weekly is usually enough. Only shorten retention below 7 days deliberately. The safety check (spark.databricks.delta.retentionDurationCheck.enabled) exists for a reason.


V-Order and Resource Profiles

How it works

V-Order applies VertiPaq-style sorting and encoding to Parquet files at write time. Direct Lake benefits most. The cost is write time: Microsoft's V-Order page puts it at "often around 15% on average." It's off by default.

What changed in Runtime 2.0

Nothing in the runtime, but the guidance moved:

The documentation disagrees on resource profiles: the V-Order page says readHeavyForSpark enables V-Order, while the Resource Profiles page says only readHeavyForPBI does. I checked in a session:

Profile V-Order Optimize Write Optimize Write bin size
writeHeavy (default)OffUnset (partitioned tables only)128 MB
readHeavyForSparkOffOn128 MB
readHeavyForPBIOnOn1 GB

readHeavyForPBI is the only profile that turns on V-Order.

What to do

Layer V-Order
BronzeOff
SilverOnly for tables Direct Lake reads directly (table property)
GoldOn for Direct Lake tables, or use readHeavyForPBI
ALTER TABLE silver.my_table SET TBLPROPERTIES ('delta.parquet.vorder.enabled' = 'true');

Direct Lake considerations

  • Deletion vectors slow cold start, and they're now on by default. Direct Lake loads them all. Purge them with OPTIMIZE.
  • Time OPTIMIZE around framing. With automatic updates on (the default), the model reframes whenever a table changes, so run OPTIMIZE straight after the load. If you've turned automatic updates off, finish OPTIMIZE before your scheduled refresh.
  • Row groups: Microsoft says Direct Lake "generally performs best with row groups between 1 million and 16 million rows." There's a native-writer setting, spark.sql.parquet.native.writer.maxRowGroupRowCount, but Microsoft advises touching it only after analysis shows row-group sizing is the problem.
  • My reasoning, not a documented claim: every rewritten file invalidates its transcoded VertiPaq segments. On Runtime 1.3, every OPTIMIZE on a liquid clustered table under 100 GB rewrote the whole table and therefore invalidated all of it. Incremental clustering should mean far less re-transcoding, not just less Spark work.
  • Use Delta Analyzer to look at row groups and segments when a model is slow.

Assessing Your Tables

If you've never maintained your tables, you can just run OPTIMIZE on everything: on Runtime 2.0, Fast Optimize makes it nearly free on tables that are already healthy. The reason to look first is the unhealthy ones. A neglected table's first OPTIMIZE rewrites most of it, and a long list of those run back-to-back is a large CU spike on a shared capacity. An assessment tells you where those big rewrites are, so you can prioritise and stagger them, and it surfaces partitioned tables and old protocols worth revisiting. This snippet reads metadata only, so it runs in seconds regardless of table size. It compares each table against its own ATFS target rather than a fixed per-layer constant, and it shows each table's clustering and protocol.

from pyspark.sql import functions as F, types as T

def to_mb(value, default=128.0):
    """Parse a Delta size string such as '128m' or '1g' into MB."""
    if not value:
        return default
    v = str(value).strip().lower()
    units = {"k": 1 / 1024, "m": 1.0, "g": 1024.0}
    return float(v[:-1]) * units[v[-1]] if v[-1] in units else float(v) / 1_048_576

rows = []
# Schema-enabled Lakehouses: SHOW TABLES only lists the current schema, so loop over every schema
tables = [f"`{s[0]}`.`{t.tableName}`"
          for s in spark.sql("SHOW SCHEMAS").collect()
          for t in spark.sql(f"SHOW TABLES IN `{s[0]}`").collect()
          if not t.isTemporary]
for name in tables:
    try:
        d = spark.sql(f"DESCRIBE DETAIL {name}").first().asDict()
        props = d.get("properties") or {}
        n, size = d.get("numFiles") or 0, d.get("sizeInBytes") or 0
        avg_mb = round(size / n / 1_048_576, 1) if n else 0.0
        target_mb = to_mb(props.get("delta.targetFileSize.adaptive") or props.get("delta.targetFileSize"))
        if n <= 1:
            status = "Skip: single file"
        elif avg_mb >= target_mb / 2:
            status = "Healthy"
        elif avg_mb >= target_mb / 4:
            status = "Review"
        else:
            status = "Needs OPTIMIZE"
        rows.append((name, n, round(size / 1_073_741_824, 3), avg_mb, target_mb,
                     ", ".join(d.get("clusteringColumns") or []) or None,
                     bool(d.get("partitionColumns")),
                     f"{d['minReaderVersion']}/{d['minWriterVersion']}",
                     "deletionVectors" in (d.get("tableFeatures") or []),
                     status))
    except Exception as e:
        rows.append((name, None, None, None, None, None, None, None, None, f"Error: {e}"))

schema = T.StructType([
    T.StructField("table", T.StringType()),
    T.StructField("num_files", T.LongType()),
    T.StructField("size_gb", T.DoubleType()),
    T.StructField("avg_file_mb", T.DoubleType()),
    T.StructField("target_mb", T.DoubleType()),
    T.StructField("cluster_by", T.StringType()),
    T.StructField("partitioned", T.BooleanType()),
    T.StructField("protocol", T.StringType()),
    T.StructField("deletion_vectors", T.BooleanType()),
    T.StructField("status", T.StringType()),
])
display(spark.createDataFrame(rows, schema).orderBy(F.col("avg_file_mb").asc_nulls_last()))
Column What it tells you
avg_file_mb vs target_mbFiles averaging under half the table's own target suggest fragmentation. An average can hide skew, so treat "Review" as a prompt to look closer.
cluster_by/partitionedPartitioned tables without concurrent-DML needs are candidates for liquid clustering.
protocol/deletion_vectors1/2 or no deletion vectors usually means a table created before Runtime 2.0 or by a non-Spark writer.
statusYour triage order.

For deletion-vector build-up, check history since the last OPTIMIZE:

from pyspark.sql import functions as F

def dvs_since_last_optimize(table):
    added = 0
    for r in spark.sql(f"DESCRIBE HISTORY {table}").orderBy(F.desc("version")).collect():
        if r.operation == "OPTIMIZE":
            break
        m = r.operationMetrics or {}
        added += int(m.get("numDeletionVectorsAdded", 0)) + int(m.get("numTargetDeletionVectorsAdded", 0))
    return added

Treat this as a trend indicator rather than an exact count of outstanding deletion vectors. It stops at the most recent OPTIMIZE commit, including Auto Compaction runs, and OPTIMIZE only purges files with more than 5% of rows deleted, so some deletion vectors can survive it. A number that keeps climbing between runs is the signal to schedule an OPTIMIZE.

For liquid clustered tables, clusteringQuality() (Scala) reports, per cluster column, the number of files, average and maximum depth (how many files overlap a given value, 1 is ideal), overlap ratio (0 is ideal) and skipping effectiveness (1 is ideal):

%%spark
import io.delta.tables.DeltaTable
display(DeltaTable.forName(spark, "silver.orders").clusteringQuality())

It's a good reminder that "clustered" doesn't mean "skippable". On a small test table with one clustered file and ten unclustered appends, every file spanned the full key range: avg_depth 11, overlap_ratio 1, skipping_effectiveness 0. No query on that key could skip a single file until OPTIMIZE clustered the new data.

Clearing a backlog: run a one-off OPTIMIZE on the "Needs OPTIMIZE" tables (Silver and Gold first), VACUUM them, then enable Auto Compaction so you don't end up back here. That's also the order Microsoft's remediation guidance gives.


Implementation Guide

Your utility notebook on Runtime 2.0

It gets much shorter:

# Auto Compaction: still off by default. Prefer the table property
# (delta.autoOptimize.autoCompact) for tables written by more than one notebook or tool.
spark.conf.set("spark.databricks.delta.autoCompact.enabled", "true")

# Evaluate Auto Compaction at checkpoints (every 10 commits) instead of after every commit
spark.conf.set("spark.microsoft.delta.autoCompact.onCheckpointOnly.enabled", "true")

# V-Order: explicit baseline; override per table or in Gold notebooks
spark.conf.set("spark.sql.parquet.vorder.default", "false")

What's gone, and why:

Pipeline order

  1. Load.
  2. OPTIMIZE where you've decided to schedule it (update-heavy MERGE targets, backlog tables).
  3. Direct Lake picks up the result (automatically, or at your scheduled refresh if automatic updates are off).
  4. VACUUM on its own, slower cadence.
Automatic updates on (default) Load commits adds deletion vectors Model reframes loads every DV OPTIMIZE commits purges DVs Model reframes clean version ⚠ users query the DV-heavy version in this window: run OPTIMIZE straight after the load Automatic updates off Load commits adds deletion vectors OPTIMIZE commits purges DVs Scheduled refresh frames users only ever see the clean version ✓ finish OPTIMIZE before the scheduled refresh
Where OPTIMIZE belongs relative to Direct Lake framing. VACUUM runs separately, on a slower schedule.

If your pipelines write Delta tables with Data Factory

This one surprised me, and it's the most practical Runtime 2.0 issue I found.

Copy activity Upsert and Copy job Merge fail on tables created by Spark on Runtime 2.0.

Every table Spark creates on Runtime 2.0 is written at protocol writer 7, and Spark explicitly lists the legacy appendOnly feature in it. The Delta writer used by Copy's Upsert and Merge (Microsoft.DI.Delta) doesn't support that feature and rejects the table:

ErrorCode=FailedToUpsertDataIntoDeltaTable ... UnsupportedTableFeatureException,
Message=Table requires writer feature(s) 'appendOnly' which are not supported by this SDK version.
  • It's not the deletion vectors themselves. Copy's writer handles those.
  • It affects every Spark-created Runtime 2.0 table, and any Runtime 1.3 table where you enabled deletion vectors (or another table feature) from Spark.
  • It also affects every liquid clustered table, regardless of who created it ('domainMetadata', 'clustering').
  • Copy job's Merge fails identically. That's the tool Microsoft recommends for incremental loads.
  • appendOnly can't be dropped (DELTA_FEATURE_DROP_NONREMOVABLE_FEATURE), so once a table lists it, Copy can never upsert into it.
  • Failed runs write nothing. Append still works.

What I'd do instead, and what my team already does: use Copy to land files (for example Parquet in the Lakehouse Files area), then MERGE into the Delta table from a Spark notebook. Copy never writes Delta, so none of this applies, and Spark is the only engine writing each table.

If you'd rather not hand-write MERGEs, the same principle works with declarative tools that run on Spark: dbt incremental models through the Fabric Spark adapter, or Materialized Lake Views, which can even ingest the landed CSV or Parquet files directly. What matters is that Spark, not Copy, writes the Delta tables. One catch with Materialized Lake Views: they only refresh incrementally when their source Delta tables have Change Data Feed enabled, otherwise each refresh is either skipped or a full rebuild.

A few other things I found while testing Copy, all on 2 October 2026:

Other compatibility notes


Upgrading from Runtime 1.3: Checklist


What's Still Your Decision

Runtime 2.0 has made most of the old configuration decisions for you. These are the ones it hasn't:


The Cheat Sheet

Layer Auto Compaction Optimize Write V-Order Clustering Deletion vectors Scheduled OPTIMIZE VACUUM retention
BronzeOn (table property)Default, on for streamingOffLiquid clustering on the merge key for MERGE targetsKeep (default)Update-heavy MERGE targets7 days
SilverOn (table property)Default, on for streamingOnly where Direct Lake reads itLiquid clustering by defaultKeep (default)Update-heavy MERGE targets14–30 days
GoldOn (table property)DefaultOn for Direct Lake, or readHeavyForPBILiquid clustering by defaultKeep, purge with OPTIMIZEStraight after loads (before scheduled refresh if automatic updates are off)7–14 days

Where the Docs Disagree

Microsoft's documentation still contradicts itself in places. Here are the conflicts I found, and what the runtime actually does:

Topic One page says Another says What I measured
File-Level Compaction Target default"Not enabled by default" (Table Compaction)"Enabled by default starting in Runtime 2.0" (Tune File Size)On by default
Fast Optimize on liquid clustered tables"Not applicable to liquid clustering" (Table Compaction)"Compatible starting in Runtime 2.0" (Liquid Clustering)It applies, and skips small bins
Fast Optimize defaultNot stated on LearnOn by default (Cole, AMA)On by default
Delta version in Runtime 2.0Delta 4.1 (Liquid Clustering, VACUUM pages)Delta 4.2 (Runtime 2.0 page)Delta 4.2.0
readHeavyForSpark and V-OrderEnables V-Order (V-Order page)Doesn't (Resource Profiles page)Doesn't
Pipelines and deletion vectorsNot supported (interoperability matrix)Supported as source and destination (Copy activity page)Read and Append work, Upsert/Merge fail on Spark-created tables
Runtime 2.0 as default for new workspaces"Planned" (Runtime 2.0 page)No other pageAlready the default

What to Watch


Where the Documentation Lives

Fabric maintenance and configuration:

From the Fabric team:

Open source:


Final Thoughts

Runtime 2.0 is the release where Fabric's maintenance story grew up. Most of the configuration I told you to set is now the default, liquid clustering finally works the way it was always meant to, and the documentation, while still contradicting itself in places, is far better than it was a year ago.

Three things I'd leave you with:

First, rewrite your utility notebook. Most of it is now redundant. Enable Auto Compaction (as a table property where you can), keep an explicit V-Order baseline, and remove the rest.

Second, use liquid clustering by default, and keep a scheduled OPTIMIZE for the tables that need it. On my test table, Runtime 2.0 rewrote 1–3% of the table where Runtime 1.3 rewrote all of it. Migration is automatic. But Auto Compaction still doesn't purge deletion vectors from update-heavy MERGE targets on its own, so those still need a scheduled OPTIMIZE.

Third, test what the documentation tells you. In a day of testing I found seven places where Microsoft's own pages disagreed with each other or with the runtime, and a Data Factory incompatibility that will break pipelines upserting into Runtime 2.0 tables. None of it is hard to check.

And as before: treat maintenance as cost control, not just performance. Fabric SKUs double at every tier, and the difference between a well-maintained platform and a neglected one may be the difference between your current SKU and the next one up.


Brad Coles is an Associate Director and Data Engineering Capability Lead ANZ at Synechron Australia, specialising in Microsoft Fabric and modern data platform engineering. Connect on LinkedIn.

If this article helped you, click here to see some ways you can support me.