The GA4 export adds a fresh events table every single day, and it never stops growing. Rebuilding your whole model over all of history on every run is slow and, on BigQuery, genuinely expensive. dbt incremental models fix that by processing only the new data.
The problem with full refreshes
GA4 lands data as sharded daily tables, events_YYYYMMDD, that pile up into years of history. A model that rescans every shard on every run scans terabytes to add one day.
On BigQuery you pay by bytes scanned, so a naive daily rebuild is the analytics equivalent of reprinting a book to add a page.
What an incremental model does
An incremental model builds the table once, then on each later run only transforms and appends the rows that are new since last time. Same output, a fraction of the cost.
In dbt you declare the materialization and give it a filter that limits each run to recent data.
{{ config(
materialized='incremental',
unique_key='event_key',
partition_by={'field': 'event_date', 'data_type': 'date'}
) }}
SELECT
event_date,
user_pseudo_id,
event_name,
event_timestamp,
CONCAT(user_pseudo_id, '-', event_timestamp) AS event_key
FROM {{ source('ga4', 'events') }}
{% if is_incremental() %}
WHERE event_date >= DATE_SUB(
(SELECT MAX(event_date) FROM {{ this }}), INTERVAL 3 DAY)
{% endif %}
Why the lookback window matters
Notice the model reprocesses the last few days, not just yesterday. That's deliberate: GA4 data arrives late and gets revised, and the intraday table settles into the daily one after a delay.
A lookback of a few days, combined with a unique key and a merge, lets late-arriving events correct themselves instead of being missed forever.
Insert or merge?
dbt gives you two incremental strategies on BigQuery, and the choice matters more than people expect. The default, insert_overwrite, replaces whole partitions, which pairs perfectly with a date-partitioned GA4 model: it wipes and rewrites just the days in your lookback window, so reprocessed data can't pile up as duplicates.
The alternative, merge, matches on your unique key and updates row by row. It's more flexible but scans more, and on billions of GA4 events that cost adds up. For a partitioned events table, insert_overwrite on the partition is usually the cheaper, cleaner default.
Watch the bytes, not just the rows
The whole point of going incremental is cost, so verify you're actually getting it. BigQuery tells you bytes billed for every job, and after your first incremental run that number should collapse from terabytes to gigabytes.
If it doesn't, your filter isn't pruning partitions, which usually means the model isn't partitioned on the same column the WHERE clause uses. A correct incremental model that still scans everything is the worst of both worlds: same bill, more complexity.
Backfilling and full refreshes
The first run, or any schema change, needs a full build, which dbt handles with a full-refresh flag. After that, the incremental logic takes over.
- Run once as a full refresh to build history.
- Let scheduled runs process only the lookback window from then on.
- Full-refresh again only when the logic or schema changes.
Where it goes wrong
- No lookback window, so late GA4 events are silently dropped.
- A weak unique key, so reprocessed days create duplicate rows.
- Forgetting to partition, so the incremental filter still scans the whole table and saves nothing.
- Assuming yesterday is final, when GA4's fresh export can still shift it.
Incremental models for the GA4 export turn a daily terabyte scan into a cheap append, which is the difference between a pipeline you can afford and one you can't. Add a lookback for late data, key it so reruns don't duplicate, and partition it so the filter actually saves money, and the ever-growing export stops being a cost problem.
Want a stronger data analyst role or a raise? Grab the FREE Product Analyst Playbook and get the exact roadmap to your next offer.
