BigQuery Loader
The BigQuery loader pulls tables from a BigQuery dataset into your data warehouse — warehouse-to-warehouse loading.
It offers two ways to read incrementally. A timestamp cursor compares a column such as updated_at against the position of the last run. A change feed reads BigQuery’s own record of what changed, using the APPENDS and CHANGES table functions. The second exists because the first is only as honest as the column it reads: an upstream job that updates rows without changing them, but moves their timestamp, makes the loader re-deliver rows that did not really change, and an edit that moves no timestamp is never seen at all.
Prerequisites
- A GCP service account with
roles/bigquery.jobUserin the project androles/bigquery.dataVieweron the dataset you want to read. That covers everything the loader runs, including the change feed. - A connected Warehouse as the load target, with write permissions on the target schema
Configuration
| Field | Type | Required | Description |
|---|---|---|---|
| Service Account JSON | Secret | Yes | The service account’s JSON key |
| Project ID | String | Yes | The project the dataset lives in |
| Dataset | String | Yes | The dataset to read tables from |
| Location | String | — | Must match the dataset’s location, for example EU or europe-west1. Defaults to US, so a dataset anywhere else needs it set |
Sync modes
| Mode | Behaviour |
|---|---|
| Full Refresh | Re-reads every row each run and replaces the target table |
| Incremental (append) | Reads what changed and appends it. A record read twice lands twice |
| Incremental (merge on key) | Reads what changed and upserts it on a primary key, leaving one row per record |
Merge mode is what you want for anything a model, audience or trait reads, because Zeotap’s own engine queries the loaded table directly.
Change detection
Set per stream (per table), not per loader, because what a table supports is a property of that table.
Timestamp cursor (default)
Compares a date or timestamp column against the last run’s position. By default Zeotap picks the first such column in catalog order; Cursor column overrides that. The Snowflake loader page describes the picker and its limits in full, and they are the same here.
Warehouse change feed
Reads BigQuery’s change history instead of a column. Two scopes:
| Scope | Reads with | Needs from you | Needs merge mode | Needs a primary key |
|---|---|---|---|---|
| New records only | APPENDS | Nothing | No | Only in merge mode |
| Inserts, updates and deletes | CHANGES | enable_change_history on the table | Yes | Yes |
New records only works on any regular table with no setup. APPENDS reads the dataset’s time-travel window, which every table has. An updated row is not reported at all, so an upstream rewrite that changes nothing about the record delivers nothing. The trade is permanent: a genuine later edit to that record is also never delivered, rows deleted at the source stay in the target, and nothing will tell you so.
Inserts, updates and deletes needs change history switched on first:
ALTER TABLE `my_dataset`.`my_table` SET OPTIONS (enable_change_history = TRUE);This needs permission to update the table (bigquery.tables.update, which BigQuery Data Editor has), so it is usually run by whoever owns the table rather than by the loader’s own service account. It is not retroactive — only changes made after that statement can be read, which is why the first change-feed run always takes a full snapshot of the table before it starts reading the feed. Switch it on at least twenty minutes before the stream’s first change-feed run: each read stops fifteen minutes behind BigQuery’s clock (see Freshness), and the first run checks the minute before that.
The stream picker says which scope each table supports. A table without change history is offered for new records only, with the statement above as the way to read everything. Views, materialized views, external tables, clones and snapshots are not offered at all, because neither function reads them.
BigQuery reports an update as two rows — the old values and the new — and reports every change in the interval rather than the net one. Zeotap collapses them to one row per primary key, the record’s state at the end of the interval, which is why this scope needs a key.
Changing a stream’s scope rebuilds its table. A table filled with new records only never received the updates and deletes made in the meantime, so switching to inserts, updates and deletes starts again from a full snapshot rather than leaving those rows stale.
Deletions and primary keys
These work exactly as on the Snowflake loader — see Deletions and Primary keys. Deletions are available only with inserts, updates and deletes and remove the row by default, and a merged change-feed stream upserts on the columns you name, never on an inferred key. BigQuery does not enforce primary keys, so Zeotap checks the key you name against the source when you save the loader and refuses one with duplicates.
Freshness
A change-feed read never reaches the present:
| Scope | Lag behind BigQuery’s clock |
|---|---|
| New records only | 2 minutes |
| Inserts, updates and deletes | 15 minutes |
CHANGES does not report the last few minutes, and a row written in the last moments before a read may not be readable yet. Each run reads up to a point that far back, and the next run starts exactly there, so nothing is skipped — it simply arrives on the following run. A table created more recently than the lag cannot be seeded yet; the run says so and the next one succeeds.
Retention is your downtime budget
Both functions reach back only as far as the dataset’s time-travel window — between two and seven days, seven by default:
ALTER SCHEMA `my_dataset` SET OPTIONS (max_time_travel_hours = 168);A loader paused, failing, or not scheduled for longer than that cannot be served incrementally. Zeotap reads a fresh snapshot of the table on the next run rather than skipping the gap. What it does with that snapshot depends on the sync mode:
| Sync mode | Recovery after a gap |
|---|---|
| Incremental (merge on key) | The snapshot replaces the table, and every row the source deleted in the meantime is removed (or, with Mark it deleted, marked) |
| Incremental (append) | The snapshot is appended. Rows the source has dropped since they were loaded — partitions it has expired, for example — stay in the table, and every row the source still holds appears again, once more for each such recovery. Deduplicate downstream, or use merge mode if one row per record matters |
The same happens when the source table has been recreated (CREATE OR REPLACE). A stream’s first change-feed run, and a switch to the change feed from a timestamp cursor, still replace the table: there is no change-feed history in it yet to keep.
The stream picker shows the window next to the toggle; when the dataset’s settings cannot be read, it shows nothing and the loader assumes the two-day minimum.
A run reads at most one day of changes. A loader that has fallen further behind advances a day per run and catches up over successive runs.
When the change feed is the wrong choice
The change feed records what happened to a table, so how the table is maintained upstream matters:
| Upstream pattern | What the loader does |
|---|---|
| Rows updated or merged in place | What the change feed is for |
The table is replaced (CREATE OR REPLACE, for example a dbt table model) | The replacement has no history from before it, so every run reads a full snapshot — replacing the target in merge mode, and appending another full copy in append mode. Use a timestamp cursor or full refresh instead |
| Truncated or deleted and reloaded | New records only re-delivers every reloaded row as new. Inserts, updates and deletes delivers each one as a change, which is correct but rewrites the whole target |
A multi-statement transaction (BEGIN … COMMIT) is committed to the table | CHANGES cannot read a time range containing one, so the next run rebuilds the target from a snapshot |
Partitions expire (partition_expiration_days) | Rows removed by expiry are not reported as deletions and stay in the target |
| Rows written through the Storage Write API’s pending or buffered streams | BigQuery does not guarantee their change timestamps are stable, so a row can arrive after the run that covered its timestamp and be missed. Default and committed streams and load jobs are unaffected |
The first run of a change-feed stream reads the whole table through a query, and BigQuery limits a query’s result to 10 GB compressed. A larger table cannot be seeded this way; read it with a timestamp cursor or full refresh, which do not have that limit.
Cost
BigQuery bills by bytes scanned. A change-feed stream scans the whole table on its first run and on every rebuild, and each interval read scans the table’s change history for that interval. Saving a merged change-feed stream also runs one query over the key columns to check they are unique. Raising max_time_travel_hours adds time-travel storage cost under physical storage billing.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| The change feed is offered for new records only | The table has no change history. Run ALTER TABLE … SET OPTIONS (enable_change_history = TRUE); it is not retroactive |
| Run fails: “reading updates and deletes needs enable_change_history” | Same cause, on a stream set to inserts, updates and deletes |
| Every table shows “change-feed metadata could not be read for this dataset” | The Location does not match the dataset’s, or the service account cannot query the dataset’s INFORMATION_SCHEMA |
| Run fails: “BigQuery rejected the APPENDS read” or ”… CHANGES read” | BigQuery refused the change-feed query for that table. The message after the colon says why — commonly change history switched on less than twenty minutes ago (wait, then run again), or a table that requires a partition filter (use a timestamp cursor) |
| Run fails: ”… was created at …, after the snapshot instant” | The table is younger than the feed’s lag. The next run, once it is older, succeeds |
| Run fails: ”… too large to seed through a query” | The table exceeds BigQuery’s 10 GB query result limit. Use a timestamp cursor or full refresh for it |
| The change feed is not offered for a table at all | It is a view, a materialized view, an external table, a clone or a snapshot. Point the loader at a regular table |
| The loader rebuilds the whole table on every run | The table is replaced upstream (CREATE OR REPLACE). See When the change feed is the wrong choice |
| The loader rebuilds the whole table unexpectedly | The loader was paused or failing for longer than the dataset’s time-travel window, a transaction was committed to the table, or the stream’s scope was changed |
| An append-mode table holds every row twice (or more) | Each recovery after a gap appends a full snapshot (see Retention is your downtime budget). Deduplicate downstream, or switch the stream to merge on key |
| Changes arrive one run later than expected | Expected: each read stops 2 or 15 minutes behind BigQuery’s clock (see Freshness) |
| Saving fails: “primary key is not unique in the source” | The named key has duplicate values. Pick a key that identifies a row |
| Updates never appear | The stream is set to New records only. Switch to inserts, updates and deletes; the table is rebuilt once, so edits made in the meantime are not lost |
Next Steps
- Creating a Loader — the general setup flow
- Snowflake Loader — the same change detection, on Snowflake
- Models — building on the loaded tables