The arithmetic of a nightly rebuild
| Night | Rows in source | Rows a table rebuild reads | Rows an incremental run reads |
|---|---|---|---|
| 1 | 90,000,000 | 90,000,000 | 90,000,000 |
| 2 | 90,300,000 | 90,300,000 | 300,000 |
| 3 | 90,600,000 | 90,600,000 | 300,000 |
| 4 | 90,900,000 | 90,900,000 | 300,000 |
What incremental changes
materialized='incremental' is the third value the materialized config accepts, beside view and table. On its first build the model creates its table from the SELECT, as a table model does. On every later build it runs the SELECT and inserts the rows that come back into the table that already exists. Nothing is dropped, and a row the table already holds is never read again or rewritten.
The running example
The lesson follows 1 source table, app.stream_events, with 1 row per play and 4 columns: event_id, handed out in order as the app records plays, track_id, played_at, and ms_played, the milliseconds the listener heard. On the first night it holds 3 rows.
| event_id | track_id | played_at | ms_played |
|---|---|---|---|
| 1 | trk_41 | 2026-03-01 08:12:00 | 214000 |
| 2 | trk_09 | 2026-03-01 09:40:00 | 9000 |
| 3 | trk_41 | 2026-03-01 21:05:00 | 198000 |
Choosing
- Rows are events that never change after they are written
- A column grows with every new row: a created timestamp or a counting id
- The source is large enough that reading all of it costs real time or money
- The SELECT keeps 1 row per source row, or groups rows that arrive together
- Rows are edited after they land: an order's status, a user's plan
- The SELECT computes totals over all history: lifetime plays per track
- The source is small: a lookup of 200 categories rebuilds in a second
- No column tells old rows from new ones
- ▸Do source rows change after they are written? If yes, the appended copy goes stale.
- ▸Which column marks a row as newer than every row loaded before it?
- ▸Would the rebuild cost matter if the source were 10 times larger next year?
| Question | plays | Lifetime plays per track |
|---|---|---|
| Do rows change after they are written? | No: a play is recorded once | Yes: every new play changes a track's total |
| Which column marks a row as newer? | `event_id`, or `played_at` while plays arrive in order | None: a track's row has no arrival time |
| Would the rebuild cost matter at 10 times the size? | Yes: 900,000,000 rows read a night | Yes, but append cannot change a total it already wrote |
| Verdict | Incremental | Table |
The file has 3 new parts: the config value incremental on the first line, the macro is_incremental() inside the if tag, and this inside the subquery. The rest is a SELECT over a source with each column named, the same shape as every model so far.
What is_incremental() checks
is_incremental() is a macro that ships with dbt, and it returns true only when all 3 of these hold at the moment the model renders:
| Situation | Configured incremental | Table exists | --full-refresh | is_incremental() |
|---|---|---|---|---|
| First build | yes | no | no | false |
| Second build | yes | yes | no | true |
| Second build with the flag | yes | yes | yes | false |
| Config set to table | no | yes | no | false |
1 file, 2 renderings
Compiled SQL is what dbt writes under target/compiled/ and sends to the warehouse. With the plays table present and no flag, the block stays, source() and this resolve to names, and the warehouse receives this statement:
this names the model's own table
this is a variable dbt sets while rendering each model, holding the relation the model writes to: schema and name. For plays built into the analytics schema it renders to analytics.plays. It renders to that name whether the table exists, because this is a name and nothing more; whether the name points at a table is the warehouse's business when the statement runs.
ref() can't do this job. ref('plays') inside plays would tell dbt that plays depends on plays, and dbt refuses the edge, since no node can be built before itself. this adds no edge: it names the table, dbt consults nothing in the DAG, and the subquery reads whatever sits under that name when the statement executes.
- Renders to the current model's own relation
- Adds no node or edge to the DAG
- Used inside the model to read what it already holds
- Renders to the same name on every run
- Renders to another model's relation
- Adds an edge: that model builds first
- Used in a different model that reads plays
- Refused inside plays itself as a cycle
Same file, 2 materializations
The animation builds plays from the same 3 rows, then adds 2 rows and builds again. Here the materialization comes from dbt_project.yml, so the model file is identical in both versions and only +materialized changes. Switch between them: the lineage, the rows and the columns match after both runs. Under table the if block renders away on both runs, because is_incremental() returns false for any model not configured incremental, and run 2 rewrites all 5 rows. Under incremental the block renders away on run 1 and stays on run 2, which inserts 2.
SELECTevent_id,track_id,played_at,ms_playedFROM {{ source('app', 'stream_events') }}{% if is_incremental() %}WHERE played_at > (SELECT MAX(played_at) FROM {{ this }}){% endif %}
is_incremental() decides whether a filter exists; the filter decides what counts as new. dbt keeps no record of which source rows it has loaded. On every run the model's own SQL has to look at the table it holds, find the newest row there, and ask the source for rows newer than it.
A watermark column
The subquery inside the if block returns 1 value, the latest played_at the table holds, and the outer WHERE keeps the source rows past it. That value is a watermark: the line the last run reached. For the filter to be right the column has to grow with every new row, so that a row arriving later always carries a larger value than every row already loaded.
Columns of 3 kinds can do that, and they're not equally safe. An id handed out in order as rows are written, such as event_id, grows with arrival by construction. So does a load timestamp, a time the source stamps on the row when it writes it. played_at is an event time: when the listener pressed play. It grows with arrival only while plays reach the source in the order they happened, and the end of this section shows a play that does not. A column like updated_at, which a later edit pushes upward, sends old rows back through the filter, and append writes them a second time.
The second night, row by row
| event_id | played_at | played_at > 2026-03-01 21:05:00 | Inserted |
|---|---|---|---|
| 1 | 2026-03-01 08:12:00 | no | no |
| 2 | 2026-03-01 09:40:00 | no | no |
| 3 | 2026-03-01 21:05:00 | no, the values are equal | no |
| 4 | 2026-03-02 07:30:00 | yes | yes |
| 5 | 2026-03-02 12:15:00 | yes | yes |
| event_id | played_at |
|---|---|
| 1 | 2026-03-01 08:12:00 |
| 2 | 2026-03-01 09:40:00 |
| 3 | 2026-03-01 21:05:00 |
| event_id | played_at |
|---|---|
| 1 | 2026-03-01 08:12:00 |
| 2 | 2026-03-01 09:40:00 |
| 3 | 2026-03-01 21:05:00 |
| 4 | 2026-03-02 07:30:00 |
| 5 | 2026-03-02 12:15:00 |
Why > and not >=
| Night | New plays in source | Inserted with > | Inserted with >= | Duplicate rows in plays |
|---|---|---|---|---|
| 2 | 2 | 2 | 3 | 1 |
| 3 | 1 | 1 | 2 | 2 |
| 4 | 3 | 3 | 4 | 3 |
Filtering on an id instead
Swapping the column changes 1 line: WHERE event_id > (SELECT MAX(event_id) FROM {{ this }}) inside the same if block. An id works when the source hands ids out in arrival order, and it has 1 advantage over a timestamp: an integer comparison has no time zone and no fractional second to get wrong. A timestamp is the choice when no such id exists, or when the source is a union of feeds whose ids overlap.
What the filter cannot see
| Filter | Night 3 watermark | Row 6's value | Inserted |
|---|---|---|---|
| `played_at > MAX(played_at)` | 2026-03-02 12:15:00 | 2026-03-01 20:00:00 | no, on this run or any later one |
| `event_id > MAX(event_id)` | 5 | 6 | yes |
The first time dbt runs an incremental model, no table of its name exists in the target schema. is_incremental() finds the table missing, returns false, and Jinja drops the if block before rendering finishes. The SQL sent is the plain SELECT with no WHERE, and dbt wraps it in a CREATE TABLE AS:
Why the block has to go
Suppose the block rendered anyway. The statement would contain SELECT MAX(played_at) FROM analytics.plays, a query over a table that does not exist. Rendering would finish without complaint, because to Jinja the name is a string, and the warehouse would answer with a database error: table not found. dbt returns false for a missing table so that the SQL it sends never names what is not there, and the model file doesn't have to know which run it is on.
| Piece of the second run | On a plays table with 0 rows |
|---|---|
| `MAX(played_at)` from `this` | NULL |
| `played_at > NULL` for each source row | unknown, so the row is filtered out |
| Rows inserted | 0 |
| Watermark for the run after | still NULL |
- No table under the model's name
- is_incremental() returns false
- CREATE TABLE AS over the whole source
- Columns and types fixed from the SELECT
- The table from the run before
- is_incremental() returns true
- INSERT INTO of the rows past the watermark
- Rows must fit the columns the table already has
What the first run costs
| Run | Table exists before | is_incremental() | Statement | Rows read |
|---|---|---|---|---|
| 1 | no | false | CREATE TABLE AS | 90,000,000 |
| 2 | yes | true | INSERT INTO | 300,000 |
| 3 | yes | true | INSERT INTO | 300,000 |
Later runs insert
When a run becomes a first run again
dbt tells a first run from a later run by looking for the table, never by remembering. Nothing in the project records that a model has run before.
dbt run --full-refresh tells dbt to treat every incremental model in the run as though its table did not exist. is_incremental() returns false, the if block is dropped, and dbt drops the existing table and creates it again from the full SELECT, reading every source row. dbt build --full-refresh does the same and runs the tests after.
| Situation | is_incremental() | Statement sent | plays afterward |
|---|---|---|---|
| Table absent, plain run | false | CREATE TABLE AS | every source row |
| Table present, plain run | true | INSERT INTO | old rows plus rows past the watermark |
| Table present, --full-refresh | false | drop, then CREATE TABLE AS | every source row |
Reason 1: the SELECT changed
| event_id | ms_played | Loaded | After a plain run | After --full-refresh |
|---|---|---|---|---|
| 1 | 214000 | night 1, before the rule | kept | kept |
| 2 | 9000 | night 1, before the rule | kept, against the rule | dropped |
| 3 | 198000 | night 1, before the rule | kept | kept |
| 4 | 45000 | night 2, under the rule | inserted | kept |
| 5 | 12000 | night 2, under the rule | not inserted | not present |
Reason 2: the source rewrote old rows
SELECTevent_id,track_id,played_at,ms_playedFROM {{ source('app', 'stream_events') }}{% if is_incremental() %}WHERE played_at > (SELECT MAX(played_at) FROM {{ this }}){% endif %}
What a full refresh costs
- Refresh after any edit to the SELECT that changes what old rows would hold
- Refresh when the source rewrote rows behind the watermark
- Batch SELECT changes and pay for 1 rebuild instead of several
- Run it through dbt build so the tests check the rebuilt table
- Expect a plain run to repair rows the table already holds
- Leave 2 versions of a rule living in 1 table
- Refresh a model whose source has dropped its oldest history
- Pass --full-refresh on a schedule; each one is a decision someone makes
> A ride-hailing company's analytics team keeps a trips table with 1 row per completed ride, built nightly from an events source that gains millions of rows a day. They convert it to incremental, filtered on an event id that counts up, and the nightly run drops from an hour to 2 minutes. A month later a fare rule changes in the SELECT, and the next scheduled run is replaced by a full refresh.
dbt enables data analysts and engineers to transform their data using the same practices that software engineers use to build applications.
github.com/dbt-labs/dbt-core
dbt and the dbt logo are trademarks of dbt Labs, LLC.
Add the rows that arrived since the last build, instead of rebuilding a table from its first row every time.
- Category
- dbt
- Difficulty
- beginner
- Duration
- 20 minutes
- Challenges
- 10 hands-on challenges
Topics covered: Rebuild or Increment, is_incremental() and this, Filtering Only New Rows, The First Run Builds It All, Full Refresh
Lesson Sections
- Rebuild or Increment (concepts: Incremental models)
A table model sends the same statement on every build: drop the table that exists, create a new one from the SELECT. The SELECT reads every row of its source each time, whether that row arrived an hour ago or was already read on the build before. For a source that gains rows every day, the rebuild on day 400 reads 400 days of rows to add 1. The arithmetic of a nightly rebuild Take a source of stream events, 1 row per play, holding 90,000,000 rows and gaining 300,000 a night. A table model reads
- is_incremental() and this (concepts: Incremental models)
An incremental model needs 1 SELECT that behaves 2 ways: read everything when there is no table, read only the new rows when there is. Jinja renders before any SQL reaches the warehouse, so the model wraps its filter in an if block and lets dbt decide, at render time, whether the filter is there at all. What is_incremental() checks When any 1 of them fails it returns false, and Jinja drops everything between the if and the endif, so the SQL dbt sends has no WHERE in it at all. To settle the seco
- Filtering Only New Rows (concepts: Incremental models)
A watermark column The second night, row by row After the first night plays holds events 1 to 3, and MAX(played_at) over it is 2026-03-01 21:05:00. The source now holds 5 rows. The filter compares each of them against that value: Row 3 sits on the watermark itself. With > it stays out, which is right, because the table already holds it. The 2 rows past the line are inserted, and the table ends the night with 5 rows and a watermark of 2026-03-02 12:15:00, which the third night's subquery will ret
- The First Run Builds It All (concepts: Incremental models)
This is the statement a table model with the same SELECT sends on every run. Whatever the if block holds, however narrow its filter, plays no part in the first build, because the block never reaches the warehouse. The first run reads every source row, and the table starts complete. Why the block has to go An empty table shows the other half of the reason. If a script had created plays with its 4 columns and no rows before dbt ever ran, the table would exist, the block would render, and MAX over
- Full Refresh (concepts: Incremental models)
Reason 1: the SELECT changed Spotify counts a stream once a track has played for 30 seconds, a rule it publishes for artists and royalties. Suppose plays has run for 30 nights keeping every event, and the team adds a WHERE on ms_played to apply the rule. A plain run applies the rule to tonight's rows and to nothing else: the 30 nights already in the table keep their short plays, because append never reads them again. On the running example, say the rule lands between night 1 and night 2. Night 2