Domain 2 · 30–35%

Ingest and transform data

The craft third of the exam: getting data in — batch or streaming — and reshaping it correctly, idempotently, and with the right engine for the job.

Three objectives:

2.1 Design and implement loading patterns

Full vs incremental loads

flowchart TD S([Loading strategy]) --> Q1{Volume and change rate} Q1 -->|"Small table, or first load"| FULL["Full load
truncate and reload"] Q1 -->|"Large table, frequent changes"| INC["Incremental load"] INC --> Q2{How do we detect change?} Q2 -->|"Reliable modified date column"| WM["Watermark
high-water mark"] Q2 -->|"Source exposes CDC / change feed"| CDC["CDC · Mirroring · Delta CDF"] Q2 -->|"No reliable change column"| HASH["Row hash comparison"] WM --> MERGE["MERGE into target"] CDC --> MERGE HASH --> MERGE
Change detection drives the design; MERGE is almost always how the change lands in Delta.
AspectFull loadIncremental load
Data movedEverything, every runOnly new/changed rows
Target operationTRUNCATE + INSERT, or overwriteMERGE / upsert / append
ComplexityLowHigher — needs state tracking
RuntimeScales with total sizeScales with change volume
IdempotentNaturallyOnly if designed for it
Best forSmall dimensions, reference data, initial seedingLarge fact tables, frequent refreshes

Caveat — idempotency is the hidden requirement

Any incremental design must survive being re-run after a failure without duplicating or losing rows. That is why MERGE on a business key beats a blind append, and why the watermark should be committed only after the load succeeds. A question describing "the pipeline failed halfway and re-running created duplicates" is testing exactly this — the fix is MERGE, not a bigger cluster.

The watermark pattern

  1. Lookup the last successful watermark from a control table.
  2. Copy source rows where ModifiedDate > @watermark into a staging/bronze area.
  3. MERGE staging into the target on the business key.
  4. Update the control table with the new maximum ModifiedDate — only now, and only on success.

Preparing data for a dimensional model

flowchart LR SRC["Source systems"] --> BRZ["Bronze
raw, as landed, append-only"] BRZ --> SLV["Silver
cleansed, typed, deduplicated, conformed"] SLV --> GLD["Gold
dimensional model"] GLD --> DIM["Dimension tables
surrogate keys, SCD"] GLD --> FCT["Fact tables
measures + dimension FKs"]
The medallion architecture. Bronze preserves history and enables replay; gold is what BI consumes.
ConceptWhat it means
Star schemaCentral fact table joined to denormalized dimensions — the target shape for Power BI
Surrogate keyA system-generated key on the dimension, independent of the source's business key; required for SCD Type 2
Business / natural keyThe source's own identifier — what you MERGE on
Conformed dimensionOne shared dimension used consistently by multiple facts
Fact grainWhat one row of the fact table represents — define it before anything else
Degenerate dimensionA dimension attribute (e.g. order number) kept on the fact with no separate table

Slowly changing dimensions

TypeBehaviourImplementation
Type 0Never changes (fixed attributes)Insert once, ignore later changes
Type 1Overwrite — no history keptMERGE with WHEN MATCHED THEN UPDATE
Type 2Full history — a new row per versionClose the old row (ValidTo, IsCurrent = false) and insert a new current row
Type 3Keeps only the previous valueAdd a PreviousValue column
PySpark · SCD Type 2 with MERGE
from delta.tables import DeltaTable
from pyspark.sql.functions import col, lit, current_date

dim = DeltaTable.forName(spark, "gold_dim_customer")

# Step 1 — close out rows whose tracked attributes changed
(dim.alias("t")
   .merge(updates.alias("s"),
          "t.customer_id = s.customer_id AND t.is_current = true")
   .whenMatchedUpdate(
       condition="t.address <> s.address OR t.segment <> s.segment",
       set={"is_current": lit(False), "valid_to": current_date()})
   .execute())

# Step 2 — insert the new current versions
(dim.alias("t")
   .merge(updates.alias("s"),
          "t.customer_id = s.customer_id AND t.is_current = true")
   .whenNotMatchedInsert(values={
       "customer_id": "s.customer_id",
       "address":     "s.address",
       "segment":     "s.segment",
       "valid_from":  "current_date()",
       "valid_to":    "null",
       "is_current":  "true"})
   .execute())

Caveat — SCD Type 2 needs two passes

A single MERGE cannot both close the old row and insert a new one for the same key, because a MERGE matches each target row once. The standard implementation is two statements (close, then insert) or a single MERGE fed by a union that duplicates changed keys with a null join key. Expect the exam to test that Type 2 = new row + valid_from/valid_to/is_current, and Type 1 = overwrite in place.

Designing a streaming loading pattern

flowchart LR SRC["Event sources
Event Hubs · IoT Hub · Kafka · custom app · CDC"] --> ES["Eventstream
route, filter, transform"] ES --> EH["Eventhouse / KQL DB
hot path, sub-second query"] ES --> LH["Lakehouse Delta table
warm path, joins with batch"] ES --> ACT["Activator
rules and alerts"] LH --> SSS["Spark structured streaming
enrich, aggregate"]
A stream commonly fans out: Eventhouse for immediate analytics, Lakehouse for durable history, Activator for reaction.

Hot / warm / cold is the design vocabulary: the hot path optimizes for latency (Eventhouse), the warm path for joinability and durability (Lakehouse Delta), the cold path for cheap long-term retention (OneLake, possibly reached via shortcut).

2.2 Ingest and transform batch data

Choosing an appropriate data store

Covered in depth on the foundations page. The one-line rule: T-SQL writes → Warehouse; Spark, files, mixed workloads → Lakehouse; high-volume events and time-series → Eventhouse.

Choosing between Dataflow Gen2, notebooks, KQL and T-SQL

Choose…When the stem says…
Dataflow Gen2"analyst", "no-code / low-code", "Power Query", "many SaaS connectors", "reuse existing Power BI dataflow logic"
Notebook (PySpark)"hundreds of millions of rows", "complex logic", "machine learning", "custom Python libraries", "code-first"
T-SQL in a Warehouse"stored procedure", "SQL developers", "CTAS", "multi-table transaction", "data already in the Warehouse"
KQL"Eventhouse", "telemetry / logs", "time-series", "real-time", "update policy at ingestion"

Dataflow Gen2 details worth knowing

Gen2 improves on Power BI dataflows (Gen1) by adding explicit data destinations — Lakehouse, Warehouse, KQL database, Azure SQL — plus Git/CI-CD support and better refresh monitoring. Staging is enabled by default: data lands in a staging Lakehouse so heavy transformations can be pushed down to the SQL/Spark engine rather than evaluated in the mashup engine.

Creating and managing OneLake shortcuts

Fundamentals are on the foundations page. What matters operationally:

Implementing mirroring

Mirroring continuously replicates an external database into OneLake as Delta tables, using the source's change feed. It is near real-time, one-way, and the replicated storage is free of extra charge within your capacity's allowance.

SourceNotes
Azure SQL DatabaseThe reference implementation; requires change feed/CDC prerequisites on the source
Azure SQL Managed InstanceSame model as Azure SQL DB
SQL ServerIncluding on-premises / VM editions
Azure Cosmos DBUses the container change feed
SnowflakeCross-platform mirroring
Azure Database for PostgreSQL / MySQLOpen-source engines
Azure Databricks (Unity Catalog)Catalog-level mirroring of Databricks tables
Open mirroringBring-your-own: land change data in the prescribed format and Fabric materializes it as a mirrored database

Verify the source list before exam day

Mirroring's supported sources expand frequently and individual sources move between preview and GA. Treat the table above as the shape of the answer, and check the current Fabric mirroring documentation for status.

Caveat — mirroring vs shortcut vs copy

Mirroring replicates continuously via CDC — pick it for "near real-time replica of an operational database without building ETL". Shortcut copies nothing and reads live — pick it for "query the data where it already lives in a lake". Copy activity / Copy job moves a batch on a schedule. Mirroring is one-way: nothing you do in OneLake flows back to the source, and the mirrored tables are read-only in Fabric.

Ingesting data with pipelines

AspectDetail
Copy activityThe workhorse: source → sink with optional mapping, staging and fault tolerance
SinksLakehouse (Tables/ or Files/), Warehouse, KQL database, Azure stores
FormatsDelta, Parquet, CSV, JSON, Avro, ORC, XML
Copy behaviourAppend, overwrite, or merge depending on sink type
StagingTwo-stage copy through interim storage — used for large or cross-region transfers and for sinks needing bulk load
Fault toleranceSkip incompatible rows and log them rather than failing the activity
ParallelismDegree of copy parallelism and partition options on the source
On-premises sourcesRequire the on-premises data gateway; VNet sources use the VNet data gateway

Copy job vs Copy activity

A Copy job is a standalone item that wraps the same engine with a simplified experience and first-class incremental copy — no pipeline authoring required. Use a Copy activity inside a pipeline when the copy is one step of a larger orchestrated flow.

Transforming with PySpark, SQL and KQL

PySpark · read, clean, write
from pyspark.sql.functions import col, year, month, sum as _sum, count

df = spark.read.format("delta").load("Tables/bronze_sales")

clean = (df
    .filter(col("amount") > 0)
    .withColumn("order_year", year("order_date"))
    .withColumn("order_month", month("order_date"))
    .dropDuplicates(["order_id"]))

clean.write.format("delta").mode("overwrite") \
     .option("overwriteSchema", "true") \
     .saveAsTable("silver_sales")

# Group and aggregate
agg = (clean.groupBy("order_year", "order_month", "region")
            .agg(_sum("amount").alias("total_amount"),
                 count("*").alias("order_count")))
T-SQL · CTAS in a Warehouse
-- CTAS is the preferred bulk-transform pattern in a Fabric Warehouse
CREATE TABLE gold.monthly_sales AS
SELECT
    YEAR(order_date)  AS sale_year,
    MONTH(order_date) AS sale_month,
    region,
    SUM(amount)       AS total_amount,
    COUNT_BIG(*)      AS order_count
FROM   silver.sales
WHERE  amount > 0
GROUP BY YEAR(order_date), MONTH(order_date), region;
KQL · aggregate in an Eventhouse
SensorReadings
| where Timestamp > ago(7d)
| summarize AvgTemp = avg(Temperature),
            MaxTemp = max(Temperature),
            Readings = count()
    by bin(Timestamp, 1h), DeviceId
| order by Timestamp desc

Caveat — overwrite vs overwriteSchema

mode("overwrite") replaces the data but still enforces the existing schema — a changed column set fails. Adding .option("overwriteSchema","true") replaces the schema too. For additive changes prefer .option("mergeSchema","true"), which appends new columns without discarding the old definition.

Denormalizing, grouping and aggregating

TechniqueWhat it doesUse when
Flatten / join downPull dimension attributes onto the fact rowReducing joins at query time for BI performance
Explode / flatten nestedexplode() arrays, dot-notation for structsSemi-structured JSON landing in bronze
Pre-aggregateMaterialize summary tables at a coarser grainDashboards repeatedly computing the same rollup
Pivot / unpivotRotate rows to columns and backReshaping for reporting

Denormalization is a trade

You buy read speed with storage and update complexity: once an attribute is copied onto millions of fact rows, changing it means rewriting them. That is precisely why SCD Type 2 dimensions exist — keep the history in the dimension, keep the fact narrow.

Handling duplicate, missing and late-arriving data

ProblemBatch approachStreaming approach
DuplicatesdropDuplicates([keys]), or window row_number() keeping the latest; MERGE on the business keydropDuplicates with a watermark to bound state
Missing valuesfillna() / COALESCE(), defaults, or drop when the column is criticalSame, applied per micro-batch
Late-arriving factsMERGE into the correct partition and recompute affected aggregateswithWatermark() defines how long to accept late events
Late-arriving dimensionsInsert an inferred member (placeholder dimension row) so the fact can load, then update it when the real record arrives
PySpark · deduplicate keeping the latest version
from pyspark.sql.window import Window
from pyspark.sql.functions import row_number, col, coalesce, lit

w = Window.partitionBy("order_id").orderBy(col("modified_at").desc())

latest = (df.withColumn("rn", row_number().over(w))
            .filter(col("rn") == 1)
            .drop("rn"))

# Fill missing values with a sensible default
filled = latest.withColumn("region", coalesce(col("region"), lit("UNKNOWN")))

Caveat — dropDuplicates vs row_number

dropDuplicates(["order_id"]) keeps an arbitrary row from each group — non-deterministic and wrong when you need "the most recent version". Use a window with row_number() ordered by the timestamp when the choice of surviving row matters. This distinction is very exam-friendly.

2.3 Ingest and transform streaming data

Choosing an appropriate streaming engine

DimensionEventstreamSpark structured streamingKQL / Eventhouse
AuthoringNo-code visual designerPySpark in a notebookKQL queries and update policies
Primary roleIngest and routeTransform and enrichStore and analyse
LatencySecondsSeconds to minutes (micro-batch)Seconds
Join with large batch dataLimited✅ Full Spark joinsVia shortcuts / materialized views
WindowingTumbling, hopping, sliding, session, snapshotTumbling, sliding, sessionFull, via bin() and time functions
DestinationsEventhouse, Lakehouse, Activator, custom endpoint, derived streamDelta tables in a LakehouseKQL tables

They compose — the question is which one does the work

The common production shape is Eventstream ingests and routes → Eventhouse serves real-time queries and Lakehouse holds durable history → Spark or KQL transforms. If a question asks for no-code routing to multiple destinations → Eventstream. Enrich the stream by joining a 500 M-row dimension → Spark structured streaming. Dashboard over the last 24 hours of telemetry → KQL on an Eventhouse.

Native tables vs OneLake shortcuts in Real-Time Intelligence

AspectNative KQL tableOneLake shortcut in the Eventhouse
Where the data livesEventhouse's own optimized storageOneLake (or external), read in place
IngestionData is ingested and indexedNone — nothing is copied
Query performanceFastest — full indexing and cachingSlower — reads Delta/Parquet across services
Storage costA second copy of the dataNo duplication
Best forHot path: active real-time analytics and alertingWarm/cold path: historical lookups, joining to lake data

Query acceleration for OneLake shortcuts

Query acceleration adds a cached, indexed copy of shortcut data inside the Eventhouse so KQL queries over it approach native-table speed.

Standard shortcutQuery-accelerated shortcut
PerformanceReads OneLake directly on every queryServed from the acceleration cache
Capacity costQuery cost onlyAdditional CU and storage for the cache
FreshnessAlways currentSlight lag while the cache catches up
Choose whenInfrequent or ad-hoc queries on cold dataFrequent, latency-sensitive queries on shortcut data

Caveat — the three-way choice

Fabric gives you an explicit trade-off ladder: native table (fastest, duplicates data, ingestion required) → accelerated shortcut (fast, extra CU + slight lag, no ingestion pipeline) → standard shortcut (cheapest, slowest, always fresh). Match the answer to whichever of speed, cost or freshness the stem prioritizes. Query acceleration has a configurable retention window — it accelerates recent data, not unbounded history.

Processing data with Eventstreams

flowchart LR SRC["Sources
Event Hubs · IoT Hub · Kafka
CDC · custom app · sample data"] --> ES["Eventstream"] ES --> OPS["Event processor
filter · manage fields · expand
join · group by · aggregate · union"] OPS --> D1["Eventhouse"] OPS --> D2["Lakehouse"] OPS --> D3["Activator"] OPS --> D4["Derived stream / custom endpoint"]
Eventstream in edit mode: sources on the left, no-code processing in the middle, one or many destinations on the right.

Processing data with Spark structured streaming

PySpark · structured streaming with watermark
from pyspark.sql.functions import window, avg, count

stream = (spark.readStream
    .format("delta")
    .option("readChangeFeed", "true")
    .table("bronze_events"))

agg = (stream
    .withWatermark("event_time", "10 minutes")     # tolerate 10 min of lateness
    .groupBy(window("event_time", "5 minutes"), "device_id")
    .agg(avg("temperature").alias("avg_temp"),
         count("*").alias("event_count")))

(agg.writeStream
    .format("delta")
    .outputMode("append")
    .option("checkpointLocation", "Files/checkpoints/sensor_agg")
    .trigger(processingTime="1 minute")
    .toTable("silver_sensor_agg"))
Output modeWritesRequires
appendOnly new rows, never revisedA watermark for aggregations — results emit when the window closes
updateOnly rows that changed this batchSink that supports updates
completeThe entire result table, every batchAggregations only; state grows unbounded without a watermark

Caveat — checkpoints and watermarks are non-negotiable

Checkpoint location is mandatory for fault tolerance: it stores stream offsets and aggregation state so a restarted job resumes exactly where it stopped. Deleting or sharing a checkpoint directory between two different streams corrupts that state — each stream needs its own. Watermarks bound how long state is retained for late events; without one, a stateful aggregation's state grows forever until the job fails on memory. "Streaming job memory grows until it crashes" → missing watermark.

Windowing functions

WindowShapeTypical question
TumblingFixed size, non-overlapping, contiguous"Count events in each 5-minute block"
HoppingFixed size, advances by a hop — overlaps when hop < size"Average over 10 minutes, reported every 5"
SlidingEmits whenever an event enters or leaves the window"Continuously updated moving average"
SessionVariable length, closed by an inactivity gap"Group a user's clicks until 30 minutes idle"
SnapshotGroups events sharing the exact same timestamp"Aggregate all readings at the same instant"
KQL · windowing
// Tumbling: bin() rounds each timestamp down to the window start
SensorReadings
| summarize Events = count(), AvgTemp = avg(Temperature)
    by bin(Timestamp, 5m), DeviceId

// Hopping: 15-minute window reported every 5 minutes
SensorReadings
| extend WindowStart = bin(Timestamp, 5m)
| summarize AvgTemp = avg(Temperature)
    by WindowStart, DeviceId
| order by WindowStart asc

Caveat — tumbling vs hopping vs sliding

An event belongs to exactly one tumbling window; it can belong to several hopping or sliding windows. If a stem says "each event must be counted once", the answer is tumbling. If it says "updated every N minutes over the last M minutes" with N < M, that is hopping. A hopping window whose hop equals its size is a tumbling window.

Scenario quick reference

ScenarioDeciding constraintAnswer
Load only rows changed since last nightChange detectionWatermark + MERGE
Re-running the failed load created duplicatesIdempotencyMERGE on the business key, not append
Keep full history of customer address changesHistorySCD Type 2 (valid_from/valid_to/is_current)
Correct a typo in a product name, no history neededOverwriteSCD Type 1
Keep only the newest row per keyDeterministic deduprow_number() over a window
Near real-time copy of Azure SQL DB, no ETLContinuous CDCMirroring
Query S3 data without copying itNo duplicationOneLake shortcut
Simple recurring incremental copy, no pipeline neededSimplicityCopy job
Ingest from an on-premises SQL ServerNetwork reachOn-premises data gateway
Analyst must build the transform with no codePersonaDataflow Gen2
Join a stream to a huge batch dimensionJoin scaleSpark structured streaming
Route one stream to Eventhouse and LakehouseFan-out, no codeEventstream, two destinations
Sub-second queries over shortcut data in RTISpeed over freshnessQuery acceleration
Count each event exactly once per 5 minutesNo overlapTumbling window
Rolling 15-min average refreshed every 5 minOverlapHopping window
Group activity until 30 minutes of inactivityGap-basedSession window
Streaming job's memory grows until it failsUnbounded stateAdd withWatermark()
Restarted stream reprocessed everythingLost offsetsCheckpoint location missing or deleted