Delta tables don't optimize themselves. Learn how V-Order encoding, file compaction with OPTIMIZE, multi-dimensional data skipping with Z-Order, and storage reclamation with VACUUM work together to make your Fabric Lakehouse queries dramatically faster and cheaper to store. This lesson goes deep into the internals so you know exactly when and why to apply each technique.

You've built the lakehouse. Data is flowing in through pipelines, notebooks are transforming records from bronze to silver to gold, and your Power BI reports are connecting via the SQL analytics endpoint. Then someone runs a query against a 500-million-row fact table and it takes 47 seconds. A product manager notices the lakehouse is consuming 800 GB of storage for what should be a 200 GB dataset. Your Direct Lake semantic model is falling back to DirectQuery mode because framing is failing. These are not edge cases — they are the completely normal consequences of skipping Delta table optimization, and they bite every team eventually.
Delta Lake is not a magic black box that performs well automatically. Under the hood, it's a collection of Parquet files governed by a transaction log, and the shape of those files — their size, their count, their internal sort order, and their encoding — determines almost everything about query performance and storage cost. Microsoft Fabric adds a layer on top of standard Delta Lake called V-Order, a Parquet write optimization that dramatically changes the economics of read performance. But V-Order is only one piece. You also need to understand file compaction with OPTIMIZE, data skipping with Z-Order, and storage reclamation with VACUUM. Together, these four mechanisms form a complete performance management system for Delta tables in a Fabric Lakehouse.
By the end of this lesson, you will be able to diagnose performance problems in Delta tables using the transaction log and file statistics, apply V-Order writes correctly in Spark and SQL, compact fragmented files with OPTIMIZE and tune its behavior, implement multi-dimensional data skipping with Z-Order clustering, and safely reclaim storage with VACUUM without breaking time travel or downstream consumers. You'll also understand how each of these optimizations interacts with Direct Lake mode in Power BI and when to apply them in a production pipeline.
What you'll learn:
Before working through this lesson, you should be comfortable with:
You'll also benefit from having a lakehouse with at least a few Delta tables loaded with real data — a fact table with tens of millions of rows will make the before/after comparisons in this lesson more meaningful.
To understand why OPTIMIZE exists, you need to understand what happens to a Delta table during continuous ingestion. Every time Spark writes data — whether from a streaming job, a Copy activity in a pipeline, or a notebook — it produces one Parquet file per Spark partition per write operation. If you're ingesting hourly batches of 50,000 rows and your Spark job has 200 partitions, each hourly load creates up to 200 tiny Parquet files. After a month, that one fact table has accumulated roughly 144,000 files, many of them containing only a few hundred rows.
This is the small file problem, and it compounds in two distinct ways. First, there's the metadata cost: when Spark plans a query against a Delta table, it reads the Delta transaction log to discover all active files, then reads the footer metadata of each Parquet file to determine whether it can be skipped. With 144,000 files, that metadata scan alone can take seconds before a single row of actual data is read. Second, there's the I/O cost: even if statistics allow Spark to skip many files, the remaining files are still tiny, meaning you get poor vectorized read throughput from the Parquet reader and poor compression ratios because compression works better on larger blocks.
The Delta transaction log lives in the _delta_log directory of each table. You can inspect it directly in a notebook to see what's happening:
from delta.tables import DeltaTable
# Point at your lakehouse table
dt = DeltaTable.forName(spark, "sales_fact")
# Get file count and size statistics
detail = dt.detail().collect()[0]
print(f"Table name: {detail['name']}")
print(f"Number of files: {detail['numFiles']}")
print(f"Size in bytes: {detail['sizeInBytes']}")
print(f"Avg file size: {detail['sizeInBytes'] / detail['numFiles'] / 1024 / 1024:.2f} MB")
If your average file size comes back as 2–5 MB on a table that should be well-optimized, you have a small file problem. The target for compacted Parquet files in a Fabric Lakehouse is typically 128–256 MB per file, though 512 MB files are reasonable for very large tables with selective filter queries.
You can also inspect the history of the table to see how many write operations have accumulated:
dt.history(20).select("version", "timestamp", "operation", "operationMetrics").show(truncate=False)
This will show you the last 20 operations — appends, merges, schema changes — along with metrics like how many files were added and removed in each version. If you see dozens of small append operations with numOutputFiles in the hundreds, you're looking at a fragmented table that needs compaction.
Key insight
The small file problem isn't just a storage issue — it's primarily a query planning issue. A table with 100,000 files of 1 MB each is often 5–10× slower to query than the same data stored in 400 files of 256 MB each, even before accounting for data skipping. The difference is entirely in metadata overhead and vectorized I/O efficiency.
V-Order is a Microsoft-developed write-time optimization for Parquet files. It's applied during the Parquet encoding step, which means it happens before the data hits storage and doesn't require any additional post-processing step. Understanding what V-Order actually does — not just that it "makes things faster" — is essential for knowing when you need it and when you can skip it.
Standard Parquet writing encodes column data in row groups (the large vertical partitions of a Parquet file, typically 128 MB each) and pages (smaller sub-divisions within a row group, typically 1 MB). V-Order applies additional sorting and encoding optimizations within each row group:
Vertical encoding sort: Rows within a row group are sorted by a heuristically chosen key (usually high-cardinality columns) before encoding. This isn't user-visible as a sort order for queries, but it improves run-length encoding (RLE) compression significantly because similar values end up adjacent to each other.
Dictionary encoding optimization: V-Order aggressively uses dictionary encoding for string columns and applies compression in a way that maximizes the effectiveness of the CPU's dictionary cache. This is why V-Order files tend to be smaller than standard Parquet files — not dramatically smaller, but meaningfully so (typically 10–30% smaller on analytical workloads).
Page-level statistics: V-Order writes tighter min/max statistics at the Parquet page level, which allows the Parquet reader to skip pages within a row group, not just entire files. This is significant for analytical queries that filter on high-cardinality columns.
The net result is that V-Order files are read significantly faster by the Fabric Parquet reader (the Microsoft-optimized reader used in both Spark and the SQL analytics endpoint). Microsoft's benchmarks show 2–5× faster query times on V-Order files compared to standard Parquet, and this tracks with real-world observations on analytical workloads.
In a Fabric Lakehouse, V-Order is enabled by default for all Spark write operations. You can verify this in a notebook:
# Check the current V-Order setting
print(spark.conf.get("spark.sql.parquet.vorder.enabled"))
# Should return "true" for Fabric Spark sessions
This means that when you write a DataFrame to a Delta table using standard PySpark syntax, V-Order is applied automatically:
# V-Order is applied here automatically in Fabric Spark
df.write.format("delta").mode("append").saveAsTable("gold.sales_fact")
OPTIMIZE also applies V-Order when it rewrites files, which is one of the reasons OPTIMIZE can actually make your table smaller even when you already have large files — it re-encodes existing non-V-Order Parquet files with V-Order compression.
There are several situations where you won't get V-Order writes:
If you have a table written by an external tool, you can bring it into V-Order by running OPTIMIZE on it in a Fabric Spark notebook — OPTIMIZE will rewrite all files with V-Order encoding enabled.
Warning
If you're working with a lakehouse that receives data from multiple sources — some Fabric-native, some external — you may end up with a mix of V-Order and non-V-Order files in the same Delta table. This is valid Delta behavior, but it means your query performance will be inconsistent. Run OPTIMIZE to normalize the file encoding across the table.
V-Order adds CPU overhead at write time. For streaming workloads where write latency matters — say, you're writing sensor data to a Delta table every few seconds — the additional encoding cost can cause write latency to spike. In these cases, you can disable V-Order for a specific write:
# Disable V-Order for a specific write operation
spark.conf.set("spark.sql.parquet.vorder.enabled", "false")
streaming_df.write.format("delta").mode("append").saveAsTable("bronze.sensor_events")
# Re-enable for subsequent operations
spark.conf.set("spark.sql.parquet.vorder.enabled", "true")
The trade-off is explicit: you get lower write latency but higher query latency. The right approach for streaming tables is usually to disable V-Order on writes and then run OPTIMIZE periodically to compact and re-encode the accumulated files.
OPTIMIZE is the command that compacts many small Parquet files into fewer, larger ones. It's conceptually simple but has a lot of nuance in how it decides which files to compact, how large the output files should be, and how it interacts with partitioning.
You can run OPTIMIZE from either a SQL cell or using the DeltaTable API in PySpark:
-- In a SQL cell or via spark.sql()
OPTIMIZE sales_fact;
# Using the DeltaTable API
from delta.tables import DeltaTable
dt = DeltaTable.forName(spark, "gold.sales_fact")
dt.optimize().executeCompaction()
OPTIMIZE reads all active Parquet files in the table (or partition, if you specify one), combines their contents into fewer, larger files, and writes those new files back to the Delta table. The old files are then marked as removed in the transaction log but kept on disk until VACUUM cleans them up. This is an important point: OPTIMIZE does not shrink your storage immediately. The old files continue to exist until VACUUM runs.
By default, OPTIMIZE targets 128 MB output files. You can override this at the session or table level:
# Set target file size to 256 MB for this session
spark.conf.set("spark.microsoft.delta.optimizeWrite.targetFileSize", 268435456) # 256 MB in bytes
Or as a table property:
ALTER TABLE gold.sales_fact
SET TBLPROPERTIES ('delta.targetFileSize' = '268435456');
Larger target file sizes (256–512 MB) are beneficial when:
Smaller target file sizes (64–128 MB) are better when:
Tip
For a gold-layer fact table serving Direct Lake semantic models, 128–256 MB files with Z-Order clustering on the most common filter columns is usually the optimal configuration. This gives the Parquet reader enough data per file to work efficiently while keeping data skipping effective.
On a large partitioned table, running OPTIMIZE on the entire table every night is expensive — it will try to compact every partition even if most partitions haven't changed. Use a WHERE clause to target only recently modified partitions:
-- Only optimize partitions for the current month
OPTIMIZE gold.sales_fact WHERE order_date >= '2024-11-01';
# PySpark equivalent with partition predicate
dt.optimize().where("order_date >= '2024-11-01'").executeCompaction()
This pattern works naturally with a medallion architecture where your gold layer receives daily incremental loads. After each daily load, OPTIMIZE only the partition that was just written. You might also run a full OPTIMIZE weekly or monthly to catch any accumulated fragmentation across older partitions.
For Implementing the Medallion Architecture in Microsoft Fabric: Bronze, Silver, and Gold Layers, a practical pipeline pattern is:
Fabric Spark also supports optimizeWrite, which is a write-time optimization that adjusts the number of output files before writing, rather than compacting afterwards. It works by coalescing shuffle partitions to target the configured file size:
ALTER TABLE gold.sales_fact
SET TBLPROPERTIES ('delta.autoOptimize.optimizeWrite' = 'true');
Or at session level:
spark.conf.set("spark.microsoft.delta.optimizeWrite.enabled", "true")
Optimized Write is particularly valuable for streaming workloads and frequent small batch loads — it reduces the number of small files created in the first place, which means OPTIMIZE has less work to do later. The trade-off is slightly higher write latency because Spark needs to do an additional shuffle to coalesce partitions.
Warning
Don't confuse optimizeWrite (a write-time coalescing feature) with the OPTIMIZE command (a post-write compaction operation). They're complementary: optimizeWrite reduces small file creation during writes, while OPTIMIZE compacts files that already exist. Use both for heavily loaded tables.
Z-Order is where things get genuinely sophisticated. It's a technique for organizing data within Parquet files so that rows with similar values on multiple dimensions end up in the same files — enabling Spark's query planner to skip entire files when a query filters on those dimensions.
Delta Lake maintains per-file statistics in the transaction log: for each active Parquet file, it records the minimum and maximum value of each column (up to 32 columns by default). When you run a query with a WHERE clause, Spark's Delta reader checks these min/max statistics and skips any file where the filter condition could not possibly match.
For example, if you have a transaction_date column and each file contains data from a specific day (because the table is partitioned by date), the min/max statistics will tightly bound each file to a single day, and a query for transaction_date = '2024-11-15' will skip all files except those in that day's partition.
But what about columns that aren't the partition key? Say your sales_fact table is partitioned by order_date, but your most common query pattern filters by both order_date AND customer_region. Without Z-Order, the data within each date partition is written in whatever order the source produced it — rows from all customer regions interleaved randomly. Every file in the partition will have customer_region statistics that span all regions, so no files can be skipped based on the customer_region filter. Spark has to read every file in the partition to answer the query.
Z-Order uses a space-filling curve (the Z-order curve, also called Morton code) to map multi-dimensional data into a single-dimensional sort order that preserves locality across all dimensions simultaneously. In practice, this means: rows with similar values on all the Z-Order columns end up adjacent to each other in the output files.
For a table Z-Ordered on (customer_region, product_category), files will tend to contain:
Now, a query filtering for customer_region = 'Northeast' AND product_category = 'Electronics' can skip Files 2 and 3 entirely based on the min/max statistics that Delta maintains.
The key insight is that this is approximate, not exact. Z-Order doesn't perfectly cluster data — it's a best-effort approximation that degrades as you add more columns to the Z-Order definition. Two or three columns is typically the sweet spot; beyond four or five columns, the clustering benefit diminishes significantly.
Z-Order is applied as part of OPTIMIZE — you can't Z-Order without also compacting files:
-- Compact files AND apply Z-Order clustering
OPTIMIZE gold.sales_fact
ZORDER BY (customer_region, product_category);
# PySpark equivalent
dt.optimize().executeZOrderBy(["customer_region", "product_category"])
With a partition predicate:
OPTIMIZE gold.sales_fact
WHERE order_date >= '2024-11-01'
ZORDER BY (customer_region, product_category);
The OPTIMIZE command will sort all data in scope by the Z-Order key, then write it into the target file size. This means the first run of OPTIMIZE with Z-Order on a large table will be expensive — it needs to read and rewrite the entire table (or partition). Subsequent runs are incremental: Delta tracks which files were already Z-Ordered and only rewrites files that have been added since the last OPTIMIZE run.
The choice of Z-Order columns is a performance design decision that should be driven by your actual query patterns. Here's the framework:
Good Z-Order candidates:
customer_id, product_sku, store_id)customer_segment in a query that also filters by segment)Poor Z-Order candidates:
The trade-off with multiple columns: Z-Order on two columns typically gives you 60–80% of the clustering effectiveness of Z-Order on one column for each of those individual columns. As you add a third column, you're diluting the clustering effect across three dimensions. By the time you get to four or five columns, the effectiveness per column has dropped significantly and you might be better served by a different strategy (like creating separate materialized tables or using Liquid Clustering, discussed below).
Key insight
If your query patterns genuinely require effective filtering on five or more columns simultaneously, Z-Order is the wrong tool. Consider whether partitioning differently, pre-aggregating, or using the SQL analytics endpoint with a covering index-like view is more appropriate. Z-Order is optimized for 2–3 commonly-used filter columns.
After running OPTIMIZE with Z-Order, you can verify the effectiveness by examining the file statistics in the transaction log:
# Read the Delta log to inspect file-level statistics
import json
log_path = "abfss://your-workspace@onelake.dfs.fabric.microsoft.com/your-lakehouse.Lakehouse/Tables/gold/sales_fact/_delta_log/"
# Read the most recent checkpoint or log file
# For simplicity, use the DeltaTable API
dt = DeltaTable.forName(spark, "gold.sales_fact")
files_df = spark.read.format("delta").load(
"abfss://your-workspace@onelake.dfs.fabric.microsoft.com/your-lakehouse.Lakehouse/Tables/gold/sales_fact/"
)
print(f"Total files after OPTIMIZE: {dt.detail().collect()[0]['numFiles']}")
The more practical test is simply to run a representative query before and after OPTIMIZE + Z-Order and compare the job metrics. In the Spark UI (accessible from the monitoring hub while the job runs), look at the "number of files pruned" metric in the Scan task — after Z-Order, this should increase significantly for queries that filter on your Z-Order columns.
It's worth knowing that Delta Lake and Fabric are evolving toward a newer clustering approach called Liquid Clustering, which addresses some of Z-Order's limitations. Liquid Clustering is an incremental, adaptive clustering approach that:
As of late 2024, Liquid Clustering is available in Fabric Spark as a preview feature. The syntax is:
-- Define clustering when creating the table
CREATE TABLE gold.sales_fact (
order_id BIGINT,
order_date DATE,
customer_id BIGINT,
product_sku STRING,
revenue DECIMAL(18,2)
) USING DELTA
CLUSTER BY (customer_id, product_sku);
-- Or add clustering to an existing table
ALTER TABLE gold.sales_fact
CLUSTER BY (customer_id, product_sku);
-- Then run OPTIMIZE to apply the clustering (no ZORDER BY needed)
OPTIMIZE gold.sales_fact;
You cannot combine Liquid Clustering with Z-Order — they're mutually exclusive clustering strategies. If you define CLUSTER BY on a table, OPTIMIZE will use Liquid Clustering automatically. For new tables in Fabric where you expect the data distribution or query patterns to evolve, Liquid Clustering is worth considering over Z-Order. For established tables with stable query patterns, Z-Order is still perfectly valid and better understood.
Every OPTIMIZE run, every UPDATE, every DELETE, every MERGE — all of these operations mark old Parquet files as removed in the Delta transaction log but leave the actual files on disk. This is intentional: it supports time travel (querying a previous version of the table) and atomic operations (if a write fails partway through, the old files are still intact).
Over time, these "tombstoned" files can accumulate significantly. It's not unusual to see a Delta table where the live data is 150 GB but the total storage consumed (including all tombstoned versions) is 450 GB. VACUUM is the command that deletes these orphaned files and reclaims the storage.
VACUUM has a configurable retention period — the minimum age of a file before it's eligible for deletion. The default is 7 days (168 hours). This means:
-- Check what VACUUM would delete without actually deleting it
VACUUM gold.sales_fact DRY RUN;
-- Run VACUUM with the default 7-day retention
VACUUM gold.sales_fact;
-- Run VACUUM with a custom retention period (e.g., 30 days)
VACUUM gold.sales_fact RETAIN 720 HOURS;
The retention period choice has real operational consequences:
| Retention | Storage Cost | Time Travel Available | Risk |
|---|---|---|---|
| 168 hours (7 days, default) | Moderate | 7 days back | Low |
| 720 hours (30 days) | High | 30 days back | Very low |
| 0 hours | Minimal | None | High |
| 24 hours | Low | 1 day back | Moderate |
Warning
Setting VACUUM retention below 7 days is explicitly unsupported by Delta Lake and will likely cause issues with downstream consumers. If you have a streaming reader consuming your Delta table or a semantic model using Direct Lake mode, those consumers may hold references to specific file versions. Vacuuming files that are still referenced by active readers causes failures. Delta Lake will actually refuse to run VACUUM with less than 7 days retention unless you explicitly override the safety check — don't do this in production.
Delta Lake includes a safety check that throws an error if you try to VACUUM with a retention period below 7 days. You can override it:
spark.conf.set("spark.databricks.delta.retentionDurationCheck.enabled", "false")
This configuration key works in Fabric Spark as well. The scenario where you legitimately need this is development/test environments where you want to aggressively reclaim storage after testing OPTIMIZE runs. In production, leave the safety check enabled. The 7-day minimum exists for a reason.
Direct Lake mode in Power BI works by directly reading the Delta table's Parquet files through OneLake, bypassing the import/export cycle. This means the semantic model is reading specific Parquet files referenced by a particular Delta table version. When you run VACUUM, you need to ensure those files haven't been made obsolete during an ongoing query.
The practical guidance is: Direct Lake mode re-frames (re-reads the file list from the Delta log) on each semantic model refresh. As long as you run VACUUM after the semantic model has refreshed and you don't vacuum files from the current or recent versions, you're safe. The default 7-day retention gives you substantial headroom. For more on how Direct Lake interacts with your Delta table file layout, see Connecting a Power BI Semantic Model to a Fabric Lakehouse in Direct Lake Mode: Creating, Refreshing, and Optimizing Delta Tables for Reporting.
Tip
A practical production schedule for a heavily updated table: run OPTIMIZE daily (targeting changed partitions), run VACUUM weekly. This keeps storage costs manageable while maintaining a full week of time travel capability for incident investigation or rollback.
The SQL analytics endpoint lets you query lakehouse Delta tables using T-SQL without a warehouse. As described in Querying Lakehouse Data with the SQL Analytics Endpoint: Writing T-SQL Against Delta Tables Without a Warehouse, this endpoint is served by a serverless SQL engine that reads the same Delta files as Spark.
The good news: V-Order, OPTIMIZE, and Z-Order all benefit T-SQL queries through the SQL analytics endpoint, not just Spark queries. The SQL engine is Microsoft's Parquet reader, which is the same one that benefits from V-Order encoding. File compaction reduces metadata overhead for the SQL engine's query planner. Z-Order statistics are respected by the SQL engine's pushdown predicates.
The nuance: the SQL analytics endpoint uses column-level statistics differently than Spark. Spark exploits Z-Order statistics through the Delta metadata reader; the SQL analytics endpoint uses a slightly different path that still benefits from tighter file-level statistics but may not exploit multi-column Z-Order as aggressively as Spark. In practice, you'll still see meaningful improvement in SQL analytics endpoint query times after OPTIMIZE + Z-Order, but Spark direct reads will typically see the largest gains.
Let's work through a complete optimization scenario. We'll simulate a fragmented table, measure the problem, apply all four optimizations, and measure the improvement.
Open a new Spark notebook in your Fabric lakehouse and run the following to create a realistic sales fact table with intentional fragmentation:
from pyspark.sql import functions as F
from pyspark.sql.types import *
import random
# Generate a realistic sales dataset
spark.conf.set("spark.sql.shuffle.partitions", "200")
# Create base data - 10 million rows
orders = spark.range(0, 10_000_000).select(
F.col("id").alias("order_id"),
(F.rand() * 1000).cast("long").alias("customer_id"),
F.date_add(F.lit("2024-01-01"), (F.rand() * 365).cast("int")).alias("order_date"),
F.when(F.rand() < 0.25, "Northeast")
.when(F.rand() < 0.5, "Southeast")
.when(F.rand() < 0.75, "Midwest")
.otherwise("West").alias("customer_region"),
F.when(F.rand() < 0.3, "Electronics")
.when(F.rand() < 0.6, "Clothing")
.when(F.rand() < 0.8, "Books")
.otherwise("Home").alias("product_category"),
(F.rand() * 500 + 10).cast("decimal(10,2)").alias("revenue"),
(F.rand() * 5 + 1).cast("int").alias("quantity")
)
# Write in 50 small batches to simulate fragmentation
# This is what happens with incremental loads over time
for i in range(50):
batch = orders.filter(F.col("order_id") % 50 == i)
batch.write.format("delta").mode("append").saveAsTable("sales_fact_test")
print("Fragmented table created.")
from delta.tables import DeltaTable
import time
dt = DeltaTable.forName(spark, "sales_fact_test")
detail = dt.detail().collect()[0]
print(f"File count: {detail['numFiles']}")
print(f"Total size: {detail['sizeInBytes'] / 1024 / 1024:.1f} MB")
print(f"Avg file size: {detail['sizeInBytes'] / detail['numFiles'] / 1024 / 1024:.2f} MB")
# Baseline query - filter by region and category
start = time.time()
result = spark.sql("""
SELECT customer_region, product_category,
SUM(revenue) as total_revenue, COUNT(*) as order_count
FROM sales_fact_test
WHERE customer_region = 'Northeast'
AND product_category = 'Electronics'
AND order_date BETWEEN '2024-06-01' AND '2024-08-31'
GROUP BY customer_region, product_category
""").collect()
baseline_time = time.time() - start
print(f"\nBaseline query time: {baseline_time:.2f} seconds")
# Run OPTIMIZE with Z-Order on our analysis columns
print("Running OPTIMIZE with Z-Order...")
start = time.time()
spark.sql("""
OPTIMIZE sales_fact_test
ZORDER BY (customer_region, product_category)
""")
optimize_time = time.time() - start
print(f"OPTIMIZE completed in {optimize_time:.1f} seconds")
# Check new file stats
detail = dt.detail().collect()[0]
print(f"\nAfter OPTIMIZE:")
print(f"File count: {detail['numFiles']}")
print(f"Total size: {detail['sizeInBytes'] / 1024 / 1024:.1f} MB (including old files)")
print(f"Avg file size: {detail['sizeInBytes'] / detail['numFiles'] / 1024 / 1024:.2f} MB")
# Same query after optimization
start = time.time()
result = spark.sql("""
SELECT customer_region, product_category,
SUM(revenue) as total_revenue, COUNT(*) as order_count
FROM sales_fact_test
WHERE customer_region = 'Northeast'
AND product_category = 'Electronics'
AND order_date BETWEEN '2024-06-01' AND '2024-08-31'
GROUP BY customer_region, product_category
""").collect()
optimized_time = time.time() - start
print(f"Baseline query time: {baseline_time:.2f} seconds")
print(f"Optimized query time: {optimized_time:.2f} seconds")
print(f"Improvement: {baseline_time / optimized_time:.1f}x faster")
# First, do a dry run to see what would be deleted
print("VACUUM dry run:")
spark.sql("VACUUM sales_fact_test DRY RUN").show(truncate=False)
# Then run the actual VACUUM (default 7-day retention)
print("\nRunning VACUUM...")
spark.sql("VACUUM sales_fact_test")
# Check actual storage after VACUUM
detail = dt.detail().collect()[0]
print(f"\nAfter VACUUM:")
print(f"File count: {detail['numFiles']}")
print(f"Actual storage: {detail['sizeInBytes'] / 1024 / 1024:.1f} MB")
Note
In this exercise, VACUUM may not delete much because the files are recent (less than 7 days old). In production, you'd run VACUUM after the 7-day retention window has passed. Use RETAIN 0 HOURS in development only (with the safety check disabled) if you need immediate storage reclamation for testing.
Here's a reusable function for production use that combines all the optimizations:
def optimize_lakehouse_table(
table_name: str,
zorder_columns: list = None,
partition_predicate: str = None,
run_vacuum: bool = True,
vacuum_hours: int = 168
):
"""
Comprehensive Delta table optimization for Fabric Lakehouse.
Args:
table_name: Fully qualified table name (e.g., 'gold.sales_fact')
zorder_columns: Columns to Z-Order by (optional, 2-3 recommended)
partition_predicate: WHERE clause to scope OPTIMIZE (e.g., "order_date >= '2024-11-01'")
run_vacuum: Whether to run VACUUM after OPTIMIZE
vacuum_hours: Retention period for VACUUM in hours (default 168 = 7 days)
"""
import time
dt = DeltaTable.forName(spark, table_name)
# Get pre-optimization stats
pre_detail = dt.detail().collect()[0]
print(f"[{table_name}] Pre-OPTIMIZE: {pre_detail['numFiles']} files, "
f"{pre_detail['sizeInBytes']/1024/1024:.1f} MB")
# Build and run OPTIMIZE command
optimize_sql = f"OPTIMIZE {table_name}"
if partition_predicate:
optimize_sql += f" WHERE {partition_predicate}"
if zorder_columns:
optimize_sql += f" ZORDER BY ({', '.join(zorder_columns)})"
start = time.time()
spark.sql(optimize_sql)
optimize_duration = time.time() - start
print(f"[{table_name}] OPTIMIZE completed in {optimize_duration:.1f}s")
# Run VACUUM if requested
if run_vacuum:
spark.sql(f"VACUUM {table_name} RETAIN {vacuum_hours} HOURS")
print(f"[{table_name}] VACUUM completed (retained {vacuum_hours} hours)")
# Get post-optimization stats
post_detail = dt.detail().collect()[0]
print(f"[{table_name}] Post-optimization: {post_detail['numFiles']} files, "
f"{post_detail['sizeInBytes']/1024/1024:.1f} MB")
return {
"table": table_name,
"pre_files": pre_detail['numFiles'],
"post_files": post_detail['numFiles'],
"pre_size_mb": pre_detail['sizeInBytes'] / 1024 / 1024,
"post_size_mb": post_detail['sizeInBytes'] / 1024 / 1024,
"optimize_duration_s": optimize_duration
}
# Example usage
result = optimize_lakehouse_table(
table_name="sales_fact_test",
zorder_columns=["customer_region", "product_category"],
partition_predicate="order_date >= '2024-11-01'",
run_vacuum=False # Run VACUUM separately on a weekly schedule
)
print(result)
Some teams add OPTIMIZE immediately after every incremental load in their pipeline. This seems thorough but is actually counterproductive. OPTIMIZE rewrites files, which costs Spark CU time and adds to your Fabric capacity consumption. If you run it after every 5-minute micro-batch, you're spending enormous resources on a rewrite cycle that produces diminishing returns.
Better approach: Use optimizeWrite at the session level to reduce small file creation during writes, then run OPTIMIZE on a scheduled cadence (daily for high-frequency tables, after each significant batch for lower-frequency tables). Consider using the Scheduling and Automating Fabric Data Pipeline Runs with Activity-Level Retries, Alerts, and Email Notifications capabilities to schedule optimization as a separate, periodic pipeline step.
If your table is partitioned by order_date, then Z-Ordering by order_date is entirely redundant — the partition already ensures that all files in a given partition have the same order_date value, so the min/max statistics are perfectly tight without Z-Order. Worse, including the partition column in Z-Order wastes one of your "effective Z-Order slots" without providing any benefit.
Fix: Never include partition columns in your ZORDER BY clause. Z-Order is for filtering within partitions.
Running OPTIMIZE ... ZORDER BY (customer_id, product_sku, store_id, order_channel, customer_segment) with five columns will produce files that are mediocrely clustered on all five dimensions but not especially well clustered on any single dimension. The clustering effectiveness degrades roughly as O(1/n) for n columns.
Fix: Identify your top 2–3 filter columns by query frequency. If you genuinely need to optimize for 5+ filter dimensions, look at Liquid Clustering or consider whether pre-aggregated materialized tables for common query patterns are more appropriate.
If you have a streaming Spark job reading your Delta table using readStream, or a Direct Lake semantic model with an active query in flight, running VACUUM can potentially remove files that those consumers hold references to.
Fix: For streaming readers, ensure Delta's ignoreChanges or ignoreDeletes options are configured appropriately, and schedule VACUUM during low-activity windows. For Direct Lake models, coordinate VACUUM with the semantic model refresh schedule.
Every OPTIMIZE run creates a new entry in the Delta transaction log. This matters for downstream systems that use readChanges on the Delta log or that use DESCRIBE HISTORY to track data lineage. Don't be alarmed if you see your table version jump dramatically after an OPTIMIZE run on a fragmented table — that's expected behavior. Each OPTIMIZE is atomic; it either completes fully or rolls back cleanly.
If OPTIMIZE reports completing but your file count barely changes, check:
Are the files already at or above the target file size? OPTIMIZE won't split large files and won't compact files that are already at the target size. Run dt.detail() to check average file size.
Is the table heavily partitioned with small data per partition? OPTIMIZE operates within partition boundaries, so if each partition has only a few files, there may be nothing to compact.
Is there a table property overriding the target file size? Check with DESCRIBE DETAIL your_table and look at the properties field.
Run OPTIMIZE on the table after the external write to re-encode files with V-Order. You can confirm V-Order is being applied by checking the Parquet file metadata:
# Check if V-Order is enabled in the session
print(spark.conf.get("spark.sql.parquet.vorder.enabled"))
# After OPTIMIZE, new files will be V-Order encoded
# You can verify by reading a file's Parquet metadata
import pyarrow.parquet as pq
# The metadata won't directly expose V-Order as a label,
# but file sizes will be smaller and read performance will be faster
Delta table optimization in a Fabric Lakehouse isn't a one-time task — it's an ongoing operational practice that needs to be built into your data pipeline design from the start. Here's what you now understand:
V-Order is Fabric's Parquet write optimization that produces smaller, faster-to-read files. It's on by default in Fabric Spark sessions, but you need to be conscious of it for external writes and streaming workloads. OPTIMIZE applies V-Order retroactively when it rewrites files.
OPTIMIZE compacts many small Parquet files into fewer, larger ones. Run it on a scheduled cadence — not after every write. Use partition predicates to scope it to recently changed data. Target 128–256 MB output files for most analytical workloads.
Z-Order organizes data within files so that rows with similar values on multiple dimensions are co-located, enabling Delta's data skipping to eliminate files based on multi-column filter predicates. Two to three columns is the sweet spot. Never Z-Order on partition columns.
VACUUM deletes the tombstoned Parquet files that OPTIMIZE and DML operations leave behind. The default 7-day retention is the right choice for production — it balances storage cost against time travel capability and safety for active readers.
Together, these four mechanisms can reduce query times by 3–10× and storage costs by 30–60% on tables that arrive from continuous incremental ingestion.
Where to go next:
optimizeWrite, proper partition design) reduces the work OPTIMIZE needs to do — Incrementally Loading Data into a Fabric Lakehouse with Watermarks and Pipeline Lookup Activities covers the ingestion side of this equation.