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.
- 2.1 Loading patterns — full vs incremental, dimensional modelling, streaming load design
- 2.2 Batch ingestion & transformation — data store and tool choice, shortcuts, mirroring, pipelines, PySpark/SQL/KQL, data quality
- 2.3 Streaming ingestion & transformation — engine choice, RTI storage options, Eventstreams, structured streaming, windowing
2.1 Design and implement loading patterns
Full vs incremental loads
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
| Aspect | Full load | Incremental load |
|---|---|---|
| Data moved | Everything, every run | Only new/changed rows |
| Target operation | TRUNCATE + INSERT, or overwrite | MERGE / upsert / append |
| Complexity | Low | Higher — needs state tracking |
| Runtime | Scales with total size | Scales with change volume |
| Idempotent | Naturally | Only if designed for it |
| Best for | Small dimensions, reference data, initial seeding | Large 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
- Lookup the last successful watermark from a control table.
- Copy source rows where
ModifiedDate > @watermarkinto a staging/bronze area. - MERGE staging into the target on the business key.
- Update the control table with the new maximum
ModifiedDate— only now, and only on success.
Preparing data for a dimensional model
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"]
| Concept | What it means |
|---|---|
| Star schema | Central fact table joined to denormalized dimensions — the target shape for Power BI |
| Surrogate key | A system-generated key on the dimension, independent of the source's business key; required for SCD Type 2 |
| Business / natural key | The source's own identifier — what you MERGE on |
| Conformed dimension | One shared dimension used consistently by multiple facts |
| Fact grain | What one row of the fact table represents — define it before anything else |
| Degenerate dimension | A dimension attribute (e.g. order number) kept on the fact with no separate table |
Slowly changing dimensions
| Type | Behaviour | Implementation |
|---|---|---|
| Type 0 | Never changes (fixed attributes) | Insert once, ignore later changes |
| Type 1 | Overwrite — no history kept | MERGE with WHEN MATCHED THEN UPDATE |
| Type 2 | Full history — a new row per version | Close the old row (ValidTo, IsCurrent = false) and insert a new current row |
| Type 3 | Keeps only the previous value | Add a PreviousValue column |
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
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"]
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:
- Create under a Lakehouse's
Tables/(the target must be a valid Delta table folder) orFiles/(any folder). - External shortcuts need a connection with credentials — organizational account, service principal, SAS or account key depending on target.
- Deleting a shortcut removes the pointer only; the source data is untouched. Deleting the source leaves a broken shortcut.
- Shortcuts can be chained (a shortcut to a shortcut), which adds latency and makes troubleshooting harder.
- Shortcut data is excluded from OPTIMIZE and VACUUM on the hosting Lakehouse.
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.
| Source | Notes |
|---|---|
| Azure SQL Database | The reference implementation; requires change feed/CDC prerequisites on the source |
| Azure SQL Managed Instance | Same model as Azure SQL DB |
| SQL Server | Including on-premises / VM editions |
| Azure Cosmos DB | Uses the container change feed |
| Snowflake | Cross-platform mirroring |
| Azure Database for PostgreSQL / MySQL | Open-source engines |
| Azure Databricks (Unity Catalog) | Catalog-level mirroring of Databricks tables |
| Open mirroring | Bring-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
| Aspect | Detail |
|---|---|
| Copy activity | The workhorse: source → sink with optional mapping, staging and fault tolerance |
| Sinks | Lakehouse (Tables/ or Files/), Warehouse, KQL database, Azure stores |
| Formats | Delta, Parquet, CSV, JSON, Avro, ORC, XML |
| Copy behaviour | Append, overwrite, or merge depending on sink type |
| Staging | Two-stage copy through interim storage — used for large or cross-region transfers and for sinks needing bulk load |
| Fault tolerance | Skip incompatible rows and log them rather than failing the activity |
| Parallelism | Degree of copy parallelism and partition options on the source |
| On-premises sources | Require 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
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")))-- 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;SensorReadings
| where Timestamp > ago(7d)
| summarize AvgTemp = avg(Temperature),
MaxTemp = max(Temperature),
Readings = count()
by bin(Timestamp, 1h), DeviceId
| order by Timestamp descCaveat — 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
| Technique | What it does | Use when |
|---|---|---|
| Flatten / join down | Pull dimension attributes onto the fact row | Reducing joins at query time for BI performance |
| Explode / flatten nested | explode() arrays, dot-notation for structs | Semi-structured JSON landing in bronze |
| Pre-aggregate | Materialize summary tables at a coarser grain | Dashboards repeatedly computing the same rollup |
| Pivot / unpivot | Rotate rows to columns and back | Reshaping 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
| Problem | Batch approach | Streaming approach |
|---|---|---|
| Duplicates | dropDuplicates([keys]), or window row_number() keeping the latest; MERGE on the business key | dropDuplicates with a watermark to bound state |
| Missing values | fillna() / COALESCE(), defaults, or drop when the column is critical | Same, applied per micro-batch |
| Late-arriving facts | MERGE into the correct partition and recompute affected aggregates | withWatermark() defines how long to accept late events |
| Late-arriving dimensions | Insert an inferred member (placeholder dimension row) so the fact can load, then update it when the real record arrives | — |
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
| Dimension | Eventstream | Spark structured streaming | KQL / Eventhouse |
|---|---|---|---|
| Authoring | No-code visual designer | PySpark in a notebook | KQL queries and update policies |
| Primary role | Ingest and route | Transform and enrich | Store and analyse |
| Latency | Seconds | Seconds to minutes (micro-batch) | Seconds |
| Join with large batch data | Limited | ✅ Full Spark joins | Via shortcuts / materialized views |
| Windowing | Tumbling, hopping, sliding, session, snapshot | Tumbling, sliding, session | Full, via bin() and time functions |
| Destinations | Eventhouse, Lakehouse, Activator, custom endpoint, derived stream | Delta tables in a Lakehouse | KQL 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
| Aspect | Native KQL table | OneLake shortcut in the Eventhouse |
|---|---|---|
| Where the data lives | Eventhouse's own optimized storage | OneLake (or external), read in place |
| Ingestion | Data is ingested and indexed | None — nothing is copied |
| Query performance | Fastest — full indexing and caching | Slower — reads Delta/Parquet across services |
| Storage cost | A second copy of the data | No duplication |
| Best for | Hot path: active real-time analytics and alerting | Warm/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 shortcut | Query-accelerated shortcut | |
|---|---|---|
| Performance | Reads OneLake directly on every query | Served from the acceleration cache |
| Capacity cost | Query cost only | Additional CU and storage for the cache |
| Freshness | Always current | Slight lag while the cache catches up |
| Choose when | Infrequent or ad-hoc queries on cold data | Frequent, 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
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"]
- Sources include Azure Event Hubs, IoT Hub, Kafka, database CDC feeds, Azure service events, sample data, and custom endpoints.
- Processing operators: filter, manage fields, expand (flatten arrays), join, union, group by, aggregate.
- Destinations: Eventhouse, Lakehouse, Activator, derived stream, custom endpoint.
- A stream can fan out to multiple destinations from the same source — a very common exam framing.
- Eventhouse destinations support direct ingestion (raw) or event-processing-before-ingestion.
Processing data with Spark structured streaming
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 mode | Writes | Requires |
|---|---|---|
| append | Only new rows, never revised | A watermark for aggregations — results emit when the window closes |
| update | Only rows that changed this batch | Sink that supports updates |
| complete | The entire result table, every batch | Aggregations 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
| Window | Shape | Typical question |
|---|---|---|
| Tumbling | Fixed size, non-overlapping, contiguous | "Count events in each 5-minute block" |
| Hopping | Fixed size, advances by a hop — overlaps when hop < size | "Average over 10 minutes, reported every 5" |
| Sliding | Emits whenever an event enters or leaves the window | "Continuously updated moving average" |
| Session | Variable length, closed by an inactivity gap | "Group a user's clicks until 30 minutes idle" |
| Snapshot | Groups events sharing the exact same timestamp | "Aggregate all readings at the same instant" |
// 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 ascCaveat — 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
| Scenario | Deciding constraint | Answer |
|---|---|---|
| Load only rows changed since last night | Change detection | Watermark + MERGE |
| Re-running the failed load created duplicates | Idempotency | MERGE on the business key, not append |
| Keep full history of customer address changes | History | SCD Type 2 (valid_from/valid_to/is_current) |
| Correct a typo in a product name, no history needed | Overwrite | SCD Type 1 |
| Keep only the newest row per key | Deterministic dedup | row_number() over a window |
| Near real-time copy of Azure SQL DB, no ETL | Continuous CDC | Mirroring |
| Query S3 data without copying it | No duplication | OneLake shortcut |
| Simple recurring incremental copy, no pipeline needed | Simplicity | Copy job |
| Ingest from an on-premises SQL Server | Network reach | On-premises data gateway |
| Analyst must build the transform with no code | Persona | Dataflow Gen2 |
| Join a stream to a huge batch dimension | Join scale | Spark structured streaming |
| Route one stream to Eventhouse and Lakehouse | Fan-out, no code | Eventstream, two destinations |
| Sub-second queries over shortcut data in RTI | Speed over freshness | Query acceleration |
| Count each event exactly once per 5 minutes | No overlap | Tumbling window |
| Rolling 15-min average refreshed every 5 min | Overlap | Hopping window |
| Group activity until 30 minutes of inactivity | Gap-based | Session window |
| Streaming job's memory grows until it fails | Unbounded state | Add withWatermark() |
| Restarted stream reprocessed everything | Lost offsets | Checkpoint location missing or deleted |