~/2025/12/17/python-data-warehouse-incremental-loads-from-postgres-to-bigquery.md
Python: Data Warehouse – Incremental Loads from Postgres to BigQuery
--- author: Tom Lasswell date: read: 5 min in: [scripts, engineering] tags: [python, data-warehouse, etl, gcloud, sql] ---
$ grep -n '^#' post.md
A full-refresh load from an operational Postgres database into BigQuery works fine right up until the source table gets big enough that re-copying the whole thing every run starts costing real time and real money. The usual fix is a watermark: track the highest value of an updated_at (or similar) column you've already loaded, and on the next run only pull rows newer than that. It sounds simple, but the parts that actually matter are where the watermark lives and when it gets advanced. Advance it before the data commits and a failed run silently skips rows forever. Advance it separately from the data and a crash between the two steps leaves them disagreeing.
This script does the whole run as three steps:
- Extract rows newer than the watermark (minus a small overlap window) from Postgres through a server-side cursor, writing them to a local newline-delimited JSON file so memory stays flat.
- Load that file into a staging table with one BigQuery load job (
WRITE_TRUNCATE), which is free on the default shared slot pool. - Run a single multi-statement transaction that
MERGEs staging into the destination and advances the watermark. Either both happen or neither does.
The obvious first draft of this job appends every 5,000-row batch with its own load job and records the watermark with insert_rows_json. Both choices cause trouble, covered in the Notes below.
Requirements
- Python 3.10 or later.
psycopg2-binary2.9.1 or later (the first release with Python 3.10 wheels) andgoogle-cloud-bigquery(pip install psycopg2-binary google-cloud-bigquery).- A destination table that already exists in BigQuery with the columns you want to copy. The script selects exactly those columns from Postgres, so extra source columns are ignored and a missing one fails the extract loudly instead of loading nulls. For example:
CREATE TABLE `my-project.warehouse.orders` ( id INT64 NOT NULL, customer_id INT64, status STRING, total NUMERIC, created_at TIMESTAMP, updated_at TIMESTAMP ) PARTITION BY DATE(created_at) CLUSTER BY id; - A watermark control table, created once:
CREATE TABLE IF NOT EXISTS `my-project.warehouse.etl_watermarks` ( source_table STRING NOT NULL, watermark_value TIMESTAMP, loaded_at TIMESTAMP, row_count INT64 ); - The watermark column must be
timestamptzin Postgres andTIMESTAMPin BigQuery, set on every insert and update (a trigger or the ORM, not application goodwill). The primary key column must be unique in the source. - Credentials through Application Default Credentials (
gcloud auth application-default loginlocally, or the attached service account on Cloud Run or a VM). Grant BigQuery Data Editor (roles/bigquery.dataEditor) on the dataset and BigQuery Job User (roles/bigquery.jobUser) on the project. - A Postgres role with
SELECTon the source table. Point it at a read replica if you have one.
Usage
First run (no watermark yet) copies the whole table; every later run copies only what changed:
python postgres_to_bigquery_incremental.py \
--pg-dsn "postgresql://etl_reader@<replica-host>:5432/app" \
--source-table public.orders \
--project my-project \
--dataset warehouse \
--destination-table orders \
--key-column id \
--watermark-column updated_at \
--lookback-minutes 10
Leave the password out of --pg-dsn: command-line arguments show up in ps and shell history. libpq picks it up from a password file instead (~/.pgpass, or the file named by PGPASSFILE, mode 0600).
Sample output on a normal run:
2025-12-17 09:00:01 INFO Last watermark for public.orders: 2025-12-16 23:58:02+00:00 (extracting from 2025-12-16 23:48:02+00:00)
2025-12-17 09:00:03 INFO Extracted 7143 rows to /tmp/tmpk2v9x1.ndjson
2025-12-17 09:00:09 INFO Loaded 7143 rows into my-project.warehouse.orders__staging
2025-12-17 09:00:14 INFO Merge committed: 7143 staged rows, watermark now 2025-12-17 08:59:58+00:00
And when nothing changed:
2025-12-17 10:00:01 INFO Last watermark for public.orders: 2025-12-17 08:59:58+00:00 (extracting from 2025-12-17 08:49:58+00:00)
2025-12-17 10:00:01 INFO No rows newer than the overlap window. Nothing to load.
Schedule it with cron, a Cloud Run job, or an Airflow task, but never let two runs for the same table overlap. Both would use the same <destination>__staging table, which each run replaces, so a second run can overwrite staging while the first is still between its load job and its MERGE. With cron, wrap the command in flock -n, which exits at once instead of starting a second copy while the previous run still holds the lock:
# m h dom mon dow command
*/15 * * * * flock -n /var/lock/pg-to-bq-orders.lock python /opt/etl/postgres_to_bigquery_incremental.py --pg-dsn "postgresql://etl_reader@<replica-host>:5432/app" --source-table public.orders --project my-project --dataset warehouse --destination-table orders >> /var/log/etl/orders.log 2>&1
Use one lock file per source table, and put it in a directory the cron user can write to. In Airflow, max_active_runs=1 on the DAG does the same job.
Re-running after a failure is safe: the watermark has not moved, the overlap rows are re-staged, and the MERGE updates them in place instead of duplicating them. If the source schema can change under you, run the schema drift check as the step before this one.
Script
#!/usr/bin/env python3
"""
postgres_to_bigquery_incremental.py
Incrementally copies new and changed rows from a Postgres table into a
BigQuery table, using a timestamp watermark column (e.g. updated_at).
Steps:
1. Read the last watermark from <project>.<dataset>.etl_watermarks.
2. Stream rows with watermark > (last watermark - lookback) out of
Postgres through a named (server-side) cursor into a local NDJSON file.
3. Load that file into <destination>__staging with one load job
(WRITE_TRUNCATE, schema copied from the destination table).
4. In one BigQuery transaction: MERGE staging into the destination on the
key column, then advance the watermark. Any failure rolls back both.
Reads: the Postgres source table; the destination table's schema; the
watermark control table.
Writes: <destination>__staging (replaced every run), the destination table,
and one row per source table in etl_watermarks.
"""
import argparse
import base64
import datetime as dt
import decimal
import json
import logging
import re
import sys
import tempfile
import uuid
import psycopg2
from google.cloud import bigquery
from psycopg2 import sql
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("pg_to_bq_incremental")
WATERMARK_TABLE = "etl_watermarks"
IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def json_default(value):
"""Serialize the Postgres types psycopg2 returns that json.dumps cannot."""
if isinstance(value, (dt.datetime, dt.date, dt.time)):
return value.isoformat()
if isinstance(value, decimal.Decimal):
return str(value) # keeps full precision for NUMERIC columns
if isinstance(value, uuid.UUID):
return str(value)
if isinstance(value, (bytes, memoryview)):
return base64.b64encode(bytes(value)).decode("ascii") # BYTES must be base64 in JSON
raise TypeError(f"Cannot serialize {type(value).__name__}")
def get_last_watermark(bq_client, project, dataset, source_table):
"""Return the stored watermark (aware datetime) for source_table, or None."""
query = f"""
SELECT watermark_value
FROM `{project}.{dataset}.{WATERMARK_TABLE}`
WHERE source_table = @source_table
"""
job_config = bigquery.QueryJobConfig(
query_parameters=[bigquery.ScalarQueryParameter("source_table", "STRING", source_table)]
)
rows = list(bq_client.query(query, job_config=job_config).result())
return rows[0]["watermark_value"] if rows else None
def extract_to_file(pg_dsn, source_table, columns, watermark_column, since, batch_size, handle):
"""Stream matching rows into handle as NDJSON. Returns the row count."""
schema_name, table_name = source_table.split(".", 1) if "." in source_table else ("public", source_table)
query = sql.SQL("SELECT {cols} FROM {table}").format(
cols=sql.SQL(", ").join(sql.Identifier(c) for c in columns),
table=sql.Identifier(schema_name, table_name),
)
params = None
if since is not None:
query = query + sql.SQL(" WHERE {wm} > %s").format(wm=sql.Identifier(watermark_column))
params = (since,)
conn = psycopg2.connect(pg_dsn)
count = 0
try:
# A named cursor lives inside a transaction; the with block commits it.
with conn:
with conn.cursor(name="pg_to_bq_extract") as cursor:
cursor.itersize = batch_size
cursor.execute(query, params)
for record in cursor:
row = dict(zip(columns, record))
handle.write(json.dumps(row, default=json_default).encode("utf-8"))
handle.write(b"\n")
count += 1
finally:
conn.close() # leaving "with conn" ends the transaction, not the connection
return count
def load_staging(bq_client, handle, staging_id, schema):
"""Replace the staging table with the file contents using one load job."""
job_config = bigquery.LoadJobConfig(
source_format=bigquery.SourceFormat.NEWLINE_DELIMITED_JSON,
write_disposition=bigquery.WriteDisposition.WRITE_TRUNCATE,
schema=schema,
)
load_job = bq_client.load_table_from_file(handle, staging_id, rewind=True, job_config=job_config)
load_job.result() # raises google.api_core.exceptions.GoogleAPICallError on failure
return load_job.output_rows
def merge_and_advance(bq_client, dest_id, staging_id, watermark_id, columns, key, watermark_column, source_table):
"""MERGE staging into the destination and advance the watermark atomically."""
col = lambda name: f"`{name}`"
update_set = ", ".join(f"{col(c)} = S.{col(c)}" for c in columns if c != key)
insert_cols = ", ".join(col(c) for c in columns)
insert_vals = ", ".join(f"S.{col(c)}" for c in columns)
script = f"""
BEGIN TRANSACTION;
MERGE `{dest_id}` AS T
USING (
SELECT * EXCEPT (_rn) FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY {col(key)} ORDER BY {col(watermark_column)} DESC) AS _rn
FROM `{staging_id}`
)
WHERE _rn = 1
) AS S
ON T.{col(key)} = S.{col(key)}
WHEN MATCHED AND S.{col(watermark_column)} >= T.{col(watermark_column)} THEN
UPDATE SET {update_set}
WHEN NOT MATCHED THEN
INSERT ({insert_cols}) VALUES ({insert_vals});
MERGE `{watermark_id}` AS W
USING (
SELECT @source_table AS source_table,
MAX({col(watermark_column)}) AS watermark_value,
COUNT(*) AS row_count
FROM `{staging_id}`
) AS S
ON W.source_table = S.source_table
WHEN MATCHED THEN
UPDATE SET watermark_value = GREATEST(W.watermark_value, S.watermark_value),
loaded_at = CURRENT_TIMESTAMP(),
row_count = S.row_count
WHEN NOT MATCHED THEN
INSERT (source_table, watermark_value, loaded_at, row_count)
VALUES (S.source_table, S.watermark_value, CURRENT_TIMESTAMP(), S.row_count);
COMMIT TRANSACTION;
"""
job_config = bigquery.QueryJobConfig(
query_parameters=[bigquery.ScalarQueryParameter("source_table", "STRING", source_table)]
)
# No exception handler: if any statement fails, BigQuery rolls the transaction back
# and the job (and therefore .result()) fails.
bq_client.query(script, job_config=job_config).result()
def run(args):
for name in (args.destination_table, args.key_column, args.watermark_column):
if not IDENTIFIER.match(name):
raise ValueError(f"Not a plain identifier: {name!r}")
bq_client = bigquery.Client(project=args.project)
dest_id = f"{args.project}.{args.dataset}.{args.destination_table}"
staging_id = f"{dest_id}__staging"
watermark_id = f"{args.project}.{args.dataset}.{WATERMARK_TABLE}"
destination = bq_client.get_table(dest_id)
columns = [field.name for field in destination.schema]
for required in (args.key_column, args.watermark_column):
if required not in columns:
raise ValueError(f"Column {required!r} is not in {dest_id}")
last_watermark = get_last_watermark(bq_client, args.project, args.dataset, args.source_table)
since = None
if last_watermark is not None:
since = last_watermark - dt.timedelta(minutes=args.lookback_minutes)
logger.info("Last watermark for %s: %s (extracting from %s)", args.source_table, last_watermark, since)
with tempfile.NamedTemporaryFile(mode="w+b", suffix=".ndjson") as handle:
count = extract_to_file(
args.pg_dsn, args.source_table, columns, args.watermark_column, since, args.batch_size, handle
)
if count == 0:
logger.info("No rows newer than the overlap window. Nothing to load.")
return
logger.info("Extracted %d rows to %s", count, handle.name)
loaded = load_staging(bq_client, handle, staging_id, destination.schema)
logger.info("Loaded %d rows into %s", loaded, staging_id)
merge_and_advance(
bq_client, dest_id, staging_id, watermark_id, columns,
args.key_column, args.watermark_column, args.source_table,
)
new_watermark = get_last_watermark(bq_client, args.project, args.dataset, args.source_table)
logger.info("Merge committed: %d staged rows, watermark now %s", loaded, new_watermark)
def parse_args():
parser = argparse.ArgumentParser(description="Incrementally load a Postgres table into BigQuery.")
parser.add_argument("--pg-dsn", required=True, help="libpq connection string or URI for the source")
parser.add_argument("--source-table", required=True, help="Postgres table, optionally schema-qualified (public.orders)")
parser.add_argument("--project", required=True, help="GCP project ID")
parser.add_argument("--dataset", required=True, help="BigQuery dataset holding the destination and etl_watermarks")
parser.add_argument("--destination-table", required=True, help="Existing BigQuery destination table")
parser.add_argument("--key-column", default="id", help="Primary key used by the MERGE (default: id)")
parser.add_argument("--watermark-column", default="updated_at", help="timestamptz column that changes on every write")
parser.add_argument("--lookback-minutes", type=int, default=10, help="Overlap re-read each run (default: 10)")
parser.add_argument("--batch-size", type=int, default=5000, help="Rows fetched per round trip (default: 5000)")
args = parser.parse_args()
if args.lookback_minutes < 0:
parser.error("--lookback-minutes must be 0 or more")
if args.batch_size < 1:
parser.error("--batch-size must be at least 1")
return args
def main():
args = parse_args()
try:
run(args)
except Exception:
logger.exception("Incremental load failed")
sys.exit(1)
if __name__ == "__main__":
main()
Notes
- Why one load job per run, not per batch. BigQuery allows 1,500 load jobs per table per day, and failed load jobs count toward it. A job that appends every 5,000-row batch separately can burn through that on a big first load or a frequent schedule. Writing one NDJSON file and loading it once keeps each run at one load job plus one query job.
- Why the watermark is written with DML, not
insert_rows_json.insert_rows_jsonuses the streaming API Google now calls the Storage Write API (REST), formerlytabledata.insertAll. Rows written that way can't be changed byUPDATE,DELETE,MERGE, orTRUNCATEfor 30 minutes, and each insert is a separate operation from the data load, so a crash between the two left them out of step. Putting bothMERGEstatements in oneBEGIN TRANSACTION ... COMMIT TRANSACTIONblock means BigQuery rolls both back if either fails. - Why the overlap window. If
updated_atdefaults tonow(), it holds the start time of the writing transaction, but the row only becomes visible when that transaction commits. A long transaction can commit a row stamped earlier than a watermark you already stored. Re-reading the last few minutes on every run picks those rows up, and theMERGEmakes the re-read harmless. Set--lookback-minuteslonger than your longest write transaction. - Duplicates in staging. If the overlap or a retry stages the same key twice, BigQuery raises
UPDATE/MERGE must match at most one source row for each target row. TheROW_NUMBER()subquery keeps only the newest version of each key, and theS.updated_at >= T.updated_atcondition stops an older staged copy from overwriting a newer row. - Cost. Batch loading from local files is free by default, using a shared slot pool with no capacity guarantee. The
MERGEis billed as a query. On on-demand pricing (first 1 TiB per month free, then $6.25 per TiB in US regions when I checked), aMERGEwith anUPDATEclause is billed for the bytes it reads plus the size of the target, or only the target partitions it touches if the table is partitioned. Partitioning the destination on a date column that recent changes cluster around keeps that second term small. - Deletes are not handled. A hard-deleted Postgres row stays in BigQuery. Either soft-delete in the source (a
deleted_atcolumn that bumpsupdated_at), or run a periodic reconciliation that loads the full list of live keys and deletes the rest withWHEN NOT MATCHED BY SOURCE THEN DELETE. - When to stop polling. Past a certain change rate, a watermark poller is the wrong tool. Google's Datastream supports PostgreSQL as a source and replicates changes into BigQuery continuously.
References
- BigQuery DML syntax: MERGE and the "at most one source row" rule
- BigQuery multi-statement transactions
- BigQuery DML: limits on rows written by the Storage Write API (REST)
- BigQuery quotas and limits: load jobs
- BigQuery pricing: on-demand compute, data ingestion
- google-cloud-bigquery
Client.load_table_from_file - BigQuery partitioned tables
- psycopg2: server-side cursors and connection context managers
- psycopg2.sql: composing queries with identifiers
- Datastream overview