PostgreSQL Loading Patterns and Idempotency

Loading patterns decide how rows enter PostgreSQL, what business slice a batch is allowed to replace, where validation happens, and how the pipeline keeps reruns safe. The live stoxx database already contains real bronze, silver, and gold schemas, so the PostgreSQL chapter can teach those choices on a concrete medallion surface instead of on synthetic staging tables.

Live Baseline

The right loading pattern depends on real table shapes, not on generic ETL folklore. The current stoxx database already shows three different realities: tiny bronze snapshots, mid-sized gold summaries, and larger silver history tables.

Current table volumes across bronze, silver, and gold

This subsection anchors the loading discussion on actual row counts and one real bronze batch boundary.

Measure the live row counts of the main pipeline tables

Use this query during initial pipeline design, after a migration, or before deciding whether a table is small enough for slice replacement or large enough to justify more incremental handling. It is typically triggered by a first-pass capacity review or by a pipeline note that must explain why one load pattern fits one table but not another. The query reads information_schema.tables and uses query_to_xml() to turn per-table row counts into a read-only inventory. Its purpose is to measure the actual current row counts of the medallion tables.

FieldSourceTypeMeaning
schema_nameinformation_schema.tables.table_schematextSchema that owns the table.
table_nameinformation_schema.tables.table_nametextTable name.
row_countdynamic COUNT(*) per tablebigintExact current row count of the table.

This query inventories the current row counts of all base tables in the bronze, silver, and gold schemas.

SELECT
    table_schema AS schema_name,
    table_name,
    (xpath(
        '/row/cnt/text()',
        query_to_xml(
            format('SELECT count(*) AS cnt FROM %I.%I', table_schema, table_name),
            false,
            true,
            ''
        )
    ))[1]::text::bigint AS row_count
FROM information_schema.tables
WHERE table_schema IN ('bronze', 'silver', 'gold')
  AND table_type = 'BASE TABLE'
ORDER BY schema_name, row_count DESC, table_name;
schema_nametable_namerow_count
bronzetrading_calendar29335
bronzedim_country212
bronzeindex_dim169
bronzesignals_daily169
bronzesignals_quarterly169
bronzeeurostoxx50_ohlcv50
bronzestoxxasia50_ohlcv50
bronzestoxxusa50_ohlcv50
bronzepulse40
bronzepulse_tickers40
bronzeoil20_ohlcv19
bronzedim_index4
goldindex_performance5351
goldscores_daily635
goldscores_quarterly176
silvereurostoxx50_ohlcv67155
silverstoxxusa50_ohlcv66000
silverstoxxasia50_ohlcv64875
silveroil20_ohlcv25080
silversignals_daily635
silversignals_quarterly188
silverindex_dim169

This inventory shows why one universal loading rule would be wrong. bronze.signals_daily is a tiny daily slice and is a natural candidate for bounded replacement or validated publish. silver.stoxxusa50_ohlcv, by contrast, already holds 66,000 rows of history and should not be reloaded from scratch casually unless the whole history is genuinely the slice being replaced.

Inspect the most recent bronze snapshot arrival

Use this query when the pipeline needs to inspect what a landed bronze batch actually looks like before any transform or publish step is applied. It is typically triggered by ingestion validation, troubleshooting, or note writing that needs a real raw snapshot example. The query runs read-only against bronze.signals_daily. Its purpose is to expose the latest landed rows together with the ingestion timestamp that defines the batch boundary.

FieldSourceTypeMeaning
_indexbronze.signals_daily._indexvarcharBusiness slice or source subset name.
symbolbronze.signals_daily.symbolvarcharTicker inside the slice.
signal_datetimestamp::datedateEffective signal date for the row.
_ingested_atbronze.signals_daily._ingested_attimestampLanded-ingestion timestamp used to identify the batch.

This query previews the latest raw bronze signal rows currently visible in PostgreSQL.

SELECT
    _index,
    symbol,
    timestamp::date AS signal_date,
    _ingested_at
FROM bronze.signals_daily
ORDER BY _ingested_at DESC, id DESC
LIMIT 12;
_indexsymbolsignal_date_ingested_at
stoxx_usa_50UBER2026-04-082026-04-07 23:29:57.303918
stoxx_usa_50CRM2026-04-082026-04-07 23:29:57.303918
stoxx_usa_50VZ2026-04-082026-04-07 23:29:57.303918
stoxx_usa_50AXP2026-04-082026-04-07 23:29:57.303918
stoxx_usa_50IBM2026-04-082026-04-07 23:29:57.303918
stoxx_usa_50INTC2026-04-082026-04-07 23:29:57.303918
stoxx_usa_50PEP2026-04-082026-04-07 23:29:57.303918
stoxx_usa_50LIN2026-04-082026-04-07 23:29:57.303918
stoxx_usa_50TMUS2026-04-082026-04-07 23:29:57.303918
stoxx_usa_50MCD2026-04-082026-04-07 23:29:57.303918
stoxx_usa_50WFC2026-04-082026-04-07 23:29:57.303918
stoxx_usa_50GS2026-04-082026-04-07 23:29:57.303918

The landing shape is exactly what a scoped bronze batch should look like: one _index, one signal date, one ingestion wave, and one row per symbol. That is the kind of source that should be validated as a complete slice before the pipeline decides whether to replace or upsert downstream data.

Validate one bronze batch before publish

Use this query immediately after landing a batch and before changing any downstream-facing table. It is typically triggered by the validation gate of a bronze-to-silver pipeline. The query runs read-only against bronze.signals_daily. Its purpose is to test whether the batch boundary is internally consistent: row count, distinct key count, date range, and landed timestamp range.

FieldSourceTypeMeaning
_indexgrouping keyvarcharBusiness slice being validated.
batch_rowsCOUNT(*)bigintNumber of rows in the landed batch.
distinct_symbolsCOUNT(DISTINCT symbol)bigintNumber of distinct entity keys in the batch.
min_signal_dateMIN(timestamp::date)dateEarliest signal date in the batch.
max_signal_dateMAX(timestamp::date)dateLatest signal date in the batch.
first_ingested_atMIN(_ingested_at)timestampEarliest ingestion timestamp in the batch.
last_ingested_atMAX(_ingested_at)timestampLatest ingestion timestamp in the batch.

This query validates the landed shape of the current stoxx_usa_50 bronze batch.

SELECT
    _index,
    COUNT(*) AS batch_rows,
    COUNT(DISTINCT symbol) AS distinct_symbols,
    MIN(timestamp::date) AS min_signal_date,
    MAX(timestamp::date) AS max_signal_date,
    MIN(_ingested_at) AS first_ingested_at,
    MAX(_ingested_at) AS last_ingested_at
FROM bronze.signals_daily
WHERE _index = 'stoxx_usa_50'
GROUP BY _index;
_indexbatch_rowsdistinct_symbolsmin_signal_datemax_signal_datefirst_ingested_atlast_ingested_at
stoxx_usa_5050502026-04-082026-04-082026-04-07 23:29:57.2996872026-04-07 23:29:57.303918

This is a clean publish candidate. The batch has the expected one-row-per-symbol shape, one signal date, and one narrow ingestion window. That is exactly the sort of boundary that should be validated before the pipeline touches a consumer-facing silver table.

Loading Decision Matrix

The business slice decides the loading pattern before the transport interface does.

Choose replacement, publish, or upsert before choosing the tool

SituationBest patternWhy it fitsPostgreSQL surface
Source is the full truth for one bounded sliceScoped full refreshSimplest way to keep the slice exactTransactional DELETE plus INSERT, often via staging
Source must be checked before downstream readers see itStaged validation then publishPreserves the safest failure boundaryStage table, validation queries, then publish transaction
Source contains mixed new and changed keysIdempotent upsertKeeps existing keys current and inserts new onesINSERT ... ON CONFLICT, sometimes MERGE
Large file or stream must move quicklyBulk transportReduces per-row protocol costCOPY, \copy, driver batch loaders

Current recommendation for the live stoxx shapes

  • Treat bronze signal snapshots as validation-first slice loads because their business boundary is naturally one _index and one signal date.
  • Treat long silver OHLCV histories as append-or-upsert pipelines, not as daily full-table replacement targets.
  • Use COPY or driver bulk movement when the batch is large, but keep the business decision about replacement versus merge separate from the transport choice.

Scoped Replacement and Idempotent Publish

The two most useful patterns for this chapter are bounded replacement and constraint-backed upsert. They solve different business problems and should not be blurred together.

Replace complete slices transactionally

When the source is the full truth for a slice, the safest publish rule is “delete exactly that slice, insert the validated replacement, and commit once”.

Refresh a bounded slice inside one transaction

Use this pattern when the batch fully replaces a known key range or business slice and partial visibility would be harmful. It is typically triggered by daily snapshots, dimension-slice rebuilds, or any feed where the incoming rows should replace the old slice atomically. The demonstration runs against a temporary table so the real medallion schemas stay untouched. Its purpose is to show the structural pattern: replace the slice inside one transaction and verify the final target state afterward.

FieldSourceTypeMeaning
instrument_idtemp demo target primary keyintegerBusiness key of the demo row.
symboltemp demo target payloadvarcharInstrument symbol.
status_codetemp demo target payloadvarcharCurrent state code.
status_notetemp demo target payloadvarcharHuman-readable status explanation.

This demo replaces a bounded slice of rows inside one transaction and returns the final target state.

CREATE TEMP TABLE slice_refresh_demo
(
    instrument_id integer PRIMARY KEY,
    symbol varchar(20),
    status_code varchar(20),
    status_note varchar(100)
);
 
INSERT INTO slice_refresh_demo
VALUES
    (1, 'ASML.AS', 'ACTIVE', 'Listed and trading'),
    (2, 'SAP.DE', 'ACTIVE', 'Listed and trading'),
    (3, 'MC.PA', 'ACTIVE', 'Halt lifted after news published');
 
BEGIN;
 
DELETE FROM slice_refresh_demo
WHERE instrument_id IN (2, 3);
 
INSERT INTO slice_refresh_demo (instrument_id, symbol, status_code, status_note)
VALUES
    (2, 'SAP.DE', 'ACTIVE', 'Reloaded from validated slice'),
    (3, 'MC.PA', 'ACTIVE', 'Reloaded from validated slice');
 
COMMIT;
 
SELECT
    instrument_id,
    symbol,
    status_code,
    status_note
FROM slice_refresh_demo
ORDER BY instrument_id;
instrument_idsymbolstatus_codestatus_note
1ASML.ASACTIVEListed and trading
2SAP.DEACTIVEReloaded from validated slice
3MC.PAACTIVEReloaded from validated slice

The final target is internally consistent because the delete and insert were published as one unit. That is the correct replacement posture for complete slices. The exact same transaction boundary should wrap real bronze-to-silver or silver-to-gold publish steps when replacement is the business rule.

Upsert only when the source is not a full replacement

If the incoming rows represent new or changed keys rather than the full truth for a slice, the safer PostgreSQL-native surface is usually INSERT ... ON CONFLICT.

Upsert with INSERT ... ON CONFLICT DO UPDATE

Use this pattern when the target owns a real uniqueness boundary and the batch contains a mix of new keys and changed existing keys. It is typically triggered by state tables, dimension tables, or incremental fact maintenance where replacement of the whole slice would be excessive. The demo runs against a temporary table with a primary key. Its purpose is to show PostgreSQL’s constraint-backed idempotent upsert behavior.

FieldSourceTypeMeaning
instrument_idtemp demo target primary keyintegerConflict target and business key.
symboltemp demo target payloadvarcharInstrument symbol.
status_codetemp demo target payloadvarcharState value after insert or update.
status_notetemp demo target payloadvarcharHuman-readable state explanation after insert or update.

This demo updates one existing key and inserts one new key by using ON CONFLICT on the primary key.

CREATE TEMP TABLE upsert_demo
(
    instrument_id integer PRIMARY KEY,
    symbol varchar(20),
    status_code varchar(20),
    status_note varchar(100)
);
 
INSERT INTO upsert_demo
VALUES
    (1, 'ASML.AS', 'ACTIVE', 'Listed and trading'),
    (2, 'SAP.DE', 'ACTIVE', 'Listed and trading');
 
INSERT INTO upsert_demo AS t (instrument_id, symbol, status_code, status_note)
VALUES
    (2, 'SAP.DE', 'HALTED', 'Awaiting exchange notice'),
    (3, 'MC.PA', 'ACTIVE', 'New instrument landed')
ON CONFLICT (instrument_id) DO UPDATE
SET
    status_code = EXCLUDED.status_code,
    status_note = EXCLUDED.status_note;
 
SELECT
    instrument_id,
    symbol,
    status_code,
    status_note
FROM upsert_demo
ORDER BY instrument_id;
instrument_idsymbolstatus_codestatus_note
1ASML.ASACTIVEListed and trading
2SAP.DEHALTEDAwaiting exchange notice
3MC.PAACTIVENew instrument landed

This is the clearest PostgreSQL upsert baseline when one unique key determines whether the row should insert or update. MERGE is available in PostgreSQL 16 and later, but ON CONFLICT remains the narrower and often more readable choice when the rule is purely conflict-target based.

Bulk Interfaces and PostgreSQL-Specific Safety

Transport choice matters for throughput, but it does not change the business semantics of the load. A fast byte mover can still publish the wrong batch if validation and transaction boundaries are weak.

COPY, \copy, and WAL-aware loading

COPY is PostgreSQL’s main native bulk-movement surface. The critical distinction is whether the file is read by the server process or streamed through the client connection.

Use COPY ... TO STDOUT to move a validated slice through the connection

Use this pattern when the pipeline wants PostgreSQL to serialize a known query result through the client connection rather than reading or writing a server-local file. It is typically triggered by exports, shell-based diagnostics, or orchestration steps that want to stream rows safely without granting the server direct file-system access to a client path. The command is read-only. Its purpose is to show the connection-stream model that underlies both COPY ... TO STDOUT and the psql \copy convenience wrapper.

COPY
(
    SELECT
        symbol,
        date,
        close
    FROM silver.stoxxusa50_ohlcv
    ORDER BY date DESC, symbol
    LIMIT 5
)
TO STDOUT
WITH (FORMAT csv, HEADER true);
symbol,date,close
AAPL,2026-04-07,253.5
ABBV,2026-04-07,206.37
AMAT,2026-04-07,354.31
AMD,2026-04-07,221.53
AMZN,2026-04-07,213.77

The transport is efficient, but the business decision is unchanged: this only moves bytes. The pipeline still has to decide whether the result represents a replacement slice, an incremental delta, or an export for another stage.

PostgreSQL-Specific Anti-Patterns

These are the operational mistakes that matter most in PostgreSQL pipeline work.

What to avoid in this chapter

  • Loading directly into a published target when the batch has not yet passed row-count and key-consistency checks.
  • Treating COPY as a business-validation feature. It is a transport, not a correctness boundary.
  • Using ON CONFLICT without a real uniqueness constraint that defines what “same row” means.
  • Replacing large slices with delete-plus-insert while forgetting that PostgreSQL will create dead tuples that still need vacuum cleanup afterward.
  • Holding load transactions open for too long. Long transactions delay visibility cleanup and can keep dead tuples alive far beyond the moment the pipeline thinks it finished.