TL;DR — dbt incremental models, when paired with database‑level time‑travel columns (
valid_from/valid_to) and idempotentdelete+insertstrategy, let analysts query any point‑in‑time state while the pipeline automatically recovers from upstream failures via materialization retries and dbt test guards.
Building reliable ETL pipelines that can both refresh data incrementally and answer “what did our data look like last week?” is a common requirement for analytics engineering teams. dbt’s incremental materialization gives you the refresh logic, but without an explicit time‑travel design you lose the ability to reconstruct historical snapshots after a failure. In this post we’ll walk through a production‑grade pattern that combines dbt incrementals, database time‑travel features, and fault‑tolerant orchestration so that every load is both efficient and reversible.
Foundations of dbt Incremental Models
How Incremental Materialization Works
dbt’s incremental materialization sits between a full refresh (replace) and a simple append. When you run dbt run, dbt compares the existing table (or view) against the new SELECT statement and applies one of three strategies:
| Strategy | Behaviour |
|---|---|
delete+insert | Removes all rows and re‑inserts the result set. Simest but expensive on large tables. |
merge | Uses the database MERGE (or UPSERT) to match rows on a unique_key and update or insert accordingly. |
append | Simply inserts new rows, assuming the source never changes existing primary keys. |
The chosen strategy is declared in the model’s config block:
{{ config(
materialized='incremental',
unique_key='order_id',
incremental_strategy='merge',
on_schema_change='add_column'
) }}
Source: dbt incremental models documentation
A critical design decision is the unique_key. It must be a column (or set of columns) that uniquely identifies a row in the source and never changes its value for the same business entity. Mis‑specifying the key leads to duplicate rows or silent data loss.
Common Pitfalls
- No
unique_key– dbt throws an error; without it, the strategy defaults todelete+insert, which can cause long downtimes on large tables. - Stale
updated_atcolumn – Using a timestamp as the key works only if the source truly updates rows in place; otherwise you get repeated inserts. - Schema drift without
on_schema_change– If a column is added or removed, dbt may fail the run unless you seton_schema_changetoadd_columnoralter_column.
Understanding these basics sets the stage for adding time‑travel capabilities without sacrificing performance.
Time‑Travel Semantics in Modern Warehouses
What Time‑Travel Means for Analysts
Time‑travel (a.k.a. “point‑in‑time query” or “historical query”) lets a user query a table as it existed at any previous timestamp. Most cloud warehouses implement this natively:
- Snowflake:
SELECT … FROM table AT TIMESTAMP => DATE '2023-01-01' - BigQuery:
SELECT … FROM table FOR SYSTEM_TIME AS OF TIMESTAMP '2023-01-01 12:00:00' - Redshift:
SELECT … FROM table AS OF SYSTEM TIME AS OF '2023-01-01'
When analysts can roll back to a prior state, they can debug data quality incidents, reconstruct lost metrics, or audit compliance changes—all without re‑running the entire ETL from raw sources.
Leveraging Database‑Level Time‑Travel
The easiest way to expose time‑travel in dbt is to add two columns to every incremental model: _valid_from (timestamp when the row became effective) and _valid_to (timestamp when it ceases to be effective, NULL for the current row). These columns are populated during the model’s SELECT and are then used by the warehouse’s time‑travel feature.
SELECT
order_id,
order_date,
total_amount,
-- dbt‑managed validity window
_valid_from,
_valid_to
FROM {{ source('raw', 'orders') }}
{% if is_incremental() %}
WHERE _valid_to IS NULL -- only current rows for merge
{% endif %}
By storing the validity window inside the model, the underlying warehouse can answer “show me orders as of yesterday” directly against the incremental table, without needing a separate snapshot store.
dbt‑level Techniques: Snapshots + Incremental
dbt snapshots capture the state of a raw table at a point in time, while incrementals keep the table up‑to‑date. A common pattern is:
- Snapshot the raw source daily (or on a schedule).
- Incremental model consumes the snapshot and applies business logic, adding
_valid_from/_valid_to. - Analyst queries use the warehouse’s time‑travel on the incremental table, knowing the validity window reflects the snapshot moment.
This hybrid approach gives you the performance of incrementals plus the historical fidelity of snapshots.
Fault‑Tolerant ETL Patterns
Retry & Backoff Strategies
dbt materializations can be wrapped by orchestration tools (Airflow, Prefect, Dagster) that implement exponential backoff on failure. A typical Airflow task looks like:
from airflow.decorators import task
from airflow.utils.retry import retry
@retry(tries=3, delay=30, backoff=2)
def run_dbt_model():
run_command(["dbt", "run", "--models", "my_incremental_model"])
If the model fails (e.g., due to a transient network glitch), Airflow retries according to the configured policy, preventing a single hiccup from halting the entire pipeline.
Idempotent Loads and Conflict Detection
The merge strategy is inherently idempotent: re‑running the same model with the same unique_key produces the same final state. However, edge cases such as duplicate source rows can cause merge to error out. To guard against this, add a dbt test that checks for duplicates before the model runs:
models:
- name: stg_orders
columns:
- name: order_id
tests:
- unique
- not_null
Running dbt test as part of the CI/CD gate ensures that upstream data quality issues are caught early, and the incremental merge won’t encounter ambiguous key conflicts.
Using dbt Tests for Data Quality
Beyond uniqueness, you can assert referential integrity, acceptable ranges, and business rules. Example test for total amount positivity:
models:
- name: dim_orders
columns:
- name: total_amount
tests:
- not_null
- accepted_range:
min: 0
max: 1000000
When a test fails, dbt marks the model run as failed, which your orchestrator can react to (e.g., send a Slack alert, pause downstream dependent models, or trigger a manual review).
Production Architecture: From Raw to Curated
Layered Model Pattern (Raw → Staging → Mart)
A robust dbt project often follows a three‑layer architecture:
| Layer | Purpose | Typical Materialization |
|---|---|---|
| Raw | Immutable copy of source system tables | table (no transformation) |
| Staging | Clean, conformed columns; add _valid_from/_valid_to | incremental |
| Mart | Business‑ready metrics, aggregated tables | incremental or view |
Diagrammatically:
raw_orders ──► stg_orders (incremental, time‑travel columns) ──► dim_orders (aggregated metrics)
This separation lets you rebuild any downstream mart from the raw layer if a catastrophic error occurs, while the incremental models keep daily refreshes lightweight.
Orchestration with Airflow or Prefect
Airflow’s dbtCloudEndpointOperator or Prefect’s dbt task provides first‑class support for triggering dbt runs, capturing logs, and reacting to success/failure. A typical DAG flow:
- Sensor checks raw source availability (e.g., S3 new file arrival).
- Task runs
dbt snapshotto capture raw state. - Task runs
dbt runfor staging and mart models. - Task posts a summary to Slack/Teams, including any test failures.
Prefect offers similar flow‑run metadata and built‑in retries, making it a lightweight alternative for teams already using Python‑centric stacks.
Monitoring and Alerting
Key metrics to surface:
- Model runtime – alert if a model consistently exceeds its SLA (e.g., > 30 minutes).
- Test pass rate – monitor the ratio of passing vs. failing tests across the project.
- Row count delta – sudden drops or spikes can signal upstream schema changes or data loss.
Tools like dbt’s --log-level combined with CloudWatch or Prometheus can feed these metrics into dashboards, giving engineers visibility before a fault cascades downstream.
Step‑by‑Step Implementation
Model Definition
Create a new model file models/marts/fact_orders.sql. The file will combine incremental logic, validity columns, and a merge strategy.
{{ config(
materialized='incremental',
unique_key='order_id',
incremental_strategy='merge',
on_schema_change='add_column'
) }}
WITH source AS (
SELECT
order_id,
order_date,
total_amount,
customer_id,
-- time‑travel validity window
DATE_TRUNC('day', order_date) AS _valid_from,
NULLIF(DATE_TRUNC('day', LEAD(order_date) OVER (PARTITION BY order_id ORDER BY order_date)), NULL) AS _valid_to
FROM {{ source('raw', 'orders') }}
),
deduped AS (
SELECT *
FROM source
QUALIFY ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY _valid_from DESC) = 1
)
SELECT
order_id,
order_date,
total_amount,
customer_id,
_valid_from,
_valid_to
FROM deduped
Explanation:
- The
WITHblock reads raw orders and computes_valid_fromas the day the order was placed, and_valid_toas the day the next order for the sameorder_idbegins (orNULLfor the latest row). QUALIFY ROW_NUMBER()removes duplicate rows that may arise from incremental batches overlapping on the same key.- The outer
SELECTexposes the columns needed by downstream marts and the validity window for time‑travel queries.
Configuring incremental_strategy
We chose merge because it’s idempotent and works well with a deterministic unique_key. If your warehouse does not support merge efficiently, switch to delete+insert and adjust the WHERE clause to only delete rows where _valid_to IS NULL.
Adding a valid_from / valid_to column for time‑travel
The _valid_from/_valid_to columns are the core of time‑travel. Once the model is materialized, analysts can query historical snapshots:
-- Snowflake example: orders as of 2023-06-15
SELECT * FROM mart.fact_orders
AT(TIMESTAMP => '2023-06-15 00:00:00');
Because each row carries its validity window, the warehouse can restrict the result set to rows where _valid_from <= '2023-06-15' AND (_valid_to IS NULL OR _valid_to > '2023-06-15').
Materialization Retries and Error Handling
Wrap the dbt run in your orchestrator with retries (as shown earlier). Additionally, dbt’s --retries flag can set a maximum number of automatic retries at the dbt level:
dbt run --models fact_orders --retries 2
If a run still fails after retries, your orchestrator can trigger a fallback: re‑run the preceding snapshot, or raise a PagerDuty incident.
Key Takeaways
- Incremental + unique_key: Choose
mergeordelete+insertand always define a immutableunique_keyto avoid duplicates and silent data loss. - Time‑travel columns: Add
_valid_from/_valid_toto every incremental model; they enable point‑in‑time queries without extra snapshots. - Idempotent design: Use
mergewith tests for uniqueness andaccepted_rangeto guarantee repeatable runs. - Fault tolerance: Leverage orchestrator retries, dbt
--retries, and test‑fail gates to isolate failures before they cascade. - Layered architecture: Raw → Staging (incremental with validity) → Mart (aggregated metrics) gives you both performance and recoverability.
- Monitoring: Track model runtime, test pass rate, and row‑count deltas to catch drift early.
Further Reading
- dbt incremental models documentation
- Snowflake time‑travel SQL guide
- Google BigQuery time‑travel reference
- dbt test best practices
- Apache Airflow dbt operator