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:
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:
- Source: Your development warehouse (reachable without a gateway because it's in Fabric)
- Query mode: Query (not stored procedure)
- First row only: Unchecked (we want ALL rows)
- Output: Each row becomes an item in the ForEach iteration
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.
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:
- Expression:
@activity('get_sink_file_name').output.exists - True Branch: Archive → Drop → Copy → Store Procedure
- False Branch: Skip (go to next table)
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:
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:
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:
-- 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:
# ── 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:
# 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:
- Your source SQL Server table has a DateTime column:
LAST_MODIFIED_AT - Your first pipeline run copies all 100,000 drivers successfully
- Your second run is incremental: only 50 records changed since the last run
- The Copy activity writes these 50 rows to the landing table... but here's the catch
- If any of those 50 rows have a NULL or empty LAST_MODIFIED_AT, Fabric writes a blank string instead of NULL
- The schema now thinks LAST_MODIFIED_AT is a string, not a DateTime
- 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:
-- 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:
@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:
- Workspace: Your Fabric workspace
- Lakehouse:
cab_landing - Table:
@activity('lkp_config_landing').output.firstRow.destination_table_name - Write behavior: Overwrite (safe because we dropped the table first)
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.
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:
- 9:00 AM: Trigger fires. Lookup queries config table. Returns 3 rows (DRIVERS, TRIPS_HEADER, TRIPS_LINE).
- 9:00:01 AM: ForEach starts. All 3 tables begin processing in parallel.
- 9:00:02 AM: For DRIVERS: Get Metadata checks if cab_landing.DRIVERSENTITY exists. It does.
- 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
- 9:00:15 AM: Archive complete. Notebook runs: DROP TABLE IF EXISTS cab_landing.DRIVERSENTITY
- 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.
- 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.
- 9:00:26 AM: Meanwhile, TRIPS_HEADER and TRIPS_LINE tables have been going through the same steps in parallel.
- 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
- First Load (Cold Start): Run the pipeline on an empty Lakehouse. Verify all tables appear. Check row counts match source. Spot-check data values.
- Incremental Load: Insert 10 rows into a source table. Run the pipeline. Verify only those 10 new rows appear in the landing table (no duplicates).
- No New Data: Run the pipeline twice with no source changes. The second run should complete successfully, but the landing table row count should not increase. Verify the Copy activity's "Rows read" and "Rows written" are both 0.
- Schema Change: Add a new column to a source table. Run the pipeline. Verify the new column appears in the landing table with NULLs for historical records.
- Archive Verification: Run the pipeline. Check that the Files/cab_landing/ directory contains the timestamped archive snapshots. Verify you can read them as Parquet.
Performance Tests
- Baseline: Run the full pipeline once. Record the end-to-end time. This is your baseline.
- Parallelism: Disable parallelism in the ForEach (set Sequential to TRUE). Run again. Compare times. You should see a measurable difference, especially with 3+ tables.
- Scale: Add more tables to the config (10, 20). Run the pipeline. Monitor your gateway connection throughput. Does the pipeline still complete within your SLA?
- Incremental vs Full: Run a full load. Record time. Then run an incremental load (after inserting just 100 rows). Compare. Incremental should be significantly faster.
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.