TL;DR — dbt incremental models, when paired with database‑level time‑travel columns (valid_from/valid_to) and idempotent delete+insert strategy, 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:

StrategyBehaviour
delete+insertRemoves all rows and re‑inserts the result set. Simest but expensive on large tables.
mergeUses the database MERGE (or UPSERT) to match rows on a unique_key and update or insert accordingly.
appendSimply 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 to delete+insert, which can cause long downtimes on large tables.
  • Stale updated_at column – 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 set on_schema_change to add_column or alter_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:

  1. Snapshot the raw source daily (or on a schedule).
  2. Incremental model consumes the snapshot and applies business logic, adding _valid_from/_valid_to.
  3. 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:

LayerPurposeTypical Materialization
RawImmutable copy of source system tablestable (no transformation)
StagingClean, conformed columns; add _valid_from/_valid_toincremental
MartBusiness‑ready metrics, aggregated tablesincremental 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:

  1. Sensor checks raw source availability (e.g., S3 new file arrival).
  2. Task runs dbt snapshot to capture raw state.
  3. Task runs dbt run for staging and mart models.
  4. 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 WITH block reads raw orders and computes _valid_from as the day the order was placed, and _valid_to as the day the next order for the same order_id begins (or NULL for the latest row).
  • QUALIFY ROW_NUMBER() removes duplicate rows that may arise from incremental batches overlapping on the same key.
  • The outer SELECT exposes 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 merge or delete+insert and always define a immutable unique_key to avoid duplicates and silent data loss.
  • Time‑travel columns: Add _valid_from/_valid_to to every incremental model; they enable point‑in‑time queries without extra snapshots.
  • Idempotent design: Use merge with tests for uniqueness and accepted_range to 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