Full table loads don't scale — and sooner or later every production pipeline needs an incremental strategy. Learn how to build a complete watermark-based incremental load pattern in Microsoft Fabric using Lookup activities, parameterized Copy activities, and PySpark notebook watermark updates.

Every production data pipeline eventually runs into the same wall: your source system has millions of rows, your lakehouse is growing by tens of millions, and running a full extract every night is starting to feel like boiling the ocean. The full load that took 4 minutes on day one now takes 47 minutes—and it's only going to get worse. Worse still, you're hammering the source database with a full table scan every time, which your DBA is quietly resenting.
The answer is incremental loading: instead of pulling everything, you pull only what's new or changed since the last time you looked. The challenge is keeping track of where "last time" ended. That's where watermarks come in. A watermark is simply a stored value—usually a timestamp or an integer ID—that marks your high-water point in the source data. Every pipeline run reads the watermark, fetches rows above it, loads them into the lakehouse, and then updates the watermark to the new maximum. It's elegant, reliable, and scales to billions of rows without drama.
In this lesson, you'll build a complete incremental load pattern in Microsoft Fabric using pipeline Lookup activities to read and write watermarks, a Copy activity to extract only the new data, and a Fabric Lakehouse table to store the watermarks themselves. By the end, you'll have a reusable, production-grade pattern you can adapt to any source system.
What you'll learn:
This lesson assumes you're already comfortable with the following:
ModifiedDate or an auto-incrementing integer like OrderID. This pattern does not work on sources with no change-tracking column whatsoever.Before touching the UI, let's make the logic crystal clear. Here's the complete flow of a single pipeline run:
2024-11-15 02:00:00).2024-11-16 01:58:43). This becomes your new watermark.WHERE ModifiedDate > '2024-11-15 02:00:00' AND ModifiedDate <= '2024-11-16 01:58:43'.2024-11-16 01:58:43 back to the watermark table.Key insight
You fetch the new maximum watermark before extracting data, not after. This bounds your query window to a fixed range. If the extraction fails halfway through, you haven't updated the watermark—so the next run will retry the same window cleanly. This is what makes the pattern safe to re-run.
Why two lookups instead of one? Because the alternative—pulling everything greater than the old watermark with no upper bound—creates a race condition. New rows could be inserted during your copy job, partially included in the extract, and then your new watermark would be set to a point that leaves gaps. Bounding both ends eliminates that risk.
The watermark table lives in your lakehouse. You'll use the SQL Analytics Endpoint to create it with a T-SQL script, which means you can version-control it and re-run it in other environments.
Navigate to your lakehouse, then click SQL analytics endpoint in the top-right menu to switch to the SQL view. Open a new query window and run this DDL:
CREATE TABLE dbo.pipeline_watermarks (
source_table_name NVARCHAR(200) NOT NULL,
watermark_column NVARCHAR(100) NOT NULL,
watermark_value DATETIME2(7) NOT NULL,
last_updated_utc DATETIME2(7) NOT NULL,
CONSTRAINT pk_pipeline_watermarks PRIMARY KEY (source_table_name)
);
Then seed it with an initial row for your first source table. The watermark value here is set far enough in the past to capture your full historical load on the first run—adjust it to match your business requirements:
INSERT INTO dbo.pipeline_watermarks
(source_table_name, watermark_column, watermark_value, last_updated_utc)
VALUES
('sales.Orders', 'ModifiedDate', '2000-01-01 00:00:00', GETUTCDATE());
Note
The source_table_name field is a logical key, not necessarily a real schema-qualified table name. You could use it as a pipeline identifier like 'crm_contacts_daily' if you're pulling from a REST API rather than a relational table. The naming convention is up to you, but be consistent.
If you're loading multiple tables, insert one row per table:
INSERT INTO dbo.pipeline_watermarks
(source_table_name, watermark_column, watermark_value, last_updated_utc)
VALUES
('sales.Customers', 'LastModified', '2000-01-01 00:00:00', GETUTCDATE()),
('inventory.Products', 'UpdatedAt', '2000-01-01 00:00:00', GETUTCDATE()),
('hr.Employees', 'ModifiedDate', '2000-01-01 00:00:00', GETUTCDATE());
Warning
The SQL Analytics Endpoint on a Lakehouse supports reading Delta tables via T-SQL, but DML write operations (INSERT, UPDATE, DELETE) are not supported directly through the endpoint. For the watermark pattern, you'll update the watermark table from within the pipeline using a Script activity or a stored procedure approach—which we'll cover shortly. The CREATE TABLE DDL above works because it creates a Delta table via the SQL endpoint; writes go through the pipeline.
This limitation is actually important to understand. Your lakehouse tables are Delta tables stored in OneLake. When you run CREATE TABLE via the SQL endpoint, Fabric creates a proper Delta table in the Tables/ section of your lakehouse. You can read it from T-SQL, PySpark, and Spark SQL. But to write to it from a pipeline, you'll use a Notebook activity or the Lakehouse's REST API rather than a raw SQL command. More on this in the pipeline build section.
To learn more about how Delta tables work in OneLake and why they're readable from so many compute engines, see OneLake Explained: One Copy of Data, Delta Tables, and Shortcuts.
Open your Fabric workspace and create a new Data pipeline. Name it something descriptive: pl_incremental_load_orders. The naming convention matters once you have dozens of pipelines.
Before adding activities, add a pipeline parameter so the same pipeline can serve multiple source tables. Click the blank canvas background (not any activity), then select the Parameters tab at the bottom of the screen. Add these parameters:
| Parameter Name | Type | Default Value |
|---|---|---|
p_source_table |
String | sales.Orders |
p_source_schema |
String | sales |
p_source_table_name |
String | Orders |
Parameterizing at this level means you can call this pipeline from a parent orchestration pipeline, passing different table names for each invocation. This is how you scale one pattern to twenty tables.
Drag a Lookup activity onto the canvas. Name it Lookup_OldWatermark.
In the Settings tab:
SELECT watermark_value
FROM pipeline_watermarks
WHERE source_table_name = '@{pipeline().parameters.p_source_table}'
Notice the dynamic expression syntax @{...}. This is Fabric pipeline expression language, which interpolates the parameter value at runtime.
Tip
Always use First row only: true on Lookup activities that should return a single record. If it's false and your query returns multiple rows, the output is an array you need to iterate over, which isn't what you want here. Setting it to true also makes the downstream expression syntax cleaner—you reference activity('Lookup_OldWatermark').output.firstRow.watermark_value rather than indexing into an array.
Add a second Lookup activity. Name it Lookup_NewWatermark. Connect it from the success output of Lookup_OldWatermark (drag the green arrow).
This activity queries the source system—not the lakehouse—for the current maximum value of the change-tracking column. Configure the linked service to point to your source SQL database (Azure SQL, SQL Server, or another compatible system).
SELECT MAX(ModifiedDate) AS new_watermark_value
FROM sales.Orders
If you've fully parameterized the table name, this becomes:
SELECT MAX(ModifiedDate) AS new_watermark_value
FROM @{pipeline().parameters.p_source_schema}.@{pipeline().parameters.p_source_table_name}
Warning
If your source table is completely empty or no rows have changed since the last run, MAX(ModifiedDate) returns NULL. You must handle this case downstream—either with an If Condition activity that skips the Copy if the new watermark is null or equal to the old watermark, or by defaulting to the old watermark value. We'll cover this in the edge cases section.
Add a Copy activity. Name it Copy_IncrementalData. Connect it from the success output of Lookup_NewWatermark.
Source settings:
SELECT *
FROM sales.Orders
WHERE ModifiedDate > '@{activity('Lookup_OldWatermark').output.firstRow.watermark_value}'
AND ModifiedDate <= '@{activity('Lookup_NewWatermark').output.firstRow.new_watermark_value}'
This is the bounded window query. Everything greater than the old mark, up to and including the new mark.
Sink settings:
bronze_orders (or whatever your target table is)Key insight
Choosing Append as the write method means every pipeline run adds rows to the Delta table rather than overwriting it. For most incremental patterns this is correct. If your source supports updates (not just inserts), you'll need a separate merge/upsert step after the copy—typically done in a PySpark notebook that runs as a subsequent activity in the same pipeline. That post-copy transformation is where the medallion architecture pattern becomes critical: the Copy activity writes raw appended data to bronze, and a notebook promotes deduplicated/merged records to silver.
Here's where we handle the limitation from earlier: you can't easily run a UPDATE statement against a lakehouse Delta table from a SQL Script activity in a Fabric pipeline (at least not reliably against the lakehouse's own tables). The most robust solution is a short PySpark notebook.
Create a new notebook in your workspace named nb_update_watermark. The notebook takes two parameters: source_table and new_watermark_value. Here's the full notebook code:
# Cell 1 — Parameters cell (toggle "Parameters" on this cell)
source_table = "sales.Orders"
new_watermark_value = "2024-01-01 00:00:00"
lakehouse_name = "your_lakehouse_name"
# Cell 2 — Update the watermark using Delta merge
from delta.tables import DeltaTable
from pyspark.sql import functions as F
from pyspark.sql.types import StructType, StructField, StringType, TimestampType
from datetime import datetime
# Parse incoming watermark value
new_wm_ts = datetime.strptime(new_watermark_value, "%Y-%m-%d %H:%M:%S.%f") \
if '.' in new_watermark_value \
else datetime.strptime(new_watermark_value, "%Y-%m-%d %H:%M:%S")
# Build a single-row DataFrame with the update
update_df = spark.createDataFrame([
(source_table, new_wm_ts, datetime.utcnow())
], ["source_table_name", "watermark_value", "last_updated_utc"])
# Reference the existing Delta table
watermark_table_path = f"Tables/pipeline_watermarks"
dt = DeltaTable.forName(spark, f"{lakehouse_name}.pipeline_watermarks")
# Merge: update if source_table_name matches, insert if new
dt.alias("target") \
.merge(
update_df.alias("source"),
"target.source_table_name = source.source_table_name"
) \
.whenMatchedUpdate(set={
"watermark_value": "source.watermark_value",
"last_updated_utc": "source.last_updated_utc"
}) \
.whenNotMatchedInsertAll() \
.execute()
print(f"Watermark updated for {source_table} to {new_wm_ts}")
Tip
Mark Cell 1 as a Parameters cell in the notebook (click the three dots on the cell and toggle "Parameters"). This lets you pass values into the notebook from the pipeline's Notebook activity without hardcoding them. When the pipeline invokes the notebook, it injects its own values for source_table and new_watermark_value, overriding the defaults in the parameters cell.
Back in the pipeline, add a Notebook activity after the Copy activity. Configure it:
nb_update_watermarksource_table: @{pipeline().parameters.p_source_table}new_watermark_value: @{activity('Lookup_NewWatermark').output.firstRow.new_watermark_value}Connect it from the success output of the Copy activity.
For more on writing PySpark notebooks in Fabric, see Transforming Data with Spark Notebooks in Microsoft Fabric: PySpark for Lakehouse Tables.
A pattern that only works when everything goes right isn't production-ready. Here are the scenarios you will encounter.
When Lookup_NewWatermark returns NULL (empty source table or no changes), passing NULL into the Copy activity query will either throw a SQL error or pull unexpected results. Add an If Condition activity between Lookup_NewWatermark and the Copy activity.
Expression:
@and(
not(equals(activity('Lookup_NewWatermark').output.firstRow.new_watermark_value, null)),
greater(
activity('Lookup_NewWatermark').output.firstRow.new_watermark_value,
activity('Lookup_OldWatermark').output.firstRow.watermark_value
)
)
Put the Copy activity and Notebook activity inside the True branch. The False branch can contain a Set Variable or simply be empty — the pipeline succeeds without copying anything, which is correct behavior.
When you seed the watermark with 2000-01-01, the first run pulls your entire history. This is intentional — but it can be slow. Two strategies:
If your source database stores timestamps in local time (Eastern, Pacific, etc.) but your pipeline or lakehouse operates in UTC, your watermark comparisons will drift by the UTC offset—potentially missing rows or double-loading them around daylight saving transitions.
The fix: standardize everything to UTC at the source query level:
SELECT MAX(CONVERT(DATETIME2, SWITCHOFFSET(CAST(ModifiedDate AS DATETIMEOFFSET), '+00:00')))
AS new_watermark_value
FROM sales.Orders
And store watermarks in UTC consistently. This is non-negotiable for any cross-timezone pipeline.
Some source systems allow records to be inserted or updated with a ModifiedDate that's in the past—ETL retries, data corrections, or slow-arriving feeds from third-party systems. These rows will fall below your current watermark and get silently missed.
The pragmatic solution: add a configurable lookback buffer. Instead of using the exact old watermark value, subtract a buffer period in your source query:
SELECT *
FROM sales.Orders
WHERE ModifiedDate > DATEADD(HOUR, -2,
'@{activity('Lookup_OldWatermark').output.firstRow.watermark_value}')
AND ModifiedDate <=
'@{activity('Lookup_NewWatermark').output.firstRow.new_watermark_value}'
This re-fetches the last two hours of data on every run, catching late arrivals at the cost of some duplicate rows in bronze. Your silver layer merge/upsert handles deduplication downstream.
Once the single-table pipeline works, you'll almost certainly want to run the same pattern across a dozen tables. The right approach is a parent-child pipeline architecture:
pipeline_watermarks table works for this) to get the list of active source tables.pl_incremental_load_orders) with the appropriate parameters.In the parent pipeline:
Add a Lookup activity that queries all active tables:
SELECT source_table_name, watermark_column
FROM pipeline_watermarks
ORDER BY source_table_name
Set First row only to false this time — you want all rows.
Then add a ForEach activity:
@activity('Lookup_AllWatermarks').output.valueInside the ForEach, add an Execute Pipeline activity:
pl_incremental_load_ordersp_source_table: @{item().source_table_name}Tip
Keep the child pipeline generic and the parent pipeline table-driven. When you need to add a new source table, you just insert a row into pipeline_watermarks—you don't touch any pipeline code. This is the difference between a system that's easy to operate and one that requires a developer every time business requirements change.
You can schedule the parent pipeline to run on whatever cadence your business needs. For scheduling guidance, see Scheduling and Automating Fabric Data Pipeline Runs with Activity-Level Retries, Alerts, and Email Notifications.
An incremental pipeline that silently fails — or silently copies zero rows when it should have copied thousands — is worse than no pipeline at all. Build observability in from the start.
After the Notebook (watermark update) activity, add a final Set Variable or Append Variable activity that captures the rows copied. The Copy activity output includes a rowsCopied property you can log:
@activity('Copy_IncrementalData').output.rowsCopied
Write this to a pipeline_run_log table in your lakehouse alongside the run timestamp, source table name, old watermark, new watermark, and status. Over time this table becomes your pipeline health dashboard.
The Fabric Monitoring Hub gives you pipeline run history and activity-level duration stats at the workspace level. For deep dives into what's available, see Monitoring Fabric Capacity Usage and Pipeline Activity with the Monitoring Hub.
Note
The rowsCopied value from the Copy activity reflects rows written to the sink, not rows read from the source. If your source query returns 10,000 rows but a sink error truncates the write, rowsCopied will be less than your source count. Always validate both sides when troubleshooting discrepancies.
Build a complete incremental load pipeline for an AdventureWorks-style orders scenario. You'll use a freely available Azure SQL sample database as the source, or simulate one with a static Azure SQL table you create.
Setup:
lh_adventure_works.CREATE TABLE and INSERT scripts from the "Setting Up the Watermark Table" section above, creating a watermark row for 'sales.SalesOrderHeader' with watermark_column = 'ModifiedDate' and watermark_value = '2014-01-01 00:00:00' (this covers the AdventureWorks date range).Build the pipeline:
pl_incremental_load_salesorderheader with the three parameters from this lesson.Lookup_OldWatermark querying pipeline_watermarks for 'sales.SalesOrderHeader'.Lookup_NewWatermark querying SELECT MAX(ModifiedDate) AS new_watermark_value FROM Sales.SalesOrderHeader on the source.Tables/bronze_salesorderheader with Append.nb_update_watermark with the PySpark merge code.Validate:
rowsCopied in the output panel.SELECT COUNT(*), MIN(ModifiedDate), MAX(ModifiedDate) FROM bronze_salesorderheader.SELECT * FROM pipeline_watermarks.rowsCopied is 0 (no new changes since the first run).ModifiedDate = GETUTCDATE(). Run the pipeline again. Confirm exactly 1 row is copied and the watermark advances.Once you've validated the pattern end-to-end, wire it up to a Power BI report via the lakehouse's SQL Analytics Endpoint to see your data in near-real-time. The Connecting a Power BI Semantic Model to a Fabric Lakehouse in Direct Lake Mode lesson shows you how.
Mistake: Using >= instead of > for the lower bound
Your source query uses WHERE ModifiedDate >= old_watermark. Since you set the watermark to the maximum of the last run, the boundary row itself gets re-copied every time. Multiply this by twenty tables and a year of daily runs, and you have significant duplicate data accumulating in bronze. Always use strict greater-than (>) for the lower bound.
Mistake: Updating the watermark before confirming the copy succeeded
If you put the watermark update activity parallel to or before the Copy activity, a failed copy leaves the watermark advanced — meaning the next run skips the failed data permanently. Always chain: Lookup → Lookup → Copy → (on success) → Update Watermark. Never update on failure paths.
Mistake: Treating integer IDs as equivalent to timestamps
Auto-incrementing integer keys look like perfect watermarks, and often they are — but gaps in sequences (caused by rolled-back transactions) mean MAX(ID) can skip values. If a transaction inserts ID 1005, 1006, 1007 then rolls back 1006, your watermark advances past 1005 and the next run misses 1007 if it was inserted during the rollback window. Timestamp-based watermarks with a lookback buffer are generally safer.
Mistake: Expression syntax errors in the Copy activity source query
The dynamic expression @{activity('Lookup_OldWatermark').output.firstRow.watermark_value} returns a .NET DateTime object, which when interpolated into a SQL string might not include the correct format for your database. If you see CONVERSION FAILED errors on the source, explicitly format the value:
@{formatDateTime(activity('Lookup_OldWatermark').output.firstRow.watermark_value, 'yyyy-MM-dd HH:mm:ss')}
Mistake: Forgetting to set the Lakehouse name in the notebook
The line DeltaTable.forName(spark, f"{lakehouse_name}.pipeline_watermarks") requires the lakehouse to be attached to the notebook session. If you see table not found errors, verify that the lakehouse is attached to the notebook (check the Explorer pane on the left side of the notebook editor) and that the lakehouse_name variable matches exactly.
Troubleshooting: Pipeline runs but no data appears in the lakehouse
Check in order:
rowsCopied shows 0? That's a source query issue returning an empty result set, not a pipeline failure.You now have a complete, production-grade incremental load pattern built on three core concepts: a watermark table that tracks where you left off, Lookup activities that read and write those watermarks, and a bounded window query that precisely extracts only what's new. The pattern is safe to re-run (watermarks only advance on success), scalable to multiple tables via a parent-child pipeline architecture, and observable through row-count logging.
The key engineering decisions that make this pattern reliable:
Where to go from here:
ModifiedDate column, explore Database Mirroring in Microsoft Fabric: Replicating Azure SQL and Snowflake into OneLake — Fabric's mirroring feature handles change tracking at the CDC level, eliminating the need for watermarks entirely on supported sources.The incremental load pattern you've built here is the backbone of most production lakehouses. Get comfortable with it, automate the scaffolding for new tables, and you'll spend far less time babysitting pipelines and far more time delivering analytics value.