8-Part Series · Microsoft Fabric Data Engineering
Part 5 of 8

Building the Landing Pipeline: Metadata-Driven Ingestion from SQL Server to Fabric Lakehouse

Master the architecture, design patterns, and implementation of a production-ready landing pipeline that handles incremental loads, CDC watermarks, and parallel processing.

✍️ Rohit Ram · Consultant, Data Analytics 📅 April 2026 ⏱ 12 min read
Fabric Pipelines Metadata-Driven ForEach Loop Copy Activity Incremental Load CDC Watermark Parallel Processing

Why This Pipeline Design Is Special

The landing pipeline (pl_ingest_landing) is where raw data meets Fabric. Unlike a hardcoded pipeline that processes a single table, this design is metadata-driven—it reads a configuration table and dynamically ingests any number of tables from SQL Server without changing a single line of code.

This approach has been used in enterprise data platforms for decades because it solves real problems: adding a new table to the data lake should never require pipeline re-deployment. It should be a database insert into a config table. This blog walks you through every activity, every decision, and every edge case you'll face building production data pipelines.

📌

Key Insight: Config-Driven Architecture: Instead of hardcoding table names, schemas, and load types into your pipeline, you externalize that logic into a configuration table. The pipeline becomes a generic engine that reads the config and adapts. Add 10 tables tomorrow? Just add 10 rows to the config table. The pipeline doesn't change.

The Lookup Activity — Reading the Config Table

Every pipeline execution starts with a Lookup activity called lkp_config_landing. This activity queries the configuration table from your fabric development warehouse:

SQL — Config Lookup
SELECT * FROM config.landing_control_tbl WHERE enabled = 1

This query returns rows like this:

table_id source_name load_type incremental_field prune_days enabled last_pipeline_runtime
1 DRIVERSENTITY incremental LAST_MODIFIED_AT 30 1 2026-04-08 12:30:35
2 TRIPSHEADERENTITY incremental LAST_MODIFIED_AT 30 1 2026-04-08 12:30:35
3 TRIPSLINEENTITY incremental TIMESTAMP 30 1 2026-04-08 12:30:35

The Lookup activity is configured to:

ForEach — Parallelism and Iteration

The output of the Lookup becomes the input to a ForEach activity. This ForEach iterates over every enabled table in your config. The critical setting here is Sequential Run—it is unchecked, which means all tables process in parallel.

If you have 3 tables, all 3 Copy activities can run at the same time. This dramatically speeds up the landing layer. The only constraint: your gateway connection bandwidth and SQL Server connections must support it. In our tests with 4-5 concurrent tables, we saw 40-60% faster execution compared to sequential processing.

Landing Pipeline Flow Lookup Config Table ForEach (Parallel) Get Metadata If Table Exists? TRUE Archive Drop Table Copy FALSE Skip (No action)

Get Metadata — The Archive Decision Gate

Inside the ForEach, the first activity is get_sink_file_name, a Get Metadata activity. Its job: check if the destination table already exists in the Lakehouse cab_landing schema.

This metadata lookup feeds into an If Condition that branches: if the table exists, we archive the old data before overwriting. If it doesn't exist (first load), we skip the archive and go straight to the copy.

🔧

Why Check Metadata First? In a data lake or a lakehouse, you never want to overwrite data without a backup. By checking if the table exists first, you can implement a versioning strategy: archive snapshots to a timestamped folder before each overwrite. This gives you point-in-time recovery and audit trails.

The If Condition — True and False Branches

The If Condition checks the Exists property returned by Get Metadata:

The Archive Copy — Protecting Your Data

The True branch starts with cp_to_archive, a Copy activity that reads the existing Delta table from cab_landing and writes it to a timestamped archive location:

Archive Path Pattern
Files/cab_landing/{table}/{YYYY}/{MM}/{DD}/{HH}/mmss.parquet

For example, if you have a table dbo.DRIVERSENTITY and the pipeline runs on 2026-04-15 at 14:32:45, the archive path becomes:

Archive Path Example
Files/cab_landing/DRIVERSENTITY/2026/04/15/14/3245.parquet

The timestamp down to the second ensures that if the pipeline runs twice in the same hour, each run gets its own subfolder. This is critical for reproducibility and auditing.

The Drop Notebook — Solving the DateTime Problem

This is where many first-time Fabric pipeline builders stumble. After archiving, we call a notebook that drops the landing table before the fresh copy lands. But the notebook needs to know which table to drop — and that name comes dynamically from the ForEach item. Here's the full three-step mechanism.

Step 1: Pass the Table Name from the Pipeline

Inside the ForEach activity, every iteration exposes the current config row as @item(). When you add the Notebook activity, open its Settings → Base parameters tab and add one parameter:

Pipeline — Notebook Activity Base Parameter
-- In the Notebook activity → Settings → Base parameters tab:

Name :   destination_table_name
Value:   @item().destination_table_name

-- @item() is the current ForEach row from the Lookup output.
-- At runtime this resolves to e.g. "cab_landing.DRIVERSENTITY"
-- for the first iteration, "cab_landing.TRIPSHEADERENTITY" for the second, and so on.

Step 2: Receive the Parameter in the Notebook

In your PySpark notebook, the first code cell must be tagged as a parameters cell (in Fabric: click the cell → ··· menu → Toggle parameter cell). Fabric injects the pipeline value here at runtime, overriding the default. The default is only used during interactive local runs:

PySpark — Parameters Cell (Cell 1, tagged as parameters)
# ── PARAMETERS CELL ─────────────────────────────────────────────────────────
# This cell must be toggled as "Parameters" in Fabric notebook settings.
# The pipeline overwrites this value at runtime via Base parameters.
# The default below is used only when running the notebook interactively.

destination_table_name = "cab_landing.DRIVERSENTITY"  # overridden by pipeline

Step 3: Use the Parameter Dynamically in the DROP Statement

Now the notebook uses the injected parameter instead of a hardcoded string. Every table the ForEach iterates over gets dropped and re-created cleanly:

PySpark — Dynamic Drop Table
# destination_table_name is injected by the pipeline at runtime
# e.g. "cab_landing.DRIVERSENTITY", "cab_landing.TRIPSHEADERENTITY", etc.

spark.sql(f"DROP TABLE IF EXISTS {destination_table_name}")

# At runtime this resolves to:
# spark.sql("DROP TABLE IF EXISTS cab_landing.DRIVERSENTITY")
# spark.sql("DROP TABLE IF EXISTS cab_landing.TRIPSHEADERENTITY")
# spark.sql("DROP TABLE IF EXISTS cab_landing.TRIPSLINEENTITY")
# — one per ForEach iteration, fully driven by the config table.
💡

Why this pattern matters: Without the parameter, you'd need a separate Drop notebook for every table. With it, one notebook handles all tables forever — add a new row to config.landing_control_tbl and the drop, copy, and watermark update all work automatically for that table too. This is the metadata-driven principle applied all the way down to individual notebook activities.

Why drop the table at all? Here's the scenario:

  1. Your source SQL Server table has a DateTime column: LAST_MODIFIED_AT
  2. Your first pipeline run copies all 100,000 drivers successfully
  3. Your second run is incremental: only 50 records changed since the last run
  4. The Copy activity writes these 50 rows to the landing table... but here's the catch
  5. If any of those 50 rows have a NULL or empty LAST_MODIFIED_AT, Fabric writes a blank string instead of NULL
  6. The schema now thinks LAST_MODIFIED_AT is a string, not a DateTime
  7. Next run, the Copy activity fails: "Cannot merge schemas—type mismatch"

Solution: Drop the table before every overwrite. This forces the Copy activity to create a fresh table with the correct schema every time. Yes, you're re-creating the table on every run—but in Fabric Lakehouse with Delta format, this is fast and safe.

⚠️

DateTime Blank String Issue: When incremental data includes DateTime columns with NULLs and the CSV/Parquet writer sends blank strings, Fabric infers the column as string type. On the next run, when the schema has DateTime, the merge fails. Dropping and re-creating the table forces Fabric to infer the schema fresh from the incoming data.

The Main Copy Activity — Dynamic SQL for Full & Incremental Loads

Now we reach the core of the pipeline: cp_ingest_landing, the Copy activity that actually brings data into Fabric.

The Copy activity uses dynamic SQL to adapt based on your config:

SQL — Dynamic Query Logic
-- If load_type = 'full'
SELECT * FROM dbo.DRIVERSENTITY

-- If load_type = 'incremental' and incremental_field exists
SELECT * FROM dbo.DRIVERSENTITY
WHERE LAST_MODIFIED_AT >= DATEADD(day, -30,
    CAST('2026-04-08 12:30:35' AS DATETIME))

In the Copy activity's Source, you configure this using the Query option and build the SQL dynamically using pipeline parameters and Lookup output. For example:

Fabric Pipeline Expression
@if(
    equals(activity('lkp_config_landing').output.firstRow.load_type, 'full'),
    concat('SELECT * FROM ', activity('lkp_config_landing').output.firstRow.source_schema,
           '.', activity('lkp_config_landing').output.firstRow.source_name),
    concat('SELECT * FROM ', activity('lkp_config_landing').output.firstRow.source_schema,
           '.', activity('lkp_config_landing').output.firstRow.source_name,
           ' WHERE ', activity('lkp_config_landing').output.firstRow.incremental_field,
           ' >= DATEADD(day, -',
           activity('lkp_config_landing').output.firstRow.prune_days,
           ', CAST(''',
           activity('lkp_config_landing').output.firstRow.last_pipeline_runtime,
           ''' AS DATETIME))')
)

The Sink is configured to:

The Watermark Stored Procedure — Enabling CDC

After the Copy completes, we call a stored procedure: sp_update_landing_config_watermark. This is how the pipeline "remembers" where it left off.

SQL — Update Watermark
EXEC config.update_landing_config_watermark
    @table_name = @activity('lkp_config_landing').output.firstRow.destination_table_name,
    @last_runtime = GETUTCDATE()

This procedure updates the last_pipeline_runtime column in config.landing_control_tbl to the current UTC timestamp. The next time the pipeline runs, the Lookup will fetch this new timestamp, and the incremental query will only pull records modified since this time.

This is Change Data Capture (CDC) done simply: no CDC table required, no log parsing. Just a datetime watermark. It's lightweight, reliable, and works with any SQL Server table.

🔧

Incremental Load Pattern: The Watermark: The watermark pattern is foundational to data engineering. Every time you successfully load a chunk of data, you record the "point in time" you loaded up to. Next load, you start from that point. This prevents re-processing the same records and keeps your landing layer fresh without re-copying the entire table.

The Pipeline in Action — What Happens End to End

Let's trace a full execution:

  1. 9:00 AM: Trigger fires. Lookup queries config table. Returns 3 rows (DRIVERS, TRIPS_HEADER, TRIPS_LINE).
  2. 9:00:01 AM: ForEach starts. All 3 tables begin processing in parallel.
  3. 9:00:02 AM: For DRIVERS: Get Metadata checks if cab_landing.DRIVERSENTITY exists. It does.
  4. 9:00:03 AM: If Condition is TRUE. Archive copy starts: reads the old DRIVERS table (100K rows), writes to Files/cab_landing/DRIVERSENTITY/2026/04/16/09/0003.parquet
  5. 9:00:15 AM: Archive complete. Notebook runs: DROP TABLE IF EXISTS cab_landing.DRIVERSENTITY
  6. 9:00:16 AM: Copy activity runs. Source SQL: "SELECT * FROM dbo.DRIVERSENTITY WHERE LAST_MODIFIED_AT >= DATEADD(day, -30, CAST('2026-04-08 12:30:35' AS DATETIME))". Only 150 new/modified records. Copies to cab_landing.DRIVERSENTITY.
  7. 9:00:25 AM: Stored procedure runs. Updates config.landing_control_tbl SET last_pipeline_runtime = '2026-04-16 09:00:25' WHERE table_id = 1.
  8. 9:00:26 AM: Meanwhile, TRIPS_HEADER and TRIPS_LINE tables have been going through the same steps in parallel.
  9. 9:00:45 AM: All three tables complete. ForEach finishes. Pipeline succeeds.

Testing the Pipeline

Testing a landing pipeline requires both functional and performance validation:

Functional Tests

Performance Tests

🚀

For Data Engineers & Architects: This landing pipeline pattern—config-driven table metadata, parallel ForEach iteration, archive-before-overwrite, incremental loading with CDC watermarks, stored procedure-based state management—is exactly the pattern used in enterprise data platforms at places like Databricks, AWS, and Google Cloud. It's not theoretical; it's proven at scale across millions of records and hundreds of pipelines. Engineers who've built this from scratch in Fabric understand data engineering fundamentals that apply across any platform.

Common Pitfalls and Solutions

Pitfall 1: Forgetting to Drop the Table — The DateTime blank string issue will catch you. Always drop before overwriting if your source has DateTime columns.

Pitfall 2: Archive Paths Colliding — If you don't include seconds in the timestamp, two runs in the same minute will overwrite each other's archives. Always go down to the second.

Pitfall 3: Watermark Not Updating — If the stored procedure fails silently, the watermark never advances, and next run will re-process all data. Monitor SP execution and log all updates.

Pitfall 4: Gateway Timeout on Large Tables — Copying 50GB over a gateway can timeout. Consider filtering at the source (WHERE conditions) or staging to Fabric Lakehouse first, then copying from there.

What's Next?

The landing layer is raw and minimally transformed. In Part 6, we'll orchestrate the next layer: the curated pipeline, which reads from landing, applies business logic, and writes to the semantic layer. This is where raw data becomes analytics-ready assets.

Rohit Ram
Rohit Ram
Senior Data Analyst · LinkedIn

Building production-grade data platforms on Microsoft Fabric. This series documents a real project — every SQL query, every design decision, every challenge encountered and solved.