Toolkit Hub /
SPARK::INTERNALS
Runtime:
00 · Orientation

End-to-End Architecture Map

The complete Spark engine lifecycle on one responsive map — from raw code submission down to OneLake Parquet bytes. Click any node to open its technical inspector, or press Trace a query to walk the full execution sequence.

Progress:
Step 0/16
Click any node to inspect engine mechanics, failure modes, and gotchas.
STAGE 1 Driver Query Compilation (Catalyst Optimizer)
01
Your Code
DataFrame API / Spark SQL
Entrypoint
02
ANTLR4 Parser
Unresolved AST generation
ParseException
03
Catalog Analyzer
Schema & type resolution
AnalysisException
04
Catalyst Optimizer
RBO pushdown & CBO join reorder
Rule rewrites
05
Physical Planning
SparkPlanner strategy matching
EXPLAIN output
STAGE 2 Driver DAG & Task Scheduling
06
DAGScheduler
Stages cut at shuffle boundaries
ShuffleMapStage
07
TaskScheduler
Task sets assigned to executor cores
Locality preference
STAGE 3 Distributed Executor Execution & Runtime Feedback
08
Executors & Memory
Unified execution vs storage heap
Spill & OOM risks
09
Shuffle Write
Local blocks / Fabric Remote Shuffle
Network barrier
10
AQE Feedback Loop
Dynamic coalescing, join switch & skew split
Runtime mutation
11
Shuffle Read / Result
Coalesced fetch & aggregation build
Final materialization
12
Dual Hardware Execution
Tungsten Janino JVM vs Velox C++ SIMD
WSCG & NEE
STAGE 4 Fabric Platform Substrate & Delta Storage
13
Fabric CU Capacity
Admission control, smoothing & burst
HTTP 430 throttle
14
Efficient Scaledown
Safe dynamic executor deallocation
RSM shuffle survival
15
Delta OneLake Storage
V-Order, deletion vectors, clustering
Direct Lake layout
16
Observability & Livy
Livy session log, Spark UI & metrics
Diagnostic emitter
01 · Foundations

Spark Engine Internals

An interactive, factual walkthrough of how Apache Spark actually executes a job — from the driver's query plan down to the bytes an executor writes to disk — plus how Microsoft Fabric's Native Execution Engine and Efficient Scaledown change specific parts of that path. Twenty-nine sections covering the engine, Spark SQL and PySpark practice, engine choice, and the Fabric platform around them. Start with the console below: pick a workload, set the engine switches, press Run job, and click the nodes to see what each part actually does.

Live cluster console — driver, executors, shuffle
DRIVER DAGScheduler · AQE SHUFFLE EXCHANGE
Idle. Choose a workload and press Run job. Click any node in the diagram to inspect it.
How to use this: the settings above change what actually happens in the run — partition counts, join strategy, whether execution is columnar or row-at-a-time, and which errors surface. Click any node (driver, an executor, the shuffle plane, a reducer) for what it is, what it does in this specific run, and what goes wrong there. After the run finishes you get a settings recommendation and best-practice notes scoped to the medallion layer you picked.
02 · Foundations

Cluster Architecture & The 1:1 Rule

🏛️ FABRIC SPARK CLUSTER ARCHITECTURE & THE 1:1 EXECUTOR TOPOLOGY TOPOLOGY & DAEMONS
DRIVER NODE (Dedicated VM) Hosts SparkContext, Session & Coordination Daemons DAGScheduler & Catalyst Optimizer Builds physical plan, cuts stages at shuffles TaskScheduler & SchedulerBackend Dispatches TaskSets, handles retries & locality MapOutputTracker Shuffle block locations BlockManagerMaster Cache & broadcast state Fabric Contextual Hooks Livy Session multiplexer • Diagnostic Emitter • Advisor Hooks FABRIC AUTOSCALE & POOL MANAGER • Enforces 1:1 Node-to-Executor mapping (No multi-exec packing) • Dynamically spins up / decommissions worker VMs based on backlog • Capacity Unit (CU) throttling & starter pool warmup management Task Launch RPC Status & Metrics WORKER NODE 1 (1:1 JVM) Size: Medium (4 vCores / 28 GB) Executor Cores / Slots (1..4) Runs 4 parallel tasks concurrently Unified Memory (28 GB) Execution (Shuffle/Sort) + Storage (Cache) BlockManager & NVMe SSD Local shuffle files & Netty server WORKER NODE 2 (1:1 JVM) Size: Medium (4 vCores / 28 GB) Executor Cores / Slots (1..4) Runs 4 parallel tasks concurrently Unified Memory (28 GB) Execution (Shuffle/Sort) + Storage (Cache) BlockManager & NVMe SSD Local shuffle files & Netty server STORAGE TIER: ONELAKE & REMOTE SHUFFLE OneLake (ABFS / Delta) • Parquet tables & transaction log • Direct reads via V-Order native reader • Liquid clustering pruning • Deletion Vectors metadata Remote Shuffle Manager (RSM) • Blob storage shuffle spill tier • Stage-level Decision Layer routing • Unblocks instant scaledown • Zero FetchFailedException stage replays

Every Spark deployment — local, YARN, Kubernetes, or a Fabric Spark pool — shares the same three roles. What differs across platforms is only who launches the executors and how.

The three roles
Role Responsibility
Driver Runs your main()/notebook code, holds the SparkContext/SparkSession, builds the DAG, and hosts the DAGScheduler, TaskScheduler, and MapOutputTracker (Sec 11). One per application.
Cluster manager Negotiates resources for executors on the driver's behalf — YARN, Kubernetes, Spark's own Standalone manager, or (on Fabric) the platform's own pool/autoscale manager. The driver never talks to hardware directly.
Executor A JVM process that runs tasks and holds data in memory/disk for the life of the application (or until decommissioned — Sec 13). One per node in Fabric (always 1:1); potentially several per node on classic YARN.

Deployment modes

The distinction that actually matters day to day is where the driver runs, not which cluster manager is in use:

  • Client mode — the driver runs on the machine that submitted the job (your laptop, a notebook kernel). Convenient for interactive work; the job dies if that machine disconnects.
  • Cluster mode — the driver itself runs inside the cluster, as just another managed process. Standard for production batch jobs; survives the submitting client disconnecting.

Fabric notebooks run the driver inside the Spark pool itself — architecturally closer to cluster mode, with the notebook UI acting as a thin client attached to a live remote session.

Fabric specifically: the 1:1 node→executor rule

This is a real structural difference from classic YARN Spark, and it changes how executor sizing works (see the worked comparison in Sec 15). In Fabric, one Spark pool node hosts exactly one executor — there's no "how many executors should I pack onto this node" decision the way there is on YARN. One node in the pool is always reserved for the driver; the rest become executors 1:1. The only exception is a single-node pool, where the driver and the sole executor share that one node's resources.

Practical effect: on Fabric, choosing a node size (Small → XXLarge) is choosing your executor shape. There's no separate spark.executor.cores packing math to do the way the classic Cloudera "5 cores per executor, N executors per node" guidance requires on YARN — see Sec 15 for the exact node-size table.
03 · Foundations

Job · Stage · Task Execution Model

Spark is lazy: transformations (select, filter, join) only build up a logical plan. Nothing executes until an action (write, collect, count) forces it. That action produces exactly one job.

The hierarchy, top to bottom
Unit Created by Boundary
Job One per action Ends when the action's result is produced.
Stage DAGScheduler A new stage begins at every shuffle boundary — i.e. every wide transformation. A job with no wide transformations is one stage.
Task TaskScheduler One task per partition, per stage. A stage with 200 partitions launches 200 tasks, scheduled onto executor cores as they free up.

Narrow vs. wide transformations

⚡ NARROW VS. WIDE TRANSFORMATIONS & STAGE BOUNDARIES EXECUTION BOUNDARY
STAGE 0: NARROW PIPELINING (FREE / 1:1) map(), filter(), select(), withColumn(), union() — In-Memory Stream Input Part 0 Row 1 (k:A), Row 2 (k:B) filter(val > 10) Pipelined in L1/L2 Output Part 0 1:1 Partition Map Input Part 1 Row 3 (k:A), Row 4 (k:C) filter(val > 10) Whole-Stage CodeGen Output Part 1 Zero Network I/O Key Architectural Advantage: • Tungsten Whole-Stage CodeGen: Fuses multiple narrow steps into one Java bytecode loop. • Zero Network Transfer: Records remain in CPU registers & L1/L2 cache without serialization. • Failure Cost = Low: If a task fails, only that single task is recomputed from source. SHUFFLE BOUNDARY (STAGE CUT) STAGE 1: WIDE TRANSFORMATION (EXPENSIVE / N:M) groupBy(key), join(), distinct(), repartition() — Hash Exchange Reducer Partition 0 (Key A) Fetches Key A from Part 0 & 1 Spill risk if memory fraction exceeded Reducer Partition 1 (Key B, C) Fetches Key B & C across network Requires SortShuffleManager index Production Cost Factors: • Serialization & Netty RPC: All partition rows must be serialized and transmitted over network. • Disk Spill: If key skew concentrates rows on 1 reducer, memory spills to NVMe SSD (Sec 11). • Decommission Risk: If source node dies before reduce finishes, entire Stage 0 is recomputed!

This distinction is the single most load-bearing concept in the entire execution model — it's the difference between "free" and "expensive":

  • Narrowmap, filter, select, union. Each output partition depends on exactly one input partition. No data crosses the network; Spark can pipeline these into a single stage.
  • WidegroupBy, join, distinct, repartition. An output partition can depend on every input partition, because rows with the same key can start anywhere. This forces a shuffle (Sec 11) and a new stage.

DAGScheduler vs. TaskScheduler

🔄 THE DUAL SCHEDULER LIFECYCLE: DAGSCHEDULER VS. TASKSCHEDULER STAGE & TASK DISPATCH
1. DAGSCHEDULER (Stage-Level) Converts Logical Plan into Stage DAG 1. Action Trigger .write, .collect(), .count() calls 2. Stage Boundary Cuts Splits at ShuffleDependencies ShuffleMapStage vs. ResultStage 3. TaskSet Generation Creates 1 Task per output partition Stage Fault Recovery: • Handles FetchFailedException • If an executor holding shuffle files dies, DAGScheduler resubmits the ENTIRE parent ShuffleMapStage. Protected by Fabric RSM (Sec 13) 2. TASKSCHEDULER (Task-Level) Dispatches Tasks based on Locality Locality Preference Ladder: 1. PROCESS_LOCAL: Data cached in JVM memory 2. NODE_LOCAL: Data on local NVMe disk / same VM 3. RACK_LOCAL: Same rack / availability zone 4. ANY: Cross-network / OneLake ABFS fetch Speculative Execution Launches duplicate task if 1 task is straggling Task-Level Retry Mechanics: • Retries single task up to spark.task.maxFailures (4x) • Blacklists problematic executor after repeated crash • Does NOT recompute stage for isolated task errors 3. EXECUTOR CORES (Hardware) Runs TaskRunner Threads TaskRunner Thread 1 (Core 1) Executes bytecode • Memory manager arena Task 0 / Stage 1 TaskRunner Thread 2 (Core 2) Executes bytecode • Memory manager arena Task 1 / Stage 1 Metrics & Status Return: • Task metrics (bytes read, spill, GC time) • Shuffle write index map • Sent via RPC heartbeat to Driver • Updates Spark UI & Diagnostic Emitter

Two schedulers, two different jobs, both living on the driver:

  • DAGScheduler — stage-level. Walks the RDD/plan dependency graph backwards from the action, cuts it into stages at shuffle boundaries, and submits stages in dependency order (a stage can't start until every stage it depends on has finished writing its output).
  • TaskScheduler — task-level. Takes one stage's worth of tasks and assigns them to executor cores, handling retries, speculative execution, and locality preference (run a task on the executor that already holds its input data, when possible).
Where a shuffle failure surfaces: if a reduce-side task can't fetch a shuffle block (its source executor is gone — Sec 13), it throws FetchFailedException. The DAGScheduler's response isn't to retry that one task — it recomputes the entire owning ShuffleMapStage. This is exactly the cost Efficient Scaledown's Shuffle Migration and Remote Shuffle Manager exist to avoid (Sec 13).

Narrow vs. Wide Transformations: Comprehensive Matrix

Category Spark Transformations Data Movement & Cost Internal Execution Behavior
Narrow (Free) select(), filter(), map(), flatMap(), withColumn(), union(), coalesce(decrease) Zero network transfer. Data stays within the executor JVM memory arena. Tungsten fuses operations via Whole-Stage Code Generation (Sec 05) into a tight Java loop executing in CPU registers.
Wide (Expensive) groupBy(), join(), distinct(), repartition(), cube(), rollup(), sort() All-to-all network shuffle. Every partition potentially writes blocks for every reducer. Forces a Stage Boundary cut by DAGScheduler. Spills to local NVMe SSD / RSM (Sec 11) if working memory fraction is exceeded.

Data Locality Levels: How TaskScheduler Places Work

TaskScheduler tries to achieve the tightest data locality possible, waiting a configurable delay before dropping to a looser level:

Locality Level Where Data Lives Performance Fabric Production Reality
PROCESS_LOCAL In the same JVM memory where the task is running. Fastest (Sub-millisecond) Achieved when reading from Spark in-memory Cache (.cache()) on an already active executor.
NODE_LOCAL On the same physical VM / node (e.g. local NVMe SSD). Fast (No network) Occurs when reading local shuffle map outputs written during earlier stages on the same node.
RACK_LOCAL On another node in the same rack / availability zone. Medium (Intra-rack network) Common in large multi-node clusters during shuffle fetch over Azure high-speed interconnect.
ANY Anywhere across network or remote object storage. Slowest (Remote ABFS read) Standard baseline for all initial OneLake table scans (ABFS protocol over HTTPS).
04 · Query Engine

Catalyst Optimizer & Multi-Engine Execution Architecture

Master Infographic: End-to-End Query Compilation & Dual Hardware Backends FABRIC RUNTIME 1.3 & 2.0
SPARK DRIVER · CATALYST OPTIMIZER (QUERY COMPILATION PIPELINE) Input Query Spark SQL (%%sql) PySpark DataFrame 1. Parser (ANTLR4) AST → Unresolved Plan Strings unverified 2. Analyzer + Catalog Analyzed Logical Plan Schema binding · exprId#12 3. Optimizer (RBO+CBO) Optimized Logical Plan Pushdown · Prune · Fold 4. SparkPlanner Physical SparkPlan Join / Agg Strategy Cost Logical Rule Batches (RBO): PushDownPredicates · ColumnPruning · FoldConstants · EliminateOuterJoin · SubqueryDecorrelation · DeltaPruning Physical Strategies & Rules: JoinSelection (BHJ / SHJ / SMJ / BNLJ) · FileSourceStrategy · EnsureRequirements (Exchange/Sort) · CollapseCodegen BACKEND A · TUNGSTEN & WHOLE-STAGE CODEGEN (JVM) Janino Bytecode Compiler Synthesizes fused Java while() loop Eliminates Volcano virtual dispatch UnsafeRow Binary Memory 64-bit word aligned · Zero GC Sequential L1/L2 CPU cache lines Plan Signature: *(1) HashAggregate, *(1) Filter, *(1) FileScan Runs on JVM Executors. Default fallback path when native engine is unsupported. BACKEND B · NATIVE EXECUTION ENGINE (VELOX SIMD C++) Gluten JNI + Substrait Plan translated to Substrait AST Zero-copy Arrow pointer handoff Velox Vectorized SIMD AVX-512 / AVX2 hardware kernels Jemalloc native arena · 1024 rows/batch Plan Signature: VeloxColumnarToRowExec, *Transformer nodes Runs in native C++ process space. ~4x faster on compute-heavy analytics.

Every SQL query and DataFrame transformation in Apache Spark passes through the Catalyst Optimizer before execution. Catalyst is an extensible relational query compiler written in Scala, structured around tree transformations and rule batches. In Microsoft Fabric, Catalyst compiles queries into physical operator trees that dispatch to either Tungsten Whole-Stage Code Generation (JVM) or the Native Execution Engine (NEE / Meta Velox C++ SIMD), dynamically guided at runtime by Adaptive Query Execution (AQE).

The Four Catalyst Compilation Phases

Phase 1 · Parsing & Abstract Syntax Tree (AST) Construction

The parser converts your text query or API chain into an Unresolved Logical Plan — an operator tree where relations (table names) and expressions (column names) are purely syntactic strings:

  • Spark SQL Path: Uses an ANTLR4-generated lexer and parser (SqlBaseParser.g4). The visitor pattern in AstBuilder turns SQL grammar tokens into logical operator nodes (e.g., UnresolvedRelation, UnresolvedAttribute).
  • PySpark DataFrame API Path: Skips ANTLR4 text parsing entirely. Python API calls (e.g. df.filter(...).select(...)) invoke Py4J bridge methods that directly instantiate Catalyst's Scala LogicalPlan nodes (Filter, Project).
  • Unresolved State: Column names like amount and table names like orders have not yet been checked against any catalog or schema. Typographical errors exist silently in this phase.
Phase 2 · Analysis & Catalog Resolution

The Analyzer resolves unresolved attributes against the active catalog (e.g. Fabric Lakehouse metastore, Hive Metastore, or session temporary views):

  • Catalog Lookup: SessionCatalog resolves table names to real physical data sources (Parquet, Delta Lake tables, Lakehouse files) and binds schemas.
  • Attribute ID Binding: Strings become typed AttributeReference instances with unique monotonic expression IDs (e.g., amount becomes amount#4: double, cust_id becomes cust_id#3: bigint).
  • Type Coercion & Function Resolution: Built-in functions are resolved. Implicit type casting is inserted where compatible (e.g. TINYINT + INT → INT).
  • Fast Failure: Missing tables or invalid column names throw AnalysisException here — immediately, before cluster resources or disk I/O are touched.
Phase 3 · Logical Optimization (RBO + CBO)

The Optimizer runs standard rule batches to restructure the logical plan into its most efficient mathematical form, independent of cluster hardware:

  • Predicate Pushdown (PushDownPredicates): Filters are moved down the operator tree past joins, projects, and window functions to sit directly atop the data source. Parquet and Delta Lake readers evaluate these filters inside file metadata statistics (row group min/max and Delta transaction log stats), skipping non-matching files before disk reads.
  • Column Pruning (ColumnPruning): Columns that are never referenced in downstream projections, joins, or aggregations are eliminated immediately at scan time, reducing I/O and deserialization.
  • Constant Folding & Simplification (FoldConstants, BooleanSimplification): Expressions like 100 * (1 + 0.20) are evaluated at compile time to 120.0. Redundant clauses like col = 5 AND TRUE collapse to col = 5.
  • Outer Join Elimination (EliminateOuterJoin): A LEFT OUTER JOIN is rewritten into an INNER JOIN if a downstream filter rejects null values on the right-hand table.
  • Subquery Decorrelation: Correlated scalar subqueries (WHERE x IN (SELECT ...)) are rewritten into semi-joins or left outer joins.
  • Cost-Based Optimizer (CBO): When table statistics exist (ANALYZE TABLE COMPUTE STATISTICS), CBO can reorder multi-table joins to minimize intermediate cardinality. Both gates are off by default in Spark 3.5 and 4.x, and Fabric does not change that: join reordering requires spark.sql.cbo.enabled=true and spark.sql.cbo.joinReorder.enabled=true. With the defaults left alone, ANALYZE TABLE statistics still improve broadcast-size estimation and filter selectivity, but no join reordering happens — a common reason teams collect statistics and see no plan change. Both keys are SQLConf entries and are runtime-mutable (Sec 14) — settable with spark.conf.set on a live session. In practice AQE (Sec 06) delivers most of the same benefit from real runtime statistics without any ANALYZE maintenance to keep current.
Phase 4 · Physical Planning & Join Strategy Selection

The SparkPlanner translates the single optimized logical plan into one or more candidate physical plans (SparkPlans) using concrete execution algorithms, choosing the lowest-cost option:

  • Join Strategy Selection: Determines whether to execute a BroadcastHashJoin, ShuffleHashJoin, SortMergeJoin, or BroadcastNestedLoopJoin based on table size estimates and join keys.
  • Aggregation Selection: Chooses between HashAggregateExec (two-phase partial/final aggregation using off-heap hash tables) and SortAggregateExec (fallback when keys are unhashable).
  • Physical Rule Execution: EnsureRequirements injects Exchange (network shuffles) and SortExec nodes to fulfill required child partitionings and orderings; CollapseCodegenStages clusters adjacent operators into single Whole-Stage CodeGen stages.
Infographic: Catalyst Tree Optimization in Action (Before vs. After)
UNOPTIMIZED ANALYZED PLAN (Naive Execution Tree) Project [name#12, (amount#4 * 1.2)] Filter (status = 'ACTIVE' AND amount > 100) Join Inner (cust_id#3 = cust_id#11) Scan orders (All 18 Cols) ⚠ Full table scan (No pushdown) Unused cols loaded to memory Scan customers (All 24 Cols) ⚠ Full table scan Reads addresses, notes, metadata RBO OPTIMIZED LOGICAL PLAN (Rewritten Tree) Project [name#12, (amount#4 * 1.2)] Join Inner (cust_id#3 = cust_id#11) Filter (amount#4 > 100.0) Scan orders (Pruned 2 Cols) PushedFilters: [status='ACTIVE'] Delta/Parquet skips 85% files Scan customers (Pruned 2 Cols) ReadSchema: [cust_id, name] 22 unused columns dropped

Physical Join Strategy Selection Hierarchy

Catalyst uses a deterministic priority tree to select the physical join algorithm. Understanding this hierarchy allows you to predict whether Spark will broadcast, shuffle, or sort your data:

Infographic: Join Selection Decision Tree & Execution Characteristics
Input Join: R ⋈ S (Keys / Hints) Side < 10MB Threshold OR broadcast(df) hint? YES BroadcastHashJoin (BHJ) ✓ ZERO network shuffle on large table Replicates build table to all executors Fastest join algorithm by 5-10× NO Equi-Join Keys (=) and Keys Sortable? YES SortMergeJoin (SMJ) / ShuffleHash ⚠ Shuffles both sides across network (Exchange) Sorts both tables on join keys · Spills to disk if memory low AQE converts SMJ → BHJ dynamically if runtime size < 10MB NO BroadcastNestedLoop / Cartesian ⛔ O(N × M) full cross product Fires on range joins, !=, OR join conditions Red flag: rewrite join condition into equi-join key

Interactive Query Progression Explorer (Simple to Complex)

Explore how Catalyst, Tungsten, and NEE compile and execute queries across increasing levels of complexity. Each level provides side-by-side runnable PySpark DataFrame code and Spark SQL query syntax, alongside the exact plan evolution at each stage:

Workload Scenario: Filter completed orders, apply a 20% tax surcharge formula, and project output columns.
PySpark DataFrame API
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
from pyspark.sql import functions as F

# Simple filter + constant arithmetic + projection
df_simple = (
    spark.read.table("silver.orders")
    .filter(
        (F.col("status") == "COMPLETED") & 
        (F.col("amount") * (1 + 0.20) > 120)
    )
    .select(
        F.col("order_id"),
        F.col("cust_id"),
        (F.col("amount") * 1.20).alias("amount_with_tax")
    )
)
df_simple.explain(mode="formatted")
Spark SQL Equivalent
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
SELECT
  order_id,
  cust_id,
  amount * 1.20 AS amount_with_tax
FROM silver.orders
WHERE status = 'COMPLETED'
  AND amount * (1 + 0.20) > 120;
CATALYST PLAN UNRESOLVED LOGICAL PLAN (AST)
'Project [unresolvedalias('order_id), unresolvedalias('cust_id), ('amount * 1.20) AS amount_with_tax#0]
+- 'Filter (('status = COMPLETED) AND (('amount * (1 + 0.20)) > 120))
   +- 'UnresolvedRelation [silver, orders], [], false

Parser Action: Column names and mathematical operations are unresolved string symbols. (1 + 0.20) remains unfactored.

CATALYST PLAN ANALYZED LOGICAL PLAN (SCHEMA BOUND)
Project [order_id#1: bigint, cust_id#2: bigint, (amount#3: double * 1.2) AS amount_with_tax#4: double]
+- Filter ((status#5: string = 'COMPLETED') AND ((amount#3: double * (1.0 + 0.2)) > 120.0))
   +- Relation silver.orders[order_id#1,cust_id#2,amount#3,order_date#4,status#5,notes#6] parquet

Analyzer Action: Catalog binds silver.orders, assigns expression IDs (order_id#1, amount#3), validates data types.

CATALYST PLAN OPTIMIZED LOGICAL PLAN (RBO REWRITTEN)
Project [order_id#1, cust_id#2, (amount#3 * 1.2) AS amount_with_tax#4]
+- Filter ((isnotnull(amount#3) AND isnotnull(status#5)) AND ((status#5 = 'COMPLETED') AND ((amount#3 * 1.2) > 120.0)))
   +- Relation silver.orders[order_id#1,cust_id#2,amount#3,status#5] parquet  // notes & order_date PRUNED

Optimizer Action: FoldConstants folded (1.0 + 0.2) → 1.2; ColumnPruning dropped notes and order_date; NullPropagation injected isnotnull() safeguards.

PHYSICAL PLAN SPARKPLAN (PRE-EXECUTION)
*(1) Project [order_id#1, cust_id#2, (amount#3 * 1.2) AS amount_with_tax#4]
+- *(1) Filter ((isnotnull(status#5) AND (status#5 = 'COMPLETED')) AND ((amount#3 * 1.2) > 120.0))
   +- *(1) FileScan parquet silver.orders[order_id#1,cust_id#2,amount#3,status#5]
         Batched: true, DataFilters: [isnotnull(status#5), (status#5 = 'COMPLETED')],
         PushedFilters: [IsNotNull(status), EqualTo(status,COMPLETED)]

Physical Planner Action: Selected vectorized Parquet batch scan. Injected PushedFilters directly into the Parquet reader footer reader. Clustered all 3 operators into *(1) WholeStageCodegen stage.

NEE NATIVE PLAN FABRIC NATIVE EXECUTION (VELOX VECTORIZED)
VeloxColumnarToRowExec
+- ProjectExecTransformer [order_id#1, cust_id#2, (amount#3 * 1.2) AS amount_with_tax#4]
   +- FilterExecTransformer ((status#5 = 'COMPLETED') AND ((amount#3 * 1.2) > 120.0))
      +- NativeFileScanTransformer parquet silver.orders[order_id#1,cust_id#2,amount#3,status#5]

Native Execution Engine (NEE): Gluten replaces operators with *Transformer nodes. Velox reads column chunks directly into C++ SIMD vector registers (AVX-512) in 1024-row batches with zero JVM row object allocation.

Workload Scenario: Multi-table join across orders and customers, filtered by partition date and customer tier, aggregated monthly by region with a HAVING revenue threshold.
PySpark DataFrame API
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
from pyspark.sql import functions as F

df_orders = spark.read.table("silver.orders") \
    .filter(F.col("order_date") >= "2026-01-01")

df_cust = spark.read.table("silver.customers") \
    .filter(F.col("tier") == "PLATINUM")

df_med = (
    df_orders.join(df_cust, "cust_id", "inner")
    .groupBy(
        df_cust.region,
        F.date_trunc("month", df_orders.order_date).alias("order_month")
    )
    .agg(
        F.count("order_id").alias("total_orders"),
        F.sum("amount").alias("total_revenue")
    )
    .filter(F.col("total_revenue") > 10000)
)
df_med.explain(mode="formatted")
Spark SQL Equivalent
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
SELECT
  c.region,
  date_trunc('month', o.order_date) AS order_month,
  COUNT(o.order_id) AS total_orders,
  SUM(o.amount) AS total_revenue
FROM silver.orders o
JOIN silver.customers c ON o.cust_id = c.cust_id
WHERE o.order_date >= '2026-01-01'
  AND c.tier = 'PLATINUM'
GROUP BY c.region, date_trunc('month', o.order_date)
HAVING SUM(o.amount) > 10000;
CATALYST PLAN ANALYZED LOGICAL PLAN
Filter (total_revenue#10 > 10000.0)
+- Aggregate [region#21, date_trunc('month', order_date#3)], [region#21, date_trunc('month', order_date#3) AS order_month#8, count(order_id#1) AS total_orders#9L, sum(amount#2) AS total_revenue#10]
   +- Join Inner, (cust_id#4 = cust_id#20)
      :- Filter (order_date#3 >= 2026-01-01)
      :  +- Relation silver.orders[order_id#1,amount#2,order_date#3,cust_id#4] parquet
      +- Filter (tier#22 = 'PLATINUM')
         +- Relation silver.customers[cust_id#20,region#21,tier#22] parquet
CATALYST PLAN OPTIMIZED LOGICAL PLAN (PARTITION & DELTA LOG PRUNED)
Filter (total_revenue#10 > 10000.0)
+- Aggregate [region#21, _date_trunc_month#25], [region#21, _date_trunc_month#25 AS order_month#8, count(order_id#1) AS total_orders#9L, sum(amount#2) AS total_revenue#10]
   +- Project [order_id#1, amount#2, region#21, date_trunc('month', order_date#3) AS _date_trunc_month#25]
      +- Join Inner, (cust_id#4 = cust_id#20)
         :- Relation silver.orders[order_id#1,amount#2,order_date#3,cust_id#4] parquet  // PartitionFilters: [order_date >= 2026-01-01]
         +- Filter (tier#22 = 'PLATINUM')
            +- Relation silver.customers[cust_id#20,region#21,tier#22] parquet   // PushedFilters: [EqualTo(tier,PLATINUM)]

Optimization Details: PartitionFilters prunes 90% of order partitions in Delta metadata. PushedFilters eliminates non-PLATINUM customer records during Parquet scan.

PHYSICAL PLAN ADAPTIVESPARKPLAN (AQE TWO-PHASE AGGREGATION & BHJ)
AdaptiveSparkPlan isFinalPlan=false
+- *(3) Filter (total_revenue#10 > 10000.0)
   +- *(3) HashAggregate [region#21, _date_trunc_month#25], [sum(amount#2), count(order_id#1)]  // FINAL AGGREGATE
      +- AQEShuffleRead coalesced
         +- Exchange hashpartitioning(region#21, _date_trunc_month#25, 200)
            +- *(2) HashAggregate [region#21, _date_trunc_month#25], [partial_sum(amount#2), partial_count(order_id#1)] // PARTIAL AGGREGATE
               +- *(2) Project [order_id#1, amount#2, region#21, date_trunc('month', order_date#3) AS _date_trunc_month#25]
                  +- *(2) BroadcastHashJoin [cust_id#4], [cust_id#20], Inner, BuildRight
                     :- *(2) FileScan parquet silver.orders[order_id#1,amount#2,order_date#3,cust_id#4]
                     +- BroadcastExchange IdentityBroadcastMode
                        +- *(1) Filter (tier#22 = 'PLATINUM')
                           +- *(1) FileScan parquet silver.customers[cust_id#20,region#21,tier#22]

Physical Execution Architecture:
1. BroadcastHashJoin: Filtered Platinum customers table (<10MB) is broadcasted via BroadcastExchange, eliminating all shuffle on the multi-GB orders table.
2. Two-Phase HashAggregate: Mapper stage performs partial_sum and partial_count locally before shuffling, reducing network payload by >95%. Reducer stage performs final aggregation and coalesces partitions under AQE.

Workload Scenario: Common Table Expression (CTE) with subquery decorrelation, Window ranking (ROW_NUMBER() OVER (...)), skew join key mitigation, and Native Execution Engine (NEE) vectorized evaluation.
PySpark DataFrame API
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
from pyspark.sql import Window, functions as F

# 1. Base enriched dataset
df_base = (
    spark.read.table("silver.orders")
    .join(spark.read.table("silver.customers"), "cust_id")
    .filter(F.col("status") == "DELIVERED")
)

# 2. Window ranking per customer segment
w_segment = Window.partitionBy("segment").orderBy(F.col("amount").desc())

df_complex = (
    df_base
    .withColumn("rank_in_segment", F.row_number().over(w_segment))
    .filter(F.col("rank_in_segment") <= 3)
    .select("segment", "rank_in_segment", "order_id", "cust_id", "amount")
)
df_complex.explain(mode="formatted")
Spark SQL Equivalent
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
WITH DeliveredOrders AS (
  SELECT
    o.order_id,
    o.cust_id,
    c.segment,
    o.amount,
    ROW_NUMBER() OVER (
      PARTITION BY c.segment 
      ORDER BY o.amount DESC
    ) AS rank_in_segment
  FROM silver.orders o
  JOIN silver.customers c ON o.cust_id = c.cust_id
  WHERE o.status = 'DELIVERED'
)
SELECT segment, rank_in_segment, order_id, cust_id, amount
FROM DeliveredOrders
WHERE rank_in_segment <= 3;
PHYSICAL PLAN ADAPTIVESPARKPLAN (WINDOW + SKEW OPTIMIZATION)
AdaptiveSparkPlan isFinalPlan=true
+- *(3) Filter (rank_in_segment#5 <= 3)
   +- *(3) Window [row_number() windowspecdefinition(segment#21, amount#2 DESC NULLS LAST, specifiedwindowframe(RowFrame, unboundedpreceding$, currentrow())) AS rank_in_segment#5], [segment#21], amount#2 DESC NULLS LAST], [segment#21]
      +- *(3) Sort [segment#21 ASC NULLS FIRST, amount#2 DESC NULLS LAST], false, 0
         +- AQEShuffleRead coalesced
            +- Exchange hashpartitioning(segment#21, 200)
               +- *(2) SortMergeJoin [cust_id#4], [cust_id#20], Inner (skew=true)
                  :- *(2) Sort [cust_id#4 ASC NULLS FIRST]
                  :  +- AQEShuffleRead (skewPartitionSplits: [P0_1, P0_2])
                  :     +- Exchange hashpartitioning(cust_id#4, 200)
                  :        +- *(1) Filter (status#3 = 'DELIVERED')
                  :           +- *(1) FileScan parquet silver.orders
                  +- *(2) Sort [cust_id#20 ASC NULLS FIRST]
                     +- Exchange hashpartitioning(cust_id#20, 200)
                        +- *(1) FileScan parquet silver.customers

Complex Execution Mechanics:
1. CTE Inlining & Pruning: The Common Table Expression is flattened into a single pipeline without materialization overhead.
2. AQE Skew Join Splitting: Skewed customer keys are detected at stage boundary; partition P0 is dynamically split into sub-tasks (P0_1, P0_2), replicating the matching customer partition and preventing straggler executor tasks.
3. WindowExec: Sorting and ranking occur in-memory using Tungsten binary row comparisons.

NEE NATIVE PLAN NATIVE VELOX WINDOW & JOIN TRANSFORMATION
VeloxColumnarToRowExec
+- FilterExecTransformer (rank_in_segment#5 <= 3)
   +- WindowExecTransformer [row_number() over segment]
      +- SortExecTransformer [segment#21, amount#2 DESC]
         +- ColumnarExchange hashpartitioning(segment#21)
            +- ShuffledHashJoinExecTransformer [cust_id#4], [cust_id#20]
               :- NativeFileScanTransformer parquet silver.orders
               +- NativeFileScanTransformer parquet silver.customers

NEE Native Advantage: On Fabric Runtime 2.0 with built-ins, the entire analytical chain remains 100% native in Velox C++. Vectorized window processing avoids Python UDF serialization boundaries entirely.

How to inspect plans on your cluster: Call df.explain(mode="formatted") for a structured numbered tree with operator detail blocks, or df.explain(mode="cost") to view estimated row counts and data size statistics that drove Catalyst's decisions.
05 · Query Engine

Tungsten Engine & Whole-Stage Code Generation

Infographic: Execution Paradigms — Volcano Iterator vs. Whole-Stage Codegen vs. Native SIMD
1. VOLCANO ITERATOR (Spark 1.x) Project.next() → returns 1 row ↓ Virtual call (next) Filter.next() → returns 1 row ↓ Virtual call (next) Scan.next() → reads 1 row ⛔ 1 virtual call per row per operator Heavy GC · CPU cache thrashing 2. WHOLE-STAGE CODEGEN (Spark 3.x) // Janino compiled Java bytecode while (scan.hasNext()) { UnsafeRow r = scan.next(); if (r.getDouble(2) > 100.0) { emit(r.getString(0), r.getDouble(2)*1.2); } } ✓ ZERO virtual calls · Data in CPU registers UnsafeRow binary memory · Zero GC 3. NATIVE VECTORIZED (NEE / Velox) // 1024-Row Arrow Columnar Vectors col_amount: [120.5, 45.0, 310.2, ...] ↓ AVX-512 SIMD Instruction (Hardware) __m512d v = _mm512_load_pd(&arr[i]); __m512d res = _mm512_mul_pd(v, v_tax); 16 rows processed per CPU cycle ✓ Hardware SIMD parallel math Zero JVM warm-up · C++ bare-metal execution
Architecture: Tungsten Binary In-Memory Row Layout (UnsafeRow)
64-BIT WORD ALIGNED MEMORY BLOCK (UnsafeRow Structure) Null BitSet 1 bit per field Word-aligned (8B) Field 0: BIGINT 8-byte Inline Primitive Raw binary bits Field 1: STRING Pointer [Offset (32b) | Length (32b)] Relative byte pointer Variable-Length Data Segment UTF-8 String Bytes (e.g. "Fabric Data Engineering") Padded to 8-byte multiple (Zero GC Overhead)

Where Catalyst decides what to execute, Project Tungsten optimizes how execution occurs on physical CPU hardware:

1. Binary In-Memory Row Format (UnsafeRow)

Tungsten stores rows as raw, word-aligned byte arrays (UnsafeRow) outside the standard JVM heap. This eliminates Java object header bloat (16 bytes per object), memory pointer chasing, and garbage collection overhead.

2. Cache-Aware Memory Layout & Sorting

Operations like radix sorting and hash aggregation operate directly on 8-byte binary prefixes packed into contiguous cache lines. CPUs prefetch these lines sequentially into L1/L2 hardware caches, achieving near-zero cache miss penalties.

3. Whole-Stage Code Generation (WSCG)

Spark's Janino compiler collapses entire pipelines of operators into a single tight Java while loop. Variables are kept directly in CPU registers rather than written to memory between operator boundaries:

Conceptual Execution Model: Volcano Iterator vs. Whole-Stage Codegen
Without Whole-Stage Codegen (Volcano Model)
CONCEPTUAL PSEUDOCODE VOLCANO ITERATOR MODEL
// Virtual function call per row per operator
for row in scan.next():
  if filter.eval(row):      // virtual dispatch
    out = project.eval(row)   // virtual dispatch
    emit(out)
Every jump across an operator boundary is a dynamic virtual dispatch that flushes CPU instruction pipelines and prevents loop unrolling.
With Whole-Stage Codegen (Janino Synthesized)
SYNTHESIZED JAVA BYTECODE WHOLE-STAGE CODEGEN FUSED LOOP
// Single fused Java function per stage
while (scan.hasNext()) {
  UnsafeRow row = scan.next();
  if (row.getDouble(2) > 100.0) {
    emit(row.getString(0), row.getDouble(2) * 1.2);
  }
}
Fused directly into Java bytecode. Variables remain in hardware CPU registers; marked in physical plans with an asterisk (*).
Relationship to Native Execution Engine (Sec 07): NEE offers an alternative execution path using vectorized C++ SIMD kernels via Meta Velox. Where an operator is supported natively, Gluten offloads it to Velox. Where an operator is unsupported, execution cleanly falls back to Tungsten's Whole-Stage Codegen JVM path.
06 · Query Engine

Adaptive Query Execution (AQE)

Architecture: AQE Dynamic Runtime Feedback & Skew Splitting
Stage 1: Materialize Tasks write shuffle files Emits MapStatus stats (Bytes & Rows per partition) Driver AQE Optimization Loop ✓ 1. Dynamic Partition Coalescing Groups small contiguous partitions < 128 MB ✓ 2. Dynamic Join Switching Converts SortMergeJoin -> BroadcastHashJoin if < 10MB ✓ 3. Dynamic Skew Join Handling Splits partitions > 5x median into [P0_1, P0_2] Stage 2: Optimized Read Coalesced Partition Tasks No idle cores, balanced skew Balanced Execution

Catalyst's physical plan (Sec 04) is built from statistics known before the query runs — table metadata, maybe column stats. AQE, on by default since Spark 3.2, corrects that plan during execution, using real statistics from stages that have already run.

The re-optimization loop
Execute materializable stages
Collect real shuffle stats
Re-run optimizer + physical planner
Plan next stages
↺ repeat until done

The Five Core AQE Features

1 · Dynamic Partition Coalescing

The Problem: Setting a rigid spark.sql.shuffle.partitions=200 produces 200 tiny, sub-second tasks on small queries (massive scheduling overhead and thousands of small files), while on large 500GB+ tables it causes massive 2.5GB task partitions that spill to disk or crash with OutOfMemory (OOM).

The Mechanics: Map tasks write shuffle blocks and report exact byte sizes to the driver via MapStatus. AQE merges adjacent small partitions into coalesced target buckets based on spark.sql.adaptive.advisoryPartitionSizeInBytes (default 64 MB, recommended 128 MB in Fabric).

EXPLAIN PLAN RECEIPT DYNAMIC COALESCING SIGNATURE
AdaptiveSparkPlan isFinalPlan=true
+- AQEShuffleRead coalesced (200 -> 14 partitions, target 128MB)
   +- Exchange hashpartitioning(customer_id#4, 200)
Key Tuning Configs:
spark.sql.adaptive.coalescePartitions.enabled = true (Fabric default: true)
spark.sql.adaptive.advisoryPartitionSizeInBytes = 134217728 (128 MB — ideal for OneLake/Parquet)
spark.sql.adaptive.coalescePartitions.initialPartitionNum = 1000 (Start high; let AQE coalesce down)
2 · Dynamic Join Strategy Switching (SMJ → BHJ)

The Problem: Catalyst determines join strategies before execution using static table metadata. If your query applies highly selective filters (e.g., WHERE order_date = '2026-08-17' AND region = 'EMEA'), Catalyst cannot know the post-filter cardinality in advance and defensively plans an expensive, two-sided Sort-Merge Join (shuffling both sides).

The Mechanics: AQE executes the filtered dimension side first. When Stage 1 materializes, the driver inspects the real byte count. If actual_size <= spark.sql.autoBroadcastJoinThreshold (default 10MB to 30MB), AQE immediately cancels the planned Sort-Merge Join and rewrites the remaining DAG into a BroadcastHashJoin—completely eliminating the fact-table shuffle!

EXPLAIN PLAN RECEIPT SORT-MERGE CONVERTED TO BROADCAST
// Driver Log: "AdaptiveSparkPlan: Plan changed from SortMergeJoin to BroadcastHashJoin"
+- *(2) BroadcastHashJoin [customer_id#4], [customer_id#20], Inner, BuildRight
   :- *(1) FileScan parquet silver.orders (Fact Table - ZERO SHUFFLE!)
   +- BroadcastExchange HashedRelationBroadcastMode (Materialized 4.2 MB)
      +- *(1) Filter (date#2 = '2026-08-17')
         +- *(1) FileScan parquet silver.dim_customers
3 · Dynamic Skew Join Optimization

The Problem: The notorious "99% of tasks finish in 5 seconds, but 1 task runs for 30 minutes" straggler problem. Caused by non-uniform key distributions (e.g. customer_id = NULL or default placeholder keys holding 80% of rows).

The Mechanics: AQE monitors partition statistics across shuffle map outputs. A partition is classified as skewed if both conditions are met:
1. partition_size > median_partition_size * spark.sql.adaptive.skewJoin.skewedPartitionFactor (default: 5×)
2. partition_size > spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes (default: 64 MB)
When detected, AQE **splits the single skewed partition into $N$ sub-partitions** (e.g., P0_1, P0_2) and replicates the matching partition on the probe side so work is evenly distributed across executor cores.

EXPLAIN PLAN RECEIPT SKEW PARTITION SPLIT SIGNATURE
+- *(2) SortMergeJoin [cust_id#4], [cust_id#20], Inner (skew=true)
   :- *(2) Sort [cust_id#4 ASC NULLS FIRST]
   :  +- AQEShuffleRead (skewPartitionSplits: [P0_1, P0_2, P0_3])
   :     +- Exchange hashpartitioning(cust_id#4, 200)
   +- *(2) Sort [cust_id#20 ASC NULLS FIRST]
      +- AQEShuffleRead (skewPartitionReplicated: [P0])
         +- Exchange hashpartitioning(cust_id#20, 200)
CRITICAL LIMITATION — JOIN SKEW ONLY: AQE skew optimization only mitigates joins. It does NOT optimize GROUP BY aggregations! A skewed GROUP BY will still bottleneck a single reducer task and requires manual salting.
4 · Fabric Remote Shuffle Manager (RSM) Integration

The Problem: In elastic lakehouse pools, dynamically deallocating idle executor VMs deletes their local SSD shuffle blocks, causing downstream tasks to fail with catastrophic FetchFailedException storms and triggering entire stage retries.

The Mechanics: Microsoft Fabric links AQE directly to the Remote Shuffle Manager (RSM). AQE pre-packages shuffle partition layouts during the map write phase and streams blocks directly into OneLake/Blob storage. When executors scale down, shuffle data survives safely in remote storage, allowing downstream reducers to fetch them without recomputing map stages.

5 · Dynamic Partition Pruning (DPP)

The Problem: Fact tables partitioned by date/region often contain thousands of partition directories. When joining with a filtered dimension table, static Catalyst cannot prune partitions before query execution.

The Mechanics: Spark evaluates the filtered dimension subquery first, broadcasts the distinct matching partition keys, and injects them directly into the fact table's file scan reader at runtime. The scan skips 95%+ of directory paths without reading their Parquet footers.

EXPLAIN PLAN RECEIPT DYNAMIC PARTITION PRUNING
+- FileScan parquet gold.fact_sales
   PartitionFilters: [dynamicpruningexpression(date_key#1 IN subquery#25)]
   ReadSchema: struct<order_id:bigint, amount:double>
⚠️ Troubleshooting: When AQE Cannot Save You (Hard Limits)
Failure Scenario Why AQE Fails to Engage Required Remediation Strategy
Skewed GROUP BY Aggregation AQE skew splitting is strictly coded for join operators (SortMergeJoin / ShuffledHashJoin). Aggregations have no probe side to replicate. Manual Salting: Add a synthetic salted key CONCAT(key, '_', FLOOR(RAND()*10)), run a 2-stage aggregation, and aggregate the salt away in the second phase.
Cartesian Products / Non-Equi Joins Joins without equality predicates (e.g. ON a.start_date < b.end_date) cannot be hashed or sort-merged; they force CartesianProduct. Rewrite the join to include at least one equi-join predicate (e.g., ON a.org_id = b.org_id AND ...) or use range-bucketing techniques.
Narrow-Only Pipeline (No Shuffles) AQE's optimization loop triggers only at shuffle exchange stage barriers. Pure map-only jobs (Filter → Project → Append) have no re-optimization barriers. Pre-tune file sizes at source using spark.sql.files.maxPartitionBytes (default 128MB).
Subqueries & Broadcast Timeouts If a broadcast relation build exceeds spark.sql.broadcastTimeout (default 300s) due to GC pauses or network choke, the query aborts. Ensure driver has sufficient RAM, or explicitly disable broadcast for that specific query with /*+ MERGE(t1) */.
07 · Query Engine

Native Execution Engine (NEE) & Gluten

Architecture: Gluten JNI Bridge to Velox SIMD Native Kernels
Catalyst Physical Plan Gluten Planner intercepts plan Substrait Binary AST Generation (Standard Cross-Engine Spec) Gluten JNI Native Bridge Zero-Copy Pointer Handoff Columnar Batch Transfer Fallback Boundary (if required): VeloxColumnarToRowExec Velox C++ Vectorized Engine SIMD Vector Processing (AVX-512) Direct Parquet / Delta Scans Jemalloc Native Memory Arena

NEE is Fabric Spark's vectorized C++ execution path — built on two open-source components: Velox (a C++ database acceleration library open-sourced by Meta) and Apache Gluten (incubating; a middle layer, originated by Intel, that translates Spark's physical plan into a form Velox can execute). General availability: Fabric Runtime 1.3.

Interactive: row-at-a-time vs. columnar/vectorized

Each row is a separate JVM object, processed one at a time through the operator chain (whole-stage codegen fuses the calls, Sec 05, but each row is still a full pass).

rows → row 1 row 2 row 3 row 4 … processed sequentially, one JVM object per row

No SIMD, JVM object overhead per row, JIT warm-up cost before the JVM's hot path even kicks in.

Rows are held in columnar batches — one contiguous array per column — and operators apply to a whole batch using SIMD-vectorized C++ kernels.

column amount: [104.2, 88.1, 250.0, 19.5, …] — one vectorized pass
column region: [3, 3, 7, 1, …] — one vectorized pass

Better CPU cache efficiency, no JVM JIT warm-up, no per-row serialization — Microsoft's published TPC-DS benchmark: roughly 4× faster on a 1 TB workload versus vanilla Spark.

Where it sits in the plan — and where it doesn't

NEE integrates after Catalyst's logical and physical optimization (Sec 04) — every existing optimization (predicate pushdown, column pruning, AQE, cost-based rewrites) still runs exactly as it would without NEE. What changes is only how a supported operator executes once the plan is already decided.

  • Supported operators offload to the vectorized Velox path.
  • Unsupported operators fall back to JVM/Tungsten execution (Sec 05) automatically, with a columnar↔row conversion at the boundary — a real cost, but scoped to just that operator.
Common pitfall: a job can silently fall back from native to JVM execution for an unsupported operator without erroring, which can look like an unexplained performance regression. Use Spark's plan-advisory tooling (or inspect the physical plan for VeloxColumnarToRow nodes) to confirm what actually ran natively.
Fabric requires NEE for Efficient Scaledown (Sec 13). Microsoft doesn't publish the exact coupling; the most likely reason is that the Decision Layer and AQE Shuffle Write both need fast, accurate runtime statistics about data as it's written — which NEE's vectorized path is what actually produces in the runtime Efficient Scaledown currently ships against. This is inference, not a documented mechanism.
08 · Deep Dive

Spark JVM vs Native Execution — Fallback Mechanics

NEE is not a switch you flip and forget. Gluten intercepts the physical plan, swaps supported nodes for Velox C++ kernels, and leaves everything else on the JVM — inserting a columnar↔row conversion at every boundary. That cost model has a consequence most teams discover the hard way: a plan that falls back mid-flight can be slower than running with NEE off entirely, because you pay conversion overhead without sustained native execution. Microsoft states the gain is greatest when queries avoid triggering fallback, and that the engine suits compute-intensive rather than I/O-bound work.

The three questions this section answers: is NEE actually running for my query (not "is it enabled")? if it fell back, what triggered it and what do I rewrite? and is NEE-on genuinely faster for this workload, measured rather than assumed?

Fallback trigger taxonomy — four tiers

Tier Trigger What to do
1 — Whole query Structured Streaming; ANSI mode on (Runtime 2.0 default); non-native sources (JSON, XML) Nothing native runs. For streaming, size compute on JVM performance. For ANSI, make the deliberate choice in Sec 17ansi_strategy in the advisor sets it per workload.
2 — Operator An operator with no Velox equivalent (certain window frames, joins, aggregate variants) That node runs on JVM with conversions either side. Check the plan for where the *Transformer chain breaks; restructure the query so the unsupported operator sits at the edge, not the middle.
3 — Expression A single unsupported function or Python UDF inside an otherwise-native operator The sneaky one: one expression drops the whole enclosing operator to JVM. Replace with built-ins; this is why "UDFs are slow" understates the damage under NEE.
4 — Type Deeply nested structs/maps, complex-type manipulation Flatten to columnar operations where the logic allows; nest once at the edge rather than repeatedly in the hot path.

The authoritative operator and expression support list lives in the Apache Gluten documentation and changes release to release — this page deliberately teaches the method rather than freezing a support matrix that goes stale.

Three ways to detect fallback (use all three)

Method What you look for
1. Read the plan df.explain() — native operators carry *Transformer and *NativeFileScan suffixes; VeloxColumnarToRowExec marks a conversion boundary. No suffixes at all with NEE enabled means everything fell back.
2. Spark Advisor (inline) Real-time fallback visibility in the notebook cell output — when a plan segment falls back, Advisor raises the alert as you run, with the unsupported operator/configuration named. The fastest feedback loop available.
3. Diagnostics pane Monitoring hub → "Native Execution Engine fallback detected" → View root cause / View fallback details (see Sec 21) — the post-run, whole-application view.
4. Automated spark_plan_analyzer N-codes: N001 non-native format, N002 UDF-forced fallback, N003 ANSI×NEE on Runtime 2.0, N004 streaming, N005 nested types, N006 high conversion-boundary ratio, N007 zero native operators despite NEE on.

Interactive: fallback simulator

Describe your query; get the predicted native/fallback outcome, the triggering tier, and the rewrite. Heuristic — always confirm against the plan and Advisor.

NEE on Runtime 2.0 vs 1.3 — Critical Differences

  • NEE supports both Runtime 1.3 (Spark 3.5 / Delta 3.2) and Runtime 2.0 (Spark 4.1 / Delta 4.2) — it is not a 2.0-only feature.
  • Runtime 2.0 broadens coverage: vectorized CSV parsing, Python and Scala UDF support, and complex types.
  • Native Delta write acceleration extends native execution into the output path rather than read/transform only — relevant for write-heavy silver pipelines.
  • The catch on 2.0: ANSI defaults to on and NEE falls back under ANSI, so an untouched Runtime 2.0 environment with NEE "enabled" may be running almost entirely on the JVM. This is the single most important thing to verify after migrating (N003/N007 catch it; nb_nee_fallback_analyzer measures it).

ANSI Mode × NEE Interaction Matrix

The Runtime 2.0 Hidden Trap — Which Combination Gives Native Execution?
Runtime ANSI Mode NEE Status Result
Runtime 1.3 OFF (default) Enabled Full native execution. Velox C++ kernels active. *Transformer operators appear in plan.
Runtime 1.3 ON (explicit) Enabled ⚠️ Partial fallback. ANSI-affected operators fall to JVM. Check plan for VeloxColumnarToRowExec boundaries.
Runtime 2.0 ON (default) Enabled 🔴 Silent full JVM fallback. ANSI is ON by default → NEE cannot run. N003/N007 fire. Common post-migration trap!
Runtime 2.0 OFF (explicit) Enabled Full native execution restored. Set spark.sql.ansi.enabled=false per-workload in Environment props. V-Order write path now also native.
Any Any Disabled JVM path only. Whole-Stage CodeGen applies but Velox never runs. Useful for streaming, or as a debugging baseline.
Action: After migrating to Runtime 2.0, always run spark_plan_analyzer or check for N003 in the Advisor pane. If you see zero *Transformer operators in your plan with NEE enabled, ANSI is almost certainly the cause. Set spark.sql.ansi.enabled=false in the Environment's Spark properties (not in-notebook — that fires after the fallback decision).

Rewrite Patterns — Removing Fallback Operators

Fallback Trigger Found in Plan Native Replacement Explanation
BatchEvalPython (row UDF) F.lower(), F.trim(), F.regexp_replace() built-ins, or @pandas_udf Row UDFs cross JVM↔Python on every row and break codegen. Arrow-based pandas UDFs batch 1000+ rows per crossing.
EvalPython (UDF in filter) Move filter to native predicate; precompute column A UDF in WHERE prevents predicate pushdown AND kills NEE.
GenerateExec (EXPLODE on deep nested) Flatten struct at bronze → silver step, then EXPLODE flat arrays Deep nested type manipulation is a Tier-4 fallback trigger. Flatten once at the source.
WindowExec (complex frame) Use rangeBetween with numeric offsets not interval types where possible Certain window frame specifications (interval-based rows) have no Velox equivalent. Convert to row-number approaches where logic allows.
09 · Resources

Unified Memory Management & Off-Heap Arenas

Architecture: Node Memory Map (JVM Heap + Off-Heap + Velox Native Arena)
TOTAL CONTAINER MEMORY (Fabric Medium: 64 GB RAM / 8 vCores) OS / System (~4GB) JVM HEAP (spark.executor.memory ~48 GB) Reserved 300MB User Memory (40%) UDFs, DataStructures ~19.0 GB Spark Unified Memory (fraction=0.6, ~28.6 GB) Storage (50%) Cache, Broadcasts Execution (50%) Shuffle, Joins, Sorts Off-Heap Direct ByteBuffers Netty Shuffle Velox Native Jemalloc Arena SIMD Vectors

Every executor is a JVM with a fixed heap (spark.executor.memory), carved up by Spark's UnifiedMemoryManager (the default since Spark 1.6) into regions that compete for the same pool. Drag the sliders — the bar below recomputes exactly the way a real executor's memory manager would.

Interactive: unified memory carve-up
spark.executor.memory 16 GB
spark.memory.fraction 0.60
spark.memory.storageFraction 0.50

What each region is for

Region Purpose
Reserved (300 MB, fixed) Hard-coded, not configurable. Holds Spark's own internal objects. Not part of any fraction calculation.
User memory (heap − 300MB) × (1 − memory.fraction). Your own data structures if you're writing RDD code with UDFs that allocate, plus safeguards against unusually large individual records.
Execution memory Shuffles, joins, sorts, aggregations — anything that needs working memory mid-computation.
Storage memory .cache()/.persist()'d data and broadcast variables.
"Unified" means dynamic borrowing. Execution and storage memory are not hard partitions — they're one pool with a soft boundary. If execution memory is idle, storage can use all of it; if storage is idle, execution can use all of it. storageFraction only sets a floor: the amount of storage memory execution is not allowed to evict once storage has claimed it. Below that floor, execution can reclaim space back from storage (evicting cached blocks) under memory pressure.
Executor memory overhead is separate again — requested by Spark on top of spark.executor.memory, not carved out of it. Default formula: max(384 MB, 0.10 × executor memory). Covers JVM internals, PySpark worker processes, and native/off-heap allocations outside the JVM heap entirely. Getting this wrong is the single most common cause of an executor OOM that isn't a data problem — and it's a session-start-only setting (Sec 14): it can't be changed on a session that's already running.
10 · Resources

Partitioning, Sizing & Parallelism

A partition is the unit of parallelism: one task processes exactly one partition. Too few partitions and cores sit idle; too many and per-task scheduling overhead dominates. Two separate settings control partition count depending on where in the plan you are.

Setting Default Controls
spark.sql.files.maxPartitionBytes 128 MB Partition size when reading files (Parquet/Delta/CSV/JSON). Read-side only — AQE does not touch this one.
spark.sql.shuffle.partitions 200 Number of partitions produced by a shuffle (join, groupBy, repartition). With AQE's coalescing on, this is really just the initial/maximum count — AQE merges down from here.
Interactive: tasks vs. cores, in waves
Shuffle partitions 200
Total executor cores 48
Rule of thumb, not a hard rule: aim for at least 2–4× as many partitions as total cores, so a straggler task (data skew, a slow node) doesn't leave most of the cluster idle waiting for the last wave. The live Config Advisor in Sec 15 applies exactly this floor.

The small-files problem

When average source file size is well under the partition target, Spark doesn't automatically launch one task per tiny file — spark.sql.files.openCostInBytes (default 4 MB) estimates the fixed cost of opening a file and packs several small files into one partition/task accordingly. But this only compensates at read time; it doesn't fix the underlying problem of driver-side file-listing overhead and Delta/Parquet metadata bloat from having millions of tiny files in storage. A compaction pass (e.g. Delta OPTIMIZE) upstream is the real fix.

11 · Resources

Shuffle Internals, Spill & Sorters

Architecture: SortShuffleWriter, Sorter Spill & Netty Reducer Fetch
Map Task Execution PartitionedAppendOnlyMap In-Memory Sorter Buffer (spills if > memory fraction) Local NVMe Disk Files shuffle_{app}_{stage}_{task}.data Single merged file containing all partition blocks shuffle_{app}_{stage}_{task}.index Array of 8-byte long offsets marking partition boundaries Reduce Task Netty Fetch BlockTransferService Async Chunked Netty Transport Local Reduce Aggregation

A shuffle physically redistributes data across the cluster so that rows sharing a key end up on the same executor — required by every wide transformation (Sec 03). This is Spark's most expensive common operation, and the one most of the rest of this document (AQE, NEE, Efficient Scaledown) exists to make cheaper or safer.

The write path

Since Spark 2.0, all shuffle behaviour runs through SortShuffleManager, which selects one of three writers per task:

BypassMergeSortShuffleWriter
<200 output partitions, no map-side aggregation/ordering needed. One file per partition, concatenated. Cheap, doesn't scale to many partitions.
SortShuffleWriter
General-purpose, for RDDs of JVM objects. Delegates to ExternalSorter: buffers, sorts by partition, spills to disk under memory pressure.
UnsafeShuffleWriter
DataFrame/Dataset path — Tungsten binary row format (Sec 05). Sorts serialized pointers, not objects. What almost all SQL/DataFrame workloads actually use.

Whichever writer runs, the physical output per task is a data file (compressed, serialized records grouped by destination partition) plus an index file (byte offsets marking where each partition's slice starts/ends) — so a reduce task can seek directly to its range instead of scanning the whole file.

The read path

MapOutputTracker (driver-resident, cached on executors) holds the authoritative map from (shuffleId, partition) to the BlockManagerId that holds it. BlockManager (one per executor) serves the actual bytes over the network on request. A reduce task's BlockStoreShuffleReader looks up locations, fetches blocks in parallel, and returns a sorted iterator over the result.

The failure mode this document keeps returning to: shuffle output is written once, to one executor's local disk, with no replica. If that executor disappears before every consumer has read its output, the fetch fails (FetchFailedException) and the DAGScheduler recomputes the entire owning stage — not just the missing partition. This is precisely the coupling Efficient Scaledown's Remote Shuffle Manager and Shuffle Migration exist to break (Sec 13), and it's the reason executors can't simply be scaled down the moment they're idle.
12 · Resources

Broadcast Hash Joins & Driver OOMs

The cheapest way to join two DataFrames is to avoid shuffling either of them. If one side is small enough to fit comfortably in memory, Spark can send a full copy to every executor (a broadcast) and perform the join locally, entirely avoiding a shuffle exchange for that operation.

Strategy When Cost
Broadcast hash join One side's estimated size is below spark.sql.autoBroadcastJoinThreshold (default 10 MB) No shuffle. Driver collects the small side and broadcasts it once; every executor pays the memory cost of holding a full copy.
Sort-merge join Neither side is small enough to broadcast Both sides shuffled (partitioned by join key), then sorted and merged. The default, general-purpose strategy.
Shuffled hash join AQE converts to this when post-shuffle partitions are all small enough (spark.sql.adaptive.maxShuffledHashJoinLocalMapThreshold) Avoids the sort step of sort-merge when a partition-local hash map is cheap enough to build.
The default threshold is conservative on purpose. 10 MB is small — in production it's common to raise spark.sql.autoBroadcastJoinThreshold to 100–500 MB once you know a given dimension table comfortably fits, since the runtime saved by skipping a shuffle usually outweighs the extra memory every executor now holds. The Config Advisor (Sec 15) computes a specific recommendation from an actual known table size, with 1.5× headroom.
The risk broadcasting trades in: Spark's size estimate can be wrong — especially after a filter that dramatically reduces row count but whose selectivity Catalyst can't measure precisely. Broadcasting a table that's actually much larger than estimated can OOM the driver (collecting it) or every executor (holding it). AQE's dynamic join-strategy switching (Sec 06) mitigates this by deciding at runtime, from real post-shuffle sizes, rather than trusting the pre-execution estimate alone — but a broadcast explicitly forced via a hint bypasses that safety net entirely.
13 · Elastic Scaling

Dynamic Allocation & Remote Shuffle Manager (RSM)

Architecture: Dynamic Scaledown & Remote Shuffle Manager (RSM) Flow
1. Idle Detection Executor idle > timeout (spark.dynamicAllocation .executorIdleTimeout) Driver marks Decommissioning 2. Shuffle Block Migration Local shuffle blocks migrated to peer active executors or Secondary Blob Storage Non-HNS BlockBlobStorage 3. Node Reclaim Executor safely terminated No FetchFailedException Capacity CU Burn Drops 4. Reducer Read Reducers fetch from RSM Remote Blob Zero recomputation

Full internals (sequence diagrams, config reference, storage/networking constraints) live in the companion document "Efficient Scaledown & the Remote Shuffle Manager — Internals Reference." This section is the condensed version, connecting it back to everything above.

Why elastic scaling and shuffle fight each other

Dynamic allocation (spark.dynamicAllocation.enabled) lets Spark release idle executors and request new ones as load changes. But an executor holding shuffle output (Sec 11) isn't safely idle even with zero running tasks — a not-yet-scheduled task may still need to fetch from it. Left alone, this either blocks scale-down (executor sits idle, billed, protecting data) or risks a costly stage recompute if the executor is removed anyway.

Spark's own fix: graceful decommissioning (3.1+)

Before an executor is torn down, if spark.storage.decommission.shuffleBlocks.enabled is on, Spark migrates its shuffle blocks to a peer executor first — or to configured fallback storage if no peer has room. Only once every block has a new home does the executor actually exit.

Fabric's four Efficient Scaledown capabilities

Remote Shuffle Manager
Writes large shuffles straight to Azure Blob Storage instead of local disk — removes the coupling entirely rather than migrating around it.
Shuffle Migration
Fabric's tuned application of the Spark 3.1+ decommission framework above, protecting whichever shuffle blocks were kept local.
Decision Layer
Per-stage router: large shuffles → remote, small shuffles → local (avoids paying network latency where it isn't worth it).
AQE Shuffle Write
AQE (Sec 06) participates in the write itself, so RSM receives fewer, better-sized blocks.
Measured effect (Microsoft's TPC-DS benchmark): 54% lower billed compute (14,952 → 6,880 VM-minutes) with Efficient Scaledown on, and up to 57% faster runtime from the Decision Layer's per-stage routing versus routing every shuffle remotely unconditionally.
Platform constraints that block adoption, not just tune it: Remote Shuffle Manager requires a standard BlockBlobStorage account with hierarchical namespace disabled (not the same account as your OneLake/ADLS Gen2 Lakehouse data), and is not currently supported behind Azure Private Link — material for any regulated, private-networking Fabric estate. Full detail and mitigation options in the companion document, Parts 10 and 12.
CRITICAL RSM CONSTRAINT: Fabric Remote Shuffle Manager cannot write to OneLake (ADLS Gen2 with HNS enabled). It requires an external standard Azure Blob Storage account (BlockBlobStorage, HNS disabled) configured in the workspace capacity. Additionally, spark.sql.rsm.decisionlayer.enabled.level=stage must be set to prevent tiny shuffles from incurring high Azure Blob HTTP REST latency.
14 · Reference

Configuration Reference & Scopes

Every Spark setting mentioned in this document, in one searchable table — default value, which section explains it, and whether it's safe to change on a running session or needs to be set before one starts (Sec 14 → this exact distinction is what broke the notebook utility during testing; see the companion notebook for the full story).

Runtime 2.0 view — Spark 4.x changed these defaults (matching rows below get badged live): spark.sql.ansi.enabled false → true; spark.sql.maxSinglePartitionBytes unlimited → 128m; spark.sql.orc.compression.codec snappy → zstd; spark.speculation.multiplier 1.5 → 3 and quantile 0.75 → 0.9; spark.shuffle.service.removeShuffle off → on; shuffle-service DB → RocksDB. Unbadged DEFAULT values are the Spark 3.5 baseline shared by both runtimes.
Setting Default Scope What it controls
15 · Reference

Interactive Config Advisor (Live Heuristics & Medallion Presets)

Heuristic calculation engine tailored for Microsoft Fabric Spark pools. Generates minimal delta configurations (omitting platform defaults to avoid pinning debt) partitioned by Medallion layer and architectural scope.

📐 CONFIGURATION SCOPING TAXONOMY (WHERE TO SET WHAT):
Workspace / Environment Artifact: Sizing pools, library wheels, runtime version, and cluster-wide defaults (set once for all notebooks).
Table Properties (DDL): Deletion vectors, Change Data Feed, Liquid clustering, V-Order defaults (permanent storage metadata).
Notebook Session (%%configure -f): Temporary job-specific overrides (e.g. tuning advisoryPartitionSizeInBytes for a massive 200GB batch).
⚡ Quick Medallion Presets (Click to Populate):
Data Shape & Skew
Cluster & Capacity Context
Managed via Fabric Workspace Pool (not pinned in %%configure)
16 · Reference

Fabric Compute Reference & Node Sizing

The concrete numbers behind every Fabric-specific recommendation above.

Node sizes (memory-optimized — fixed 8 GB RAM per vCore)

Node size vCores Memory
Small 4 32 GB
Medium 8 64 GB
Large 16 128 GB
XLarge 32 256 GB
XXLarge 64 512 GB

XLarge and XXLarge require a non-trial Fabric SKU. Source: Microsoft Learn, "Apache Spark compute for Data Engineering and Data Science."

Capacity units → Spark vCores

1 Capacity Unit (CU) = 2 Spark vCores. A pool's maximum size is bounded by the capacity SKU hosting it — not every node size is reachable on every SKU.

SKU Capacity units Spark vCores Starter pool default max nodes
F2 2 4 1
F4 4 8 1
F8 8 16 2
F16 16 32 3
F32 32 64 8
F64 64 128 10
F128 128 256 10
F256 256 512 10

Starter pools default to Medium node size. Source: Microsoft Learn, "Configure starter pools."

Starter pool vs. custom pool

  • Starter pool — pre-hydrated, ~5 second session start, fixed at Medium node size. You can still adjust autoscale bounds and dynamic executor allocation, but not the node size itself.
  • Custom pool — you choose node size and full autoscale/dynamic-allocation configuration, at the cost of a normal (slower) session cold start.

Within a node: cores and memory are still adjustable

Choosing a node size fixes the executor's ceiling, not necessarily what you use. A Fabric environment's Compute pane lets you under-populate an executor's core count (e.g. run an 8-vCore node's executor at 4 cores, trading parallelism for per-task memory headroom) and choose among a fixed menu of executor-memory values for that node size — both are session-start settings (Sec 14), configured via the environment or a %%configure cell, not spark.conf.set() mid-session.

17 · Fabric Platform

Spark 3.5 vs 4.x — Runtime 1.3 vs Runtime 2.0

Fabric currently offers two runtime generations. Runtime 1.3 (Apache Spark 3.5, Delta Lake 3.2) is GA and the recommended production runtime. Runtime 2.0 launched at FabCon Atlanta (March 2026) in Public Preview on Spark 4.0/Delta 4.0 and has since been upgraded in place to Spark 4.1 / Delta Lake 4.2 — as of mid-2026 it is GA: production-ready. Note it is not yet the default runtime, so you must select it explicitly at workspace or Environment level.

Component stack
Component Runtime 1.3 (GA) Runtime 2.0 (GA)
Apache Spark 3.5 4.1 (launched as 4.0, upgraded in place)
Delta Lake 3.2 4.1
Java / JDK 11 21
Scala 2.12 2.13
Python 3.11 3.13
OS Mariner 2.0 Azure Linux 3.0

Interactive: what actually changed in Spark 4.x

Change Spark 3.5 Spark 4.x Impact
The single biggest breaking change: ANSI SQL mode is ON by default in Spark 4.x (spark.sql.ansi.enabled=true). Operations that silently returned NULL on Spark 3.5 — arithmetic overflow, divide-by-zero, invalid casts, out-of-range array indexing — now throw runtime exceptions. Audit any logic that depends on silent NULLs; use try_cast, try_divide, try_add etc., or set spark.sql.ansi.enabled=false during migration to isolate the runtime upgrade from data-quality cleanup.
The ANSI × NEE interplay nobody tells you about: the Native Execution Engine (Sec 07) currently falls back to JVM execution under ANSI mode (a documented NEE limitation, alongside streaming and JSON/XML). Since Spark 4.x turns ANSI on by default, a Runtime 2.0 job with NEE enabled faces a real choice today: keep ANSI's correctness guarantees, or set spark.sql.ansi.enabled=false to get native vectorized speed. Decide per workload class — data-quality-critical silver pipelines may prefer ANSI; scan-heavy gold aggregations may prefer NEE — and revisit as Microsoft extends native ANSI coverage.

Migration checklist: Runtime 1.3 → 2.0

  • Re-publish every Environment that has libraries. The Python 3.11 → 3.13 jump means custom and public libraries must be re-added and Published, or jobs fail with "No module found" / "Class not found". Export the library list first.
  • ANSI audit (above) — pre-test on Runtime 1.3 by setting spark.sql.ansi.enabled=true in a dev environment and running your suite before you ever switch runtimes.
  • Scala/JVM code: Scala 2.12 → 2.13 and JDK 11 → 21 mean any custom JARs must be recompiled.
  • Deprecated paths: WASB protocol → use ABFS; the old EventHubConnector → use the Kafka Spark connector; SparkR is deprecated in Spark 4.x.
  • Config default drift from upstream Spark 4.x: ORC compression snappy → zstd, spark.sql.maxSinglePartitionBytes now 128m (was unlimited), speculation is less aggressive (multiplier 1.5 → 3, quantile 0.75 → 0.9), external shuffle service now removes shuffle files for deallocated executors by default.
  • Delta 4.x-only table features (e.g. collations, VARIANT columns) are currently Spark-only — enabling them on a table breaks readability from other Fabric engines (SQL endpoint, older readers). Gate them behind interop review; deletion vectors and CDF are fine on both runtimes.
18 · Fabric Platform

Feature Explorer — Engine Compatibility

Driven by the runtime selector in the top bar (currently: Runtime 1.3). Features are grouped by which engine ships them — SPARK ENGINE DELTA ENGINE FABRIC PLATFORM — because that is what actually gates availability: the Spark version and Delta version come from the runtime you pick (Sec 17); Fabric platform features mostly work on both. Cards that need Runtime 2.0 dim out when 1.3 is selected; switch the selector to see what upgrading unlocks.

19 · Fabric Platform

Capacity Units, Smoothing, Bursting & Throttling

Architecture: Capacity Smoothing Windows & 3-Tier Throttling Engine
Smoothing Windows (Leaky Bucket) Interactive Jobs 5-Minute Moving Window (Interactive Notebooks) Background Jobs 24-Hour Moving Window (Pipelines, SJDs, Schedules) Bursting: Up to 3x Base Allocation allowed during spikes Three-Tier Throttling Enforcement Tier 1: Interactive Delay 10-min average consumption > 100% Tier 2: Background Reject 1-hour average consumption > 100% Tier 3: Hard Stop HTTP 430 24-hr cumulative capacity exhausted

Every Fabric Spark job draws from a capacity (F-SKU). Understanding the CU model is what turns "the job randomly failed with HTTP 430" into a predictable, monitorable system.

The CU model in three equations

Rule Meaning
1 CU = 2 Spark vCores An F64 capacity = 64 CU = 128 Spark vCores of base compute.
Burst factor = 3× (concurrency) Fabric Spark lets concurrent jobs collectively use up to 3× base vCores (F64 → up to 384 vCores across jobs). Bursting aids concurrency, not one job's max size — a single job is still bounded by its pool's max nodes.
CU-seconds = vCore-seconds ÷ 2 Billing/consumption: a Medium node (8 vCores) running 10 minutes consumes 8 × 600 ÷ 2 = 2,400 CU-seconds.

Smoothing — why a Spark spike doesn't instantly throttle you

Fabric classifies Spark operations as background operations, and background usage is smoothed over 24 hours: a burst of consumption is spread across the next day's capacity rather than billed against the minute it ran. This is why a heavy overnight batch doesn't immediately break morning interactive work — but it also means overuse accumulates silently as carryforward overage that must be burned down. Watch cumulative utilisation, not instantaneous.

The documented throttling ladder (what happens as overage accumulates)
Cumulative overage Effect
≤ 10 minutes of future capacity Nothing — overage protection; jobs run, debt carried forward.
10 – 60 minutes Interactive delay — user-submitted operations wait ~20 seconds at submission.
60 minutes – 24 hours Interactive rejection — user-submitted operations refused; scheduled/background continue.
> 24 hours Background rejection — everything refused until the overage burns down.

Job admission: what HTTP 430 actually means

Fabric Spark uses optimistic job admission: a job is admitted if its minimum node count fits, then grows toward its max via autoscale if capacity allows. When even the minimum doesn't fit: interactive (notebook-submitted) jobs are rejected immediately with HTTP 430 — TooManyRequestsForCapacity, while scheduled/pipeline-triggered jobs enter a SKU-dependent queue and start when capacity frees. Practical consequences: set realistic pool minimums (a min of 1 node admits far more often than a min of 10), and put retry-with-backoff on any automation that submits interactively.

Emitting issues early — before users feel them

  • Capacity Metrics App — the canonical view of utilisation, smoothing, carryforward overage and per-item CU attribution. Review the overage tab weekly, not just after incidents.
  • Surge protection — set a background-rejection threshold on the capacity so runaway background jobs get refused before they push the capacity into the interactive-rejection band that hurts users.
  • Activator / alerting on capacity metrics — alert at e.g. 80% smoothed utilisation and on any throttling event, rather than discovering 430s from users.
  • Fabric Apache Spark diagnostic emitter — ship Spark driver/executor logs and metrics to Log Analytics, Azure Storage, or Event Hubs; alert on FetchFailedException, OOM patterns, and admission failures centrally (Sec 21).
  • Autoscale Billing for Spark — moves Spark to serverless pay-as-you-go compute billed per job, off the capacity entirely: the right lever when Spark contends with Power BI/warehouse workloads on a shared capacity.
Reserved vs pay-as-you-go: both provision the same CU mechanics — same smoothing, bursting, throttling. PAYG capacities can be paused/resumed (pausing a throttled capacity settles overage); reservations trade that flexibility for a substantial discount (of the order of 40%, region-dependent). A common enterprise pattern: reserve the steady-state baseline, keep spiky experimental workloads on PAYG or Autoscale Billing.

Where settings actually live — the configuration hierarchy

Every knob in this document belongs to exactly one of five layers. Knowing which layer owns a setting tells you where to change it and who it affects — and mirrors what you see in the Fabric portal (Workspace settings → Data Engineering/Science → Spark settings, with its Pool / Environment / Jobs / High concurrency tabs).

Layer What lives here
1 — Capacity (admin portal) SKU (CU), Job bursting switch, surge protection, Autoscale Billing for Spark. Affects every workspace on the capacity.
2 — Workspace Spark settings Pool tab: default pool (Starter = Medium nodes, autoscale 1–10, ~5–10s start) or custom pools — node family/size, autoscale range, dynamic executor allocation. Environment tab: workspace default Environment and default runtime version — this is where Runtime 1.3 vs 2.0 is selected. Jobs tab: job admission behaviour (e.g. reserving max cores for active jobs — trades throughput for guaranteed completion). High concurrency tab: session sharing for notebooks/pipelines. Plus "Customize compute configurations for items" — if off, items cannot override the workspace pool.
3 — Environment item Runtime version override, attached pool + compute overrides (driver/executor size, dynamic allocation), Spark properties (the right home for team-wide session-start settings from Sec 14), libraries (public + custom — remember the re-publish rule when moving to Runtime 2.0, Sec 17), and the Acceleration tab — the NEE toggle.
4 — Notebook / job session %%configure -f at the top of a notebook (session-start scope: executor shape, memory fractions, Efficient Scaledown keys) and per-SJD submit parameters. Requires "Customize compute" on at layer 2.
5 — Running session spark.conf.set() for runtime-mutable keys only (AQE, shuffle partitions, broadcast threshold, ANSI…). The RUNTIME/SESSION-START split in Sec 14 is exactly the layer-4 vs layer-5 boundary.

Precedence flows downward: a notebook's %%configure beats the Environment's Spark properties, which beat workspace defaults. Debug rule: when a setting "doesn't stick", check whether a lower layer is overriding it — or whether it's session-start scope being set too late.

Interactive: capacity planner

Planned job: nodes 4
Planned job: duration (min) 30
20 · Fabric Platform

CU Accounting & Per-Entity Chargeback

The Capacity Metrics App tells you which item consumed capacity. It does not tell you which entity in a metadata-driven framework did — one generic notebook running 40 entities appears as one notebook. Closing that gap is a logging decision, not a platform feature.

Extending etl_run_log

Column Purpose
engine python-notebook | spark | copy-activity — makes the engine-choice decision auditable after the fact.
vcores What the run actually had (2 for a default Python notebook; nodes × node vCores for Spark).
duration_seconds Wall-clock of the work itself.
cu_seconds Estimate: duration_seconds × vcores ÷ 2 (1 CU = 2 Spark vCores).
rows_read / bytes_landed / files_landed Denominators for cost-per-row and cost-per-GB trends — the numbers that make a regression visible.
requests_made / throttle_waits API-specific: distinguishes "the source got slower" from "our code got slower".
spark_app_id The jump-off into the Monitoring hub (Sec 21).

Where to write it

Target Use when
Fabric SQL Database Default. Batched executemany at the end of each run (Sec 36). Transactional, joinable to the rest of the metadata, queryable from pipelines. Never one insert per row.
Eventhouse (KQL) Streaming-scale or very high run counts, where append throughput and time-series queries matter more than joins. Sits naturally beside workspace-monitoring data (Sec 21).
Delta table in a Lakehouse Acceptable fallback when no SQL DB exists — but expect small-file churn from frequent appends, and schedule maintenance accordingly (Sec 33).
Correction worth stating plainly: a tempting design is to roll up cross-workspace cost by exporting from the Capacity Metrics App and joining it to your run log. Microsoft's documentation states that consumption from, usage of, or modification of the metrics app's semantic model isn't supported, and its retained history is short. Treat the app as the billing authority and a reconciliation surface — not an ETL source.

The supported inversion: your own run log is the cross-workspace rollup, because every run writes to one central SQL Database regardless of which workspace executed it. Reconcile against the app periodically by eye, and expect drift — your estimate omits session startup, library installation and idle session time, and the app itself excludes library-management consumption and system Spark jobs, while attributing all Spark work as background against the notebook, SJD or lakehouse item. Data there is also subject to a 10–15 minute refresh latency, which is precisely why an in-run estimate is useful: it is available immediately.
What this buys you. Cost per entity, per row and per GB, trended over time — so a crawl that quietly doubled its page count, or a silver merge that started rewriting whole files, shows up as a slope rather than as a capacity upgrade request. Alert on your numbers (seconds old), and use the Capacity Metrics App to confirm the absolute figures.
21 · Fabric Platform

Troubleshooting UI, Skew & History Server

Everything Sec 01Sec 13 explained becomes practical here: each internal mechanism leaves a specific fingerprint in the Spark UI, and reading those fingerprints is how you diagnose slow or failing jobs instead of guessing. In Fabric: Monitoring hub → your application → Spark UI / Spark history server, or from a notebook's run status bar → "View run details". Logs live under the same detail view (driver stdout/stderr, executor logs), and the in-notebook Spark Advisor surfaces common issues inline.

Where each symptom shows up
Symptom Where in the UI What it means
Disk / memory spill Stages → stage detail → summary metrics: "Spill (memory)" / "Spill (disk)" columns non-zero Execution memory (Sec 09) couldn't hold the working set — sort/aggregate/join buffers overflowed to disk. Every spilled byte is written and re-read.
Skew Stage summary: max task duration ≫ 75th percentile; one task processing GBs while median processes MBs Hot keys concentrated in one partition (Sec 06's skew-join handles joins; aggregation skew needs salting).
Executor OOM Executors tab: dead executors; logs show exit code 137 / java.lang.OutOfMemoryError Task working set exceeded heap+overhead (Sec 09). Distinct from driver OOM.
Driver OOM Job dies at collect/broadcast step; driver log OOM Usually collect()/toPandas() on big data, or broadcasting a "small" table that wasn't (Sec 12).
Stage retry storm Jobs tab: stages with attempt > 0; FetchFailedException in failed-task reason Shuffle blocks lost with their executor (Sec 11, Sec 13) — the exact problem Efficient Scaledown addresses.
Too many tiny tasks Stage with thousands of sub-second tasks Over-partitioning or the small-files problem (Sec 10); scheduling overhead dominates.
AQE plan changes SQL/DataFrame tab: AdaptiveSparkPlan isFinalPlan=true, AQEShuffleRead coalesced nodes; driver log "Plan changed from…" Normal — AQE re-optimizing (Sec 06). Compare initial vs final plan to understand what runtime stats changed.
NEE fallbacks SQL plan: operators without *Transformer/NativeFileScan suffixes; VeloxColumnarToRowExec conversions; Gluten tab Part of the plan fell back to JVM (Sec 07) — check for streaming/JSON/XML/ANSI triggers before assuming NEE is "on".
HTTP 430 at submit Job never starts; submission error TooManyRequestsForCapacity Capacity admission failure (Sec 19), not a Spark problem — check Capacity Metrics App.

Interactive: symptom → diagnosis → fix

Pick a symptom above.

The monitoring detail view, tab by tab — and where Livy fits

Before Spark ever runs your code, Fabric routes the submission through Apache Livy, the REST session layer: notebook or Spark Job Definition → Livy session request → capacity admission (Sec 19) → container acquisition → Environment resolution (runtime version, libraries, Spark properties) → spark-submit → session idle, ready for statements. Each notebook session is a Livy session with an id and state machine (not_started → starting → idle ⇄ busy → dead). This matters diagnostically because a whole class of failures happens at the Livy layer, before any Spark job exists — capacity 430s, library resolution conflicts, un-published Environments, session timeouts — and those appear in the Livy log, not in Spark's UI.

Monitoring hub → application detail — what each tab answers
Tab What it shows / when to use it
Jobs Job → stage → task tree with live progress, durations, rows and data read/written per job. Failed stages surface here first (e.g. a failed genShuffleDependency at VeloxSparkPlanExecApi.scala job — the Velox class name in the description is itself a signal the plan ran on NEE). Click through to the failing stage, then use the symptom table above.
Resources Executor allocation over time — cores in use vs allocated, when autoscale added/removed executors. The place to verify dynamic allocation and Efficient Scaledown behaviour (Sec 13) and to spot idle over-provisioning.
Logs Selector for Livy log (session bootstrap: admission, library install progress, spark-submit line — read this when the session never started or took minutes to start), Driver stdout/stderr (your print()/logging output, collect-side errors, full stack traces), and per-executor stderr by attempt (task-side OOMs, FetchFailed detail). Filter by errors/warnings, download for offline grep.
Data Input/output summary per job — quick sanity check that a job actually read/wrote the volumes you expected (a 0-row read often means a wrong path/filter, not a performance problem).
Item snapshots A read-only snapshot of the notebook/SJD as it was when this run executed, including parameter values. Indispensable when the notebook has been edited since the failing run — you debug the code that ran, not the code that exists now.
Diagnostics pane Fabric's built-in advisor over the run: Data and time skew (skewed task %, max vs avg executor time, max/min/avg data read, skewness score — its thresholds catch what the percentile check above finds manually) and Native Execution Engine fallback detected, with "View root cause" naming the operator/expression that forced the JVM path (Sec 07) and "View fallback details" listing every fallback. Check this pane before manual archaeology — it often answers the question outright.
Monitor run series Duration and data-volume trend across runs of the same activity. The regression detector: a job that crept from 4 to 11 minutes over two weeks shows as a slope here long before anyone complains — pair with Autotune to see whether tuning is converging.
Spark History Server The full classic Spark UI (SQL tab, stage detail, event timeline) for completed runs — everything in the symptom table above, post-mortem. Event-log based, so it works after executors are long gone.
Which log answers which question: "session won't start / slow to start" → Livy log (admission, library install). "My code raised" → driver stderr (stack trace) + Item snapshot (exact code). "Executor died" → that executor's stderr, last attempt (look for java.lang.OutOfMemoryError or exit 137 just before the gap). "Where did the time go" → History Server SQL tab + stage metrics. "Is this run worse than usual" → Monitor run series. "Is NEE actually accelerating" → Diagnostics fallback panel + *Transformer suffixes in the SQL plan. "Is the platform throttling me" → Capacity Metrics App, not this view at all (Sec 19).

Centralised log emission — catch it before the user does

The Fabric Apache Spark diagnostic emitter (configured per Environment) streams driver/executor logs and metrics to Azure Log Analytics, Azure Storage, or Event Hubs. Once logs land in Log Analytics, standing KQL alerts on OutOfMemoryError, FetchFailedException, spill metrics, and 430 admission failures turn every failure class on this page into a proactive signal instead of a user report. Pair with the emitted Spark event log (see "Observable artifacts", Sec 03) for full-fidelity postmortems.

Reading order that works in practice: SQL tab (which query, final vs initial plan) → Jobs tab (which stage failed/slow) → that stage's summary metrics (spill? skew? task count?) → Executors tab (dead executors, GC time, shuffle read/write balance) → driver/executor logs for the specific exception. Resist starting in the logs; the UI narrows the search space first.
22 · Fabric Platform

Spark Logs REST API & Automated Diagnostics

📊 FABRIC SPARK MONITORING TOPOLOGY & DIAGNOSTIC FLOW END-TO-END ARCHITECTURE
1. ENTRY SURFACES Monitoring Hub Global workspace fleet view Item Recent Runs Notebook / SJD run history Notebook In-Cell Live jobGroup progress & bulb Pipeline Spark Activity DAG execution & deep-links Spark Job Definition Batch submission & snapshot 2. SPARK APPLICATION DETAIL PAGE Jobs Tab High-Concurrency Filter Stage/Task breakdown & code Resources Tab Allocated vs Active Execs Real-time CPU/Memory charts Logs Tab Livy / Prelaunch / Driver Keyword search & download Data Tab Input & Output Inventory Format, size, OneLake paths Item Snapshots Immutable Code State Params & pipeline hierarchy Diagnostics Panel Spark Advisor Engine Root-cause failure analysis Side Summary / Properties Flyout: App ID • Livy ID • Submission Time • Duration • Status • Pool Node Size • Submitter 3. ENGINE & TELEMETRY Spark History Server Full OSS DAG & SQL tab Retained event logs Diagnostic Emitters Log Analytics / Eventhouse Streaming KQL & alert rules Fabric REST API /v1/workspaces/.../logs Scriptable in-memory triage Capacity Metrics App CU consumption & smoothing

The Spark UI is not actually confusing — it is five separate surfaces that look like one, and nobody tells you which one answers which question. This section maps them, then shows the part most people never discover: every one of them is a documented REST API, so "this cell is slow" can become a script instead of a click-through.

The five surfaces, and what each one actually is

Surface Where What it is
In-cell progress Below the running cell A live job/stage/task progress bar scoped to that cell — the only surface that already answers "which cell" without you asking.
Spark Advisor (in-cell) Expandable panel under the cell Real-time static + runtime analysis — Info/Warning/Error counts, skew detection — generated as the cell runs. This is the notebook lightbulb, and it has a REST API twin (below).
Recent runs / Monitoring hub Item context menu → Recent runs, or Monitor in the nav The list of Spark applications (one per notebook run), each with a Livy Id and an Application Id — your entry point into everything else.
Application detail Click an application from Recent runs The tabbed view from Sec 21: Jobs, Resources, Logs, Data, Item snapshots, Diagnostics.
Spark History Server Link from Application detail The full classic OSS Spark UI — SQL tab, stage detail, event timeline — for completed runs.
The cell-to-job link that makes this navigable. Fabric tags every Spark job with a jobGroupId corresponding to the notebook cell that triggered it. That is not a UI convenience — it is a real, queryable field, and it is the join key between "cell 14 is slow" and "which jobs/stages/tasks actually ran".

The 6 Application Detail Tabs & Key Production Capabilities

Tab / Panel Key Capabilities Production Troubleshooting Value
Jobs Tab High-Concurrency Filtering: In shared interactive sessions, filter jobs by notebook to isolate your execution.
• Job status, stage/task metrics, data read/written.
• Code snippet popup to copy the exact cell SQL/PySpark code.
Quickly isolates which job failed or hung in a multi-tenant session without wading through teammates' cells.
Resources Tab Allocated vs. Active Executors: Visual timeline of elastic autoscale in action.
• Core & Memory time-series utilization charts.
• Filterable by jobGroup (notebook cell).
Detects executor starvation, zombie idle nodes waiting on shuffle data (Sec 13), and capacity throttling boundaries.
Logs Tab 3 Separate Log Streams:
  1. Livy: Session submission, lifecycle & queuing.
  2. Prelaunch: Node warmup, container bootstrap & pool allocation.
  3. Driver: Stdout, stderr, print outputs, log4j stack traces.
• Search keyword filtering & one-click log download.
When a job fails before running tasks, the Prelaunch/Livy logs reveal pool allocation failures or wheel dependency conflicts that never reach the Driver log.
Data Tab • Automatic inventory of all input & output files.
• Displays format (Delta/Parquet/CSV), size, source, and resolved OneLake paths.
• Download files or copy ABFS paths directly.
Identifies write amplification and verifies whether file skipping (Sec 24b) actually pruned partitions without writing diagnostic query plans.
Item Snapshots • Hierarchical tree of parent Pipelines, Notebooks, and SJDs.
Immutable code snapshot: Captures the exact notebook cell code, widget inputs, and parameters at execution time.
Guarantees reproducible root-cause analysis even if developers modify or overwrite the notebook after the scheduled run finishes.
Diagnostics & Advisor Spark Advisor Rule Engine: Info, Warning, Error alerts.
• Automated checks: Data skew (>3× task gap), Cartesian products (cross joins), uncoalesced tiny partitions, broadcast hash join candidates, GC pressure.
Automates 80% of routine performance triage with structured remediation hints directly in the browser.

The REST API layer — the same data, scriptable

Fabric exposes Spark application internals as a REST API that mirrors the OSS Spark History Server API contract exactly — same endpoints, same parameters, same JSON shapes for jobs, stages, tasks, executors, storage and streaming. On top of that, Fabric adds endpoints OSS Spark does not have: the Advisor and resource-usage graphs, as APIs.

Endpoint (under /applications/{appId}/{attemptId}/…) Answers
jobs Every Spark job in the application — duration, status, stage count. Filter/sort by duration to find the slow one; each job carries its jobGroupId back to the cell.
stages / stages/{id}/{attempt}/taskList Per-stage summary and per-task detail — the shuffle read/write, spill, and max-vs-median duration numbers that diagnose skew (Sec 21's symptom table, as JSON).
executors / allexecutors Executor lifetime, GC time, task counts — dead executors and their timing point straight at Sec 09-style OOM diagnosis.
environment The full effective Spark config for the run — confirms what actually applied vs. what you intended (Sec 14).
advice Fabric-only. The Spark Advisor's Info/Warning/Error findings, as structured data — filterable by stageId, jobId, jobGroupId, executorId. Programmatic access to the same skew-detection and anti-pattern analysis the notebook UI shows.
resourceUsage Fabric-only. Executor CPU/memory utilization over time, filterable by jobGroup — the Resources-tab chart as numbers you can threshold and alert on.
logs?type=driver&fileName=… Fabric-only. Raw driver stdout/stderr text — your print() output and full stack traces, fetchable without opening the portal.

Base pattern: GET /v1/workspaces/{workspaceId}/notebooks|sparkJobDefinitions|lakehouses/{itemId}/livySessions/{livyId}/applications/{appId}/{attemptId}/{endpoint}. Auth: a pre-authenticated Fabric REST client from inside a notebook, or an Entra app/SPN token from outside (the same token pattern as Sec 27's SQL Database connectivity).

Turning "this cell is slow" into a diagnosis — the workflow

  1. Get the application id + Livy id of the run that felt slow (Recent runs, or captured at session start).
  2. List jobs, sort by duration, and note the jobGroupId of the slowest one — that is your cell.
  3. List that job's stages, sort by duration; pull the slowest stage's task list.
  4. Compute max-vs-median task duration from the task list — a large gap is skew, not general slowness (Sec 21).
  5. Check spill in the stage summary metrics — non-zero spill means the fix is partition sizing or executor shape (Sec 09), not more time.
  6. Pull Advisor findings for that job/stage — often this step alone names the cause.
  7. Cross-check resource usage for the same jobGroup window — confirms whether executors were memory-starved or just busy.

This is exactly what nb_fabric_log_diagnostics.ipynb automates — point it at an application id and it runs steps 2–7 and prints a ranked diagnosis instead of five tabs of manual cross-referencing.

Interactive: which surface answers your question

Pick a question above.
Working proof: nb_fabric_log_diagnostics.ipynb implements the client for every endpoint above and exercises the full workflow against realistic, schema-accurate simulated responses (clearly labelled demo mode) — the live-Fabric calls are unchanged code, only the transport differs. Executed locally end to end, zero errors.
23 · Fabric Platform

Log Analytics, KQL & Event Log Analysis

Two distinct things people conflate. The diagnostic emitter streams logs and metrics continuously to an external destination — good for alerting and fleet monitoring. Event logs are the per-application raw event stream the Spark UI is rendered from — good for deep post-mortem analysis of one run. You want both, for different reasons.

Minimum emitter configuration — Log Analytics

Set these as Spark properties on an Environment (not in a notebook — this is session-start scope). Five properties is the whole thing:

SPARK PROPERTIES FABRIC ENVIRONMENT -> SPARK COMPUTE
spark.synapse.diagnostic.emitters: MyLA
spark.synapse.diagnostic.emitter.MyLA.type: "AzureLogAnalytics"
spark.synapse.diagnostic.emitter.MyLA.categories: "Log,EventLog,Metrics"
spark.synapse.diagnostic.emitter.MyLA.workspaceId: <LOG_ANALYTICS_WORKSPACE_ID>
spark.synapse.diagnostic.emitter.MyLA.secret: <LOG_ANALYTICS_WORKSPACE_KEY>
# Use Key Vault instead of an inline secret (do this in production):
spark.synapse.diagnostic.emitter.MyLA.secret.keyVault: <AZURE_KEY_VAULT_URI>
spark.synapse.diagnostic.emitter.MyLA.secret.keyVault.secretName: <SECRET_NAME>
# REQUIRED when using the default/starter pool - without it the emitter never engages:
spark.fabric.pools.skipStarterPools: "true"
The four gotchas that waste an afternoon.
  1. spark.fabric.pools.skipStarterPools: "true" is required on the default pool. Starter pools are pre-warmed and do not pick up emitter configuration. Miss this line and everything looks configured but nothing arrives — the single most common failure.
  2. Category names differ by destination. Log Analytics uses Log,EventLog,Metrics. Azure Storage and Event Hubs use DriverLog,ExecutorLog,EventLog,Metrics. Copying a Storage example into a Log Analytics emitter silently collects nothing useful.
  3. The secret is the Log Analytics primary key (Azure portal → your LA workspace → Agents → Primary key), not an Entra token or a connection string.
  4. Users submitting Spark jobs need read access to the Key Vault secret when using the Key Vault route, or the emitter fails to initialise per-session.
Choosing a destination
Destination Use when
Azure Log Analytics You want KQL, alert rules and workbooks over Spark logs, and you already have an LA workspace. Best for alerting.
Azure Storage Cheap long-term retention of raw JSON lines. Shortcut the container into a Lakehouse and query it with Spark — the pattern for fleet-wide historical analysis.
Azure Event Hubs Streaming onward into an Eventstream → Eventhouse, so Spark logs sit beside workspace-monitoring data in one KQL estate (Sec 21).

Filtering exists too: spark.synapse.diagnostic.emitter.<name>.filter.eventName.match takes a comma-separated list of Spark event names, which is how you keep volume (and cost) down when you only care about a few event types.

Event logs — the deeper source

The emitter's EventLog category and the per-application event log are the same underlying event stream. The difference is access: the emitter pushes it continuously to a destination you configured in advance; the REST API lets you pull the complete log for a specific past application, whether or not you had an emitter configured. For "this run was slow, why?", pulling the log is the direct route.

HTTP REST API FABRIC MONITORING REST API CONTRACT
GET https://api.fabric.microsoft.com/v1/workspaces/{workspaceId}/notebooks|sparkJobDefinitions|lakehouses/{itemId}/livySessions/{livyId}/applications/{appId}/{attemptId}/logs
# Returns binary application/zip stream containing JSON-lines Spark event logs

Runnable PySpark client: Paste any portal Monitoring-hub URL or REST endpoint, acquire the token ambiently via notebookutils, and parse the raw event log stream directly in memory:

PYSPARK RUNNABLE IN FABRIC NOTEBOOK (IN-MEMORY PARSER)
import io, json, zipfile, requests, notebookutils
from spark_eventlog_analyzer import resolve_spark_rest_url

# 1. Accepts ANY portal monitoring URL or REST endpoint
portal_or_api_url = "https://app.fabric.microsoft.com/groups/0000.../sparkapplications/application_1741176604085_0001?workspaceId=...&livyId=...&itemId=..."
rest_url = resolve_spark_rest_url(portal_or_api_url, endpoint="logs")

# 2. Acquire ambient Entra ID Bearer token in Fabric Notebook
token = notebookutils.credentials.getToken("pbi")
resp = requests.get(rest_url, headers={"Authorization": f"Bearer {token}"})

# 3. Stream & Unzip in-memory (zero temp files written to disk)
events = []
if resp.status_code == 200:
    with zipfile.ZipFile(io.BytesIO(resp.content)) as z:
        for fname in z.namelist():
            if not fname.endswith("/"):
                with z.open(fname) as zf:
                    events.extend([json.loads(line) for line in zf if line.strip()])
    print(f"Successfully parsed {len(events)} events from Fabric REST API.")
Why you get "bad URL" — the five causes, in order of frequency.
  1. Missing /{attemptId}/. The logs endpoint requires it — normally 1, or 2 after a retry. Omitting it returns an unhelpful 400/404 that does not mention the attempt.
  2. Wrong item-kind segment. A Spark Job Definition's application under /notebooks/ 404s. The segment must match the item that launched the app: notebooks, sparkJobDefinitions or lakehouses.
  3. Pasting the portal URL. The Monitoring-hub address contains the right ids but in a different shape — it is not an API endpoint. The ids are extractable, though.
  4. Copy artefacts: quotes, a trailing slash, a #fragment, or a query string picked up with the copy.
  5. The application has not finished. Event logs finalise on completion; querying a running session returns nothing useful.
The toolkit's spark_eventlog_analyzer.parse_fabric_url() handles (1)–(4) automatically — it accepts the REST URL, a relative path, or a portal URL with ids in the query string, defaults the attempt to 1, and detects the item kind. For anything it genuinely cannot parse it explains which part is missing rather than failing opaquely.

What the event log gives you that the UI does not

Analysis Why the raw events are needed
Exact skew ratio per stage The UI shows a percentile summary; the event log has every SparkListenerTaskEnd, so you can compute max/median precisely and identify the executor that ran the straggler.
Spill attribution Memory Bytes Spilled and Disk Bytes Spilled per task — aggregate by stage, by executor, or correlate with peak execution memory.
Critical path Rank stages by wall time as a percentage of total stage time — tells you where a job actually spent its life rather than which stage looks busiest.
Effective configuration SparkListenerEnvironmentUpdate carries the actual config the application ran with — ground truth versus what you believe you set (Sec 14).
Executor lifecycle Added/removed timestamps and removal reasons — how much of the run had how much compute, and whether executors were lost non-idle.
Cross-run trending The logs are files. Parse a month of them into a Delta table and trend skew, spill and duration per pipeline — impossible through the UI.
Working proof: spark_eventlog_analyzer.py + nb_eventlog_analysis.ipynb. The notebook generates a real Spark event log in-session (including a deliberately skewed join), parses it, and reports the critical path, skew ratios, spill, GC pressure and findings — with assertions proving the configuration was read from the log rather than assumed, and that the skew detector fired on real data. Every URL shape above is unit-tested. The only unexercised part is the Fabric HTTP download itself, which needs a live session.
24 · Fabric Platform

Delta Table Optimization — V-Order, Liquid, DV, CDF

Architecture: Delta ACID Transaction Log & Deletion Vector Bitmaps
_delta_log/ (ACID Commit Protocol) 000000.json Add file_1.parquet 000001.json (MERGE) Add DV: dv_1.roaring Optimistic Concurrency Control (OCC) Conflict resolution checks concurrent appends vs updates Data Layer (Parquet + Roaring Bitmaps) file_1.parquet (V-Ordered) Original data rows remain intact dv_1.roaring (DV) Soft-deleted row index mask Direct Lake & Engine Reader Scans file_1.parquet applying dv_1 mask in-memory (No file rewrite cost!)

Storage-layout decisions have as much performance impact as any Spark setting. This section maps every Delta/Fabric optimization to when to turn it on, per medallion layer and per runtime.

Feature reference — availability and when to enable
Feature R1.3 / R2.0 What it does & when to enable
Optimize Write ✓ / ✓ (on by default) Bin-packs writes toward a target file size (default bin 1 GB) at write time — the first defence against small files. Leave on everywhere; consider a smaller bin (e.g. 128 MB) for very merge-heavy tables so rewrites touch less data.
V-Order ✓ / ✓ Write-time sorting/encoding optimized for Power BI Direct Lake and SQL-endpoint reads, at ~10–15% write cost. Enable on gold/consumption tables; disable on write-heavy bronze/staging. Check your workspace's current default with spark.conf.get("spark.sql.parquet.vorder.default") rather than assuming — defaults have changed across Fabric updates.
Deletion Vectors ✓ / ✓ MERGE/UPDATE/DELETE mark rows in a sidecar bitmap instead of rewriting whole files — dramatically faster mutations on large tables. Enable (delta.enableDeletionVectors=true) on merge/update-heavy silver tables; run periodic OPTIMIZE/REORG TABLE … APPLY (PURGE) to physically remove soft-deleted rows; confirm all external readers support DV first.
Liquid Clustering preview⚠ / ✓ CLUSTER BY (col…) replaces both partitioning and Z-ORDER for most tables: incremental, no cardinality trap, re-clusterable without rewrite. Choose 1–4 columns you actually filter on. Mutually exclusive with partitioning. On Runtime 1.3 (Delta 3.2) it's preview-gated; treat as standard from Runtime 2.0 (Delta 4.2) — with the Spark-only interop caution from Sec 17 in mind.
Partitioning ✓ / ✓ Legacy layout tool. Under ~1 TB it usually hurts (small files, listing overhead). Still legitimate for low-cardinality retention boundaries (e.g. partition by load date to drop old data cheaply). Prefer liquid clustering for query pruning.
Change Data Feed ✓ / ✓ delta.enableChangeDataFeed=true records row-level changes; downstream silver→gold jobs read only table_changes() since the last processed version instead of re-scanning. The backbone of incremental medallion processing.
Materialized Lake Views Fabric feature (preview) Declarative CREATE MATERIALIZED LAKE VIEW … AS SELECT — Fabric manages refresh and lineage for silver→gold transformations. Prefer over hand-rolled "rebuild the aggregate" notebooks where the transform is expressible as SQL.
OPTIMIZE + VACUUM ✓ / ✓ Scheduled compaction (OPTIMIZE) fixes small files retroactively; VACUUM reclaims storage past the retention window (default 7 days — never lower it below your time-travel/CDF consumer needs).

How CDF works internally — and when it drives increments automatically

Without CDF the transaction log already lets you diff two versions, but only at file granularity: you can see which files were added and removed. For appends that is enough to derive inserted rows. For an UPDATE or MERGE it is not — a rewritten file contains changed and untouched rows, and nothing in the log tells you which is which.

Enabling delta.enableChangeDataFeed closes that gap: operations that modify existing rows write additional CDC files into a _change_data/ directory, carrying a _change_type of insert, update_preimage, update_postimage or delete, plus _commit_version and _commit_timestamp. Two efficiency details matter in practice:

  • Pure appends write no CDC files. Insert-only changes are derived from the files added in the log, so append-heavy bronze carries almost no CDF overhead — the write amplification lands on updates, deletes and merges.
  • Maintenance emits nothing. OPTIMIZE, compaction and VACUUM are not logically data-changing, so they produce no change rows. Your incremental consumer will not see phantom churn after a compaction job (proven in the companion notebook).
Three consumption modes — only one is automatic
Mode Who tracks the position When to use it
Batch / manual You do — persist the last processed version in a watermark table Full control of batching, ordering, error handling and recovery. The pattern shown below.
Structured Streaming The checkpoint Continuous processing. Note that streaming over Delta already handles append-only increments without CDF — you add CDF when updates and deletes must propagate too.
Materialized Lake View Fabric does Genuinely automatic: Fabric's decision engine picks incremental, full or skip refresh and detects changes through CDF. Incremental refresh requires CDF on the source tables — without it the engine can only choose skip or full. Constraints: optimal refresh applies only to MLVs defined in Spark SQL (PySpark-defined MLVs always full-refresh), and non-Delta sources always full-refresh.

Pattern: CDF deltas without MLV, watermarked in Fabric SQL Database

When you need control MLV does not give you — custom batching, bespoke error handling, or a PySpark transform — CDF plus a watermark table is the durable equivalent. The watermark lives in Fabric SQL Database alongside the rest of the metadata (etl_cdf_watermark: entity, source table, last_version, run id, timestamp), upserted forward-only so concurrent runs can never rewind it.

Anti-pattern — read everything, filter after 3.5+
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
changes = (spark.read.format("delta")
.option("readChangeFeed", "true")
.option("startingVersion", 0) # every version ever
.load(SILVER))
changes = changes.filter(
changes._commit_version > last_version) # filtered AFTER reading
Delta opens the CDC files for the whole history and Spark discards most of it. Cost grows with table age, not with change volume — the exact problem CDF exists to solve.
Bounds pushed into the source 3.5+
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
changes = (spark.read.format("delta")
.option("readChangeFeed", "true")
.option("startingVersion", last_version + 1) # exclusive of applied
.option("endingVersion", target_version) # bounded batch
.load(SILVER)
.where(F.col("_change_type") != "update_preimage")
.where("status = 'complete'") # pushes into the scan
.select("order_id","order_date","amount","status",
"_change_type","_commit_version")) # narrow ReadSchema
startingVersion/endingVersion are source options, not filters: Delta uses them to decide which commits and CDC files to open at all — files outside the window are never read. Predicates and projection then push down within that window. Verified in the plan: PushedFilters: [EqualTo(status,complete), Not(EqualTo(_change_type,update_preimage))].
Applying every intermediate state 3.5+
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
# one MERGE per change row - a key updated 3 times
# is written 3 times, and ordering bugs are silent
for batch in changes.collect():
apply(batch)
Slow, driver-bound, and order-sensitive. CDF legitimately contains several rows per key across versions.
Collapse to net change, then recompute only what moved 3.5+
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
w = Window.partitionBy("order_id").orderBy(
F.col("_commit_version").desc())
net = changes.withColumn("rn", F.row_number().over(w)) \
             .where("rn = 1").drop("rn")
# second pushdown: only the grains this batch touched
touched = [r.order_date for r in
net.select("order_date").distinct().collect()]
recomputed = (spark.read.format("delta").load(SILVER)
.where(F.col("order_date").isin(touched)
& (F.col("status") == "complete"))
.groupBy("order_date")
.agg(F.sum("amount").alias("revenue")))
target_tbl.alias("t").merge(recomputed.alias("s"),
"t.order_date = s.order_date") \
  .whenMatchedUpdateAll().whenNotMatchedInsertAll().execute()
set_watermark(entity, SILVER, target_version, net.count())
One row per key, one MERGE, and the aggregate recomputed only for affected dates — the isin(touched) predicate prunes the scan to those files. The watermark advances only after the MERGE succeeds, so a failure re-processes rather than skips.
The retention trap. CDF data under _change_data/ is subject to VACUUM retention (default 7 days), and log versions age out too. A consumer that stalls longer than retention holds a watermark that can no longer be read — and the failure mode you want is a loud fallback to full refresh, not a silent gap. Guard it explicitly:
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
earliest = min(h.version for h in
spark.sql(f"DESCRIBE HISTORY delta.`{path}`").collect())
if last_version + 1 < earliest:
raise LookupError(f"watermark {last_version} predates earliest retained "
f"version {earliest} - CDF aged out; full refresh required")
Then alert on the leading indicator: current_version - last_version growing, and watermark age approaching your retention window. Also remember CDF is not retroactive — it records changes only from the commit where it was enabled, so turn it on before you need it.

Why your MLV keeps doing a full refresh — and the programmatic equivalent

MLV's Optimal Refresh picks between skip, incremental and full based on what changed and what the definition can express — and it silently downgrades to full rather than failing, which is convenient until it becomes invisible. Five independent conditions force full refresh; only one of them is "unsupported SQL constructs":

Condition Detail
Any source lacks CDF delta.enableChangeDataFeed=true is required on every source table referenced by the MLV. Without it on even one, optimal refresh can only choose between skip and full — incremental is off the table entirely.
Defined in PySpark Optimal refresh applies only to MLVs defined in Spark SQL. A PySpark-defined MLV always full-refreshes, regardless of what the logic does.
Non-Delta source Any source that is not a Delta table forces full refresh for that MLV, full stop.
Unsupported SQL construct Window functions and non-deterministic functions are the documented examples. The MLV still creates and still refreshes — it just silently falls back. This is the one your instinct already suspected.
Any delete or update on a source — independent of the above The one most people never check. Incremental refresh currently requires the source to be append-only between refreshes. If a source table recorded even one DELETE or UPDATE since the last refresh, Fabric falls back to full — even with CDF enabled and a query using only supported SQL constructs. On a big table fed by upstream MERGE/UPDATE logic, this is very often the actual cause, not the SQL.

Microsoft is piloting refresh hints with select customers to improve this — not yet generally available. Force a full refresh deliberately (e.g. after a correction) with REFRESH MATERIALIZED LAKE VIEW [ws.lh.schema].MLV_Name FULL;

Diagnose which condition is firing

SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
-- 1. CDF on every source? (run per source table)
SHOW TBLPROPERTIES silver.orders ('delta.enableChangeDataFeed');
-- 2. Deletes/updates since the last MLV refresh? (the usual culprit)
DESCRIBE HISTORY silver.orders
-- inspect `operation` for UPDATE / DELETE / MERGE since the MLV's last refresh timestamp
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
# 3. Was the last refresh actually incremental? Check MLV refresh history/lineage in the portal,
# or query the refresh metadata if exposed in your Fabric version - full vs incremental is recorded
# per refresh cycle.

The same pattern in pure Spark SQL

Everything above was PySpark. Here is the identical logic as %%sql — useful in its own right since Sec 30 covers Spark SQL as a first-class surface, and directly relevant here because PySpark-defined MLVs always full-refresh (previous subsection) — a pure-SQL transform is what stays eligible for MLV's optimal refresh in the first place, and is also what you fall back to writing by hand when MLV's other constraints block it.

The one real difference between 3.5 and 4.x here: holding the watermark as a value you can reference mid-script. Spark 4.x has native session variablesDECLARE / SET VAR — so the whole pattern becomes a single SQL script with no host language at all. Spark 3.5 has no SQL-level variables, so the watermark is read into Python and passed back in via args={} parameter markers (Sec 30) — still parameterized, still safe, just one line of glue instead of zero.
Runtime 1.3 — Spark SQL + parameter markers 3.5+
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
wm = spark.sql(
"SELECT last_version FROM ctl.cdf_watermark WHERE entity_name = :e",
args={"e": "silver_orders_to_gold"}).collect()[0][0]
target = spark.sql(
"SELECT max(version) FROM (DESCRIBE HISTORY silver_orders)"
).collect()[0][0]
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
CREATE OR REPLACE TEMP VIEW v_net_changes AS
SELECT order_id, order_date, amount, status
FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY order_id ORDER BY _commit_version DESC) AS rn
FROM table_changes('silver_orders', :wm + 1, :tgt)
WHERE _change_type != 'update_preimage' AND status = 'complete'
) WHERE rn = 1
The version bounds and the watermark both arrive as bound parameters — one Python read, one Python pass-back, everything else is SQL text. args={"wm": wm, "tgt": target} on the final spark.sql() call.
Runtime 2.0 — pure SQL script, zero Python 4.x only
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
BEGIN
DECLARE wm BIGINT;
DECLARE tgt BIGINT;
SET VAR wm = (SELECT last_version FROM ctl.cdf_watermark
WHERE entity_name = 'silver_orders_to_gold');
SET VAR tgt = (SELECT max(version) FROM (DESCRIBE HISTORY silver_orders));
CREATE OR REPLACE TEMP VIEW v_net_changes AS
SELECT order_id, order_date, amount, status
FROM table_changes('silver_orders', wm + 1, tgt)
WHERE _change_type != 'update_preimage' AND status = 'complete'
QUALIFY ROW_NUMBER() OVER (
PARTITION BY order_id ORDER BY _commit_version DESC) = 1;
END
Session variables (GA in 4.1) hold the watermark and target version as real SQL values; QUALIFY (available on both runtimes, shown here for contrast) collapses the window filter into the same statement instead of a wrapping subquery. The whole ingestion-to-merge unit is one script, reviewable as one artifact.

The rest of the pipeline — apply and advance, in SQL either way

SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
-- Recompute only the grains this batch touched (predicate pushes into the source scan)
CREATE OR REPLACE TEMP VIEW v_recomputed_gold AS
SELECT s.order_date, SUM(s.amount) AS revenue, COUNT(*) AS orders
FROM silver_orders s
WHERE s.status = 'complete'
AND s.order_date IN (SELECT DISTINCT order_date FROM v_net_changes)
GROUP BY s.order_date;
-- Apply as one MERGE, full business key
MERGE INTO gold_daily t
USING v_recomputed_gold s
ON t.order_date = s.order_date
WHEN MATCHED THEN UPDATE SET t.revenue = s.revenue, t.orders = s.orders
WHEN NOT MATCHED THEN INSERT (order_date, revenue, orders)
VALUES (s.order_date, s.revenue, s.orders);
-- Advance the watermark - forward-only, so concurrent/retried runs can never rewind it
MERGE INTO ctl.cdf_watermark t
USING (SELECT 'silver_orders_to_gold' AS entity_name, {target} AS new_version) s
ON t.entity_name = s.entity_name
WHEN MATCHED AND s.new_version > t.last_version
THEN UPDATE SET last_version = s.new_version, updated_at = current_timestamp();

On 3.5, {target} is a bound parameter like the earlier queries; on 4.x it is literally the tgt session variable, referenced by name with no substitution at all. ctl.cdf_watermark is a plain Delta control table here so the whole example stays self-contained and portable — the equivalent using Fabric SQL Database as the control store follows the pyodbc pattern in Sec 36; only the watermark read/write changes, the CDF logic above is identical either way.

Working proof: both variants — 3.5-style parameter markers and the 4.x session-variable script — run as %%sql cells in nb_cdf_incremental_pattern.ipynb against the same tables as the PySpark version, with the identical idempotency and retention-guard checks.
The programmatic equivalent, when MLV genuinely cannot help. If your source has updates and deletes — which is most conformed silver tables — MLV's incremental mode cannot apply today, no matter how you rewrite the SQL. The direct substitute is exactly what Sec 27's metadata framework plus CDF gives you manually: nb_cdf_incremental_pattern.ipynb reads the change feed with version bounds pushed to the source, collapses inserts/updates/deletes to net change per key — the exact case MLV's append-only requirement excludes — recomputes only the touched grains, and MERGEs the result, with the watermark held in Fabric SQL Database. You get incremental cost on a table MLV would fully rebuild every cycle, at the cost of owning the orchestration MLV would otherwise manage for you. Reach for it specifically when the diagnosis above shows update/delete traffic on the source, or a PySpark-only transform — not as a default replacement for MLV where MLV's constraints don't bind.
Working proof: nb_cdf_incremental_pattern.ipynb executes this end to end — watermark table, version-bounded reads with the pushdown asserted from the physical plan, net-change collapsing, MERGE, an idempotency check (second run processes zero rows), proof that OPTIMIZE emitted no change rows, and the retention guard firing on a stale watermark.

Interactive: table optimization advisor

Medallion defaults that rarely need changing: Bronze — append-only, Optimize Write on, V-Order off, schema-on-read leniency, CDF on if silver consumes incrementally. Silver — deletion vectors + CDF on, liquid clustering on the business keys you join/filter by, V-Order off unless the SQL endpoint queries silver directly. Gold — V-Order on, liquid clustering (or none if small), MLVs where the transform is SQL-expressible, scheduled OPTIMIZE. Everywhere — explicit schemas, never inferSchema in production paths.
DIRECT LAKE SILENT FALLBACK TRAPS: Power BI Direct Lake falls back silently to DirectQuery if: (1) RLS/CLS is configured on the SQL Analytics Endpoint instead of the Power BI Semantic Model, (2) Table memory exceeds the SKU capacity limit, (3) Table contains >5,000 fragmented files (target 400–500 MB per file with V-Order enabled), or (4) Active Deletion Vector fragmentation exceeds 20%.
24b · Diagnostics, Skipping & SQL-to-Spark Tuning

Column Statistics, Data Skipping & SQL-to-Spark Tuning Masterclass

In traditional relational databases (SQL Server, Oracle, Snowflake), 90% of performance engineering revolves around index seeks, query plan operators, updating statistics, and query decomposition. Over OneLake Delta tables in Microsoft Fabric, achieving sub-second query performance and 10× compute cost reductions requires mastering the 3 tiers of lakehouse statistics, eliminating Delta Transaction Log skipping blind spots, diagnosing Native Execution Engine (NEE / Velox) fallbacks from Spark logs, and systematically refactoring legacy SQL into high-throughput distributed Spark SQL patterns.

📊 THE 3 TIERS OF LAKEHOUSE STATISTICS & SKIPPING MECHANISMS
DRIVER TO EXECUTOR PIPELINE
1. DELTA LOG STATS DRIVER LEVEL
Location: _delta_log/*.json & *.checkpoint.parquet
_delta_log/0000X.json ("stats"):
• numRecords: 1,500,000
• minValues: {"id": 1, "date": "2026-01-01"}
• maxValues: {"id": 1500, "date": "2026-08-17"}
• nullCount: {"id": 0, "status": 12}
⚠️ 32-Column Ceiling in Delta Log: Delta Lake only serializes min/max structs in the transaction log for the first 32 columns by default to prevent transaction log metadata bloat!
Outcome: Driver prunes entire Parquet files before scheduling tasks (0 storage bytes downloaded).
2. PARQUET FOOTERS EXECUTOR / NEE
Location: End of each physical part-*.parquet file
Parquet Metadata Footers:
• Row Group 1 (128 MB / 1M rows)
• Dictionary page column encodings
• Column index & offset index
• Covers ALL columns (no 32-col limit)
⚡ NEE Acceleration: Velox C++ reader reads page footers asynchronously and skips 128MB row-groups in SIMD memory.
Outcome: File is downloaded/opened, but unmatching 128MB row-groups are skipped without decompression.
3. CATALYST CBO METASTORE / CATALOG
Location: Hive Metastore / Fabric Catalog Metadata
Catalog Metadata (ANALYZE TABLE):
• Table row count & total raw byte size
• Number of Distinct Values (NDV)
• Equi-width value histograms
• Average column width & null counts
⚠️ Static Only: CBO statistics are NOT populated automatically and must be refreshed via ANALYZE TABLE.
Outcome: Catalyst optimizer chooses BroadcastHashJoin vs ShuffledHashJoin prior to physical planning.

1. The 32-Column Data Skipping Trap in the Delta Transaction Log

A critical distinction in Lakehouse architecture: The 32-column ceiling applies strictly to the Delta Transaction Log (_delta_log), NOT to the underlying Parquet files!

By default, Delta Lake writers only collect and serialize min/max statistics in the transaction log for the first 32 columns of a table schema (delta.dataSkippingNumIndexedCols = 32). If your wide fact table has 60 columns and your queries filter on column 35 (e.g. store_region, tenant_id, event_status, or is_active), Delta cannot skip a single Parquet file at the driver level. The driver assumes all files could contain matching records and schedules tasks to scan every file in OneLake!

PHYSICAL EXECUTION TRACE: FILTERING ON COLUMN 35 (e.g. WHERE tenant_id = 'TENANT_99')
Step 1 (Driver Delta Log Evaluation): The Spark Driver inspects _delta_log/00000X.json. Because tenant_id is column #35, its minValues and maxValues are NULL in the log.
Driver Outcome: 0% File Skipping (Driver schedules tasks to scan 10,000 Parquet files from OneLake).

Step 2 (Executor FileScan & Parquet Footer Evaluation): Executors download and open the 10,000 Parquet files. The Parquet Page Footers DO contain stats for all columns, allowing the executor to skip 128MB row groups locally.
The Fatal Penalty: You still paid the full network latency, OneLake transaction costs, and task scheduling overhead of opening 10,000 files!
The Trap — Filtering on Columns Past Index 32 in Delta Log
SPARK SQL DDL DEFAULT 32-COL LOG LIMIT
-- Wide table with 50 columns
CREATE TABLE silver.fact_telemetry (
  device_id STRING, -- col 1 (indexed in _delta_log)
  -- ... cols 2 to 32 (indexed in _delta_log) ...
  col32_meta STRING, -- col 32 (indexed in _delta_log)
  tenant_id STRING,  -- col 33 (NOT in _delta_log! min/max omitted)
  status STRING     -- col 34 (NOT in _delta_log! min/max omitted)
) USING DELTA;

-- 💥 SILENT FULL TABLE SCAN AT DRIVER LEVEL:
-- Driver reads ALL 10,000 files because tenant_id stats are missing in _delta_log!
SELECT * FROM silver.fact_telemetry WHERE tenant_id = 'TENANT_99';
The Delta transaction log records empty min/max structs for columns 33+. The driver submits tasks to scan 100% of files.
The Fix — Explicit Stats Columns or Expanded Limit
SPARK SQL REMEDIATION EXPLICIT STATS INDEXING
-- OPTION A: Target only high-cardinality filter columns (Best Practice)
ALTER TABLE silver.fact_telemetry SET TBLPROPERTIES (
  'delta.dataSkippingStatsColumns' = 'device_id, tenant_id, status, event_timestamp'
);

-- OPTION B: Raise the global indexed column ceiling
ALTER TABLE silver.fact_telemetry SET TBLPROPERTIES (
  'delta.dataSkippingNumIndexedCols' = '64'
);

-- Backfill stats for existing historical Parquet files:
ANALYZE TABLE silver.fact_telemetry COMPUTE DELTA STATISTICS;
Using delta.dataSkippingStatsColumns keeps transaction log JSON compact while ensuring exact min/max bounds are indexed in _delta_log for high-value filter columns.

2. How to Genuinely Utilise Spark Logs & Metrics to Diagnose Gaps

When a Spark job is slow or consuming excessive Capacity Units (CUs) in Fabric, do not guess. Inspect the Fabric Monitoring Hub, Spark History Server SQL Graph, and Driver Logs (stderr) for these quantifiable physical signatures:

Diagnostic Target Where to Look in Logs / UI Physical Signature / Symptom Root Cause & Immediate Remediation
0% Data Skipping (Full Table Scan) Spark UI ➔ SQL Tab ➔ Click FileScan parquet node (or DESCRIBE HISTORY table). number of files read (12,450) equals total files in table; scan time dominates job duration. Filter predicate is past column 32 in Delta log, or data is randomly sorted without clustering.
➔ Set delta.dataSkippingStatsColumns + run OPTIMIZE table CLUSTER BY (filter_col).
Native Execution (NEE) Fallback Driver stderr / Livy Log ➔ search for Gluten or VeloxColumnarToRowExec. Physical plan contains alternating VeloxColumnarToRow and RowToVeloxColumnar operators; CPU usage spikes. A non-vectorized Python UDF (@udf) or unsupported complex expression forced a fallback to the slow Java VM.
➔ Rewrite using built-in Spark SQL functions or Higher-Order array transforms.
Severe Partition Data Skew Spark UI ➔ Stages Tab ➔ Expand Task Duration Metrics (Min / 25th / Median / 75th / Max). Median task duration = 1.8s; 75th percentile = 2.1s; Max task duration = 42 minutes. Single task processes 90% of rows. Heavy skew on join/group key (e.g. NULL or default dummy ID).
➔ Enable AQE Skew Join (spark.sql.adaptive.skewJoin.enabled=true) or apply explicit key salting.
SortMergeJoin Memory Spill Spark UI ➔ Stages Tab ➔ Aggregated Metrics ➔ Spill (Memory) & Spill (Disk). Spill (Memory): 140 GB, Spill (Disk): 38 GB. Executor NVMe drives saturated with temp spill files. Join threshold misestimate or shuffle partitions too coarse.
➔ Lower partition advisory size (spark.sql.adaptive.advisoryPartitionSizeInBytes=67108864) or force BroadcastHashJoin if dimension < 256MB.
Small-File Task Explosion Spark UI ➔ Jobs Tab ➔ Total tasks count for scan stage > 20,000 tasks lasting < 150ms each. Task scheduling overhead (Livy/Driver RPC) takes 80% of stage time; average file size < 8 MB. Micro-batch streaming appends without compaction.
➔ Run OPTIMIZE table to consolidate into 500MB Parquet files with V-Order.

3. SQL-to-Spark Query Rewriting Masterclass (5 Core Production Patterns)

Migrating traditional T-SQL / Oracle scripts directly into Spark SQL without refactoring frequently leads to 10× slower execution. Here are the 5 architectural query refactorings every data engineer must apply:

Pattern A: Eliminating Multi-Scan CTE Traps (The Recomputation Vulnerability)

In SQL Server and Oracle, CTEs (WITH cte AS (...)) are often materialized or referenced efficiently. In Apache Spark, CTEs and DataFrames are lazily evaluated abstract syntax trees (ASTs). If a CTE is referenced 3 times in downstream joins or unions, Spark parses and executes the entire scan, filter, and aggregation 3 separate times from OneLake!

Legacy Anti-Pattern: CTE Referenced Multiple Times
SPARK SQL 3× FULL ONELAKE TABLE SCANS
WITH AggregatedSales AS (
  -- Heavy scan over 500M rows with group by
  SELECT cust_id, SUM(amount) AS total_spend, COUNT(*) AS orders
  FROM silver.fact_orders
  GROUP BY cust_id
)
-- 💥 DISASTER: AggregatedSales is re-scanned and re-aggregated 3 separate times!
SELECT c.cust_name, a1.total_spend
FROM dim_customer c
JOIN AggregatedSales a1 ON c.cust_id = a1.cust_id
LEFT JOIN AggregatedSales a2 ON c.parent_cust_id = a2.cust_id;
Optimized Refactoring: Lineage Truncation / Single Scan
PYSPARK / SQL 1× SCAN + LOCAL CHECKPOINT
# 1. Compute heavy aggregation ONCE and break Catalyst lineage graph
df_agg = (spark.table("silver.fact_orders")
          .groupBy("cust_id")
          .agg(F.sum("amount").alias("total_spend"), F.count("*").alias("orders"))
          .localCheckpoint(eager=True)) # Cut DAG & persist in executor NVMe cache

df_agg.createOrReplaceTempView("agg_sales")

# 2. Downstream query reads pre-materialized buffer from NVMe
spark.sql("""
  SELECT c.cust_name, a1.total_spend
  FROM dim_customer c
  JOIN agg_sales a1 ON c.cust_id = a1.cust_id
  LEFT JOIN agg_sales a2 ON c.parent_cust_id = a2.cust_id
""")

Pattern B: Replacing Python UDFs with Native Velox Vectorized Expressions

Standard Python UDFs (@udf) require serializing rows from JVM off-heap memory through a local Unix socket to a spawned Python worker daemon. This introduces severe serialization overhead, completely breaks Whole-Stage CodeGen, and forces the Native Execution Engine (Velox) to fall back to the slow Java VM.

Anti-Pattern: Python Row-by-Row UDF (Disables NEE)
PYSPARK UDF IPC SERIALIZATION BOTTLENECK
from pyspark.sql.functions import udf

# 💥 Slow: 1 IPC serialization roundtrip PER ROW
@udf("string")
def clean_phone(phone):
    if not phone: return None
    return "".join([c for c in phone if c.isdigit()])

df.withColumn("clean_phone", clean_phone("raw_phone"))
Optimized Refactoring: Native Vectorized Regex / SQL Function
NATIVE SPARK SQL 100% VELOX C++ VECTORIZED
from pyspark.sql import functions as F

# ✓ 25× Faster: Executes directly in Velox C++ SIMD registers with ZERO serialization!
df_clean = df.withColumn("clean_phone", F.regexp_replace(F.col("raw_phone"), r"\D", ""))

# Explain plan verification receipt:
# +- ProjectExecTransformer [regexp_replace(raw_phone#1, \D, ) AS clean_phone#2]

Pattern C: Resolving Skew Joins via AQE Heuristics & Key Salting

When a join key contains extreme skew (e.g. 50,000,000 rows with cust_id = NULL or a default placeholder ID), a single reducer task receives gigabytes of data and runs for 45 minutes while the remaining 199 tasks finish in 2 seconds.

PYSPARK / SPARK SQL KEY SALTING TECHNIQUE FOR EXTREME SKEW
-- Step 1: Add a random salt (0-9) to the skewed large table
CREATE OR REPLACE TEMP VIEW v_orders_salted AS
SELECT *, CONCAT(cust_id, '_', CAST(FLOOR(RAND() * 10) AS INT)) AS salted_join_key
FROM silver.fact_orders;

-- Step 2: Explode the small dimension table 10× to match all possible salt keys
CREATE OR REPLACE TEMP VIEW v_dim_customer_replicated AS
SELECT c.*, CONCAT(c.cust_id, '_', salt_id) AS salted_join_key
FROM dim_customer c
LATERAL VIEW EXPLODE(ARRAY(0,1,2,3,4,5,6,7,8,9)) t AS salt_id;

-- Step 3: Join on salted key -> Skew is perfectly distributed across 10 parallel tasks!
SELECT o.order_id, c.cust_name, o.amount
FROM v_orders_salted o
JOIN v_dim_customer_replicated c ON o.salted_join_key = c.salted_join_key;

Pattern D: Single-Pass Window Functions vs. Multi-Scan Self-Joins (Top-N per Group)

A classic SQL pattern for finding the most recent record per entity is joining a table back to a grouped subquery on MAX(date). In Spark, this causes 2 full table scans, 2 shuffles, and an expensive SortMergeJoin. A single-pass window function cuts I/O by 50% and executes via native Velox WindowExecTransformer.

Legacy RDBMS Pattern: Group-By Self Join
SPARK SQL 2× TABLE SCANS + 2 SHUFFLES
-- 💥 Reads fact_orders TWICE + performs SortMergeJoin
SELECT o.*
FROM silver.fact_orders o
JOIN (
  SELECT cust_id, MAX(order_date) AS max_dt
  FROM silver.fact_orders
  GROUP BY cust_id
) m ON o.cust_id = m.cust_id AND o.order_date = m.max_dt;
Optimized Refactoring: Single-Pass Window Filter
NATIVE WINDOW 1× SCAN + VELOX WINDOW PIPELINE
-- ✓ Scans OneLake exactly ONCE with native Velox WindowExecTransformer
WITH RankedOrders AS (
  SELECT *, 
         ROW_NUMBER() OVER (PARTITION BY cust_id ORDER BY order_date DESC) AS rnk
  FROM silver.fact_orders
)
SELECT * EXCEPT(rnk)
FROM RankedOrders
WHERE rnk = 1;

4. Production Diagnostic Workbook (Run Directly in Fabric Notebook)

Use this automated diagnostic script to audit table skipping health, file fragmentation, deletion vector overhead, and column stats coverage across your Lakehouse:

PYSPARK FABRIC LAKEHOUSE HEALTH AUDITOR
from pyspark.sql import functions as F

def audit_delta_table_health(table_name):
    """Comprehensive health check for Data Skipping, Small Files & Deletion Vectors"""
    print(f"=== AUDITING TABLE: {table_name} ===")
    
    # 1. Inspect Physical File Sizing & Distribution
    df_files = spark.sql(f"SELECT * FROM {table_name}.`$files`")
    file_metrics = df_files.select(
        F.count("*").alias("total_files"),
        F.round(F.sum("size_in_bytes") / (1024**3), 2).alias("total_gb"),
        F.round(F.avg("size_in_bytes") / (1024**2), 2).alias("avg_file_mb"),
        F.sum(F.when(F.col("size_in_bytes") < 32*1024*1024, 1).otherwise(0)).alias("small_files_lt_32mb"),
        F.sum(F.when(F.col("deletionVector").isNotNull(), 1).otherwise(0)).alias("files_with_dvs")
    ).collect()[0]
    
    print(f"  • Total Parquet Files: {file_metrics['total_files']:,}")
    print(f"  • Total Table Size: {file_metrics['total_gb']} GB")
    print(f"  • Average File Size: {file_metrics['avg_file_mb']} MB (Target: 400-500 MB)")
    print(f"  • Fragmented Small Files (<32MB): {file_metrics['small_files_lt_32mb']:,}")
    print(f"  • Files with Deletion Vectors: {file_metrics['files_with_dvs']:,}")
    
    # 2. Check Table Properties for Data Skipping Indexing in _delta_log
    df_props = spark.sql(f"SHOW TBLPROPERTIES {table_name}").collect()
    props_map = {row['key']: row['value'] for row in df_props}
    stats_cols = props_map.get('delta.dataSkippingStatsColumns', '[NOT SET - Defaults to first 32 cols in _delta_log]')
    print(f"  • Delta Log Indexed Columns: {stats_cols}")
    
    # Actionable Recommendations
    if file_metrics['small_files_lt_32mb'] > 100:
        print(f"  🚨 RECOMMENDATION: Run 'OPTIMIZE {table_name}' to compact small files.")
    if file_metrics['files_with_dvs'] > (file_metrics['total_files'] * 0.25):
        print(f"  🚨 RECOMMENDATION: Run 'REORG TABLE {table_name} APPLY (PURGE)' to purge soft deletion vectors.")

# Run audit across lakehouse tables:
audit_delta_table_health("silver.orders")
24c · Automated Lakehouse Maintenance

Conditional Lakehouse Table Optimization & Health Checks Framework

Running blind, fixed-schedule OPTIMIZE commands across hundreds of Lakehouse tables burns massive Microsoft Fabric Capacity Units (CUs) on tables that are already healthy. The Microsoft Fabric "Check-Then-Act" Conditional Optimization Pattern uses the built-in sys.sp_get_table_health_metrics stored procedure on the SQL Analytics Endpoint to inspect physical table health first, triggering Spark OPTIMIZE and REORG only when anomalies are detected.

🛡️ THE FABRIC "CHECK-THEN-ACT" CONDITIONAL OPTIMIZATION PATTERN
CAPACITY UNIT (CU) SAVINGS: 60%–80%
1. SCRIPT ACTIVITY SQL ENDPOINT
Execution: T-SQL Stored Procedure
EXEC sys.sp_get_table_health_metrics
'dbo.FactSales'
Returns JSON output containing PotentialAnomalyType and file distribution metrics.
2. IF CONDITION DATA FACTORY
Expression: Dynamic Evaluation
@greater(int(activity('Check Table Health')
.output.resultSets[0].rows[0]
['PotentialAnomalyType']), 0)
If PotentialAnomalyType > 0 (anomaly detected) ➔ Route to True Branch.
3. SPARK NOTEBOOK TRUE BRANCH
Action: Targeted Compaction
# Parameterized Spark SQL
spark.sql(f"OPTIMIZE {table_name}")
Executes V-Order compaction only when table is actually fragmented!

1. Potential Anomaly Types Decoded

The sys.sp_get_table_health_metrics procedure analyzes the underlying Delta log and file storage, classifying table health into structured anomaly codes:

Anomaly Code Anomaly Description Physical Degradation Trigger Recommended Automated Action
Type 0 (Clean) Healthy / Balanced Table Average file size between 300MB–500MB; deletion vector overhead < 5%. No-op: Pipeline skips optimization entirely, consuming 0 Spark CUs.
Type 1 (Small Files) Excessive Small-File Count Table contains >1,000 fragmented files under 32MB from streaming or micro-batch appends. Run OPTIMIZE {table} (optionally with ZORDER BY / CLUSTER BY).
Type 2 (DV Bloat) High Deletion Vector Ratio More than 20% of files have active deletion vector bitmaps from heavy UPDATE/DELETE/MERGE operations. Run REORG TABLE {table} APPLY (PURGE); VACUUM {table} RETAIN 168 HOURS;
Type 3 (Skewed Files) High File-Size Variance Extreme disparity between smallest file (<1MB) and largest file (>2GB). Run OPTIMIZE {table} to balance file sizes into uniform 500MB chunks.

2. Reusable Multi-Table Automated Maintenance Suite (PySpark)

For automated, catalog-wide maintenance across all tables in a Fabric Lakehouse, use this production PySpark workbook. It inspects table health metrics dynamically and dispatches maintenance tasks only where needed:

PYSPARK / SPARK SQL UNIVERSAL CONDITIONAL LAKEHOUSE OPTIMIZER
from pyspark.sql import functions as F

def optimize_lakehouse_tables_conditionally(database_name="dbo", small_file_threshold_mb=32, dv_ratio_threshold=0.20):
    """
    Scans all Delta tables in the database, audits health metrics, 
    and executes OPTIMIZE / REORG only when degradation is detected.
    """
    tables = [row.tableName for row in spark.sql(f"SHOW TABLES IN {database_name}").collect()]
    print(f"Auditing {len(tables)} tables in database '{database_name}'...")
    
    for tbl in tables:
        full_table = f"{database_name}.{tbl}"
        try:
            # 1. Read Delta $files metadata
            df_files = spark.sql(f"SELECT * FROM {full_table}.`$files`")
            metrics = df_files.select(
                F.count("*").alias("total_files"),
                F.round(F.avg("size_in_bytes") / (1024**2), 2).alias("avg_file_mb"),
                F.sum(F.when(F.col("size_in_bytes") < small_file_threshold_mb*1024*1024, 1).otherwise(0)).alias("small_files"),
                F.sum(F.when(F.col("deletionVector").isNotNull(), 1).otherwise(0)).alias("files_with_dv")
            ).collect()[0]
            
            total_files = metrics["total_files"]
            small_files = metrics["small_files"]
            dv_files = metrics["files_with_dv"]
            
            needs_optimize = (small_files > 50) or (total_files > 200 and metrics["avg_file_mb"] < 100)
            needs_purge = (total_files > 0) and ((dv_files / total_files) >= dv_ratio_threshold)
            
            if needs_optimize or needs_purge:
                print(f"⚡ MAINTAINING {full_table}: Total={total_files}, Small={small_files}, DV={dv_files}")
                if needs_purge:
                    print(f"  -> Purging Deletion Vectors on {full_table}...")
                    spark.sql(f"REORG TABLE {full_table} APPLY (PURGE)")
                if needs_optimize:
                    print(f"  -> Running V-Order Compaction on {full_table}...")
                    spark.sql(f"OPTIMIZE {full_table}")
            else:
                print(f"✓ SKIPPING {full_table} (Healthy: avg {metrics['avg_file_mb']} MB, 0 DV bloat)")
        except Exception as e:
            print(f"⚠️ Could not audit {full_table}: {str(e)}")

# Run automated conditional maintenance across Lakehouse:
optimize_lakehouse_tables_conditionally("dbo")
24d · Lakehouse Data Modeling

Lakehouse Data Modeling Masterclass: Data Vault 2.0 (Silver) & Kimball Dimensional (Gold) Side-by-Side

Modern enterprise Lakehouses face a classic architectural conflict: Data integration requires resilience against schema drift, multi-source ingestion concurrency, and complete historical auditability, while BI and analytics require fast, intuitive star schemas optimized for Power BI Direct Lake mode. Here is how Data Vault 2.0 (in the Silver Layer) and Kimball Dimensional Modeling (in the Gold Layer) sit side-by-side seamlessly over OneLake Delta tables.

🏛️ FABRIC MEDALLION DATA MODELING: DATA VAULT 2.0 (SILVER) ➔ KIMBALL (GOLD)
DUAL-PARADIGM LAKEHOUSE ARCHITECTURE
1. BRONZE (RAW LOG) RAW HISTORY
Model: Transient Raw Landing
• Append-only source JSON/Parquet
• Schema-on-read leniency
• Ingestion metadata (_load_dts, _source_file)
• Replayable landing Delta tables
2. SILVER (DATA VAULT 2.0) ENTERPRISE CORE
Model: Hubs, Links & Satellites
Hubs: Unique business keys (SHA-256)
Links: Multi-table relationships & transactions
Satellites: Historical context & change tracking
• Stored as physical Delta tables for scalable ingestion
3. GOLD (KIMBALL STAR) BI & DIRECT LAKE
Model: Facts & Conformed Dims
Surrogate Keys: BIGINT (64-bit Integer)
Dimensions: SCD Type 1 & SCD Type 2
Facts: Transactional & Periodic Snapshots
• V-Order enabled for sub-second Direct Lake

1. How Data Vault 2.0 & Kimball Dimensional Sit Side-by-Side

Architectural Dimension Silver Layer — Data Vault 2.0 (System of Record) Gold Layer — Kimball Dimensional (Information Marts)
Primary Purpose System of Record & Multi-Source Integration: Decouples raw business keys from source schema changes; 100% auditability and zero-loss history. Business Consumption & BI: Star schema optimized for Power BI Direct Lake semantic models, self-service analysts, and sub-second aggregation.
Core Entities Hubs (Distinct Keys), Links (Relationships/Joins), Satellites (Attributes & History with HashDiffs), PIT / Bridge tables. Fact Tables (Measures & Foreign Keys), Conformed Dimensions (SCD Type 1 & Type 2 with BIGINT Surrogate Keys).
Key Datatypes & Mechanism Deterministic Hash Keys (STRING SHA-256): Enables independent, parallel asynchronous ingestion from 10+ sources without cross-source surrogate key lookups. BIGINT / INT (64-bit / 32-bit Integers): MANDATORY FOR GOLD. 64-bit integer keys allow VertiPaq dictionary vectorization, SIMD CPU registers, and 80% lower RAM usage than string keys in Direct Lake mode.
Physical Delta Table Tuning delta.enableDeletionVectors = true
delta.enableChangeDataFeed = true
V-Order OFF (saves CPU write overhead during high-frequency ingestion micro-batches).
delta.parquet.vorder.enabled = true (Mandatory for Power BI Direct Lake)
Liquid Clustering: CLUSTER BY (date_id, customer_sk)
• Target Parquet file size: 400–500 MB.

2. Silver Hubs & Links: Physical Delta Tables vs. Materialized Lakehouse Views (MLVs) as Data Scales

A critical architectural decision in Microsoft Fabric: Should Hubs and Links be stored as physical Delta tables or defined as Materialized Lakehouse Views (MLVs)?

Implementation Approach How It Operates Internally Scalability Over Time (1B+ Rows) Verdict & Best Practice
Physical Delta Tables (Recommended for Hubs & Links) Hubs and Links are written as physical Delta tables using MERGE or INSERT IF NOT EXISTS with Change Data Feed (CDF). Highly Scalable: Ingestion only evaluates incoming micro-batch records against the existing Delta log index. Supports Liquid Clustering (CLUSTER BY (hk)) and sub-second point lookups. ⭐ BEST PRACTICE FOR SILVER HUBS & LINKS: Guarantees deterministic, fast incremental ingestion without re-scanning raw Bronze history.
Materialized Lakehouse Views (MLVs) Defined via SQL query (CREATE MATERIALIZED VIEW ...). Automatically maintained by Fabric's background refresh engine upon upstream Bronze commits. Moderate Scalability: Background refresh engine manages incremental state. However, complex multi-link joins can experience refresh compute overhead at massive scale. IDEAL FOR POINT-IN-TIME (PIT) & BRIDGE TABLES: Perfect for joining Hubs + Links + Satellites into intermediate queryable views without manual orchestrations.
Virtual SQL Views (Unmaterialized) Standard SQL CREATE VIEW ... AS SELECT DISTINCT ... FROM bronze. Fails at Scale: Every query against the Hub forces a full scan of all historical Bronze files from scratch. Cannot be queried via Power BI Direct Lake mode. ANTI-PATTERN: Never use virtual views for high-volume Silver Hubs or Gold analytical tables.

3. Why Gold Star Schema Surrogate Keys MUST Be BIGINT (Not String UUIDs)

In Power BI Direct Lake mode and Polaris Distributed Query Execution, joining Fact and Dimension tables on 32-character MD5 / SHA-256 strings is a fatal performance anti-pattern:

⚡ DIRECT LAKE MEMORY & JOIN VECTORIZATION COMPARISON
String SHA-256 Surrogate Keys (32–64 bytes per row): VertiPaq cannot bit-pack wide strings efficiently. In a 500M row Fact table, foreign key string columns consume 32 GB of memory in Power BI capacity memory limits, triggering silent fallback to DirectQuery!
BIGINT 64-bit Integer Keys (8 bytes per row / xxhash64): VertiPaq bit-packs integers down to 4–8 bytes. The same 500M row foreign key column consumes only 2.4 GB of memory (85% reduction), evaluates joins using native CPU SIMD registers, and stays 100% within Direct Lake memory limits.

4. Production Implementation: Silver Data Vault to High-Performance Gold Star Schema

Here is the production-grade Spark SQL pattern generating Silver Data Vault Delta tables and transforming them into an optimal Gold Star Schema with `BIGINT` surrogate keys, V-Order enabled, and Liquid Clustering:

SPARK SQL DDL SILVER DATA VAULT 2.0 DELTA TABLES
-- 1. SILVER HUB: Physical Delta table with SHA-256 Hash Key for Ingestion Concurrency
CREATE TABLE IF NOT EXISTS silver.hub_customer (
    customer_hk   STRING NOT NULL, -- SHA-256(customer_id) for multi-source ingest
    customer_id   STRING NOT NULL,
    load_dts      TIMESTAMP NOT NULL,
    record_source STRING NOT NULL
) USING DELTA
CLUSTER BY (customer_hk)
TBLPROPERTIES (
    'delta.enableChangeDataFeed' = 'true',
    'delta.enableDeletionVectors' = 'true'
);

-- 2. SILVER SATELLITE: Attribute History with HashDiffs
CREATE TABLE IF NOT EXISTS silver.sat_customer_profile (
    customer_hk    STRING NOT NULL,
    load_dts       TIMESTAMP NOT NULL,
    hash_diff      STRING NOT NULL, -- SHA-256(name + email + tier + city)
    customer_name  STRING,
    customer_email STRING,
    customer_tier  STRING,
    city           STRING,
    record_source  STRING NOT NULL
) USING DELTA
CLUSTER BY (customer_hk);
SPARK SQL GOLD CONFORMED DIMENSION (BIGINT SURROGATE KEYS + V-ORDER)
-- 3. GOLD CONFORMED DIMENSION (SCD Type 2): Optimal for Power BI Direct Lake
CREATE OR REPLACE TABLE gold.dim_customer
USING DELTA
CLUSTER BY (customer_id, is_current)
TBLPROPERTIES (
    'delta.parquet.vorder.enabled' = 'true', -- Mandatory for Direct Lake
    'delta.dataSkippingStatsColumns' = 'customer_sk, customer_id, customer_tier, is_current'
) AS
WITH CustomerHistory AS (
    SELECT 
        h.customer_id,
        s.customer_name,
        s.customer_email,
        s.customer_tier,
        s.city,
        s.load_dts AS valid_from,
        LEAD(s.load_dts) OVER (PARTITION BY h.customer_hk ORDER BY s.load_dts ASC) AS valid_to
    FROM silver.hub_customer h
    JOIN silver.sat_customer_profile s ON h.customer_hk = s.customer_hk
)
SELECT 
    -- ✓ FASTEST DESIGN: 64-bit BIGINT hash surrogate key (8 bytes vs 32-byte string)
    CAST(xxhash64(CONCAT(customer_id, '_', CAST(valid_from AS STRING))) AS BIGINT) AS customer_sk,
    customer_id,
    customer_name,
    customer_email,
    customer_tier,
    city,
    valid_from,
    COALESCE(valid_to, TIMESTAMP'9999-12-31 23:59:59') AS valid_to,
    CASE WHEN valid_to IS NULL THEN 1 ELSE 0 END AS is_current
FROM CustomerHistory;

-- 4. GOLD FACT TABLE: Joining on BIGINT surrogate keys for maximum throughput
CREATE OR REPLACE TABLE gold.fact_orders
USING DELTA
CLUSTER BY (order_date_id, customer_sk)
TBLPROPERTIES ('delta.parquet.vorder.enabled' = 'true') AS
SELECT 
    o.order_id,
    CAST(DATE_FORMAT(o.order_date, 'yyyyMMdd') AS INT) AS order_date_id,
    c.customer_sk, -- BIGINT foreign key
    o.order_amount,
    o.tax_amount,
    o.discount_amount
FROM silver.fact_orders_raw o
JOIN gold.dim_customer c 
  ON o.customer_id = c.customer_id 
 AND o.order_date >= c.valid_from 
 AND o.order_date < c.valid_to;
25 · Deep Dive & Troubleshooting

Fabric Warehouse, SQL Endpoint & Fabric SQL Database — Polaris & SQL Engine Internals & Performance Tuning

Spark is not the only engine querying OneLake Delta tables. The Fabric Data Warehouse and the SQL analytics endpoint share Microsoft's distributed, serverless T-SQL engine — Polaris — while Fabric SQL Database brings a full-featured operational SQL database with autonomous Delta mirroring. Achieving sub-second response times and eliminating latency requires mastering DQP distributed execution, diagnosing bottlenecks with PDW DMVs, optimizing statistics, and tuning query execution plans.

1. Polaris Distributed Architecture

Component Architectural Mechanism & Role
SQL Front End (SQL-FE) Entry point for TDS (port 1433) client connections (SSMS, Power BI, Azure Data Studio, pyodbc). Handles Entra ID / RBAC authentication, AST compilation, metadata catalog resolution, and initial logical query plan generation.
Distributed Query Processor (DQP) The distributed brain of Polaris. Evaluates data volume, estimates join cardinalities from statistics, decomposes the logical query into a DAG of physical sub-queries (dqp_steps), and coordinates distributed data movement (ShuffleMove, BroadcastMove).
Polaris Compute Pool Stateless backend compute nodes, each equipped with dedicated CPU, high-speed RAM, and NVMe SSD local caching. Each node runs an Execution Service and a single-node C++ Query Execution engine derived from the SQL Server relational engine.
Data Cells (Storage Abstraction) The foundational unit of data management in Polaris. A dataset in OneLake is partitioned into logical "cells" (groups of Parquet files). The DQP assigns cells dynamically to backend compute nodes for parallel execution.
Stateless Durability & Failover All state and metadata reside externally in OneLake Delta logs and centralized metadata services. If a compute node fails mid-query, the Topology Manager reassigns its assigned cells to healthy nodes in the topology with zero data loss.
Architectural Consequence: Polaris compute is serverless and autonomous — backend capacity scales dynamically with workload demand. There is no cluster to size, no executor shape to tune, and no spark.conf to set. Where Spark performance is largely an engine configuration and memory tuning challenge (Sec 14), Warehouse performance is strictly a data modelling, statistics, and SQL query structure challenge.

2. Interactive: How a Warehouse / SQL Endpoint Query Executes Under the Hood

STEP 1 OF 6: T-SQL QUERY INGESTION
📥
1. Client Query
TDS (Port 1433)
🛡️
2. SQL-FE
Auth & Compile
🧠
3. DQP Optimizer
Distributed Plan
🗺️
4. Topology
Cells ➔ Nodes
5. Distributed QE
Parquet Scans
📤
6. Assembly
Result Stream
🗄️ SHARED ONELAKE DELTA STORAGE LAYER: Same Delta transaction logs & V-Order Parquet files queried concurrently by Spark and Polaris engines.
ZERO DATA DUPLICATION
STAGE 1: T-SQL Query Ingestion (Endpoint or Warehouse)
DMV: sys.dm_exec_sessions
The client application (Power BI Direct Lake, SSMS, Azure Data Studio, or Python pyodbc) sends a standard T-SQL query over the Tabular Data Stream (TDS) protocol on port 1433. The connection is authenticated via Microsoft Entra ID (Azure AD OAuth2 / Service Principal).
Diagnostic Receipt: Check sys.dm_exec_sessions for client library version, Entra tenant ID, and active connection properties.

3. How to Resolve Performance Issues in Fabric Warehouse & SQL Analytics Endpoint

When T-SQL queries run slow in Fabric Warehouse or the SQL Analytics Endpoint, use this systematic diagnostic workflow to identify the root cause across Result Caching, Distributed Data Movement, Statistics Quality, and OneLake File Compaction:

Performance Symptom Diagnostic DMV / Query Physical Mechanism & Root Cause Remediation & Optimization Strategy
Slow Distributed Joins (Shuffle Bottleneck) Query sys.dm_pdw_request_steps filtering by request_id. Check for operation_type = 'ShuffleMoveOperation'. DQP is redistributing millions of rows across Polaris compute nodes because join keys are not clustered or statistics are missing. 1. Push down filters before the join.
2. Create statistics on join columns: CREATE STATISTICS s_col ON tbl(col);
3. Pre-aggregate intermediate data into a CREATE TABLE ... AS SELECT (CTAS) staging table.
Result Cache Misses (High Compute Costs) Check execution times for repeated queries. If duration is identical, Result Set Caching is bypassed. Non-deterministic T-SQL functions (e.g. GETDATE(), NEWID(), RAND()), Row-Level Security (RLS) policies, or active Delta writes invalidate the cache. 1. Replace GETDATE() with a deterministic parameter passed from the client application.
2. Maintain read-heavy analytical views as deterministic queries.
Small-File Scan Latency (File Proliferation) Query sys.dm_pdw_sql_requests. Check total_elapsed_time on individual compute nodes reading OneLake. Underlying Delta table contains thousands of <10MB fragmented Parquet files, forcing Polaris QE nodes to issue hundreds of thousands of HTTP GET calls to OneLake. Run OPTIMIZE table in Spark or utilize Fabric Auto-Compaction to merge small files into optimal 400–500 MB V-Order Parquet files.
Stale / Missing Statistics (Wrong Plan Choice) Query sys.stats and sys.stats_columns; check STATS_DATE(). Polaris relies on statistics to choose between BroadcastMove (cheap replicate) and ShuffleMove (expensive repartition). Missing stats force conservative, slow plans. Execute UPDATE STATISTICS schema.table WITH FULLSCAN; on high-volume fact and dimension tables after large batch loads.
Procedural Cursor & Loop Latency Inspect sys.dm_exec_requests. Thousands of individual short-lived requests generated sequentially. Using T-SQL WHILE loops or row-by-row cursors causes massive DQP scheduling latency because each iteration submits an independent distributed query. Refactor procedural loops into set-based T-SQL operations using MERGE, window functions (ROW_NUMBER()), or batch CTAS statements.
Production Polaris DMV Diagnostic Suite (Run in SSMS / Fabric Warehouse Query Editor)
T-SQL DIAGNOSTICS POLARIS DISTRIBUTED QUERY INSPECTION
-- 1. Identify Currently Running & Slowest Recent Distributed Queries
SELECT TOP 15
    r.request_id,
    r.status,
    r.submit_time,
    r.total_elapsed_time / 1000.0 AS elapsed_seconds,
    r.command
FROM sys.dm_pdw_exec_requests r
ORDER BY r.total_elapsed_time DESC;

-- 2. Trace Distributed DQP Steps for a Specific Bottlenecked Query
-- Replace 'QID12345' with the request_id from query #1
SELECT 
    s.request_id,
    s.step_index,
    s.operation_type,      -- Look for 'ShuffleMoveOperation' vs 'BroadcastMoveOperation'
    s.total_elapsed_time / 1000.0 AS step_seconds,
    s.command
FROM sys.dm_pdw_request_steps s
WHERE s.request_id = 'QID12345'
ORDER BY s.step_index ASC;

-- 3. Audit Outdated or Missing Statistics Across All Warehouse Tables
SELECT 
    SCHEMA_NAME(t.schema_id) AS schema_name,
    t.name AS table_name,
    s.name AS stats_name,
    STATS_DATE(s.object_id, sstats_id) AS last_updated,
    s.auto_created,
    s.user_created
FROM sys.stats s
JOIN sys.tables t ON s.object_id = t.object_id
ORDER BY last_updated ASC;

4. Resolving Performance Issues in Fabric SQL Database (Operational Engine)

Fabric SQL Database brings the full-featured, transactional Azure SQL Database engine into Microsoft Fabric with Autonomous Mirroring to OneLake Delta tables. Because it powers high-concurrency transactional OLTP workloads, performance tuning differs fundamentally from distributed Polaris warehouses:

Diagnostic Focus Where to Look in Fabric SQL DB Physical Bottleneck & Symptom Remediation & Optimization Strategy
Query Plan Regressions & High CPU Query Store DMVs: sys.query_store_runtime_stats & sys.query_store_plan. A previously fast query suddenly consumes 90% CPU due to a sub-optimal plan compiled after data growth. 1. Force the known-good plan via Query Store:
EXEC sp_query_store_force_plan @query_id, @plan_id;
2. Use OPTION (RECOMPILE) for queries with dynamic multi-variable search criteria.
Missing Index & Table Scan Latency Query sys.dm_db_missing_index_details and sys.dm_db_missing_index_group_stats. Frequent point-lookups and range filters scanning entire tables, causing high logical reads and buffer cache churn. Create covering non-clustered indexes with INCLUDE clauses:
CREATE NONCLUSTERED INDEX idx_cust ON sales(cust_id) INCLUDE (order_date, total_amount);
Lock Contention & Blocking Inspect sys.dm_os_wait_stats for LCK_M_* waits and query sys.dm_tran_locks. Long-running transactional writes holding exclusive locks, blocking concurrent reads and causing application timeouts. Enable Read Committed Snapshot Isolation (RCSI) so readers do not block writers and writers do not block readers.
OneLake Mirroring Latency Inspect Mirroring Monitoring tab in Fabric Workspace or query sys.dm_change_feed_log_scan_sessions. Transactional changes taking minutes to appear in Lakehouse Delta tables. 1. Ensure primary keys exist on all mirrored tables.
2. Avoid massive unbatched single transactions; break large updates into batches of 50,000 rows.
Fabric SQL Database Performance Tuning Workbook (Run in Fabric SQL DB Editor)
FABRIC SQL DB MISSING INDEXES & TOP REGRESSED QUERIES
-- 1. Identify Top Missing Indexes with High Potential User Impact
SELECT TOP 10
    ROUND(s.avg_total_user_cost * s.avg_user_impact * (s.user_seeks + s.user_scans), 0) AS improvement_score,
    d.statement AS table_name,
    d.equality_columns,
    d.inequality_columns,
    d.included_columns
FROM sys.dm_db_missing_index_group_stats s
JOIN sys.dm_db_missing_index_groups g ON s.group_handle = g.index_group_handle
JOIN sys.dm_db_missing_index_details d ON g.index_handle = d.index_handle
ORDER BY improvement_score DESC;

-- 2. Find Top 10 Most CPU-Intensive Queries in Query Store
SELECT TOP 10
    q.query_id,
    qt.query_sql_text,
    SUM(rs.count_executions) AS total_executions,
    ROUND(AVG(rs.avg_duration) / 1000.0, 2) AS avg_duration_ms,
    ROUND(AVG(rs.avg_cpu_time) / 1000.0, 2) AS avg_cpu_ms
FROM sys.query_store_query q
JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id
JOIN sys.query_store_plan p ON q.query_id = p.query_id
JOIN sys.query_store_runtime_stats rs ON p.plan_id = rs.plan_id
GROUP BY q.query_id, qt.query_sql_text
ORDER BY avg_cpu_ms DESC;
26 · Deep Dive

Spark Pain-Point Step-Through Diagrams

Four failure modes account for most Spark incidents. Each one below is animated step by step so the mechanism is visible rather than described — pick a pain point, then step through what actually happens inside the executors.

27 · Reference

Reading & Fixing Spark Query Plans

The physical plan is the engine's confession: every performance problem in this document leaves a named operator or a telltale attribute in EXPLAIN output. This section teaches the reading skill; the companion spark_plan_analyzer.py utility automates it across whole notebook trees.

Getting a plan (without running anything)

How What you get
df.explain("formatted") Numbered operator tree + per-operator detail blocks — the mode to learn on.
EXPLAIN FORMATTED <sql> Same, as a SQL statement. Plans are compiled, never executed — safe on production tables (Delta may run small metadata jobs to read the log).
df.explain("cost") Optimized logical plan with row-count/size statistics — why the optimizer chose what it chose.
df.explain("codegen") The generated Java — for verifying whole-stage fusion (Sec 05).
SQL tab, after running The plan with runtime metrics and isFinalPlan=true — the only place AQE's actual decisions are visible (Sec 06).
spark_plan_analyzer.review() Walks a notebook + its %run/notebook.run/runMultiple dependency tree, EXPLAINs every extractable SQL, lints the code, and emits one markdown review with all plans attached. PlanRecorder.grab(df, name) captures DataFrame-API plans mid-notebook.

Interactive: annotated plan — click any line

// Select a sample query plan above to explore operator nodes
Click an operator line to decode it.

Red flags — operator/attribute → problem → fix

In the plan Problem Fix
CartesianProduct / BroadcastNestedLoopJoin No usable equi-join keys — row-explosion or per-row scans of the broadcast side. Fix the ON clause (type mismatch or UDF-wrapped key kills equi-join detection); bucketize range joins into keys.
BatchEvalPython Row-at-a-time Python UDF: breaks codegen fusion, serializes every row, forces NEE fallback. Built-ins first; else @pandas_udf (shows as ArrowEvalPython).
PushedFilters: [] Predicate pushdown failed — the scan reads everything, Spark filters late. Filter on the raw column; cast the literal, not the column; no UDFs in predicates.
PartitionFilters: [] Partitioned/clustered table fully scanned. Literal predicate on the partition/cluster column, no functions on the column side.
SortMergeJoin (small side) Two sorts + two shuffles where a broadcast would do. broadcast(dim) hint, or confirm AQE converts it in the final plan.
Exchange × many Shuffle storm — each Exchange is a full network+disk barrier. Reuse grain (aggregate once), Storage-Partition/bucketed joins, persist genuinely reused intermediates.
no AdaptiveSparkPlan wrapper AQE off for this query — no coalescing, no join conversion, no skew handling. spark.sql.adaptive.enabled=true and ask why it was disabled.
missing *(n) prefixes Operators outside whole-stage codegen — interpreted row-by-row evaluation. Usually a UDF or unsupported expression in the middle; remove it and fusion returns.
ReadSchema: struct<40 fields> Column pruning didn't reach the scan. Select needed columns before wide ops; avoid select("*") pass-through in shared helpers.
Analyzer workflow that scales: run review("nb_entry.ipynb", "plan_review.md", spark) in a scheduled QA notebook — it follows the dependency tree, EXPLAINs every SQL against the live catalog, flags L-codes (code anti-patterns: unbounded collect, inferSchema, session-start conf.set, except-pass…) and P-codes (plan issues above), each with a rewrite suggestion and the related settings. Findings land in one markdown review — attach it to PRs, or write it to a Lakehouse Files folder and alert on CRITICAL counts like any other Sec 21 signal.
Connection to Column Statistics & Skipping (Sec 24b): The plan's FileScan node reveals whether Delta file skipping is working — look for numFilesSkipped > 0 in the plan's data statistics comment. If skipping shows 0%, your filter column may be past the 32-column index limit. See Sec 24b — Column Stats & SQL Tuning for the full diagnostic and remediation workflow.
28 · Reference

The Settings Matrix — Unified Baseline

This document shows %%configure blocks in several places, each tuned to the topic at hand. That is useful in context and confusing in aggregate. This section is the reconciliation: one baseline, plus the exact deltas each workload class adds. If a block elsewhere in the document disagrees with this table, this table wins.

Read this first, or the rest will mislead you. Only three settings below are ones most teams should ever set by hand: advisoryPartitionSizeInBytes, the ANSI/NEE decision on Runtime 2.0, and the Efficient Scaledown group for shuffle-heavy autoscaling jobs. Everything else is either a Fabric default that is already correct, or a setting you should change only after the plan and stage metrics have told you to (Sec 29).

Layer A — the universal baseline (set once, Environment level)

Setting Value Why it is here
spark.sql.adaptive.enabled true Fabric default. Listed only so you can confirm it was not disabled by an inherited Environment.
spark.sql.adaptive.coalescePartitions.enabled true Fabric default. The half of AQE that fixes partition sizing from runtime stats.
spark.sql.adaptive.skewJoin.enabled true Fabric default. Handles join skew; aggregation skew still needs salting.
spark.native.enabled true NEE. GA on both runtimes; also settable via the Environment's Acceleration tab.
spark.microsoft.delta.optimizeWrite.enabled true Fabric default. First defence against small files.

Do not paste this into %%configure. Every line is already the Fabric default — restating defaults pins them, so a future platform improvement silently does not reach you. Verify, don't set.

Layer B — workload deltas (the only blocks worth writing)

Bronze ingest — scan and append, little or no shuffle
JSON (%%configure) SESSION-START ONLY · CELL 1
%%configure -f
{"conf": {
"spark.sql.parquet.vorder.default": "false"
}}

One line, and even that is only needed if your workspace default enables V-Order. No shuffle tuning, because there is no shuffle. First ask whether this needs Spark at all — under ~10 GB a Python notebook is a fraction of the CU (Sec 34).

Silver conform — join + MERGE, shuffle-dominated
JSON (%%configure) SESSION-START ONLY · CELL 1
%%configure -f
{"conf": {
"spark.sql.adaptive.advisoryPartitionSizeInBytes": "134217728",
"spark.remote.shuffle.enabled": "true",
"spark.sql.rsm.decisionlayer.enabled.level": "stage",
"spark.storage.decommission.shuffleBlocks.enabled": "true",
"spark.sql.parquet.vorder.default": "false"
}}

128 MB advisory targets post-shuffle partitions that fit per-core execution memory. The three Efficient Scaledown keys matter only if the pool autoscales — they let executors release without taking shuffle blocks with them (Sec 13).

Gold aggregate — wide groupBy serving BI
JSON (%%configure) SESSION-START ONLY · CELL 1
%%configure -f
{"conf": {
"spark.sql.adaptive.advisoryPartitionSizeInBytes": "268435456",
"spark.sql.parquet.vorder.default": "true"
}}

256 MB because gold aggregates produce fewer, larger partitions and the output is small. V-Order on: this is the layer Direct Lake and the SQL endpoint read.

Interactive / ad hoc
JSON (%%configure) SESSION-START ONLY · CELL 1
%%configure -f
{"conf": {}}

Genuinely nothing. Use the starter pool, high concurrency, and Autotune if the query shapes repeat. Tuning interactive sessions by hand usually costs more time than it saves.

Runtime 2.0 only — the ANSI × NEE decision (add to any block above)
JSON (SPARK CONF) %%configure OR ENVIRONMENT PROPERTIES
// throughput-critical batch: regain native execution
"spark.sql.ansi.enabled": "false"
// correctness-critical: keep ANSI guards, accept the JVM path
"spark.sql.ansi.enabled": "true"

There is no universally right answer — it is a per-workload decision, and the one setting on Runtime 2.0 you genuinely must make consciously (Sec 08).

Reconciling the conflicts you may have spotted

Apparent conflict Resolution
advisory size 128 MB vs 256 MB Both correct, different layers. 128 MB for silver (many partitions, merge-heavy, spill-sensitive); 256 MB for gold (fewer, larger partitions, scan-heavy). If unsure, start at 128 MB — under-sized partitions cost scheduling overhead, over-sized ones cost spill, and spill is the more expensive failure.
V-Order true vs false Layer-dependent by design: false on bronze/silver (you pay ~10-15% write cost for reads that happen elsewhere), true on gold only.
ANSI true vs false Runtime 2.0 only, and deliberately unresolved — it is a trade-off, not a default. Decide per workload and record the decision in the Environment.
Efficient Scaledown keys present/absent Only include them when the pool autoscales and the job shuffles heavily. On a fixed-size pool they add nothing.
Memory fractions appearing in some blocks They should not. The Config Advisor now emits only non-default settings; any block showing spark.memory.fraction=0.6 is restating a default and can be deleted.

Interactive: generate the exact block for your workload

29 · Reference

Recommended Baseline Defaults & Scopes

Most Spark tuning advice assumes you already have a baseline. This is that baseline: what to set once at the Environment level so individual notebooks rarely need to tune anything, plus what deliberately stays at its default. Every value is a starting point to validate, not a law — the Config Advisor and cluster console derive per-workload numbers from your actual data.

The most important default is the one you don't change. AQE on, Optimize Write on, Intelligent Cache on, high concurrency on — Fabric's defaults are good. The common failure mode is not under-tuning; it is a team pasting settings from a blog into every notebook and defeating AQE.

Layer 3 — set once on the Environment (applies to every notebook)

Setting Baseline Why
spark.sql.adaptive.enabled true Default since 3.2. Partition coalescing, join conversion and skew splitting all depend on it. Never turn off.
spark.sql.adaptive.coalescePartitions.enabled true The half of AQE that fixes both over- and under-partitioning from runtime stats.
spark.sql.adaptive.advisoryPartitionSizeInBytes 134217728 The one number worth setting deliberately. 128 MB targets post-shuffle partitions that fit comfortably in per-core execution memory. Raise to 256 MB for very large scans; lower if you see spill.
spark.sql.adaptive.skewJoin.enabled true Splits hot join partitions automatically. Aggregation skew still needs salting.
spark.sql.shuffle.partitions 200 (leave) With AQE coalescing this is only an upper bound — tuning it is mostly obsolete. Raise only if a stage is throttled at 200 before coalescing.
spark.sql.autoBroadcastJoinThreshold leave default AQE re-evaluates broadcast eligibility from real post-filter sizes, which beats a static threshold. Raise only with measured dimension sizes and driver headroom.
spark.native.enabled true NEE via the Environment's Acceleration tab. Verify it is actually native (*Transformer operators), especially on Runtime 2.0 where ANSI causes fallback.
spark.microsoft.delta.optimizeWrite.enabled true Fabric default. First defence against small files; drop bin size to 128 MB for merge-heavy tables.
spark.sql.parquet.vorder.default false at Environment Enable per-table on gold instead — a blanket default taxes every bronze/silver write for reads that happen elsewhere.

Runtime-specific baselines

Setting Runtime 1.3 Runtime 2.0 Note
spark.sql.ansi.enabled false (default) decide explicitly On by default in 4.x. Choose true for correctness guards or false to regain NEE offload — per workload, recorded in the Environment, not ad hoc.
Liquid clustering preview flag standard Delta 3.2 requires the clustered-table preview flag; Delta 4.2 does not.
spark.speculation leave off leave off Speculation masks skew rather than fixing it. If enabled, 4.x defaults are already less aggressive (3 / 0.9).
Efficient Scaledown / RSM opt-in opt-in Enable for shuffle-heavy autoscaling jobs; it is what makes aggressive scale-down safe.

Settings people change that they usually shouldn't

Setting Why leaving it alone is usually right
spark.memory.fraction / storageFraction The unified model already lets execution and storage borrow from each other. Raising storage to "help caching" starves execution and causes the spill you were trying to avoid.
spark.sql.files.maxPartitionBytes Read-side sizing that AQE does not touch. 128 MB matches typical block sizes; changing it usually treats a small-files symptom that OPTIMIZE should fix.
spark.executor.cores above ~5 More cores per executor means less memory per task and more HDFS/object-store contention. Fabric's fixed node shapes already encode sane ratios.
spark.sql.crossJoin.enabled Enabling it silences the symptom of a broken join predicate. Fix the predicate.
spark.driver.memory (as an OOM fix) Driver OOM almost always means collect()/toPandas() or a mis-sized broadcast. More heap postpones the same failure.
Hard-coded repartition(n) Not a config, but the same instinct — it overrides exactly what AQE is measuring for you.

Baseline by workload shape

Shape Starting point
Bronze ingest (scan + write) Small pool, few nodes. No shuffle tuning needed. V-Order off, CDF on if silver reads incrementally. Check whether Spark is needed at all — under ~10 GB a Python notebook with Polars/DuckDB is a fraction of the CU.
Silver conform (join + MERGE) Advisory partition size 128 MB; deletion vectors + CDF on the target; liquid clustering on join/filter keys; Efficient Scaledown on. This is the shape that most rewards tuning.
Gold aggregate (wide groupBy) Advisory 128–256 MB; V-Order on the target; OPTIMIZE after load; consider a Materialized Lake View instead of orchestrating a rebuild. Size the pool from shuffle volume, not input size.
Interactive / ad hoc SQL Defaults plus high concurrency; consider Autotune, which converges on repetitive query shapes. Keep sessions warm rather than tuning.
Streaming Partition count sized to steady-state micro-batch, not peak. NEE will fall back — size compute on JVM performance.
Order of operations when something is slow: read the plan (Sec 27) → check stage metrics for spill and skew (Sec 21) → fix the code or the table layout → only then touch settings. In practice a cast on a join key, a Python UDF, or a missing OPTIMIZE explains more slow jobs than any configuration value does.
30 · Best Practices

Spark SQL in Fabric — Patterns & 4.x Features

Everything the DataFrame API can express, Spark SQL can too — and it goes through the identical Catalyst pipeline (Sec 04), so the plan-reading skills from Sec 27 apply unchanged. What differs is ergonomics, the pitfalls SQL invites, and the fact that Spark 4.x adds genuinely new SQL surface.

Where to write SQL, and how to parameterize it safely

Surface Use when
%%sql cell magic Exploration and readable one-shot statements. Output renders as a table. Cannot take Python variables — which is a feature: no accidental injection.
spark.sql("…") Programmatic SQL, returns a DataFrame for chaining. The workhorse in metadata-driven pipelines.
spark.table("lh.schema.tbl") Just reading a table — clearest intent, no SQL parse.
SQL analytics endpoint T-SQL over the same Delta tables for BI/analyst consumption. Read-only — transformation logic belongs in Spark or the Warehouse, not here.
Anti-pattern — f-string SQL 3.5+
PYSPARK (ANTI-PATTERN) SQL STRING INTERPOLATION VULNERABILITY
cust = get_customer_id() # from a widget/param
df = spark.sql(f"""
  SELECT * FROM orders
  WHERE customer_id = {cust}
""")
String-built SQL: injection-prone with any user-supplied value, and every distinct value produces a distinct query text — defeating plan reuse and making the SQL untestable in isolation.
Parameter markers 3.5+
PYSPARK (PARAMETERIZED) RUNNABLE IN FABRIC NOTEBOOK
df = spark.sql(
"SELECT order_id, amount FROM orders WHERE customer_id = :cust",
args={"cust": cust})
Values bound, not concatenated. Same query text every run. Also note the explicit column list — SELECT * blocks column pruning at the scan (S001).

Interactive: SQL pattern gallery

Spark 4.x SQL: what's genuinely new

These have no 3.5 equivalent — they are reasons to move to Runtime 2.0, not just syntax sugar. Each pair below shows the 3.5 workaround and the 4.x native form.

Multi-step logic on 3.5 3.5
PYSPARK (ANTI-PATTERN) DRIVER LOOP OVER PARTITIONS
# control flow must live in Python
for region in regions:
spark.sql(f"INSERT INTO gold.summary "
f"SELECT ... WHERE region = '{region}'")
Orchestration logic and SQL are split across two languages; the loop is invisible to anyone reading the SQL.
SQL scripting 4.x only
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
BEGIN
DECLARE region_cursor STRING;
FOR row AS SELECT DISTINCT region FROM silver.orders DO
INSERT INTO gold.summary
SELECT * FROM silver.orders WHERE region = row.region;
END FOR;
END
Control flow, variables and exception handling inside SQL itself (GA in Spark 4.1). The whole unit is reviewable as one artifact.
Nested subqueries 3.5
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
SELECT region, total FROM (
SELECT region, SUM(amount) AS total FROM (
SELECT * FROM orders WHERE status = 'complete'
) GROUP BY region
) WHERE total > 1000
Reads inside-out; each nesting level adds cognitive load in review.
PIPE syntax 4.x only
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
FROM orders
|> WHERE status = 'complete'
|> AGGREGATE SUM(amount) AS total GROUP BY region
|> WHERE total > 1000
Reads top-to-bottom like a dataframe chain. Identical plan — this is readability, which is a performance feature for the humans maintaining it.
Reusable logic on 3.5 3.5
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
@udf("double")
def net_amount(amount, vat):
return amount / (1 + vat)
Python UDF: opaque to Catalyst, per-row serialization, and under NEE it drops the enclosing operator to JVM (N002).
SQL UDF 4.x only
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
CREATE FUNCTION net_amount(amount DOUBLE, vat DOUBLE)
RETURNS DOUBLE
RETURN amount / (1 + vat);
Catalog-resident, optimizer-transparent, zero serialization — and NEE-friendly. The right home for shared business logic.
ANSI changes SQL semantics on 4.x (Sec 17): the same CAST('abc' AS INT) that returned NULL on 3.5 throws on Runtime 2.0. Wrap knowingly-dirty conversions in try_cast/try_divide rather than disabling ANSI globally — unless you have chosen native_speed for NEE reasons, which is a workload-level decision.
31 · Best Practices

CDF Incremental Pipeline in Pure SQL

Everything here is %%sql. No DataFrame API, no PySpark. The goal: take an MLV that Fabric refreshes fully every cycle, and reproduce its output incrementally using Change Data Feed plus MERGE, so cost scales with change volume rather than table size.

Step 0 — First, confirm the MLV really is full-refreshing (and why)

Do not rewrite anything until you know which of the five gating conditions is firing (Sec 24). Run these three checks:

SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
-- (a) Is CDF on for EVERY source the MLV references? Any single miss = full refresh.
SHOW TBLPROPERTIES MarketRisk_Position_LTH_Exposures ('delta.enableChangeDataFeed');
SHOW TBLPROPERTIES MarketRisk_Position_LTH_Sensitivities ('delta.enableChangeDataFeed');
-- ...repeat for all sources, including any upstream MLVs in the chain

-- (b) Has any source seen a non-append operation since the last refresh?
--     UPDATE / DELETE / MERGE anywhere = full refresh, regardless of SQL constructs.
SELECT version, timestamp, operation
FROM (DESCRIBE HISTORY MarketRisk_Position_LTH_Exposures)
WHERE operation IN ('UPDATE','DELETE','MERGE')
ORDER BY version DESC LIMIT 20;
-- (c) Does the definition use constructs that block incremental?
--     Window functions and non-deterministic functions are the documented blockers.
SHOW CREATE TABLE mlv.Standard_PositionLookthrough_Link;
Worked example — a real MLV that can never refresh incrementally. Consider a lookthrough link MLV of this shape (abbreviated from a production risk model):
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
CREATE OR REPLACE MATERIALIZED LAKE VIEW mlv.Standard_PositionLookthrough_Link
TBLPROPERTIES (delta.enableChangeDataFeed=true) AS
WITH source_rows AS (
SELECT 'exposures' AS source_name, ... FROM MarketRisk_Position_LTH_Exposures WHERE Reportlevel <= 1
UNION ALL
SELECT 'volmetrics' AS source_name, ... FROM MarketRisk_Position_LTH_VolMetricsTerms WHERE Reportlevel <= 1
UNION ALL ... -- 9 sources in total
),
ranked_rows AS (
SELECT ..., xxhash64(EffectiveDate, EntityId, ...) AS match_key,
ROW_NUMBER() OVER (PARTITION BY source_name, match_key, COALESCE(RiskSetting,'')
ORDER BY record_id DESC, record_key DESC) AS row_number
FROM source_rows
)
SELECT ... FROM exposures pos
INNER JOIN volmetrics vol ON pos.match_key = vol.match_key
LEFT JOIN barra bar ON pos.match_key = bar.match_key ...
Three independent blockers, any one of which forces full refresh:
  1. ROW_NUMBER() OVER (...) — a window function, the canonical documented unsupported construct. Deduplication-by-ranking is the most common reason production MLVs never go incremental.
  2. Non-append source traffic — risk tables of this kind are typically reloaded or corrected per effective date, so UPDATE/DELETE/MERGE appear in their history. That alone forces full refresh even with CDF on and no window function.
  3. Chained MLVs — a downstream MLV joining mlv.Standard_PositionLookthrough_Link inherits the problem: if any referenced MLV or table lacks CDF, or the chain contains a full-refresh node, incremental is off for that node too. Check every node in the DAG, not just the leaf.
A frequent third failure in chains like this: the final consumption MLV is declared without TBLPROPERTIES (delta.enableChangeDataFeed=true), so even though the intermediates have it, anything reading the final view cannot go incremental. Enable CDF on every node you intend to consume incrementally.

Step 1 — Control table for watermarks (pure SQL)

SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
CREATE TABLE IF NOT EXISTS ctl.cdf_watermark (
entity_name STRING NOT NULL,
source_table STRING NOT NULL,
last_version BIGINT NOT NULL,
updated_at TIMESTAMP
) USING DELTA
TBLPROPERTIES (delta.enableDeletionVectors = true);
-- Seed once per entity. Version 0 = "process everything from the beginning".
INSERT INTO ctl.cdf_watermark
SELECT 'position_lookthrough', 'MarketRisk_Position_LTH_Exposures', 0, current_timestamp()
WHERE NOT EXISTS (SELECT 1 FROM ctl.cdf_watermark WHERE entity_name = 'position_lookthrough');

Step 2 — Create the target as a real Delta table, not a view

This is the table the MLV used to produce. Because you now own it, you also choose its physical properties — which an MLV does not let you tune directly:

SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
CREATE TABLE IF NOT EXISTS gold.position_lookthrough_link (
Standard_PositionLookthrough_Link_Key BIGINT,
EffectiveDate DATE,
EntityId STRING,
BenchmarkId STRING,
Reportlevel INT,
RiskSetting STRING,
RP_PositionId_Hash_Key BIGINT,
SecurityId INT,
_merged_at TIMESTAMP
) USING DELTA
CLUSTER BY (EffectiveDate, EntityId) -- liquid clustering: GA on Runtime 2.0
TBLPROPERTIES (
delta.enableDeletionVectors = true, -- MERGE marks rows instead of rewriting files
 delta.enableChangeDataFeed = true, -- so the NEXT layer can also go incremental
 delta.targetFileSize = '400mb' -- gold layer target (Sec 30)
);

Step 3 — The incremental load, entirely in SQL

Two runtime flavours. The logic is identical; only how the watermark is held differs.

Runtime 2.0 — one self-contained SQL script 4.x only
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
BEGIN
DECLARE wm BIGINT;
DECLARE tgt BIGINT;
SET VAR wm = (SELECT last_version FROM ctl.cdf_watermark
WHERE entity_name = 'position_lookthrough');
SET VAR tgt = (SELECT max(version)
FROM (DESCRIBE HISTORY MarketRisk_Position_LTH_Exposures));
-- 1. Changed keys only: version-bounded, so files outside the window are never opened
 CREATE OR REPLACE TEMP VIEW v_changed_keys AS
SELECT DISTINCT EffectiveDate, EntityId
FROM table_changes('MarketRisk_Position_LTH_Exposures', wm + 1, tgt)
WHERE _change_type != 'update_preimage'
AND Reportlevel <= 1;
-- 2. Recompute the FULL business logic, but only for affected grains.
 --    Window functions are fine here - you are not an MLV, nothing falls back.
 CREATE OR REPLACE TEMP VIEW v_recomputed AS
WITH scoped AS (
SELECT e.* FROM MarketRisk_Position_LTH_Exposures e
JOIN v_changed_keys k
ON e.EffectiveDate = k.EffectiveDate AND e.EntityId = k.EntityId
WHERE e.Reportlevel <= 1
),
ranked AS (
SELECT s.*,
xxhash64(EffectiveDate, EntityId, BenchmarkId, Reportlevel, OriginalName) AS match_key
FROM scoped s
QUALIFY ROW_NUMBER() OVER (
PARTITION BY EffectiveDate, EntityId, BenchmarkId, OriginalName
ORDER BY MarketRisk_Position_LTH_Exposures_Id DESC) = 1
)
SELECT xxhash64(match_key) AS Standard_PositionLookthrough_Link_Key,
EffectiveDate, EntityId, BenchmarkId, Reportlevel,
CAST(NULL AS STRING) AS RiskSetting,
xxhash64(RP_PositionId) AS RP_PositionId_Hash_Key,
TRY_CAST(split(RP_PositionId, '~')[1] AS INT) AS SecurityId,
current_timestamp() AS _merged_at
FROM ranked;
-- 3. Apply. Full business key in the ON clause.
 MERGE INTO gold.position_lookthrough_link t
USING v_recomputed s
ON t.Standard_PositionLookthrough_Link_Key = s.Standard_PositionLookthrough_Link_Key
AND t.EffectiveDate = s.EffectiveDate -- narrows the file scan on the target
 WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;
-- 4. Advance the watermark ONLY after the MERGE commits, forward-only
 MERGE INTO ctl.cdf_watermark t
USING (SELECT 'position_lookthrough' AS entity_name, tgt AS new_version) s
ON t.entity_name = s.entity_name
WHEN MATCHED AND s.new_version > t.last_version
THEN UPDATE SET last_version = s.new_version, updated_at = current_timestamp();
END
Session variables (DECLARE/SET VAR) and SQL scripting (BEGIN…END) are GA in Spark 4.1, so the whole unit is one reviewable artifact with no host language.
Runtime 1.3 — same SQL, watermark via a temp view 3.5
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
-- No session variables on 3.5. Hold the bounds in a one-row temp view
-- and cross-join it, so the SQL still parameterises itself with no Python.
CREATE OR REPLACE TEMP VIEW v_bounds AS
SELECT
(SELECT last_version FROM ctl.cdf_watermark
WHERE entity_name = 'position_lookthrough') AS wm,
(SELECT max(version)
FROM (DESCRIBE HISTORY MarketRisk_Position_LTH_Exposures)) AS tgt;
-- table_changes() needs LITERAL version arguments - it cannot read them
-- from a view. On 3.5 you therefore either:
--   (a) pass them in from the orchestrator as pipeline parameters, or
--   (b) accept one line of host-language glue (spark.sql(..., args={}))
-- Everything downstream of the change read stays pure SQL:
CREATE OR REPLACE TEMP VIEW v_changed_keys AS
SELECT DISTINCT EffectiveDate, EntityId
FROM table_changes('MarketRisk_Position_LTH_Exposures', ${wm_plus_1}, ${tgt})
WHERE _change_type != 'update_preimage' AND Reportlevel <= 1;
The one genuine 3.5 limitation: table_changes() requires literal version arguments, and 3.5 has no SQL variable to supply them. Use pipeline parameters (${...} substitution at the notebook/pipeline layer) or one spark.sql(..., args={}) call. The recompute, MERGE and watermark update are identical to the 4.x version.

Step 4 — Handling deletes properly

The recompute above propagates inserts and updates. Deletes need an explicit branch, because a deleted source row simply stops appearing in the recomputed set:

SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
-- Rows deleted from the source since the watermark
CREATE OR REPLACE TEMP VIEW v_deleted_keys AS
SELECT DISTINCT xxhash64(EffectiveDate, EntityId, BenchmarkId, Reportlevel, OriginalName) AS match_key
FROM table_changes('MarketRisk_Position_LTH_Exposures', ${wm_plus_1}, ${tgt})
WHERE _change_type = 'delete';
-- Apply as a second MERGE branch (deletion vectors make this cheap)
MERGE INTO gold.position_lookthrough_link t
USING v_deleted_keys s
ON t.Standard_PositionLookthrough_Link_Key = xxhash64(s.match_key)
WHEN MATCHED THEN DELETE;

If a key can be deleted and re-inserted within the same window, order matters: apply deletes before the upsert MERGE, or filter deleted keys that also appear in v_recomputed. This ordering problem is precisely what MLV's append-only requirement exists to avoid — owning it is the cost of owning the incremental path.

Step 5 — The retention guard, in SQL

SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
-- If the watermark predates the oldest retained version, CDF history has aged out
-- (VACUUM retention). Fail loudly and full-refresh rather than silently skipping data.
SELECT
CASE WHEN (SELECT last_version FROM ctl.cdf_watermark
WHERE entity_name = 'position_lookthrough')
< (SELECT min(version) FROM (DESCRIBE HISTORY MarketRisk_Position_LTH_Exposures))
THEN raise_error('CDF history aged out - full refresh required')
ELSE 'ok' END AS retention_check;
What you gain and what you give up. Gain: incremental cost on a table MLV rebuilds every cycle, window functions and any SQL you like, control of clustering/DV/file size on the target, and a watermark you can inspect. Give up: Fabric-managed refresh, lineage and DAG orchestration — you now own scheduling, delete ordering and the retention guard. Only take this on when the diagnosis in Step 0 shows a blocker you cannot remove. If the only problem is a window function you could express differently, fixing the MLV definition is cheaper than owning a pipeline.
32 · Best Practices

Coding Standards — Interactive Gallery

The full catalogue from fabric_coding_standards.md, browsable here with the analyzer codes that detect each anti-pattern. Every example is tagged 3.5+ (both runtimes), 4.x only, or differs (shown as a version pair).

Full document with additional context: open the rendered standards · automated enforcement: spark_plan_analyzer.review(entry, out, spark, runtime="fabric-2.0").

33 · Best Practices

Delta Table Maintenance Runbook

Maintenance sits below the transformation layer: it applies to the tables themselves, regardless of what wrote them. Spark notebooks, dbt-fabric, Dataflow Gen2, Copy activity and the Python kernel all produce Delta tables with identical maintenance needs — and Python-kernel tables are typically in worse shape by default, because no auto-compaction fires and VACUUM never runs automatically. Fabric Warehouse is the exception; it manages its own storage.

Why this is a cost problem, not a tidiness problem. Capacity SKUs roughly double in cost at each tier. Neglected tables accumulate small files, deletion vectors build up, clustering goes stale, and queries scan far more data than they need — so the platform works harder without your data or user count growing. The upgrade you eventually buy is often maintenance debt, not demand.

Layer targets — explicit, not implicit

Layer Target file size Why this number
Bronze ~128 MB A pragmatic default rather than a hard requirement — bronze is read by Spark, not Direct Lake or the SQL endpoint. The priority is preventing small-file accumulation, not hitting an exact size.
Silver ~256 MB Balances Spark processing efficiency for transformation workloads — this is where correctness of the target starts to matter.
Gold ~400 MB The critical one. The SQL analytics endpoint and Power BI Direct Lake have genuine performance dependencies on file size.

Note the distinction that trips people up: a maintenance target is the threshold for deciding whether to call OPTIMIZE — it is not the output file size. Adaptive Target File Size controls the actual output when OPTIMIZE runs, adapting downward for small tables; delta.targetFileSize as a table property gives it a per-table ceiling.

Interactive: Maintenance Cadence Advisor

Describe your table's write patterns. Get a recommended maintenance schedule with specific OPTIMIZE, DV purge, and VACUUM cadences — and the health-check query to verify conditions before each run.

Principles worth stealing

  • Maintenance should cost nothing when tables are healthy. Gate every OPTIMIZE on a metadata check (DESCRIBE DETAIL — no data scan, runs in seconds) and skip tables already within tolerance of their layer target. This is exactly what the health audit notebook does before the maintenance notebook acts.
  • The 7-day VACUUM floor is non-negotiable. Enforce it in code, not as a documentation note somebody skims — retention below 168 hours risks breaking readers mid-query and destroys your time-travel/CDF window (the CDF retention trap).
  • Direct Lake coordination matters. For gold tables serving Direct Lake, VACUUM must run after the semantic model has re-framed to the latest commit — otherwise you can pull files out from under it.
  • Table properties beat session configs for shared tables. A session config applies only to the current notebook session; a table property is set once and applies no matter which pipeline, engine or tool writes next. For anything written by more than one process, put it on the table.
  • Rebaseline once, then maintain. A neglected Lakehouse needs REORG TABLE … APPLY (PURGE) plus OPTIMIZE to rewrite files to target and purge accumulated deletion vectors — a one-off, expensive pass. Afterwards, incremental maintenance is cheap.
  • Log every decision. No silent skips, no silent failures — a maintenance run that reports "nothing to do" must be distinguishable from one that errored quietly.
Prior art worth reading before you build your own: delta-doctor (Brad Coles, Apache 2.0) is a Fabric Notebook Library implementing exactly this discipline — diagnosis, treatment and preventative care as seven importable notebooks, with layer-driven defaults, health-gated OPTIMIZE and an enforced VACUUM floor. Its companion write-up, Delta Table Maintenance in Microsoft Fabric: A 2026 Practitioner's Guide, covers the reasoning. The kit's nb_lakehouse_health_audit and nb_lakehouse_maintenance cover similar ground with a metadata-driven, dry-run-first bias; delta-doctor goes further on layer targets and orchestration. Evaluate both rather than writing a third from scratch.
34 · Best Practices

Efficient Code & Engine Choice (Spark vs Python)

The most expensive Spark mistake in Fabric isn't a bad config — it's using a multi-node Spark session for work a single small node could do. Fabric Python notebooks run on a single node (default 2 vCores / 16 GB) at a fraction of the CU cost of even the smallest Spark session, and modern single-node engines (Polars, DuckDB, delta-rs) read and write the same OneLake Delta tables.

Engine decision — heuristics to validate, not laws
Working data size Recommended engine
< ~5–10 GB Python notebook + Polars or DuckDB (reading Delta via scan_delta / delta_scan, writing via delta-rs). Single node, seconds to start, minimal CU.
~10 – 100 GB Small Spark session — single-node or 2–3 Medium nodes; NEE on; still fits comfortably without wide-cluster shuffles.
> ~100 GB, joins/shuffles Scaled Spark pool sized by the Config Advisor (Sec 15) + Efficient Scaledown (Sec 13) so autoscale releases nodes fast.

Two caveats that override size: (1) writes destined for gold/Direct Lake should go through Spark, because delta-rs cannot apply V-Order; (2) anything needing a genuinely distributed shuffle (large joins, global sorts) is Spark territory regardless of input size.

PySpark efficiency checklist

  • Built-in functions over UDFs, always. Catalyst can't optimize through a Python UDF (Sec 04), and each row crosses the JVM↔Python boundary. When a UDF is unavoidable, use pandas/Arrow UDFs — and note NEE now supports offloading them (Sec 07).
  • Never collect()/toPandas() unbounded data to the driver — the #1 driver-OOM cause (Sec 21). Aggregate first; sample deliberately with limit().
  • Explicit schemas everywhere. inferSchema on CSV/JSON triggers an extra full scan and produces drift-prone types. Define schemas in the metadata layer (Sec 37), not inline.
  • Cache with discipline: cache() only DataFrames reused 2+ times, and unpersist() when done — cached data competes with execution memory (Sec 09) and is a spill accelerant.
  • Broadcast deliberately (Sec 12): hint known-small dimensions; never force-broadcast something whose size you haven't measured.
  • Partition-count hygiene (Sec 10): coalesce() to shrink without shuffle, repartition() only when you need redistribution; let AQE coalescing do routine right-sizing.
  • MERGE hygiene on Delta: always a full-key ON clause; pre-filter the source to the affected key range; deletion vectors on for the target (Sec 24).
  • Debuggability by construction: pure functions per transform step (unit-testable off-cluster), one logical step per notebook cell, row-count/expectation assertions between medallion hops, and structured (JSON) log lines so the diagnostic emitter (Sec 21) can index them.
Polars/DuckDB in Fabric, concretely: in a Python notebook, polars.scan_delta() and DuckDB's delta_scan() read Lakehouse tables directly (local path /lakehouse/default/Tables/… when a default Lakehouse is attached, or an abfss:// URI). Both are lazy/vectorized — push filters and column selection into the scan and only materialize the result. The companion notebook ships tested, working examples of exactly this pattern plus the delta-rs write path with its V-Order caveat.
35 · Best Practices

Ingestion Patterns — Rate-Limited APIs & Archives

Two ingestion shapes that Spark handles badly and teams keep giving to Spark anyway. Both are network-bound single-node work: a rate-limited crawl spends its life asleep honouring the source's limit, and a zip download is one stream to one machine. This section covers where they land in the engine-choice model (Sec 34), how they register against the existing metadata schema (Sec 36), and why NEE has nothing to offer until the data is already Delta (Sec 08).

The costly instinct. A 5-requests-per-second API limit means a 100,000-row crawl takes at least ~100 seconds of wall-clock no matter what compute you point at it. Doing that on a Medium Spark node burns roughly 4× the CU of a Python notebook — and the extra capacity is spent idling inside sleep(). Distributing a rate-limited crawl does not make it faster; the limit is the source's, not yours.

Where these land in the engine decision

Shape Engine Why
Rate-limited API (JSON) Python notebook Latency-bound, not compute-bound. Single node, cheap, and the crawl state (cursor) is trivially resumable. Use Copy activity instead when the endpoint is simple REST-to-file with no bespoke pagination, backoff or transformation.
Zipped JSON over HTTPS Python notebook One stream, one machine. Spark cannot parallelize a single compressed archive — a zip is not splittable, so a 20-node cluster reads it on one executor while 19 idle.
The transform afterwards Spark (or Python if small) Once landed as many gzipped NDJSON files, the read is parallelizable and the size thresholds in Sec 34 apply normally.

Landing layout: datetime partitions, splittable files

Land into raw/{entity}/ingest_date=YYYY-MM-DD/ingest_hour=HH/ and write gzipped NDJSON — one record per line. Three consequences worth being explicit about:

  • Replays are idempotent. Re-running a window overwrites its partition rather than appending duplicates — no dedup pass required downstream.
  • Bronze loads prune. A partitioned landing zone lets the bronze reader touch only new windows instead of listing the whole raw area — the small-files listing cost described in Sec 10 is what you are avoiding.
  • NDJSON is line-splittable; a single JSON document is not. One 2 GB JSON array is read by exactly one task. Many gzipped NDJSON parts are read in parallel (gzip blocks each file, but files parallelize).

Rate limiting that survives contact with a real API

Naive — sleep and hope 3.5+
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
for page in pages:
r = requests.get(url) # no limiter
time.sleep(0.2) # guessed
if r.status_code == 429:
time.sleep(60) # ignores Retry-After
continue # infinite loop risk
Fixed sleeps under-use the quota when the API is fast and over-run it when it is slow. Ignoring Retry-After when the server has told you the answer is how crawls get blocked. No retry ceiling means a persistent 500 loops forever.
Token bucket + Retry-After + bounded retries 3.5+
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
bucket.take() # blocks until a token frees
try:
return json.loads(urlopen(req, timeout=t).read())
except HTTPError as e:
retryable = e.code == 429 or 500 <= e.code < 600
if not retryable or attempt > max_retries:
raise # 4xx = your bug; don't burn quota
wait = e.headers.get("Retry-After")
time.sleep(float(wait) if wait else min(2 ** attempt, 60))
The bucket enforces the contract before sending, so 429s become rare; the retry loop is the safety net. Only 429 and 5xx are retried — retrying a 403 or 400 just spends quota confirming your request is wrong.
Persist the cursor after every page, not at the end. A crawl that dies at page 400 of 500 should resume at 400. Write the next cursor to etl_watermark inside the page loop — it is one small write against metadata versus re-issuing hundreds of rate-limited requests.

Metadata: one new table, keyed to the existing schema

etl_api_config extends Sec 36's schema on entity_id and holds only what JSON columns express badly: base_url, auth_secret_name (a Key Vault secret name), pagination shape (page_style/page_param/records_path/cursor_path), requests_per_second, burst, max_retries, timeout_seconds and landing_pattern. Everything else — target table, layer, load type, engine hint — stays in etl_entity. Adding an API becomes two INSERTs.

NEE scope for ingestion — where acceleration starts

Stage NEE benefit Why
API crawl / zip download None — no Spark Python notebook; there is no plan to accelerate.
Raw JSON/NDJSON read in Spark None — Tier 1 fallback JSON is not a native source (Sec 08). The read falls back to JVM regardless of NEE being enabled.
Parse → write Delta (bronze) Partial The JSON parse falls back; the write path can use native Delta write acceleration. Expect conversion boundaries here.
Bronze → silver → gold Full Delta/Parquet in, built-ins throughout, flat columns — the shape NEE was built for.

Design consequence: parse JSON once at the bronze boundary and never again. Every downstream job that re-parses raw JSON re-enters Tier 1 fallback and forfeits acceleration for that whole operator.

Interactive: ingestion planner

Working proof: nb_api_zip_ingestion.ipynb runs both patterns against a real local HTTP server — token-bucket limiting, a deliberate 429 with Retry-After honoured, cursor persisted per page, datetime-partitioned gzipped NDJSON output, streamed zip extraction member-by-member, and CU-estimate logging. Executed end to end, zero errors.
36 · Best Practices

Metadata Store on Fabric SQL Database

Sec 37 established why metadata beats hard-coding. This section is the how, on the real store: a Fabric SQL Database item — transactional, cheap at metadata scale, queryable from notebooks and pipelines, and version-controllable via its DDL. The working prototype is nb_metadata_sqldb_prototype.ipynb, which runs against a local equivalent so every statement is proven, then switches to Fabric SQL DB by parameter.

The schema

Table Purpose
etl_source Systems: source_id, kind (file/api/db), connection name (secret stays in Key Vault), base path/endpoint, default format, enabled.
etl_entity Per-table config: entity_id, source_id, target lakehouse/table, layer, load_type, merge_keys, watermark_column, cluster_by, table_properties (JSON), engine_hint, pool_hint, enabled.
etl_watermark Current high-water mark per entity, upserted transactionally at the end of a successful run. Separated from the run log so the hot read is a single row.
etl_run_log Audit: run_id, entity_id, timings, rows, status, error, spark_application_id (the jump-off into Sec 21).
dq_rules Declarative quality rules per entity with severity — consumed by the generic runner in nb_data_quality.
vw_active_entities View joining source+entity with the enabled filters applied, so notebooks never embed filter logic.

Connecting from a notebook — two paths, different jobs

pyodbc + Entra token — control plane 3.5+
PYSPARK FABRIC NOTEBOOK UTILS
# Obtain Entra ID bearer token for Azure SQL / Fabric SQL Database
token = notebookutils.credentials.getToken("https://database.windows.net/")
jdbc_url = f"jdbc:sqlserver://{SQL_SERVER}:1433;database={SQL_DB};encrypt=true;"
# Read a VIEW: Push filter predicates into SQL engine directly
meta = (
spark.read.format("jdbc")
.option("url", jdbc_url)
.option("accessToken", token)
.option("query", "SELECT entity_id, target_table, load_type FROM dbo.vw_active_entities WHERE layer = 'silver'")
.load()
)
# Parallel read of a LARGE table with partition pushdown
big = (
spark.read.format("jdbc")
.option("url", jdbc_url)
.option("accessToken", token)
.option("dbtable", "dbo.fact_positions")
.option("partitionColumn", "position_id")
.option("lowerBound", 1)
.option("upperBound", 50_000_000)
.option("numPartitions", 16) # 16 concurrent JDBC connections
.load()
)
Use for reads of config and all writes (run log, watermarks). Parameterized statements, executemany for batches, real transactions.
Spark JDBC — bulk reads only 3.5+
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
meta = (spark.read.format("jdbc")
.option("url", jdbc_url)
.option("query", "SELECT * FROM dbo.vw_active_entities")
.option("accessToken", tok_str)
.load())
Fine when metadata is genuinely large or you want it as a DataFrame to join. Never for row-by-row run-log writes — see gotchas.

Gotchas — the list that saves days

Gotcha What happens / what to do
Token struct packing The Entra token must be UTF-16-LE encoded and length-prefixed into attrs_before={1256: …}. Get this subtly wrong and you get an opaque login failure, not a helpful error.
ODBC driver presence Driver name/version varies by runtime image. Probe pyodbc.drivers() and select rather than hard-coding "ODBC Driver 18" — this is a classic Runtime 1.3 → 2.0 breakage.
Writing logs via Spark JDBC A DataFrame write per run-log row is an enormous per-row overhead and can deadlock under concurrency. Batch with pyodbc executemany, or accumulate and write once per run.
Metadata as a runtime dependency Querying config inside a per-partition function turns the SQL DB into a bottleneck and can exhaust connections. Fetch once per run, cache to a dict, broadcast if needed.
Connections across runMultiple Notebooks in a shared session must not share a connection object across concurrent activities. Open per activity, close in finally.
Watermark upsert races Two runs of one entity can interleave. Use a single MERGE (or UPDATE … WHERE new > current) so the watermark only moves forward — never last-writer-wins.
Non-idempotent run ids Derive run_id from a deterministic key (entity + scheduled slot) if you need retries to update rather than duplicate rows.
String-concatenated SQL Injection plus plan-cache churn. Parameterize (? in pyodbc) — as much a performance rule as a security one.
Mirroring latency If you read the SQL DB's mirrored OneLake tables from Spark, remember it is near-real-time, not synchronous. Control-plane reads (what should I run now?) go direct via pyodbc; analytical reads over history can use the mirror.
Secrets in metadata Store connection names, resolve values from Key Vault at run time. A metadata row is not a secret store.
Schema drift in JSON columns table_properties as JSON is flexible but unvalidated — validate on read and fail loudly rather than silently ignoring a typo'd property.
Performance shape that works: one vw_active_entities query at run start → config cached in a dict → per-entity work driven from memory → run-log rows accumulated in a list → one batched executemany write + one watermark MERGE per entity at the end. The SQL DB is touched twice per run, not once per row.
37 · Best Practices

Metadata-Driven Orchestration Frameworks

Hard-coded paths, schemas, and per-table notebooks don't survive contact with the 40th source system. The alternative: a small relational metadata store that describes every source and target, and a small set of generic notebooks that read it.

The metadata store

Fabric SQL Database is the natural home — transactional, cheap at this scale, queryable from notebooks (via connection string + Entra auth) and from pipelines alike. A workable minimal schema:

Table Purpose (key columns)
etl_source Source registry: source_id, kind (file/api/db), connection ref (name only — secrets stay in Key Vault), landing path pattern, format, schema JSON, enabled flag.
etl_entity Per-table config: entity_id, source_id, target lakehouse/table, layer, load type (full/incremental/CDF), merge keys, watermark column, cluster-by columns, table properties (DV/CDF/V-Order), engine hint (python/spark), pool size hint.
etl_run_log Run audit: run_id, entity_id, start/end, rows read/written, watermark advanced to, status, error, Spark application id (links straight to Sec 21's Monitoring hub).

One generic ingestion notebook then loops the enabled entities: resolve config → choose engine (Sec 34) → load → validate → merge → write run log. Adding source #41 becomes an INSERT, not a new notebook. The companion notebook demonstrates the full loop with a working metadata table.

Orchestration options — and when a pipeline isn't the right tool

Option Use when
notebookutils.notebook.runMultiple() A DAG of related notebooks that should share one Spark session — you define dependencies as a DAG object and Fabric runs them with concurrency inside the session. Massive CU saving vs a pipeline invoking N notebooks that each spin up their own session; the natural engine for the metadata-driven loop above.
Data pipeline Cross-item orchestration (copy activities + notebooks + warehouse procs), external triggers, retry/alerting policies, parameter passing between heterogeneous activities. Best as the outer scheduler invoking one coordinator notebook, not as a per-table fan-out.
Spark Job Definition Production batch jobs as versioned artifacts (py files / JARs) with their own schedule and retry — closer to spark-submit than to a notebook; good for hardened, non-interactive workloads.
Apache Airflow job (Fabric-hosted) Complex cross-platform DAGs, existing Airflow estate, or dependency logic beyond what pipelines express.
Activator (event-driven) Trigger on data/file arrival or capacity/job events rather than a clock — e.g. run ingestion when the source drops a file, alert on Sec 19/Sec 21 signals.
Materialized Lake Views Where a silver→gold transform is expressible as SQL, let Fabric own refresh + lineage instead of orchestrating it at all (Sec 24).
The pattern that composes all of this: pipeline (or Activator) triggers one coordinator notebook → coordinator reads etl_entity from Fabric SQL DB → routes small entities to Python-notebook processing and big ones into a runMultiple DAG on one right-sized Spark session (Sec 15/Sec 19) → every run writes etl_run_log with its Spark application id → Sec 21's diagnostic emitter and the Capacity Metrics App close the observability loop. No hard-coded paths, one session's worth of CU, and every failure traceable in two clicks.
38 · Fabric Platform

Running in Fabric — Sessions, Wheels & Imports

Code that works locally often needs no change to run in Fabric — but three things do differ, and each one bites teams that assume otherwise.

1. You do not create a Spark session

Fabric starts the session for you (via Livy, Sec 21) before your first cell runs. spark, sc and notebookutils are already bound. Calling SparkSession.builder…getOrCreate() is at best a no-op and at worst misleading, because master(), Delta wiring and executor shape are all decided by the Environment and pool — not by your code.

Local habit carried into Fabric 3.5+
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
spark = (SparkSession.builder
.appName("my_job")
.master("local[4]") # ignored/meaningless in Fabric
.config("spark.executor.memory","8g") # too late: session exists
.getOrCreate())
The builder silently returns the existing session, and every config on it is dropped — so the job runs with settings you think you set.
Fabric-first, with a local fallback that never fires in Fabric 3.5+
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
try:
spark # provided by Fabric
IN_FABRIC = True
except NameError: # local/dev only
IN_FABRIC = False
from pyspark.sql import SparkSession
from delta import configure_spark_with_delta_pip
spark = configure_spark_with_delta_pip(
SparkSession.builder.master("local[4]")).getOrCreate()
Session-start settings go in a %%configure -f cell above this, or in the Environment's Spark properties. Only runtime-mutable keys can be set from code (Sec 14).

2. Importing your own modules is not sys.path

Route When to use it
Environment custom library The production answer. Upload a .whl (Python also accepts .py) to the Environment and Publish; every notebook attached to it can import. Remember the re-publish requirement when moving to Runtime 2.0 (Python 3.11 → 3.13).
Notebook Resources ("builtin") Upload the .py to the notebook's Resources folder and from builtin import my_module. Stays attached to the notebook; good for notebook-specific helpers. Manual and notebook-scoped — not affected by Environment publishing.
Lakehouse Files + sys.path sys.path.append("/lakehouse/default/Files/code") after uploading there. Works, but the path is a runtime dependency and easy to break across workspaces.
%run another notebook For helpers defined in notebooks rather than .py modules. Note it executes the notebook in your session rather than importing a module — and behaviour differs from Jupyter's %run.
Inline %pip install Session-scoped and manual — fine for interactive exploration, wrong for production paths (slow session starts, unauditable versions).

Python (non-Spark) notebooks are the constrained case: the practical route today is a wheel installed inline, e.g. %pip install /lakehouse/default/Files/code/toolkit-0.1-py3-none-any.whl.

3. Choosing the notebook type

Item type Use when
Spark notebook (PySpark) Distributed shuffles, large joins, V-Order writes, Delta DDL (OPTIMIZE, VACUUM, ALTER TABLE all require Spark). The default for the kit's notebooks.
Python notebook Single node, no Spark session at all — you do not create one, and there is nothing to size. Right for <~10 GB work with Polars/DuckDB/delta-rs, API calls, and orchestration glue (Sec 34). Note the maintenance consequence: tables written this way get no auto-compaction, so they need maintenance more, not less (Sec 33).
Spark Job Definition Hardened non-interactive batch as a versioned artifact — closer to spark-submit than to a notebook.
Kit convention: every notebook here is Fabric-first — it uses the session Fabric provides and only falls back to building one when no session exists (i.e. local development). That fallback is what let every notebook be executed and verified before shipping; it never runs inside Fabric.
39 · Fabric Platform

Runtime 2.0 Deep Dive — Spark 4.1 & Delta 4.2

Status: Runtime 2.0 is Generally Available on Apache Spark 4.1 and Delta Lake 4.2, with Python 3.13, Java 21, Scala 2.13 and Azure Linux 3.0. It is production-ready — but it is not yet the default runtime, so you select it explicitly at workspace or Environment level. Sec 17 lists the changes; this section shows what they do to real code.

1. ANSI mode — the change that breaks working code

Runtime 1.3 behaviour 3.5
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
SELECT CAST('abc' AS INT) AS a, -- NULL
 CAST('9999999999' AS INT) AS b, -- NULL (overflow wraps to NULL)
 10 / 0 AS c, -- NULL
 element_at(array(1,2,3), 9) AS d; -- NULL
Every bad value silently becomes NULL. Pipelines "succeed" and quietly emit nulls downstream — which is exactly why ANSI exists.
Runtime 2.0 behaviour 4.1
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
-- Same statements now THROW:
--   CAST_INVALID_INPUT, ARITHMETIC_OVERFLOW, DIVIDE_BY_ZERO,
--   INVALID_ARRAY_INDEX
-- Version-portable rewrite - works identically on BOTH runtimes:
SELECT try_cast('abc' AS INT) AS a,
try_cast('9999999999' AS INT) AS b,
try_divide(10, 0) AS c,
try_element_at(array(1,2,3), 9) AS d;
Migration technique: set spark.sql.ansi.enabled=true on Runtime 1.3 and run your suite. Every failure is a bug Runtime 2.0 would surface — found before you migrate, on a runtime you can roll back instantly.

2. SQL scripting — control flow without a host language

SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
-- 4.1 only. Loop over entities and load each, entirely in SQL.
BEGIN
DECLARE done BOOLEAN DEFAULT false;
DECLARE rows_loaded BIGINT DEFAULT 0;
FOR entity AS (SELECT entity_name, target_table FROM ctl.etl_entity WHERE enabled) DO
BEGIN
-- per-entity work here
 SET VAR rows_loaded = rows_loaded + 1;
EXCEPTION WHEN OTHERS THEN
INSERT INTO ctl.etl_error VALUES (entity.entity_name, current_timestamp());
END;
END FOR;
INSERT INTO ctl.etl_run_log VALUES ('batch', rows_loaded, current_timestamp());
END

Exception handling and loops inside SQL. On 1.3 this logic lives in Python around the SQL, split across two languages.

3. VARIANT — semi-structured without the JSON-string tax

1.3 — parse on every read 3.5
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
-- Runtime 1.3: Parse JSON on every read (falls back from NEE)
SELECT
get_json_object(payload, '$.device.id') AS device_id,
get_json_object(payload, '$.reading') AS reading
FROM bronze.telemetry;
2.0 — parse once, query as structure 4.1
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
-- 1. Create table with native VARIANT column (Delta 4.x / Spark 4.1)
CREATE TABLE bronze.telemetry (
event_id BIGINT,
payload VARIANT
) USING DELTA;
-- 2. Ingest raw JSON payload directly into binary shredded VARIANT
INSERT INTO bronze.telemetry
SELECT
event_id,
parse_json(raw_payload)
FROM landing.raw;
-- 3. Query semi-structured elements with zero JSON parsing overhead
SELECT
payload:device.id::STRING AS device_id,
payload:reading::DOUBLE AS reading
FROM bronze.telemetry;
Binary-encoded, shredded storage. Interop caution: VARIANT is a Delta 4.x feature — Spark-only today, so do not use it on tables other Fabric engines must read.

4. PIPE syntax — readability as a performance feature

SPARK SQL (PIPE SYNTAX) NOTEBOOK %%sql (SPARK 4.1+ / RUNTIME 2.0)
-- 4.1 only. Identical plan to the nested-subquery form; far easier to review.
FROM silver.orders
|> WHERE status = 'complete'
|> AGGREGATE SUM(amount) AS revenue GROUP BY order_date, region
|> WHERE revenue > 10000
|> ORDER BY revenue DESC
|> LIMIT 100;

5. SQL UDFs — shared logic that Catalyst can see through

1.3 — Python UDF 3.5
PYSPARK RUNNABLE IN FABRIC NOTEBOOK
@udf("double")
def net_of_vat(amount, vat):
return amount / (1 + vat)
Opaque to the optimizer, per-row serialization, and it drops the enclosing operator out of NEE (N002).
2.0 — SQL UDF 4.1
SPARK SQL NOTEBOOK %%sql OR SQL ENDPOINT
CREATE FUNCTION net_of_vat(amount DOUBLE, vat DOUBLE)
RETURNS DOUBLE
RETURN amount / (1 + vat);
SELECT net_of_vat(gross, 0.20) FROM silver.invoices;
Catalog-resident, inlined by the optimizer, zero serialization, NEE-friendly.

6. Delta Lake 4.2 and NEE coverage

  • Liquid clustering is standard — no preview flag, unlike Delta 3.2 on Runtime 1.3.
  • NEE now accelerates Python UDFs, Scala UDFs and complex types (arrays, maps, structs), plus vectorized CSV — materially wider coverage than at 1.3. Built-ins still beat UDFs, but the penalty is smaller.
  • Delta 4.x-only features (VARIANT columns, collations) remain Spark-only. Enabling them on a table read by the SQL endpoint, Direct Lake or another engine breaks that reader. Gate behind interop review.

7. The migration checklist that actually bites

Item What happens if you skip it
Re-publish Environments with libraries Python 3.11 → 3.13 means every custom/public library must be re-added and Published. Skip it and jobs fail with "No module found" / "Class not found". Export your library list first.
Recompile Scala/Java JARs Scala 2.12 → 2.13, JDK 11 → 21. Old JARs fail to load.
ANSI audit Silent-NULL logic starts throwing in production. Pre-test on 1.3 as shown above.
Verify NEE is actually native ANSI-on defaults mean an "NEE enabled" 2.0 session can run entirely on the JVM. Check for *Transformer operators (Sec 08).
Deprecated paths WASB → ABFS; EventHubConnector → Kafka connector; SparkR → sparklyr/PySpark.
Rollback plan Runtime is an Environment-level setting, so reverting to 1.3 is a config change, not a rebuild. Keep the 1.3 Environment intact until you are confident.
Release channels. Fabric now offers runtime release channels so you can validate upcoming runtime changes before they become the default — worth adopting alongside 2.0 so future upgrades are tested rather than discovered.
40 · Fabric Platform

Runtime Migration Decision Framework

Sec 17 lists what changed; this decides whether your workload should move. Runtime 2.0 is now GA, so the question is no longer is it safe but is your code ready.

Verdict by workload class
Workload What 2.0 gains Verdict
Batch ETL (Delta, built-ins) Delta 4.2, liquid clustering ungated, native write acceleration, Spark 4.x perf Pilot now. Lowest-risk class — but run the ANSI pre-test first.
Interactive / ad hoc SQL PIPE syntax, SQL scripting, SQL UDFs, session variables Pilot now. Biggest ergonomic win; low blast radius if it misbehaves.
Streaming transformWithState, State Data Source, Real-Time Mode (4.1) Pilot in isolation. Real gains, but streaming still falls back from NEE and state migration needs care.
ML / feature engineering Spark ML on Connect, Python 3.13, newer pandas/PyArrow Move deliberately. Library re-publish churn is highest here (Python 3.13); pin and test thoroughly.
BI-serving gold (Direct Lake) Delta 4.2 write path Move last. GA removes the preview risk, but Delta 4.x-only features still risk interop with non-Spark readers, and the upside is smallest here.

Interactive: migration readiness scorer

Eight questions. Scores your specific blockers rather than giving a generic verdict.

Runbook

  1. Pre-flight on 1.3: set spark.sql.ansi.enabled=true in a dev Environment and run your suite. This surfaces the dominant 4.x breakage before you change runtimes. Automate with review(..., runtime="fabric-2.0") to flag ANSI-sensitive code and NEE interactions.
  2. Export Environment library lists for every Environment, then re-add and Publish after switching (mandatory — Python 3.11 → 3.13).
  3. Recompile custom Scala/Java JARs for Scala 2.13 / JDK 21.
  4. Sweep deprecated paths: WASB → ABFS; EventHubConnector → Kafka connector; SparkR → sparklyr/PySpark.
  5. Gate Delta 4.x-only features (collations, VARIANT columns) on tables read by other Fabric engines.
  6. Decide ANSI × NEE per workload and record it in the Environment's Spark properties, not ad hoc in notebooks.
  7. Verify NEE is actually native post-migration (N003/N007, Advisor alerts, nb_nee_fallback_analyzer) — an ANSI-on 2.0 environment can silently run JVM-only.
  8. Rollback plan: keep the 1.3 Environment intact and switchable; runtime is an Environment-level setting, so reverting is a config change, not a rebuild.
41 · Practical

Environment Setup Runbook

Everything in this document is settings-and-patterns; this section is the order to apply them in on an existing workspace, without a rebuild. Work top-down: each step depends on the one above, and steps 1–4 are one-time while 5–8 are per-pipeline habits.

Before you change anything: run nb_lakehouse_health_audit and open the Capacity Metrics App. Capture the current state — file counts, average file sizes, CU consumption by item — so every change afterwards has a before/after. Tuning without a baseline is guessing with extra steps.

Step 1 — Capacity & workspace (admin, one-time)

  • Confirm the SKU and burst posture. Note CU → vCores (×2) and the 3× burst ceiling (Sec 19). If a single large job needs the ceiling, Job bursting must be on and the pool's Autoscale max set high enough.
  • Turn on surge protection with a background-rejection threshold, so runaway background work is refused before it pushes the capacity into the interactive-rejection band that hurts users.
  • Decide Autoscale Billing for Spark if Spark contends with Power BI on a shared capacity — it moves Spark to serverless per-job billing, off the capacity entirely.
  • Separate dev from prod capacity where budget allows; a paused small dev capacity stops ad-hoc notebooks competing with production.

Step 2 — Pools (workspace Spark settings → Pool tab)

  • Starter pool for interactive/dev: Medium nodes, seconds to start. Leave it alone.
  • Custom pool for production batch: size from the Config Advisor, set minimum nodes low (admission is optimistic — a low minimum admits far more often) and Autoscale max at what the job genuinely needs.
  • Leave "Customize compute configurations for items" ON if you want notebooks to be able to override; off means the workspace pool wins everywhere.
  • High concurrency ON — session sharing across notebooks is a large CU saving for interactive work.

Step 3 — Environment items (the real control plane)

  • Create one Environment per medallion layer or per workload class, not one per notebook. This is where runtime version, Spark properties, libraries and the NEE toggle live (Sec 38).
  • Set the runtime version deliberately — 1.3 for production, 2.0 only in a pilot Environment (Sec 40).
  • Apply the baseline Spark properties from Sec 29. Remember: only settings that differ from defaults belong here.
  • Acceleration tab → NEE on, then verify it is actually native (Sec 08) rather than assuming.
  • Upload utility modules as an Environment custom library and Publish — this is what makes import fabric_workload_advisor work everywhere.

Step 4 — Metadata & table properties (one-time, then self-sustaining)

  • Deploy the Fabric SQL Database schema (Sec 36) and register your existing sources into etl_source/etl_entity. This is the highest-leverage single change: it turns N notebooks into one.
  • Run a one-off property sweep: deletion vectors + CDF on merge-heavy silver, V-Order on gold only, clustering on tables ≥10 GB (Sec 24).
  • If tables have never been maintained, do a rebaselineREORG … APPLY (PURGE) then OPTIMIZE — before switching to incremental maintenance (Sec 33).

Steps 5–8 — the habits that keep it healthy

Step Habit Why
5 Session config at the top of every notebook A %%configure cell or Environment attachment — never spark.conf.set for session-start keys (Sec 14).
6 Run-log write at the end of every run Entity, engine, duration, CU estimate, Spark app id (Sec 20) — this is your cross-workspace cost rollup and your incident jump-off.
7 Maintenance as the last pipeline activity Health-gated OPTIMIZE per table, VACUUM on a schedule respecting the 7-day floor (Sec 33).
8 Weekly review of audit + capacity Health report trend, throttling events, CU per entity. Fifteen minutes weekly prevents the quarterly emergency.

Interactive: generate your config pack

42 · Fabric Platform

Beyond Spark — OneLake Security, CI/CD & Governance

This document is deliberately Spark-and-Lakehouse deep. But an expert Fabric data engineer is judged on four adjacent areas too, and neglecting them is what separates a good notebook author from someone who can own a platform. This section is a map with enough depth to act on, not a substitute for the docs — statuses move, so verify anything marked preview.

1. Security & governance

Layer What it does / when to use it
Workspace roles Admin / Member / Contributor / Viewer — the coarse grant. Most over-permissioning starts here: Contributor on a production workspace is write access to everything in it.
Item permissions Per-item sharing for cases where workspace-level is too broad.
OneLake security (RLS/CLS) Centralizes access control at the data layer so rules follow the data across engines rather than being re-implemented per experience. GA'd April 2026. Applied from the Lakehouse/mirrored item via "Manage OneLake security" roles.
Semantic model RLS Report-scoped rules. Layers stack — a user must satisfy the effective rules on the whole path, and the semantic model must not be used to grant back what the data layer denied.
Workspace Identity A managed identity for the workspace, letting pipelines/notebooks/shortcuts reach secured sources without stored credentials, and enabling trusted workspace access from storage accounts. The correct answer to "where do we put the service principal secret".
Sensitivity labels & Govern tab Purview-based labelling; as of January 2026 the governance insights moved from the Purview Hub into the Govern tab of the OneLake Catalog — label coverage, endorsement, security health in one place.
Encryption Microsoft-managed keys by default; customer-managed keys via Key Vault where required.

Known sharp edge: combining shortcuts, SQL-endpoint RLS and OneLake security across workspaces is not fully supported — the practical pattern is to materialize gold physically rather than shortcut it, or centralize RLS in the semantic layer.

2. CI/CD and environment promotion

  • Git integration stores item definitions — notebook code, pipeline JSON, configuration. Data is not version controlled: Delta tables, warehouse data and OneLake files stay in OneLake. Treat your metadata DDL as code and version it explicitly.
  • Deployment pipelines promote dev → test → prod. Note that internal shortcuts are remapped across stages, but target tables/folders are not created automatically — you create them in the target workspace after deployment.
  • Variable libraries eliminate hard-coded Lakehouse IDs, connection strings and parameters, resolving per workspace so promotion needs no manual reconfiguration. This is the supported answer to the hard-coded-abfss problem flagged in Sec 34.
  • Programmatic deployment via fabric-cicd or the Fabric REST APIs when you need Azure DevOps/GitHub Actions to drive it rather than the portal.

3. Real-time & streaming

Option Use when
Eventstream No-code ingestion from Event Hubs, Kafka, IoT and CDC sources into Eventhouse or Lakehouse. The path used in Sec 21 to land Spark diagnostic logs.
Eventhouse / KQL Time-series and log-shaped data at high ingest rates, queried with KQL. Better than Delta for append-heavy telemetry with time-window queries.
Structured Streaming on Spark When you need Spark transformations on the stream. Remember: streaming falls back from NEE (Sec 08), and Spark 4.x brings transformWithState and the State Data Source (Sec 17).
Activator Event-driven triggering — run on data or file arrival rather than a clock, and alert on capacity/job events.

4. Ingestion alternatives & the semantic layer

Tool Where it beats a notebook
Copy job / Copy activity Straightforward source-to-file/table movement with no bespoke logic — cheaper and simpler than hand-written code (Sec 35).
Mirroring Near-real-time replication of an operational database into OneLake with no pipeline to maintain. Remember it is near-real-time, not synchronous.
Dataflow Gen2 Low-code transformation for citizen developers. Produces the same Delta tables — and therefore the same maintenance needs (Sec 33).
dbt-fabric SQL-first transformation with lineage and testing, for teams already invested in dbt.
Direct Lake semantic models The reason V-Order and file sizing on gold matter (Sec 24). Direct Lake reads Delta files directly rather than importing — but the model must re-frame to pick up new commits, which is why VACUUM ordering matters for gold tables (Sec 33).
Star schema at gold Wide flat tables are convenient for Spark and poor for BI. Model gold as conformed dimensions plus fact tables — the semantic layer's performance ceiling is set here, not in Power BI.
43 · Practical

Expert Learning Path & Milestones

Thirty-six sections is a reference, not a curriculum. This is the order that builds understanding rather than trivia, with a concrete artifact at each stage so learning is verified by doing. Pick your current level below.

Recommended Deep-Dive Sequence (Added This Version)

New High-Value Sections — Suggested Order
  1. Sec 04 · Catalyst Optimizer — Understand how SQL/PySpark becomes a physical plan. Foundation for everything below.
  2. Sec 06 · Adaptive Query Execution (5-Feature Masterclass) — How Spark self-corrects at runtime. DPP, RSM integration, Skew Join splitting.
  3. Sec 08 · NEE Fallback Deep Dive — ANSI×NEE matrix: why Runtime 2.0 with NEE enabled can still run entirely on the JVM.
  4. Sec 24b · Column Stats & SQL Tuning Masterclass — The 32-column skipping trap, maintenance diagnosis, and the SQL-to-Spark Rosetta Stone.
  5. Sec 24 · Delta Optimization — V-Order, Liquid Clustering, Deletion Vectors, CDF internals.
  6. Sec 15 · Config Advisor (Medallion Presets) — Generate Bronze/Silver/Gold environment configs with Minimal Delta mode.
  7. Sec 33 · Table Maintenance Cadence Advisor — Interactive schedule generator for OPTIMIZE, DV purge, and VACUUM.
  8. Sec 27 · Query Plan Reading — How to read df.explain("formatted") and the SQL tab DAG. Bridges everything above.
The honest test of expertise is not recalling that AQE coalesces partitions. It is being handed a slow job and knowing — without guessing — whether the cause is the code, the table layout, the session config, the pool size, or the capacity; and being able to prove it from the plan and the metrics before changing anything. Everything in this document exists to make that sequence routine.
44 · Reference

Comprehensive Master Glossary

AQE
Adaptive Query Execution — runtime re-optimization using real post-shuffle statistics. Handles coalescing, join strategy switching, skew splitting, and DPP. Sec 06
BlockManager
Per-executor component serving shuffle/cached blocks to remote requesters. Sec 11
Bloom Filter Index
Probabilistic bit-array index on high-cardinality columns enabling whole-file skipping. Not available in FabricCREATE BLOOMFILTER INDEX is a Databricks-proprietary Delta extension and is not part of OSS Delta Lake, which is what Fabric Spark runs. The Fabric equivalents are Delta data skipping via delta.dataSkippingStatsColumns and liquid clustering. Sec 24b
Broadcast join
Sends a full copy of a small table to every executor to avoid a shuffle. Default threshold: 10MB (configurable). Sec 12
Catalyst CBO
Cost-Based Optimizer component of Catalyst. Uses column statistics (NDV, histograms, row counts) from ANALYZE TABLE to estimate cardinality and choose join orders. Sec 04, Sec 24b
CDF (Change Data Feed)
delta.enableChangeDataFeed=true — records row-level changes (insert/update_pre/update_post/delete) in sidecar CDC files. Powers incremental silver→gold processing. Sec 24
Checkpoint (.checkpoint())
Truncates the DataFrame lineage DAG by materializing to OneLake storage. The Spark equivalent of writing to a #temp table in SQL — prevents re-computation and StackOverflowError on deeply-chained transformations. Sec 03
DAGScheduler
Driver component that cuts a job into stages at shuffle boundaries. Sec 03
Data Skipping
Delta Lake's file-pruning mechanism. At query planning time, the driver reads minValues/maxValues from the Delta log and eliminates files whose ranges don't overlap the query filter. Limited to first 32 columns by default. Sec 24b
Decision Layer
Fabric: per-stage router choosing local vs. remote shuffle storage. Sec 13
Deletion Vectors (DV)
Roaring bitmap sidecar files that soft-delete rows from Parquet files without rewriting them. Dramatically accelerates MERGE/UPDATE/DELETE. Requires periodic REORG TABLE APPLY (PURGE) to physically remove. Sec 24
DPP (Dynamic Partition Pruning)
AQE feature that injects a partition filter from the build side of a join into the probe side scan at runtime, eliminating entire Parquet files before read. Sec 06
Executor decommissioning
Spark 3.1+ graceful executor retirement, migrating shuffle blocks before exit. Sec 13
FetchFailedException
Thrown when a shuffle block can't be fetched; triggers full stage recompute. Sec 03, Sec 11
Liquid Clustering
CLUSTER BY (col1, col2) — replaces partitioning and Z-ORDER. Incremental, no cardinality trap, re-clusterable without full rewrite. The lakehouse equivalent of a Clustered Columnstore Index. GA on Runtime 2.0. Sec 24
MapOutputTracker
Driver-resident metadata service: (shuffleId, partition) → block location. Sec 11
NEE
Native Execution Engine — Fabric's vectorized C++ path (Velox + Apache Gluten). Falls back silently to JVM under ANSI mode (Runtime 2.0 default), Structured Streaming, and unsupported operators. Sec 07, Sec 08
Partition
The unit of parallelism; one task processes one partition. Sec 10
RSM
Remote Shuffle Manager — writes shuffle data to Azure Blob Storage, enabling executors to scale down without losing shuffle blocks. Sec 13
Shuffle
Redistribution of data across executors required by a wide transformation. Sec 03, Sec 11
Stage
A DAG unit bounded by shuffle exchanges; one job can contain many. Sec 03
TaskScheduler
Driver component assigning a stage's tasks to executor cores. Sec 03
Tungsten
Spark's binary memory format + whole-stage code generation project. Sec 05
Unified Memory Manager
Default (since 1.6) dynamic execution/storage memory sharing. Sec 09
V-Order
Fabric Parquet write-time sort/encoding optimized for VertiPaq (Power BI Direct Lake) in-memory scans. Adds ~10–15% write cost. Enable on gold/consumption tables only. Sec 24, Sec 15
VeloxColumnarToRowExec
The plan operator marking the boundary between NEE native (columnar Velox) and JVM (row-based) execution. Presence in plan = NEE fallback boundary. Multiple occurrences = significant conversion overhead. Sec 08
Wide transformation
An operation requiring data movement between partitions — forces a shuffle. Sec 03