
Launch: Bruin CLI
Bruin CLI is an open-source data pipeline tool built with Go, with built-in data ingestion, transformation, and data quality checks.
Choose between full refresh, append, merge, delete+insert, time_interval, and SCD2 based on source updates, deletes, keys, late data, and history requirements.

Arsalan Noorafkan
Developer Advocate

Quick answer: choose an incremental data loading strategy from the way the source changes. Use a full refresh when the result is cheap to rebuild, append for immutable new rows, merge for inserts and updates identified by a stable key, delete+insert or time_interval when you can safely rebuild a complete group or time window, and SCD2 when you need historical versions. If the source cannot reliably expose updates and deletes, start with CDC instead of pretending a timestamp filter will find them.
One of the fastest ways to make a pipeline expensive is to process every row every time it runs. On BigQuery, that means scanning and rewriting more data than the job needs. On ClickHouse, it means more CPU, I/O, and background merges. Neither is much fun when the table grows.
But I also see data engineers reach for an incremental model before it has earned its complexity. If a table is small, changes rarely, or can be rebuilt in seconds, a view or full refresh is often the better choice. It is easier to understand, easier to backfill, and much harder to corrupt quietly. Saving a few seconds today is not worth six months of repairing a brittle loading scheme.
The useful question is not "which strategy is fastest?" It is "what can the source prove about change?"
Before picking a strategy, get specific about how the source behaves. This usually means asking the people who own it a few slightly annoying questions:
updated_at reliable, and does every update touch it?Those answers decide whether an incremental filter is safe. A table with a reliable updated_at and a real primary key is a very different problem from an event log with late arrivals or a source that mutates history without recording when it did so.
This is the decision tree I use:
Is a complete rebuild still cheap?
-> yes: create+replace
-> no:
Are rows immutable after they arrive?
-> yes: append
-> no:
Do you have a stable, non-null key and a reliable update cursor?
-> yes, and only current state matters: merge
-> no, but you can rebuild a complete group: delete+insert
-> no, but each SQL run owns a complete time window: time_interval
-> historical versions matter: scd2_by_column or scd2_by_time
-> updates or deletes are otherwise invisible: capture them with CDC
The destination still matters for cost, locking, partitions, and supported SQL. But it comes second. A cheap warehouse operation cannot recover a source change that your filter never saw.
| Strategy | Use it when | Watch out for |
|---|---|---|
create+replace | The table is small or cheap to rebuild | You pay to recreate the whole table every run |
truncate+insert | You need a full reload without dropping and recreating the table | The job still reads and rewrites the full result |
append | The source is genuinely append only | Re-runs and late data can duplicate rows |
merge | Existing rows change and a stable primary key identifies them | NULL keys, missed updates, and source deletes create duplicates or stale values |
delete+insert | You can fully rebuild a known group of records | The delete boundary must match the data you reload |
time_interval | A SQL asset can safely rebuild each scheduled time window | The query filter must match the runtime interval exactly |
scd2_by_column | You have a complete current snapshot and want to track changed attributes | It stores history on purpose, so table size and query logic grow |
scd2_by_time | The source has a reliable change timestamp that defines each version | Missing or unreliable timestamps break the history |
CDC belongs beside this table, but it is not another materialization strategy. It is a way to capture inserts, updates, and deletes from the source when timestamps and lookback windows are not trustworthy. The destination can still use merge, append an immutable change log, or build an SCD2 table from those captured changes.
The snippets below use Bruin SQL assets because the materialization declaration sits beside the SELECT. The Bruin materialization reference covers the full schema and platform support.
These are examples, not a requirement: use your warehouse's SQL dialect and your orchestrator's configuration in a real project. The decision about how a source changes comes first. The date examples use the CLI's inclusive start_date and end_date values, so render an asset before a custom backfill to confirm its exact window.
If a table is small or fast to rebuild, start with create+replace. It replaces the destination table with the query result on every run:
/* @bruin
name: analytics.daily_revenue
type: bq.sql
materialization:
type: table
strategy: create+replace
@bruin */
SELECT
order_date,
SUM(amount) AS revenue
FROM raw.orders
GROUP BY order_date;
There is no incremental filter because the query intentionally rebuilds the whole result. Keep this strategy while the table is cheap enough to recreate and the simpler failure mode is worth more than a smaller bill.
truncate+insert is the other full-reload option. It empties the existing table and inserts the new result without dropping and recreating the table:
/* @bruin
name: analytics.current_inventory
type: bq.sql
materialization:
type: table
strategy: truncate+insert
@bruin */
SELECT product_id, warehouse_id, stock
FROM raw.current_inventory;
Use it when keeping the existing table object matters. Warehouse behaviour around transactions, metadata, and permissions varies, so render and test it on the platform you actually run.
append only when new rows stay newappend is the simplest strategy. Each run inserts the rows in its window and leaves what is already in the destination alone.
It works well for immutable event streams: application events, log records, or transactions where an event never changes after it is written. The source query must return only data that has not been loaded before.
The word "immutable" matters. If the source sometimes updates an event, delivers it late, or returns the same range twice during a backfill, append will happily insert it again. It is not doing anything wrong. It has no instruction to look for an existing version.
When I use append, I want a proper event ID or another clear deduplication plan. "It probably will not happen" is not a plan.
/* @bruin
name: analytics.events
type: bq.sql
materialization:
type: table
strategy: append
@bruin */
SELECT event_id, event_type, event_date
FROM raw.events
WHERE event_date BETWEEN '{{ start_date }}' AND '{{ end_date }}';
For the default daily schedule, both values resolve to the day being processed. The query therefore inserts that day's events once. A backfill needs the same care: make its date range explicit and check it before running.
merge when the latest version is what mattersmerge updates rows that match a primary key and inserts rows it has not seen before. It is the normal choice for entities such as customers, orders, subscriptions, or product records where the current state matters more than the old one.
The key must be stable and complete. A missing key is not a small data-quality issue here. It changes the behaviour of the load.
In particular, check for NULL values in merge keys. In SQL, NULL = NULL is not TRUE. A source row with a nullable key cannot match its existing target row with a normal equality condition, so a MERGE can insert it again on every run. Both I and Sabri have lost sleep over versions of this.
Use not_null and unique checks for merge keys. If you are working in BigQuery, our null-safe merge benchmark shows why a nullable key needs more care than adding COALESCE and hoping for the best.
Also make sure the query reads a safe window of updates. If the source updates a row without updating updated_at, no incremental strategy based on that timestamp can find it.
/* @bruin
name: analytics.customers
type: bq.sql
materialization:
type: table
strategy: merge
columns:
- name: customer_id
type: STRING
primary_key: true
- name: email
type: STRING
update_on_merge: true
- name: plan
type: STRING
update_on_merge: true
@bruin */
SELECT customer_id, email, plan
FROM raw.customers
WHERE DATE(updated_at) BETWEEN '{{ start_date }}' AND '{{ end_date }}';
The primary key decides whether a row matches. update_on_merge: true names the columns that should take the incoming value when it does.
One more catch: a normal merge does not delete a target row just because that row disappeared from the source query. Propagating source deletes requires a delete signal, such as CDC, a tombstone record, a soft-delete column, or a complete group replacement.
delete+insertdelete+insert is a good fit when the source can return a complete group of records that you can replace safely. A date partition is the usual example: remove one day's rows, then insert that day again.
This handles corrections much better than append, because a re-run replaces the records for the group rather than adding more copies. It also avoids the row-by-row matching cost of a merge when the natural unit of change is a whole partition.
The hard rule is simple: only delete what you can fully reconstruct. If the job deletes a day but the query reloads only part of that day, the destination now has a gap. If the query reaches farther back than the deleted range, the extra rows may duplicate what was already there.
/* @bruin
name: analytics.orders
type: bq.sql
materialization:
type: table
strategy: delete+insert
incremental_key: order_date
@bruin */
SELECT order_id, order_date, status
FROM raw.orders
WHERE order_date BETWEEN '{{ start_date }}' AND '{{ end_date }}';
The materialization deletes target rows whose order_date appears in the query result, then inserts that result. So the query must return the complete set for every date it touches.
time_interval for scheduled SQL windowstime_interval makes the time window explicit for a SQL asset. Bruin gives each scheduled run a start_date and end_date. The strategy deletes the destination rows in that interval, then inserts the query result for the same interval.
This SQL asset rebuilds a daily event window. bq.sql is just the concrete dialect used in the example:
/* @bruin
name: analytics.events
type: bq.sql
materialization:
type: table
strategy: time_interval
incremental_key: event_date
time_granularity: date
@bruin */
SELECT event_id, event_date
FROM raw.events
WHERE event_date BETWEEN '{{ start_date }}' AND '{{ end_date }}';
For the default daily run, both variables render to the same date, so the asset owns that day. If you manually pass --start-date 2026-07-20 --end-date 2026-07-21, it owns both calendar days. This is why bruin render is worth using before a backfill.
The query filter and the materialization window have to stay in sync. Do not reach three days back in the WHERE clause while the strategy deletes one day. That looks like a late-arrival fix, but it inserts three days of rows that the load did not delete first. Use an interval modifier to widen the window instead.
Bruin's interval modifiers widen the start and end dates together. Run bruin render assets/events.sql before executing a new materialization. It shows the compiled SQL with the real dates and makes boundary mistakes much easier to catch. The Bruin render guide walks through the output, and the incremental vs full refresh guide covers regular runs, backfills, and --full-refresh.
Sometimes the latest value is not enough. If you need to answer "what did this customer plan look like in March?" or "which account owner was assigned when the opportunity closed?", keep the history with an SCD2 strategy.
SCD2 creates a new version when a tracked row changes and records the period that version was valid. It is useful for audit, attribution, and historical reporting. It also asks more of the model: define the business key, decide which attributes should trigger a new version, and be clear about late updates. Do not use it because it feels more complete. Use it when someone will query the history.
scd2_by_column compares the non-key columns in a complete current snapshot. It adds _valid_from, _valid_until, and _is_current to preserve the history:
/* @bruin
name: analytics.customer_plan_history
type: bq.sql
materialization:
type: table
strategy: scd2_by_column
incremental_key: updated_at
columns:
- name: customer_id
type: STRING
primary_key: true
- name: plan
type: STRING
- name: region
type: STRING
- name: updated_at
type: TIMESTAMP
@bruin */
SELECT customer_id, plan, region, updated_at
FROM raw.customer_snapshot;
The incremental_key is optional for scd2_by_column, but useful here because it makes the validity range follow the source's updated_at rather than the processing time. The input must be a complete snapshot. A partial incremental query would make omitted current records look deleted.
scd2_by_time is the stricter option. It requires a date or timestamp incremental_key and derives each version's validity period from it:
/* @bruin
name: analytics.product_stock_history
type: bq.sql
materialization:
type: table
strategy: scd2_by_time
incremental_key: source_updated_at
columns:
- name: product_id
type: INTEGER
primary_key: true
- name: stock
type: INTEGER
- name: source_updated_at
type: TIMESTAMP
@bruin */
SELECT product_id, stock, source_updated_at
FROM raw.product_snapshot;
Use this version only when source_updated_at is trustworthy. A timestamp that is late, missing, or updated inconsistently makes the historical timeline just as misleading as a bad merge key.
Incremental loading and change data capture solve different parts of the pipeline.
Polling updated_at can be enough when the source guarantees that every change updates the column and rows are not hard deleted. It is simple, easy to inspect, and often the right starting point.
CDC is the safer choice when deletes matter, updates do not reliably touch a timestamp, or a lookback query would put too much load on the source. A log-based CDC reader captures the change at the database layer, then the destination still needs an application strategy. That may be a merge into a current-state table, an append-only raw change log, or an SCD2 model.
The longer explanation is in What is CDC in data engineering?. The short version: CDC is not a magic replacement for modelling. It gives the pipeline a better record of what changed.
An incremental data loading strategy defines how a pipeline applies new or changed source rows to an existing destination table. Common choices include append, merge, partition replacement, time-window replacement, and SCD2 history.
Use append when source rows are immutable and every run returns only new records. Use merge when existing rows can change and you have a stable, non-null primary key plus a reliable way to find updated rows.
Use a full refresh when the table is small or cheap to rebuild, when the source cannot reliably identify changed rows, or when you need a complete reconciliation. The simpler load is often safer until scale makes it too expensive.
Reprocess a complete lookback window with delete+insert or time_interval so the pipeline removes and reloads the same range. In Bruin, use interval modifiers to widen both the query and materialization window together.
Not by itself. A normal merge updates matching rows and inserts new ones, but it does not know that a missing source row was deleted. Use CDC, tombstone records, soft-delete flags, or a complete group replacement when deletes must propagate.
Incremental loading describes how a destination table is updated. Change data capture describes how inserts, updates, and deletes are detected at the source. A pipeline can capture changes with CDC and then apply them with merge or another destination strategy.
A successful pipeline run only tells you that the SQL executed. It does not tell you that the load was correct.
The checks should follow the strategy:
merge, check that keys are not_null and unique.append, check duplicate event IDs if a replay is possible.delete+insert and time_interval, check the rebuilt window for duplicates, unexpected row-count changes, and freshness.The first choice does not need to be permanent. Start with the least complicated strategy that accurately captures the source's changes, then move to CDC or a more involved model when the source gives you evidence that you need it. A boring pipeline that you can re-run safely is usually the professional move.
So, what does your source actually promise about change, and can your next backfill prove that promise?

Bruin CLI is an open-source data pipeline tool built with Go, with built-in data ingestion, transformation, and data quality checks.

A NULL in a join key never matches itself, so your incremental MERGE quietly duplicates rows and your joins quietly drop them. Here is why it happens, why the obvious null-safe fix (IS NOT DISTINCT FROM) turns your join into a cross join, and the OR-form that stays a hash join, with a full BigQuery benchmark.

A SQL unit test checks a query's logic with controlled input rows and an expected result. Learn what SQL unit tests cover, how they differ from data quality checks, and how to write them with Bruin.
Practical updates on open-source data pipelines, AI analysts, governance, and what we are shipping at Bruin.