~/2025/12/17/python-data-warehouse-incremental-loads-from-postgres-to-bigquery.md

Python: Data Warehouse – Incremental Loads from Postgres to BigQuery

---
author: 
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:

  1. 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.
  2. Load that file into a staging table with one BigQuery load job (WRITE_TRUNCATE), which is free on the default shared slot pool.
  3. 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-binary 2.9.1 or later (the first release with Python 3.10 wheels) and google-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:
    sql
    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:
    sql
    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 timestamptz in Postgres and TIMESTAMP in 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 login locally, 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 SELECT on 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:

bash
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:

text
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:

text
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:

bash
# 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

python
#!/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_json uses the streaming API Google now calls the Storage Write API (REST), formerly tabledata.insertAll. Rows written that way can't be changed by UPDATE, DELETE, MERGE, or TRUNCATE for 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 both MERGE statements in one BEGIN TRANSACTION ... COMMIT TRANSACTION block means BigQuery rolls both back if either fails.
  • Why the overlap window. If updated_at defaults to now(), 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 the MERGE makes the re-read harmless. Set --lookback-minutes longer 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. The ROW_NUMBER() subquery keeps only the newest version of each key, and the S.updated_at >= T.updated_at condition 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 MERGE is 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), a MERGE with an UPDATE clause 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_at column that bumps updated_at), or run a periodic reconciliation that loads the full list of live keys and deletes the rest with WHEN 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