You’ve just kicked off a nightly ETL job and noticed it’s taking forever, even though only a handful of rows changed since the last run. Also, you wonder if there’s a smarter way to tell the system “just give me what’s new” without scanning the whole table again. That’s where high water mark indexing comes into play Simple, but easy to overlook..
And yeah — that's actually more nuanced than it sounds.
What Is High Water Mark Indexing
At its core, a high water mark is simply the highest value seen so far in a column that monotonically increases — think of an ever‑growing timestamp or an auto‑incrementing ID. High water mark indexing uses that value as a bookmark. Instead of reprocessing every row, the process remembers where it left off and only pulls rows whose marker sits above that stored point.
The basic idea
Imagine a log file that only ever gets lines appended to the end. The same principle applies to database tables when a column reliably only goes up. In real terms, if you keep track of the line number you last read, you never need to re‑read the beginning. The index isn’t a traditional B‑tree or bitmap; it’s a lightweight marker stored outside the table, often in a control table or a workflow state store Simple as that..
This changes depending on context. Keep that in mind.
Where it shows up
You’ll see this pattern in data warehouses loading incremental fact tables, in CDC (change data capture) pipelines that pull from source logs, and even in some streaming platforms that need to know where they left off in a partitioned log. Anywhere you have an append‑only‑like column, a high water mark can serve as the checkpoint.
Why It Matters / Why People Care
When you ignore the high water mark, you end up doing unnecessary work. Scanning millions of rows just to find a few dozen changes wastes CPU, I/O, and time. In a nightly batch window that’s already tight, that waste can push jobs can mean missed SLAs or higher cloud bills Not complicated — just consistent..
Saves time
By limiting reads to rows past the stored mark, the same query that might have taken ten minutes can drop to under a minute. That speedup compounds when you run the job dozens of times a day.
Reduces load
Source systems — especially transactional OLTP databases — aren’t built for massive table scans. A high water mark approach keeps the source happy, lowering lock contention and leaving more resources for user traffic.
Real‑world impact
A retail company I worked with cut their nightly sales‑fact load from 45 minutes to 4 minutes simply by switching from a full table scan to a high water mark‑driven incremental load. The downstream reporting dashboards became available earlier, and the ETL team stopped getting paged for overruns It's one of those things that adds up..
How It Works (or How to Do It)
Implementing a high water mark isn’t magic, but it does require a few deliberate steps. The exact details vary by technology, but the pattern stays the same.
Tracking the high water mark
First, you need a column that never decreases. Common candidates:
- An auto‑incrementing primary key
- A
LAST_UPDATEDtimestamp with timezone - A monotonically increasing sequence number from a log
You store the last seen value somewhere durable — a tiny control table, a workflow variable, or even a file in object storage. After each successful load, you update that stored value to the maximum marker you just processed.
Implementing in SQL
A typical incremental load looks like this:
-- fetch the current water mark
SELECT max_watermark INTO @wm FROM etl_state WHERE job = 'sales_fact';
-- pull only new rows
INSERT INTO dw.sales_fact (cols...)
SELECT cols...
FROM src.sales
WHERE id > @wm; -- assuming id is the ever‑increasing key
-- update the water mark
UPDATE etl_state SET max_watermark = (SELECT MAX(id) FROM src.sales)
WHERE job = 'sales_fact';
If you’re using a timestamp, the comparison changes to WHERE updated_at > @wm. The key is that the comparison column must reflect the same monotonic property you’re tracking.
Using timestamps or sequences
Timestamps work well when you can trust the source clock and when updates are rare. If rows can be updated in place, a pure timestamp may miss changes unless you also track a version number. Sequences or identity columns are safer because they only increase on insert, but they won’t capture updates to existing rows unless you pair them with a change‑data‑capture mechanism Worth keeping that in mind..
Incremental load patterns
Beyond the simple “greater than” filter, you sometimes need to handle:
- Late‑arriving rows – rows with a timestamp slightly older than the current mark but that arrived after the window closed. That said, a common fix is to keep a small buffering window (e. g.Day to day, , process rows where
timestamp > watermark - 5 minutes). - Partition switches – in partitioned tables, you may switch out old partitions and need to reset the mark for the new partition. - Multiple sources – when joining several tables, each may have its own watermark; you typically take the maximum of them as the safe point.
Common Mistakes / What Most People Get Wrong
Even though the concept is simple, teams often stumble on the details
Even though the concept is simple, teams often stumble on the details.
Treating the watermark as a point-in-time guarantee
A high water mark tells you what has been processed, not what was true at a specific instant. If your source system allows hard deletes, rows that existed during the last run can vanish before the next one. The watermark won’t flag them. If you need a true audit trail or slowly changing dimension Type 2 history, you need change data capture (CDC) or a separate delete-detection strategy — don’t rely on the watermark alone But it adds up..
Ignoring transaction boundaries
In the SQL example above, the SELECT MAX(id) and the INSERT are separate statements. If new rows land in src.sales between those two steps, the UPDATE captures a watermark higher than the rows you actually inserted. The next run starts after those new rows, and they are silently skipped. Wrap the read and the state update in a single transaction, or use a REPEATABLE READ / SERIALIZABLE isolation level, or — better yet — let your orchestrator (Airflow, dbt, Dagster) manage the state atomically after a successful task completion That's the part that actually makes a difference. Simple as that..
Using MAX(updated_at) on a table with clock skew
Distributed systems don’t share a perfect clock. If the application server writing to src.sales is five seconds ahead of the database server, or if a daylight-saving transition shifts timestamps, WHERE updated_at > @wm becomes a lottery. Prefer a monotonically increasing sequence or an identity column for the watermark itself. If you must use timestamps, add a configurable “lookback buffer” (e.g., WHERE updated_at > @wm - INTERVAL '10 minutes') and accept the cost of re-processing a few rows to guarantee completeness.
Forgetting the initial bootstrap
Every watermark job needs a “day zero.” Teams often hard-code a start date in the control table, then forget about it. Months later, a schema change or a full reload requirement exposes the fact that the bootstrap logic was never tested. Treat the initial load as a first-class code path: version it, parameterize it, and run it in CI Simple as that..
Letting the control table become a bottleneck
A single-row etl_state table updated by dozens of concurrent DAGs becomes a hotspot. Contention on that row serializes your pipeline. Shard the state by job and partition (e.g., job='sales_fact', partition='2024-05'), or move state into the orchestrator’s metadata database where row-level locking is optimized for this exact pattern Most people skip this — try not to..
Advanced Patterns Worth Knowing
Composite watermarks for multi-table extracts
When a fact table depends on three dimension tables, each with its own update cadence, a single scalar watermark isn’t enough. Store a JSON blob or a small relation: { "dim_customer": 4021, "dim_product": 188, "dim_store": 55 }. The extract query then joins against each dimension’s watermark. This prevents the “dimension updated but fact didn’t see it” race condition.
Watermark validation as a data quality gate
Don’t just trust the number. After the load, assert that MAX(watermark_col) IN target >= stored_watermark. If the target is empty but the watermark advanced, alert immediately. This catches the silent “query ran, wrote zero rows, updated state anyway” failure mode that plagues overnight batches Worth keeping that in mind. No workaround needed..
Time-travel and backfill without breaking the chain
Need to reprocess January because a business rule changed? Don’t reset the global watermark. Instead, introduce a backfill_id column in the target. Run a one-off job with WHERE id BETWEEN jan_start AND jan_end AND backfill_id IS NULL, write rows tagged with backfill_id = '2024-rule-change', and leave the production watermark untouched. Your incremental job continues forward; analysts can filter or union the backfill as needed.
When Not to Use a High Water Mark
- Full CDC requirements: If you need every intermediate state of a row (update → update → delete), a watermark on
updated_atoridonly sees the final version. Use log-based CDC (Debezium, Fivetran, native CDC) instead. - Non-monotonic sources: CSVs dropped on SFTP, API endpoints that return “last 100 records” with no cursor, or spreadsheets edited in place. There is no reliable watermark here; you need snapshot-based diffing or hash-based change detection.
- Tiny tables: If the source is 5,000 rows and fits in memory, a
TRUNCATE + INSERTis simpler, faster to debug, and eliminates an entire class of state bugs. Don’t add complexity until the data volume demands it.
Closing Thought
The high water mark is the humble workhorse of data
The high water mark is the humble workhorse of data engineering—simple, effective, and often overlooked until it breaks. Because of that, its strength lies in its ability to balance performance and simplicity, but its effectiveness hinges on thoughtful implementation. By recognizing its constraints and pairing it with complementary patterns like composite watermarks or time-travel backfills, teams can build pipelines that are both resilient and maintainable. That said, the key takeaway is to resist over-engineering: start with the simplest solution that works, and evolve your approach as your data landscape grows in complexity. In an era where real-time and batch systems increasingly converge, mastering these foundational techniques ensures you’re not just moving data—you’re moving it correctly.