Views and tables
When a model is rebuilt over an existing relation of the same type, bothview and table build with a single atomic create or replace statement. There is no drop, rename, or swap step, so the relation is never briefly missing and a failed run leaves the previous version in place.
models/staging/stg_zipcode_zones.sql
table to view, or the reverse, rebuilds without manual intervention. A cross-type switch is two statements, so it is not atomic: if the create fails, nothing is left at that name.
Partitioning a table
table models accept partition_by, either as a single column name or a list.
models/marts/fct_trips_hourly.sql
partitioned by (...) clause unparsed, so Iceberg transform expressions such as days(pickup_ts) or bucket(16, zip) work as well as plain column names.
Because a table model is replaced in full on every run, changing partition_by takes effect on the next dbt run with no extra flag. An incremental model is different — see insert_overwrite.
Ephemeral models
Anephemeral model is never persisted. dbt inlines it into each downstream model as a CTE, which keeps a shared filter or cast in one file instead of repeating it in every model that needs it.
Inlining is not the same as computing the result once. Depending on the plan the engine picks, a CTE referenced more than once can be evaluated once per reference, so an ephemeral model does not guarantee an expensive source scan runs only once. Use a table or incremental model when downstream models need to share a materialized result.
models/staging/stg_taxi_trips.sql
batch_end is a project variable that bounds every read of the taxi source, which holds about 186 million trips. Give it a default in dbt_project.yml:
dbt_project.yml
dbt run --vars '{batch_end: "2009-01-01 04:00:00"}'.
Incremental models
Incremental models stage each run’s new rows, then apply one of four strategies to the target table. The staged rows live in a temporary view whose name is unique to the target, so concurrent runs that build a same-named model into other schemas do not read each other’s rows.delete+insert with a unique_key stages into a table beside the target instead, and drops it after the run.
microbatch is not supported. Selecting it fails with dbt’s own “not valid for this adapter” error before the model’s materialization SQL runs. The model’s pre-hooks still run first, so a pre-hook that writes data takes effect even though the model fails.
append
The default strategy. With nounique_key and no strategy set, dbt appends the staged rows.
models/marts/fct_trip_events.sql
append inserts whatever the model selects, so the model has to avoid re-inserting rows it already loaded. The watermark filter above does that. The coalesce covers an empty table: if a run loads no rows, for example because batch_end is earlier than the first trip, max(pickup_ts) is NULL. A comparison with NULL matches no rows, so without the fallback no later run would load anything. The merge and insert_overwrite examples below use the same fallback.
merge
Upserts onunique_key, which accepts a single column name or a list for a composite key.
models/marts/fct_trips_by_zone_daily.sql
is_incremental() filter uses >=, so each run recomputes the latest day already in the table, plus any newer days. merge updates the existing rows for that day in place and inserts the rest. Without the filter the model still gives the right answer, but re-aggregates every day on every run.
merge also honors merge_update_columns and merge_exclude_columns to narrow which columns an update touches, and incremental_predicates to add conditions to the match.
A unique_key value that appears more than once in the staged data does not always fail the run:
- If the key already exists in the table, the run fails with
MERGE_CARDINALITY_VIOLATIONand leaves the table unchanged. - If the key is new,
mergeinserts every copy with no error, and the table now holds duplicates. - A first build stores whatever the model selects, duplicates included.
group by in the example above does. Add a unique test on the key as well: the run catches only the first case, and the test catches the other two.
delete+insert
Deletes the target rows whoseunique_key appears in the staged data, then inserts every staged row. For most models it reaches the same end state as merge, including models whose is_incremental() filter reads {{ this }}, and swapping between the two is a one-line change.
unique_key, delete+insert degrades to a pure append — there is nothing to delete.
insert_overwrite
Replaces whole partitions. Every partition present in the staged data is rewritten in full, and partitions the staged data does not touch are left alone. Use it when a run recomputes a whole day or region rather than individual rows.models/marts/fct_trips_hourly_incremental.sql
partition_byis required. Without it, the run fails at compile time, because an unpartitioned overwrite would replace the whole table. Usematerialized='table'if that is what you want.- Every
partition_bycolumn must be selected by the model. On an incremental run, a partition column missing from the model’s output fails the run at compile time instead of filing every staged row into aNULLpartition. On the first build, the engine rejects thecreate tablewithCouldn't find column <name>. - Adopting the strategy on an existing table requires
--full-refresh, as does changingpartition_by. The overwrite scopes by the table’s actual partitioning, not by your config, and the adapter does not detect a mismatch between the two. On an existing unpartitioned table, the firstinsert_overwriterun without--full-refreshreplaces the whole table with the staged rows. A first build needs no--full-refresh, because it creates the partitioned table directly.
spark.sql.sources.partitionOverwriteMode unchanged for other statements on the runtime.
unique_key and incremental_predicates are accepted but ignored. Partition replacement does not key on rows or scope by a predicate.
Handling schema changes
on_schema_change controls what happens when the model’s columns drift from the table’s.
sync_all_columns would need alter column ... type, which the Iceberg catalog rejects. Use append_new_columns, and promote a type yourself when you need it widened.
When append_new_columns adds a geometry column to an Iceberg V3 table, the adapter creates it as geometry(<srid>). The SRID comes from a data_type: geometry(<srid>) declared for the column in the model’s YAML, or else from the model’s values, which must all share one SRID; reproject mixed values with ST_Transform in the model. A V3 column’s coordinate reference system is fixed once the column exists, so the adapter does not guess one: a new geometry column with no non-null values yet needs the declaration, and without it the run fails before the table is altered. See Declaring geometry column types.
A table that is not Iceberg V3 (format-version below 3) cannot take a new geometry column in place. The run fails before altering the table and names the fix: rebuild the model with --full-refresh, or migrate the table to Iceberg V3 in place and run again:
fail, the run fails with dbt’s schema-change error. Under append_new_columns, every strategy writes NULL into the dropped column on the new rows; the engine does not fill missing columns on insert, so the adapter supplies the NULL. Under the default ignore, the run fails with an UNRESOLVED_COLUMN error, because the insert still lists every column the table has. Switch the model to append_new_columns, or drop the column from the table yourself.
Snapshots
dbt snapshot builds SCD2 history tables on Havasu. It uses dbt’s stock snapshot materialization with Wherobots dialect hooks.
snapshots/snap_trips_by_zone_daily.sql
trip_count or avg_fare changes, the snapshot closes the old version and records a new one.
Supported configuration:
- Strategies:
timestamp(withupdated_at) andcheck(withcheck_colsas a column list or'all'). unique_key: a single column or a list for a composite key.hard_deletes: all three modes.ignore(the default) leaves a vanished row’s history untouched,invalidateclosesdbt_valid_toon its current version, andnew_recordalso inserts a tombstone version withdbt_is_deleted = 'True'.dbt_valid_to_current: set an explicit sentinel, such as"cast('9999-12-31' as timestamp)", instead of leavingdbt_valid_toNULLon open versions.- Schema evolution: a new source column is added to the snapshot table automatically on the next run, except a
geometryorgeographycolumn (see below).
A new geometry or geography source column needs one manual step. Snapshot schema evolution adds the column with a bare If the snapshot table is still at format-version 2 (check with
geometry or geography type, which current runtimes reject because it declares no coordinate reference system, so the snapshot run fails. Add the column yourself with its CRS, then run dbt snapshot again. The existing history is kept.show tblproperties <table> ('format-version')), first upgrade it in place with alter table <table> set tblproperties ('format-version'='3').int to bigint, values that still fit the old type store correctly and the run proceeds. A value that overflows fails with the engine’s CAST_OVERFLOW_IN_TABLE_INSERT error (SQLSTATE 22003) instead of being stored wrong. Promote the column yourself:
Seeds
dbt seed loads comma-separated values (CSV) files as tables. Values are written inline as SQL literals rather than bound parameters, so the loader handles quoting, typed date and timestamp literals, and geometry constructors itself.
dbt_project.yml
Generating documentation
dbt docs generate works against Wherobots. For a selection of up to 100 relations, the adapter describes only those relations. Above 100, dbt falls back to describing every relation in the schemas involved, including tables that dbt does not manage. Column types in the catalog keep their Wherobots spellings, including geometry and raster.
Next steps
Spatial data in dbt models
Seed geometry from four formats, manage SRIDs, and test spatial invariants.
Adapter overview
Install, configure a profile, and verify the connection.
Havasu tables
How Wherobots stores tables, and the table properties you can set.

