Bulk-Loading Sensor Readings into PostGIS with COPY and ST_MakePoint

To load millions of environmental sensor rows into PostGIS quickly, stream them with COPY into an unlogged staging table, then run a single INSERT ... SELECT ... ON CONFLICT that builds each point with ST_SetSRID(ST_MakePoint(lon, lat), 4326) and deduplicates on the natural key. COPY is roughly an order of magnitude faster than row-by-row INSERT because it batches the whole payload into one server round-trip and bypasses per-statement parsing; deferring geometry construction and conflict handling to a set-based database step keeps that speed while still producing indexable, idempotent output for the PostGIS storage and spatial indexing schema.


Why COPY plus Staging Beats Direct Inserts

Three costs dominate a naive load. First, each INSERT statement is parsed, planned, and committed individually, so a million single-row inserts pay a million round-trips. Second, if the target table has a GiST index on its geometry column, every insert updates that index in place — cheap once, brutal a million times. Third, constructing geometry on the client and shipping WKB, or calling ST_GeomFromText per row, adds parsing overhead the load does not need.

COPY collapses the first cost: the entire batch travels as one stream and PostgreSQL writes it with minimal per-row overhead. But COPY is deliberately dumb — it cannot evaluate ON CONFLICT, cannot call a function like ST_MakePoint, and cannot target a subset of columns with expressions. That is why the fast pattern is two-phase. Phase one COPYs raw lon/lat numeric columns into a staging table that has no geometry column and no indexes, so nothing slows the write. Phase two runs one set-based INSERT ... SELECT from staging into the real partitioned table, and it is there that ST_MakePoint builds the point and ON CONFLICT DO UPDATE absorbs re-delivered readings.

ST_MakePoint matters specifically because it consumes the numeric lon and lat directly. ST_GeomFromText('POINT(...)') would force you to format those floats into a WKT string first, which is slower and exposes you to locale bugs where a comma decimal separator silently corrupts coordinates. Building the geometry inside the database, from numeric columns, is both faster and safer.

An unlogged staging table skips write-ahead logging entirely, which nearly doubles COPY throughput. The trade-off — unlogged tables do not survive a crash — is irrelevant for staging, because the data is transient and re-derivable from the source batch.


Production-Ready Implementation

The function below takes an iterable of reading tuples, COPYs them into an unlogged staging table via copy_expert, upserts into the partitioned target with ST_MakePoint, and truncates staging. It is self-contained and safe to drop into an ingestion worker.

# python 3.11 · psycopg2-binary==2.9.9 · shapely==2.0.4
import csv
import io
from typing import Iterable, Sequence

import psycopg2

# Column order for both staging COPY and the target INSERT.
COLUMNS = ("station_id", "observed_at", "metric", "value", "quality_flag", "lon", "lat")

DDL_STAGING = """
CREATE UNLOGGED TABLE IF NOT EXISTS sensor_readings_staging (
    station_id   text             NOT NULL,
    observed_at  timestamptz      NOT NULL,
    metric       text             NOT NULL,
    value        double precision,
    quality_flag smallint         NOT NULL DEFAULT 0,
    lon          double precision NOT NULL,
    lat          double precision NOT NULL
)
"""

# Build the point in the database; dedupe on the natural key.
UPSERT = """
INSERT INTO sensor_readings
    (station_id, observed_at, metric, value, quality_flag, lon, lat)
SELECT station_id, observed_at, metric, value, quality_flag, lon, lat
FROM sensor_readings_staging
ON CONFLICT (station_id, observed_at, metric)
DO UPDATE SET
    value        = EXCLUDED.value,
    quality_flag = EXCLUDED.quality_flag,
    lon          = EXCLUDED.lon,
    lat          = EXCLUDED.lat
"""


def bulk_load_readings(dsn: str, rows: Iterable[Sequence]) -> int:
    """
    Bulk-load sensor readings into a partitioned PostGIS table.

    Each row must be (station_id, observed_at, metric, value, quality_flag, lon, lat)
    with lon/lat as WGS 84 decimal degrees. Geometry is built server-side with
    ST_SetSRID(ST_MakePoint(lon, lat), 4326) via the target table's generated column.

    Strategy:
        1. COPY raw rows into an unlogged, index-free staging table (fast path).
        2. INSERT ... SELECT into the target, deduplicating on the natural key.
        3. TRUNCATE staging.

    Returns the number of rows written to staging. Time: O(n) per batch.
    """
    # Serialize rows to an in-memory CSV buffer for copy_expert.
    buffer = io.StringIO()
    writer = csv.writer(buffer)
    n = 0
    for row in rows:
        writer.writerow(row)
        n += 1
    buffer.seek(0)

    copy_sql = (
        f"COPY sensor_readings_staging ({', '.join(COLUMNS)}) "
        "FROM STDIN WITH (FORMAT csv)"
    )

    with psycopg2.connect(dsn) as conn, conn.cursor() as cur:
        cur.execute(DDL_STAGING)
        cur.execute("TRUNCATE sensor_readings_staging")   # start clean
        cur.copy_expert(copy_sql, buffer)                 # fast bulk write
        cur.execute(UPSERT)                               # geometry + dedupe
        cur.execute("TRUNCATE sensor_readings_staging")   # release space
        conn.commit()

    return n

The target’s geom column is a GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint(lon, lat), 4326)) STORED column, so the INSERT ... SELECT never names it — the point materializes automatically and can never disagree with the raw coordinates. If your schema does not use a generated column, add ST_SetSRID(ST_MakePoint(lon, lat), 4326) explicitly to the SELECT and the target column list.

For a full-partition load, drop or skip the GiST index during the upsert and build it once afterward; maintaining it per row erases most of the COPY advantage.


Parameter Tuning Guide

Batch size and session settings should scale with sensor throughput. Larger batches amortize round-trip and index-build cost but hold more memory and lengthen the transaction; the sweet spot rises with row volume.

Sensor throughput Rows per batch COPY buffer Staging Index strategy Session tuning
Low (weather, < 10k rows/run) 5,000 in-memory StringIO unlogged keep GiST live defaults
Medium (air quality, ~100k rows/run) 50,000 in-memory StringIO unlogged keep GiST live synchronous_commit=off
High (dense network, ~1M rows/run) 100,000–250,000 streamed file object unlogged build GiST after load maintenance_work_mem=1GB
Backfill (historical, 10M+ rows) 500,000 streamed file object unlogged, per-partition build GiST + BRIN after load max_wal_size raised, autovacuum paused

maintenance_work_mem governs how fast the post-load GiST build runs; raising it to 512 MB–1 GB for the session shortens index creation on large partitions substantially.

synchronous_commit=off at the session level lets the upsert commit without waiting for WAL flush, safe for re-derivable telemetry because a lost transaction can simply be replayed from the source batch.

Backfills should load one monthly partition at a time in timestamp order so the target’s BRIN index on observed_at stays tightly correlated — see the GiST versus BRIN index decision guide for why out-of-order backfills wreck BRIN pruning.


Verification and Testing

The test below stands up the staging and target tables, loads a batch with a deliberate duplicate, and asserts that the row is upserted rather than duplicated and that the geometry was constructed with the correct SRID. It assumes a test PostGIS database reachable via TEST_DSN.

# python 3.11 · psycopg2-binary==2.9.9
import os
import pytest
import psycopg2


TEST_DSN = os.environ.get("TEST_DSN", "dbname=eiot_test")


def test_bulk_load_is_idempotent_and_builds_geometry():
    rows = [
        ("st-01", "2026-07-01T00:00:00Z", "pm25", 12.4, 0, -122.42, 37.77),
        ("st-02", "2026-07-01T00:00:00Z", "pm25", 9.1, 0, -122.27, 37.80),
        # Duplicate natural key with a corrected value — must update, not duplicate.
        ("st-01", "2026-07-01T00:00:00Z", "pm25", 13.0, 1, -122.42, 37.77),
    ]

    bulk_load_readings(TEST_DSN, rows)

    with psycopg2.connect(TEST_DSN) as conn, conn.cursor() as cur:
        # Exactly two logical observations survive the duplicate.
        cur.execute("SELECT count(*) FROM sensor_readings")
        assert cur.fetchone()[0] == 2

        # The duplicate resolved to the later value and flag.
        cur.execute("""
            SELECT value, quality_flag
            FROM sensor_readings
            WHERE station_id = 'st-01' AND metric = 'pm25'
        """)
        value, flag = cur.fetchone()
        assert value == pytest.approx(13.0)
        assert flag == 1

        # Geometry was built with SRID 4326 from the raw coordinates.
        cur.execute("""
            SELECT ST_SRID(geom), ST_X(geom), ST_Y(geom)
            FROM sensor_readings
            WHERE station_id = 'st-02'
        """)
        srid, x, y = cur.fetchone()
        assert srid == 4326
        assert x == pytest.approx(-122.27)
        assert y == pytest.approx(37.80)

For a throughput reference rather than a correctness check, load a synthetic million-row batch and compare wall-clock time against a row-by-row execute loop. On commodity hardware the COPY-plus-staging path typically completes in single-digit seconds where the loop takes minutes.


Gotchas

copy_expert needs an unclosed, rewound buffer. After writing rows to a StringIO, call buffer.seek(0) before copy_expert, or PostgreSQL receives an empty stream and silently loads zero rows. For batches too large to hold in memory, pass a real file object or a streaming generator wrapper instead.

CSV quoting corrupts free-text station IDs. If station_id or metric can contain commas, quotes, or newlines, the csv.writer defaults handle quoting, but a hand-built COPY string will not. Always serialize through csv.writer and load with FORMAT csv, never with a manual tab-join, which breaks on embedded delimiters.

Null coordinates reach the generated geometry. A row with null lon or lat produces a null point, which NOT NULL on lon/lat rejects at insert time. Filter or quarantine such rows before staging; do not rely on the geometry column to catch them, because a null point is a valid geometry value.

Timezone-naive timestamps drift on load. COPY into a timestamptz column interprets naive strings in the server’s TimeZone setting, so a naive 2026-07-01 00:00:00 can land an hour off. Emit explicit UTC offsets (...Z or +00:00) in the source rows so the parse is unambiguous.