Unpartitioned Delta tables make Spark read everything before filtering anything. This lesson teaches you how to choose partition keys that actually improve performance, write partitioned gold-layer tables with PySpark, and verify that partition pruning is firing in both Spark and the SQL analytics endpoint.

Picture this: your gold-layer sales fact table has grown to 800 million rows, spanning three years of transaction data from dozens of regional markets. Every morning, your Power BI reports hit that table with date-filtered queries — "show me last month's revenue by product category" — and every morning, Spark reads the entire table before filtering down to the 2% of rows that actually matter. Your queries are slow, your capacity units are burning, and your stakeholders are asking why the dashboard takes 45 seconds to load.
Partitioning is the structural answer to that problem. When you partition a Delta table correctly, Spark and the SQL analytics endpoint can skip entire directories of Parquet files before reading a single row — a technique called partition pruning. The result is dramatically faster queries, lower compute consumption, and a lakehouse that scales gracefully as data volumes grow. But partitioning is also one of the most misunderstood optimizations in the lakehouse world. Choose the wrong key and you'll fragment your data into millions of tiny files, which is arguably worse than no partitioning at all.
By the end of this lesson, you'll be able to make confident, informed decisions about partition key selection, write partitioned Delta tables using PySpark inside a Fabric notebook, verify that partition pruning is actually happening, and query partitioned tables efficiently from both Spark and the SQL analytics endpoint. We'll work through a realistic e-commerce analytics scenario from start to finish.
What you'll learn:
You should be comfortable working in Fabric Spark notebooks and understand how Delta tables are stored in a lakehouse. If you need a foundation, review Transforming Data with Spark Notebooks in Microsoft Fabric: PySpark for Lakehouse Tables and Writing Data from a Spark Notebook to a Fabric Lakehouse Delta Table: Append, Overwrite, and Merge Patterns with PySpark. You should also understand the basics of OneLake's Delta table storage model.
Before writing a single line of code, you need a mental model of what partitioning actually does to your files. This understanding will save you from making decisions that feel intuitive but perform terribly in practice.
When you write an unpartitioned Delta table, PySpark creates a flat directory of Parquet files inside the table's OneLake path, like this:
Tables/
sales_fact/
_delta_log/
part-00000-abc123.snappy.parquet
part-00001-def456.snappy.parquet
part-00002-ghi789.snappy.parquet
Every query that filters by date, region, or any other column must open every Parquet file and check whether each row satisfies the filter. Spark's columnar statistics (min/max per file) help somewhat, but there's still overhead from opening potentially thousands of files.
When you partition the same table by, say, sale_year and sale_month, Spark creates a subdirectory for each partition value combination:
Tables/
sales_fact/
_delta_log/
sale_year=2023/
sale_month=1/
part-00000-abc123.snappy.parquet
sale_month=2/
part-00000-def456.snappy.parquet
sale_year=2024/
sale_month=1/
part-00000-ghi789.snappy.parquet
Now when a query filters WHERE sale_year = 2024 AND sale_month = 3, the query engine navigates directly to that directory and reads only those files. Every other directory is completely skipped — this is partition pruning, and it's a filesystem-level optimization that happens before any data is decoded.
The Delta transaction log (_delta_log) records partition metadata alongside file statistics, giving Spark two layers of optimization: directory-level skipping from partition pruning, and file-level skipping from column statistics within each partition.
Key insight
Partition pruning is a directory navigation optimization, not just a filter pushdown. Spark doesn't read files and then discard rows — it never opens the files at all. This makes it dramatically more effective than columnar statistics alone for large tables.
This is the decision that determines whether partitioning helps or hurts you. Getting it wrong is surprisingly easy, and the consequences — millions of tiny files, degraded write performance, slower queries — can take weeks to diagnose.
A partition column's cardinality is the number of distinct values it produces. Low cardinality means few partitions; high cardinality means many.
Too low (e.g., a boolean is_active column): You end up with two partitions, which provides almost no pruning benefit. Most queries touch both partitions anyway.
Too high (e.g., customer_id or order_id): With 2 million customers, you create 2 million directories, each containing a handful of tiny Parquet files. The overhead of opening all those files and reading the transaction log metadata crushes any pruning benefit. This is the "small files problem" in its worst form.
The sweet spot: Columns with tens to a few thousand distinct values that align with how your queries actually filter. Date dimensions are the canonical example. A column like sale_date truncated to sale_year_month (e.g., "2024-03") gives you maybe 36 partitions for three years of data — large enough to prune aggressively, small enough to keep files at a healthy size.
Partition pruning only fires when your query filter uses the partition column. If your most common queries filter by region but you partitioned by product_category, you gain nothing.
Ask yourself:
WHERE clauses most consistently across your key reports and pipelines?In most operational analytics scenarios, the answer involves time — either a date column or a year/month column derived from it. For multi-tenant SaaS data or regional analytics, a tenant or region identifier might be equally important.
You can partition on multiple columns, which creates nested directory structures. This works well when queries consistently filter on both dimensions — for example, sale_year and sale_month together, or region and year.
But each additional partition column multiplies the number of directories. If you have 3 years × 12 months × 15 regions, you're looking at 540 partitions minimum. That's manageable, but 3 years × 365 days × 15 regions = 16,425 partitions starts to cause problems.
Warning
Do not partition on raw date columns (e.g., sale_date DATE). A daily partition key over three years creates over 1,000 directories. Unless your table has tens of billions of rows and you query exactly one day at a time, you'll create millions of small files. Use year-month or year-quarter instead, or rely on Z-Order within larger partitions for date-level filtering.
Before committing, profile your data. In a Fabric notebook, run something like this against your source data:
from pyspark.sql import functions as F
df = spark.read.format("delta").load("Tables/sales_raw")
# Check cardinality and distribution for candidate partition columns
print("=== Cardinality Check ===")
df.select(
F.countDistinct("sale_year_month").alias("year_month_distinct"),
F.countDistinct("region_code").alias("region_distinct"),
F.countDistinct("product_category").alias("category_distinct"),
F.countDistinct("customer_id").alias("customer_distinct"),
F.count("*").alias("total_rows")
).show()
# Check data distribution (skew) for the top candidate
print("\n=== Distribution Check for year_month ===")
df.groupBy("sale_year_month") \
.count() \
.orderBy("sale_year_month") \
.show(50)
You're looking for:
Tip
Target partition sizes of 512 MB to 2 GB of uncompressed data per partition. If your table is 100 GB total and you create 50 partitions, you're at 2 GB each — a healthy size. If you create 5,000 partitions, each partition averages 20 MB, which is too small and will create small file problems.
Now let's build the actual table. We'll work with a realistic e-commerce sales dataset that you might encounter in a gold layer after processing through a medallion architecture.
Assume you're building a gold_sales_fact table in your lakehouse. Your silver layer has already cleaned and conformed the data. You're now materializing the gold table that Power BI reports and SQL analytics queries will hit.
Here's the schema we're working with:
from pyspark.sql import functions as F
from pyspark.sql.types import (
StructType, StructField, StringType, IntegerType,
DecimalType, DateType, TimestampType
)
# Define schema explicitly for the gold table
gold_schema = StructType([
StructField("order_id", StringType(), False),
StructField("order_date", DateType(), False),
StructField("sale_year", IntegerType(), False),
StructField("sale_month", IntegerType(), False),
StructField("customer_id", StringType(), False),
StructField("customer_segment", StringType(), True),
StructField("region_code", StringType(), False),
StructField("product_id", StringType(), False),
StructField("product_category", StringType(), True),
StructField("quantity", IntegerType(), False),
StructField("unit_price", DecimalType(10, 2), False),
StructField("gross_revenue", DecimalType(12, 2), False),
StructField("discount_amount", DecimalType(10, 2), True),
StructField("net_revenue", DecimalType(12, 2), False),
StructField("load_timestamp", TimestampType(), False)
])
A critical best practice: derive your partition columns explicitly rather than partitioning on raw date columns. This gives you control over cardinality and makes the partition directory names human-readable.
# Read from silver layer
silver_df = spark.read.format("delta").load("Tables/silver_sales")
# Derive partition columns from order_date
gold_df = silver_df.select(
"order_id",
"order_date",
F.year("order_date").alias("sale_year"),
F.month("order_date").alias("sale_month"),
"customer_id",
"customer_segment",
"region_code",
"product_id",
"product_category",
"quantity",
"unit_price",
"gross_revenue",
"discount_amount",
(F.col("gross_revenue") - F.coalesce(F.col("discount_amount"), F.lit(0))).alias("net_revenue"),
F.current_timestamp().alias("load_timestamp")
)
print(f"Rows to write: {gold_df.count():,}")
gold_df.show(5)
The key difference from an unpartitioned write is the .partitionBy() method on the DataFrameWriter:
# Write the initial partitioned gold table
# Use overwrite for the initial load; we'll handle incremental appends next
(
gold_df
.write
.format("delta")
.mode("overwrite")
.partitionBy("sale_year", "sale_month")
.option("overwriteSchema", "true")
.save("Tables/gold_sales_fact")
)
print("Initial partitioned write complete.")
After this runs, navigate to your lakehouse explorer in the Fabric UI. Under Tables, you'll see gold_sales_fact, and if you expand the Files view, you'll see the nested directory structure: sale_year=2022/sale_month=1/, sale_year=2022/sale_month=2/, and so on.
You can also inspect the partition layout programmatically:
# Verify the partition structure
partitions = spark.sql("SHOW PARTITIONS gold_sales_fact")
partitions.show(20)
# Check files per partition (useful for spotting small file problems)
from delta.tables import DeltaTable
dt = DeltaTable.forName(spark, "gold_sales_fact")
detail = dt.detail()
detail.select("name", "numFiles", "sizeInBytes", "partitionColumns").show(truncate=False)
Note
The partitionColumns field in the Delta table detail tells you definitively which columns the table is partitioned on. This is stored in the Delta transaction log and is authoritative — don't rely on inspecting directory names alone.
Once your table is established, incremental loads should append new data without rewriting existing partitions. This is where partitioning really earns its keep: each monthly load only writes to the new month's directory.
# Incremental load: append a new month's data
new_month_df = spark.read.format("delta").load("Tables/silver_sales") \
.filter((F.year("order_date") == 2024) & (F.month("order_date") == 4)) \
.select(
"order_id", "order_date",
F.year("order_date").alias("sale_year"),
F.month("order_date").alias("sale_month"),
"customer_id", "customer_segment", "region_code",
"product_id", "product_category", "quantity",
"unit_price", "gross_revenue", "discount_amount",
(F.col("gross_revenue") - F.coalesce(F.col("discount_amount"), F.lit(0))).alias("net_revenue"),
F.current_timestamp().alias("load_timestamp")
)
# Append only — existing partitions untouched
(
new_month_df
.write
.format("delta")
.mode("append")
.partitionBy("sale_year", "sale_month")
.save("Tables/gold_sales_fact")
)
print("Incremental append complete.")
One of Delta Lake's most powerful features for partitioned tables is the ability to atomically replace a single partition while leaving all others intact. This is essential for reprocessing corrections — maybe last month's data had a bug and you need to rewrite just that partition.
# Reprocess a specific partition: replace sale_year=2024, sale_month=3
corrected_march_df = spark.read.format("delta").load("Tables/silver_sales_corrected") \
.filter((F.year("order_date") == 2024) & (F.month("order_date") == 3)) \
.select(
"order_id", "order_date",
F.year("order_date").alias("sale_year"),
F.month("order_date").alias("sale_month"),
"customer_id", "customer_segment", "region_code",
"product_id", "product_category", "quantity",
"unit_price", "gross_revenue", "discount_amount",
(F.col("gross_revenue") - F.coalesce(F.col("discount_amount"), F.lit(0))).alias("net_revenue"),
F.current_timestamp().alias("load_timestamp")
)
# The replaceWhere option overwrites ONLY the matching partition
(
corrected_march_df
.write
.format("delta")
.mode("overwrite")
.option("replaceWhere", "sale_year = 2024 AND sale_month = 3")
.save("Tables/gold_sales_fact")
)
print("Partition replacement complete. Only sale_year=2024/sale_month=3 was rewritten.")
replaceWhere is a surgical operation — it adds a new transaction log entry that replaces the files in the matching partition directories while leaving every other partition's files completely unchanged. This is safe, atomic, and fast.
Tip
replaceWhere works even on non-partition columns (it will scan files using column statistics), but it's most efficient and predictable when your replaceWhere expression matches your partition columns exactly. Aligning the two gives you full directory-level replacement without any file scanning.
Writing a partitioned table is only half the job. You need to confirm that pruning is actually happening — and structure your queries to guarantee it fires.
Use .explain() to inspect the physical query plan. Look for PartitionFilters in the output:
# Query that SHOULD prune to sale_year=2024, sale_month=1
pruned_query = spark.table("gold_sales_fact") \
.filter((F.col("sale_year") == 2024) & (F.col("sale_month") == 1)) \
.groupBy("region_code") \
.agg(F.sum("net_revenue").alias("total_revenue"))
# Print physical plan — look for PartitionFilters
pruned_query.explain("formatted")
In the output, you should see something like:
(1) Scan parquet spark_catalog.default.gold_sales_fact
PartitionFilters: [isnotnull(sale_year#12), (sale_year#12 = 2024),
isnotnull(sale_month#13), (sale_month#13 = 1)]
PushedFilters: []
ReadSchema: ...
The PartitionFilters line confirms that the plan will skip all partitions except sale_year=2024/sale_month=1. If you see an empty PartitionFilters: [] on a query you expect to prune, your filter is not on a partition column.
You can also check at runtime how many files were actually read:
# Enable metrics to see files read
spark.conf.set("spark.sql.adaptive.enabled", "true")
# After running the query, check the Spark UI in the Fabric notebook
# or use the metrics from the DataFrame
result = pruned_query.collect()
# For a quick check, compare file counts
all_files = spark.sql("SELECT COUNT(*) as file_count FROM (SELECT input_file_name() as f FROM gold_sales_fact) t").collect()[0][0]
print(f"Total files in table: {all_files}")
# Check recent operations and their metrics
dt = DeltaTable.forName(spark, "gold_sales_fact")
history = dt.history(5)
history.select("version", "timestamp", "operation", "operationMetrics").show(truncate=False)
When you query a lakehouse table through the SQL analytics endpoint, the same partition pruning applies — but you need to write your predicates correctly.
The most important rule: the partition filter must be a direct, non-transformed predicate on the partition column.
-- ✅ This WILL prune: direct equality filter on partition columns
SELECT
region_code,
SUM(net_revenue) AS total_revenue
FROM gold_sales_fact
WHERE sale_year = 2024
AND sale_month = 1
GROUP BY region_code
ORDER BY total_revenue DESC;
-- ✅ This WILL prune: range filter on sale_year
SELECT
sale_year,
sale_month,
SUM(net_revenue) AS total_revenue
FROM gold_sales_fact
WHERE sale_year BETWEEN 2023 AND 2024
GROUP BY sale_year, sale_month
ORDER BY sale_year, sale_month;
-- ❌ This will NOT prune effectively: function applied to partition column
SELECT
region_code,
SUM(net_revenue) AS total_revenue
FROM gold_sales_fact
WHERE CAST(sale_year AS VARCHAR) = '2024'
AND sale_month = 1
GROUP BY region_code;
That last query is a common mistake. Wrapping a partition column in a function (CAST, CONVERT, DATEPART, etc.) prevents the query optimizer from matching the predicate to the partition directory. Always filter on the raw partition column value.
Warning
If your partition column is sale_year (integer) but your application passes the filter as a string '2024', implicit type coercion may prevent partition pruning in some contexts. Always match the data type of your filter literal to the data type of your partition column.
The Fabric SQL analytics endpoint supports EXPLAIN for understanding query plans, though the output is less detailed than Spark's .explain(). For deeper performance analysis, run your queries in a notebook where you have full access to the Spark UI and query metrics.
Partitioning is one layer of a three-layer optimization stack in Delta tables. Understanding how these layers interact helps you apply them correctly. You can read about the full suite in Optimizing Delta Table Performance in a Fabric Lakehouse: V-Order, OPTIMIZE, VACUUM, and Z-Order for Faster Queries and Smaller Storage, but here's how it fits with partitioning:
Layer 1 — Partitioning: Directory-level skipping. Eliminates entire partitions from the read. Works for any column but only when the filter exactly matches partition column values.
Layer 2 — Z-Ordering: File-level skipping within a partition. Clusters related rows into the same files so that column statistics (min/max per file) become more useful for filtering. Run OPTIMIZE ... ZORDER BY after writing data.
Layer 3 — V-Order: File-level compression and encoding optimization within Parquet files. Automatically applied in Fabric and improves read performance across all queries.
The practical implication: use partitioning for your highest-level filter (the dimension you always filter on), then Z-Order within partitions for your secondary filter dimensions.
# After writing the partitioned table, run OPTIMIZE with Z-Order
# This clusters data within each partition by order_date and product_category
# so secondary filters on those columns also benefit from file skipping
spark.sql("""
OPTIMIZE gold_sales_fact
ZORDER BY (order_date, product_category)
""")
print("OPTIMIZE with Z-ORDER complete.")
Now your query stack looks like this:
sale_year = 2024 AND sale_month = 1 → partition pruning eliminates 35 of 36 partitionsproduct_category = 'Electronics' → Z-Order statistics skip most files within the remaining partitionKey insight
Z-Order has no benefit across partition boundaries. If you Z-Order by order_date across an unpartitioned table, it helps — but if you Z-Order by a column within each monthly partition, each partition's files are individually clustered. This is actually fine for within-partition filtering, but it won't help you avoid reading multiple partitions.
If you're using Direct Lake mode in Power BI to report against your lakehouse, partitioning affects how framing and transcoding work.
Direct Lake reads Delta table data directly from OneLake Parquet files into the Power BI engine's VertiPaq columnar store during a process called framing. When Power BI refreshes the semantic model, it reads the Delta transaction log to identify which Parquet files contain current data, then loads those files into memory.
For partitioned tables, Direct Lake's framing is partition-aware — it can identify which partitions changed since the last frame and process only those. This means:
The recommendation: keep your gold-layer tables partitioned by the same time grain that your Power BI reports use as their primary time filter. If your reports filter by month, partition by year and month. Your DAX measures and report slicers will naturally align with partition boundaries, and Direct Lake's framing will be as efficient as possible.
You'll build a complete partitioned gold table for a retail analytics scenario, from profiling through writing through verification.
In a Fabric notebook attached to your lakehouse, run this cell to generate a realistic sample dataset:
from pyspark.sql import functions as F
from pyspark.sql.types import *
import random
from datetime import date, timedelta
# Generate synthetic sales data: 5 million rows, 3 years, 8 regions, 12 categories
spark.conf.set("spark.sql.shuffle.partitions", "16")
regions = ["US-EAST", "US-WEST", "US-CENTRAL", "EU-WEST", "EU-EAST", "APAC", "LATAM", "MEA"]
categories = ["Electronics", "Clothing", "Home & Garden", "Sports", "Beauty", "Books",
"Automotive", "Food & Grocery", "Toys", "Office", "Health", "Jewelry"]
segments = ["Enterprise", "SMB", "Consumer"]
# Create date range: Jan 2022 - Dec 2024
start_date = date(2022, 1, 1)
date_range = [(start_date + timedelta(days=i)).isoformat() for i in range(365 * 3)]
# Generate the dataset using Spark
sales_data = spark.range(0, 5_000_000).select(
F.concat(F.lit("ORD-"), F.lpad(F.col("id").cast("string"), 8, "0")).alias("order_id"),
F.element_at(
F.array([F.lit(d) for d in date_range[:100]]), # Sample for speed
(F.col("id") % 100 + 1).cast("int")
).cast("date").alias("order_date"),
F.element_at(
F.array([F.lit(r) for r in regions]),
(F.col("id") % 8 + 1).cast("int")
).alias("region_code"),
F.element_at(
F.array([F.lit(c) for c in categories]),
(F.col("id") % 12 + 1).cast("int")
).alias("product_category"),
F.element_at(
F.array([F.lit(s) for s in segments]),
(F.col("id") % 3 + 1).cast("int")
).alias("customer_segment"),
F.concat(F.lit("CUST-"), (F.col("id") % 500_000).cast("string")).alias("customer_id"),
F.concat(F.lit("PROD-"), (F.col("id") % 10_000).cast("string")).alias("product_id"),
(F.rand() * 490 + 10).cast(DecimalType(10, 2)).alias("unit_price"),
((F.rand() * 9) + 1).cast("int").alias("quantity")
).withColumn(
"gross_revenue", (F.col("unit_price") * F.col("quantity")).cast(DecimalType(12, 2))
).withColumn(
"discount_amount", (F.col("gross_revenue") * F.when(F.rand() > 0.7, F.rand() * 0.2).otherwise(F.lit(0))).cast(DecimalType(10, 2))
).withColumn(
"net_revenue", (F.col("gross_revenue") - F.col("discount_amount")).cast(DecimalType(12, 2))
).withColumn(
"order_date", F.date_add(F.lit("2022-01-01"), (F.col("id") % 1096).cast("int"))
).withColumn("sale_year", F.year("order_date")) \
.withColumn("sale_month", F.month("order_date")) \
.withColumn("load_timestamp", F.current_timestamp())
# Write as silver (unpartitioned) first
sales_data.write.format("delta").mode("overwrite").save("Tables/silver_sales_exercise")
print(f"Silver table written: {sales_data.count():,} rows")
silver_df = spark.read.format("delta").load("Tables/silver_sales_exercise")
print("=== Cardinality Profile ===")
silver_df.select(
F.countDistinct("sale_year").alias("year_distinct"),
F.countDistinct("sale_month").alias("month_distinct"),
F.countDistinct(F.concat(F.col("sale_year").cast("string"), F.lit("-"), F.col("sale_month").cast("string"))).alias("year_month_distinct"),
F.countDistinct("region_code").alias("region_distinct"),
F.countDistinct("product_category").alias("category_distinct"),
F.countDistinct("customer_id").alias("customer_distinct"),
F.count("*").alias("total_rows")
).show()
print("=== Distribution by Year-Month ===")
silver_df.groupBy("sale_year", "sale_month") \
.count() \
.orderBy("sale_year", "sale_month") \
.show(40)
gold_df = silver_df.select(
"order_id", "order_date", "sale_year", "sale_month",
"customer_id", "customer_segment", "region_code",
"product_id", "product_category", "quantity",
"unit_price", "gross_revenue", "discount_amount", "net_revenue",
"load_timestamp"
)
(
gold_df
.write
.format("delta")
.mode("overwrite")
.partitionBy("sale_year", "sale_month")
.option("overwriteSchema", "true")
.save("Tables/gold_sales_exercise")
)
print("Partitioned gold table written.")
# Register as a managed table for SQL access
spark.sql("CREATE TABLE IF NOT EXISTS gold_sales_exercise USING DELTA LOCATION 'Tables/gold_sales_exercise'")
# Check plan for a partitioned query
partitioned_query = spark.table("gold_sales_exercise") \
.filter((F.col("sale_year") == 2024) & (F.col("sale_month") == 3)) \
.groupBy("product_category") \
.agg(F.sum("net_revenue").alias("total_revenue"), F.count("*").alias("orders"))
partitioned_query.explain("formatted")
# Compare against unpartitioned read
unpartitioned_query = spark.read.format("delta").load("Tables/silver_sales_exercise") \
.filter((F.year("order_date") == 2024) & (F.month("order_date") == 3)) \
.groupBy("product_category") \
.agg(F.sum("net_revenue").alias("total_revenue"), F.count("*").alias("orders"))
print("\nPartitioned result:")
partitioned_query.show()
print("\nUnpartitioned result:")
unpartitioned_query.show()
Compare the execution times in the notebook cell output. The partitioned query should complete significantly faster because it reads only 1/36th of the total data.
spark.sql("OPTIMIZE gold_sales_exercise ZORDER BY (order_date, product_category)")
print("OPTIMIZE with Z-Order complete.")
# Check the result
dt = DeltaTable.forName(spark, "gold_sales_exercise")
dt.detail().select("numFiles", "sizeInBytes").show()
Symptom: Write performance is very slow. The table has thousands of directories. Queries that should be fast are actually slower than the unpartitioned equivalent.
Cause: Each partition becomes a directory, and each partition has at least one Parquet file. With 2 million customer IDs, you have 2 million directories and 2 million tiny files. The Delta log operations and filesystem metadata requests dominate execution time.
Fix: Drop the high-cardinality partition, or derive a lower-cardinality bucketing column. For customer-based analytics, consider partitioning by customer_region or customer_segment instead, and rely on Z-Order within partitions for customer-level filtering.
Symptom: Queries appear to not prune. .explain() shows empty PartitionFilters.
Cause: WHERE YEAR(order_date) = 2024 — if order_date is your partition column (not sale_year), this filter won't prune because the function creates a derived expression.
Fix: Either filter directly on the partition column (WHERE sale_year = 2024) or derive the partition column explicitly during the write so it exists as a literal value in the data.
Symptom: Error: AnalysisException: Failed to merge fields... or schema mismatch errors.
Fix: Add .option("overwriteSchema", "true") when intentionally overwriting the schema, or .option("mergeSchema", "true") when appending data with additional columns. This is especially relevant when you're handling schema evolution in Delta tables.
Symptom: Each partition directory contains dozens of tiny files (e.g., 100 files of 2 MB each per partition).
Cause: When you write, Spark's default shuffle partitions (200) create 200 output tasks, each potentially writing to all partition directories. With 36 time partitions and 200 tasks, you could end up with 200 small files per partition directory.
Fix: Before writing, repartition your DataFrame to control output file count:
# Repartition to one Spark partition per table partition
# This creates 1 file per partition directory (before OPTIMIZE)
gold_df_repartitioned = gold_df.repartition("sale_year", "sale_month")
(
gold_df_repartitioned
.write
.format("delta")
.mode("overwrite")
.partitionBy("sale_year", "sale_month")
.save("Tables/gold_sales_fact")
)
Alternatively, configure the shuffle partition count to something reasonable before the write and run OPTIMIZE afterward to compact files within each partition.
Symptom: Developers write SQL like WHERE FORMAT(order_date, 'yyyy-MM') = '2024-01' instead of using the partition column.
Fix: Document your partition columns clearly and train your team to use them. Better yet, create SQL views over the table that expose common filter patterns using the partition columns correctly:
-- In the SQL analytics endpoint, create a view for last month
CREATE VIEW gold_sales_last_month AS
SELECT * FROM gold_sales_fact
WHERE sale_year = YEAR(GETDATE())
AND sale_month = MONTH(DATEADD(MONTH, -1, GETDATE()));
You've now built a solid, practical understanding of table partitioning in a Fabric Lakehouse — from the filesystem mechanics through key selection, writing, pruning verification, and the interaction with other optimization layers.
The core principles to carry forward:
replaceWhere is your friend for reprocessing. It surgically replaces a partition without touching others..explain("formatted") in Spark and by checking PartitionFilters in the query plan.Where to go from here:
If you're building incremental load patterns on top of this partitioned foundation, Incrementally Loading Data into a Fabric Lakehouse with Watermarks and Pipeline Lookup Activities shows how to orchestrate partition-aligned loads through data pipelines.
If your gold table feeds a Power BI semantic model, Connecting a Power BI Semantic Model to a Fabric Lakehouse in Direct Lake Mode walks through making your partitioned Delta tables work optimally with Direct Lake framing.
And if you need to audit the history of your partitioned table — who rewrote what partition and when — Implementing Delta Lake Time Travel in a Fabric Lakehouse covers the transaction log, historical snapshots, and rollback patterns that make Delta Lake production-ready.
Microsoft Fabric Fundamentals
Implementing End-to-End Pipeline Error Handling in Microsoft Fabric: If Condition Activities, Failed Dependencies, and Email Alerts
Joining Multiple Delta Tables Across Fabric Lakehouses and Warehouses in a Single Spark Notebook: Cross-Workspace Queries, OneLake Paths, and Writing Results to a Gold Layer Table