Bronze Layer Loading

The bronze layer is the raw landing zone of the PostgreSQL medallion pipeline. Its job is not to normalize or interpret business meaning. Its job is to preserve source shape, attach ingestion metadata, and define a restartable handoff into later silver transforms. The live stoxx database already contains a bronze schema, which makes it possible to mirror the SQL Server bronze note against real PostgreSQL tables instead of abstract examples.

Database Setup And Connection

Before any bronze rows land, the schema has to exist and the loader has to connect through a PostgreSQL-native client. PostgreSQL simplifies the DDL boundary compared with SQL Server because CREATE SCHEMA IF NOT EXISTS is available directly.

Idempotent schema creation

The current bronze schema already exists in stoxx, but the creation pattern is still worth making explicit because every bootstrap or rebuild path depends on it.

Create the bronze schema idempotently

Use this command during first-time environment bootstrap, rebuilds, or deployment scripts that need to guarantee the bronze namespace exists before any tables are created. It is typically triggered by cluster initialization or by pipeline setup code running against a fresh database. The command is state-changing, but harmless when the schema already exists because PostgreSQL treats the IF NOT EXISTS clause as a no-op with a notice. Its purpose is to make the bronze namespace creation step rerunnable.

This command ensures the bronze schema exists before loader DDL runs.

CREATE SCHEMA IF NOT EXISTS bronze;
CREATE SCHEMA
NOTICE:  schema "bronze" already exists, skipping

This is exactly the kind of DDL surface a bronze bootstrap needs: explicit, rerunnable, and safe to execute on every setup pass without custom catalog guards.

Python connection helper

For PostgreSQL, the clean Python baseline is a PostgreSQL-native driver such as psycopg, not an ODBC abstraction borrowed from the SQL Server chapter. The key operational goal is still the same: centralize host, port, database, role, and transaction behavior in one reusable connection factory.

Build a PostgreSQL-native connection helper with environment-driven settings

Use a shared helper like the following when bronze loaders need one authoritative connection pattern for local runs, orchestrated jobs, and ad-hoc replay scripts. It is typically triggered by loader implementation work, not by SQL troubleshooting. The code is not state-changing on its own. Its purpose is to centralize connection policy, transaction defaults, and environment-variable lookup.

This Python helper builds a PostgreSQL connection from environment variables instead of hardcoded credentials.

from pathlib import Path
import os
 
from dotenv import load_dotenv
import psycopg
 
 
def get_connection(autocommit: bool = False, database: str | None = None):
    env_path = Path(__file__).resolve().parent.parent / ".env"
    load_dotenv(env_path)
 
    return psycopg.connect(
        host=os.getenv("PGHOST", "localhost"),
        port=int(os.getenv("PGPORT", "5434")),
        dbname=database or os.getenv("PGDATABASE", "stoxx"),
        user=os.getenv("PGUSER", "postgres"),
        password=os.getenv("PGPASSWORD"),
        autocommit=autocommit,
    )

The important design choice is not the library syntax itself. It is the fact that host, port, database, user, and autocommit policy are centralized in one place so every bronze loader inherits the same operational boundary.

Symbol lookup helper

Many bronze loaders need the current set of symbols and the earliest available history date before they can decide which raw files or API requests to process.

Read symbols and history starts from bronze.index_dim

Use this query when a loader needs the current ticker universe for one index slice, or when the operator wants to validate that the bronze dimension table already holds the expected symbols and history boundaries. It is typically triggered by orchestration code or by ad-hoc data inspection. The query runs read-only against bronze.index_dim. Its purpose is to surface the symbol list and the earliest price-data start date that downstream loaders can use as a lower bound.

FieldSourceTypeMeaning
symbolbronze.index_dim.symbolvarcharTicker symbol to process.
price_data_startbronze.index_dim.price_data_startdateEarliest date for which historical price data is expected.

This query returns the first few symbols and their historical lower bounds from the bronze dimension table.

SELECT
    symbol,
    price_data_start
FROM bronze.index_dim
ORDER BY symbol
LIMIT 5;
symbolprice_data_start
0388.HK2021-01-01
1299.HK2021-01-01
1810.HK2021-01-01
2269.HK2021-01-01
3382.T2021-01-01

This is the bronze equivalent of “what entities should I load?” The query stays close to the raw metadata and does not yet impose silver-level interpretation.

Bronze Table Surface

The bronze schema is already populated in the current PostgreSQL lab. That makes it possible to inspect the actual landed table shapes and compare them to the intended bronze design rules.

Current bronze metadata and signal tables

The most important structural rules are visible immediately: surrogate key, _index business slice, _ingested_at arrival time, and payload columns that remain close to the source.

Inspect the current columns of bronze.index_dim

Use this query when validating the metadata landing shape, comparing the PostgreSQL bronze table to the SQL Server source, or reviewing whether the table is still source-faithful. It is typically triggered by schema review or by a loader change that needs to know exactly which fields the raw dimension table exposes. The query reads information_schema.columns. It is read-only. Its purpose is to show the current column surface of the bronze metadata table.

FieldSourceTypeMeaning
column_nameinformation_schema.columns.column_nametextColumn name in ordinal order.
data_typeinformation_schema.columns.data_typetextPostgreSQL data type of the column.
is_nullableinformation_schema.columns.is_nullabletextWhether the column allows nulls.

This query inspects the landed column surface of bronze.index_dim.

SELECT
    column_name,
    data_type,
    is_nullable
FROM information_schema.columns
WHERE table_schema = 'bronze'
  AND table_name = 'index_dim'
ORDER BY ordinal_position;
column_namedata_typeis_nullable
idintegerNO
_indexcharacter varyingNO
_ingested_attimestamp without time zoneNO
symbolcharacter varyingNO
long_namecharacter varyingYES
short_namecharacter varyingYES
sectorcharacter varyingYES
sector_keycharacter varyingYES
industrycharacter varyingYES
industry_keycharacter varyingYES
countrycharacter varyingYES
citycharacter varyingYES
websitecharacter varyingYES
long_business_summarytextYES
exchangecharacter varyingYES
full_exchange_namecharacter varyingYES
exchange_timezone_namecharacter varyingYES
exchange_timezone_shortcharacter varyingYES
currencycharacter varyingYES
financial_currencycharacter varyingYES
quote_typecharacter varyingYES
marketcharacter varyingYES
range_startdateYES
price_data_startdateYES

This is a good bronze shape. It keeps the source attributes visible, adds _index and _ingested_at as landing metadata, and avoids premature normalization into downstream-oriented dimensions.

Inspect the current columns of bronze.signals_daily

Use this query when reviewing a raw signal snapshot feed or when a pipeline change needs the exact landed field names and nullability. It is typically triggered by schema mapping, raw-to-silver planning, or ingestion validation. The query reads information_schema.columns. It is read-only. Its purpose is to show the column layout of the current bronze daily-signals table.

FieldSourceTypeMeaning
column_nameinformation_schema.columns.column_nametextColumn name in ordinal order.
data_typeinformation_schema.columns.data_typetextPostgreSQL data type of the column.
is_nullableinformation_schema.columns.is_nullabletextWhether the column allows nulls.

This query inspects the landed column surface of bronze.signals_daily.

SELECT
    column_name,
    data_type,
    is_nullable
FROM information_schema.columns
WHERE table_schema = 'bronze'
  AND table_name = 'signals_daily'
ORDER BY ordinal_position;
column_namedata_typeis_nullable
idintegerNO
_indexcharacter varyingNO
_ingested_attimestamp without time zoneNO
symbolcharacter varyingNO
timestamptimestamp without time zoneNO
current_pricedouble precisionYES
forward_pedouble precisionYES
price_to_bookdouble precisionYES
ev_to_ebitdadouble precisionYES
dividend_yielddouble precisionYES
market_capbigintYES
betadouble precisionYES
fifty_two_week_changedouble precisionYES
sandp_52_week_changedouble precisionYES
fifty_day_averagedouble precisionYES
two_hundred_day_averagedouble precisionYES
dist_from_52_week_highdouble precisionYES
target_median_pricedouble precisionYES
recommendation_meandouble precisionYES
upside_potentialdouble precisionYES

Again the bronze rule is visible in the column design: the landed feed is still recognizable as the original signal payload, not a consumer-facing silver model.

Inspect the indexes that currently exist on key bronze tables

Use this query when evaluating bronze-table read patterns, or when comparing the migrated PostgreSQL schema to the original SQL Server design. It is typically triggered by performance review or by DDL planning for native PostgreSQL loaders. The query reads pg_indexes. It is read-only. Its purpose is to show which access paths the current bronze tables actually have.

FieldSourceTypeMeaning
indexnamepg_indexes.indexnamenameName of the index.
indexdefpg_indexes.indexdeftextFull DDL definition of the index.

This query shows the current index surface of two representative bronze tables.

SELECT
    indexname,
    indexdef
FROM pg_indexes
WHERE schemaname = 'bronze'
  AND tablename IN ('index_dim', 'signals_daily')
ORDER BY tablename, indexname;
indexnameindexdef
index_dim_pkeyCREATE UNIQUE INDEX index_dim_pkey ON bronze.index_dim USING btree (id)
signals_daily_pkeyCREATE UNIQUE INDEX signals_daily_pkey ON bronze.signals_daily USING btree (id)

The migrated PostgreSQL bronze tables currently expose only surrogate-key primary indexes. That is enough for identity and table integrity, but a production-native bronze design would usually add workload-facing indexes or uniqueness boundaries if slice deletes or ON CONFLICT landing is going to depend on business keys such as (_index, symbol) or (symbol, timestamp).

Dynamic OHLCV Tables

The bronze layer also contains per-index OHLCV tables. These are the main history-bearing exception to the otherwise snapshot-heavy bronze surface.

Current OHLCV table naming surface

The current lab preserved the bronze and silver OHLCV tables as separate physical tables per index family.

List the live OHLCV tables across bronze and silver

Use this query when validating that the expected OHLCV tables exist for both raw and transformed layers, or before writing loader logic that needs to target a specific table by convention. It is typically triggered by schema discovery or by a new loader implementation. The query reads information_schema.tables. It is read-only. Its purpose is to show the current dynamic OHLCV table naming surface.

FieldSourceTypeMeaning
table_schemainformation_schema.tables.table_schematextSchema containing the table.
table_nameinformation_schema.tables.table_nametextOHLCV table name.

This query lists the current bronze and silver OHLCV tables in the PostgreSQL lab.

SELECT
    table_schema,
    table_name
FROM information_schema.tables
WHERE table_schema IN ('bronze', 'silver')
  AND table_name LIKE '%ohlcv'
ORDER BY table_schema, table_name;
table_schematable_name
bronzeeurostoxx50_ohlcv
bronzeoil20_ohlcv
bronzestoxxasia50_ohlcv
bronzestoxxusa50_ohlcv
silvereurostoxx50_ohlcv
silveroil20_ohlcv
silverstoxxasia50_ohlcv
silverstoxxusa50_ohlcv

This naming scheme keeps the business slice visible directly in the table name while still respecting the medallion layer boundary through the schema.

Preview a live bronze OHLCV slice

Use this query when validating the raw historical landing shape of one OHLCV table, or before designing a merge or upsert boundary for historical prices. It is typically triggered by schema walkthrough, feed validation, or note writing. The query runs read-only against bronze.stoxxusa50_ohlcv. Its purpose is to show the raw landed OHLCV row shape.

FieldSourceTypeMeaning
symbolbronze.stoxxusa50_ohlcv.symbolvarcharTicker symbol.
datebronze.stoxxusa50_ohlcv.datedateTrading date.
openbronze.stoxxusa50_ohlcv.opendouble precisionOpening price.
highbronze.stoxxusa50_ohlcv.highdouble precisionDaily high price.
lowbronze.stoxxusa50_ohlcv.lowdouble precisionDaily low price.
closebronze.stoxxusa50_ohlcv.closedouble precisionClosing price.
volumebronze.stoxxusa50_ohlcv.volumebigintDaily volume.

This query previews the latest rows currently present in the raw bronze USA OHLCV table.

SELECT
    symbol,
    date,
    open,
    high,
    low,
    close,
    volume
FROM bronze.stoxxusa50_ohlcv
ORDER BY date DESC, symbol
LIMIT 5;
symboldateopenhighlowclosevolume
AAPL2026-04-07256.155256.19245.7253.560820961
ABBV2026-04-07206.23206.49201.6634206.377807749
AMAT2026-04-07348.145356345.5354.313670017
AMD2026-04-07218.255222.0984215.375221.5324796216
AMZN2026-04-07211.24213.97209.08213.7726704731

The bronze OHLCV table is already close to a typed historical fact table. That is why these feeds are the main bronze exception that often justify append-and-correct landing instead of complete slice replacement.

Loading Patterns (JSON To Bronze)

Bronze loading should still be chosen by source contract first. The two dominant cases are snapshot feeds and historical OHLCV feeds.

Snapshot-style feeds

Daily signals and similar payloads are typically full-slice snapshots. That makes transactional replacement the safest raw landing posture.

Replace one snapshot slice transactionally

Use this pattern when the incoming payload is the complete truth for one _index slice and the safest outcome is to replace that slice atomically. It is typically triggered by daily snapshot feeds or dimension refreshes. The demo runs against a temporary table so the real bronze schema is untouched. Its purpose is to show the bronze snapshot rule: replace the slice inside one transaction, then read the final landed state.

FieldSourceTypeMeaning
_indextemp demo slice keytextBusiness slice being refreshed.
symboltemp demo payloadtextEntity key inside the slice.
signal_datetemp demo payloaddateSnapshot date of the signal row.
recommendation_meantemp demo payloaddouble precisionExample signal measure.
_ingested_attemp demo payloadtimestampArrival timestamp of the landed row.

This demo replaces one bronze snapshot slice and returns the final target contents.

CREATE TEMP TABLE bronze_snapshot_demo
(
    _index text,
    symbol text,
    signal_date date,
    recommendation_mean double precision,
    _ingested_at timestamp without time zone
);
 
INSERT INTO bronze_snapshot_demo
VALUES
    ('stoxx_usa_50', 'AAPL', DATE '2026-04-07', 1.8, TIMESTAMP '2026-04-07 23:00:00'),
    ('stoxx_usa_50', 'AMD', DATE '2026-04-07', 2.1, TIMESTAMP '2026-04-07 23:00:00'),
    ('stoxx_europe_50', 'ASML.AS', DATE '2026-04-07', 1.7, TIMESTAMP '2026-04-07 23:00:00');
 
BEGIN;
 
DELETE FROM bronze_snapshot_demo
WHERE _index = 'stoxx_usa_50';
 
INSERT INTO bronze_snapshot_demo
VALUES
    ('stoxx_usa_50', 'AAPL', DATE '2026-04-08', 1.6, TIMESTAMP '2026-04-08 23:00:00'),
    ('stoxx_usa_50', 'AMD', DATE '2026-04-08', 2.0, TIMESTAMP '2026-04-08 23:00:00');
 
COMMIT;
 
SELECT
    _index,
    symbol,
    signal_date,
    recommendation_mean,
    _ingested_at
FROM bronze_snapshot_demo
ORDER BY _index, symbol;
_indexsymbolsignal_daterecommendation_mean_ingested_at
stoxx_europe_50ASML.AS2026-04-071.72026-04-07 23:00:00
stoxx_usa_50AAPL2026-04-081.62026-04-08 23:00:00
stoxx_usa_50AMD2026-04-0822026-04-08 23:00:00

This is the correct bronze pattern for a full-slice snapshot feed. The new rows become visible together, and unrelated slices remain untouched.

Append-and-correct OHLCV history

Historical OHLCV feeds are different because the feed can append new dates and occasionally correct existing ones. That is where PostgreSQL’s key-based upsert becomes useful.

Upsert raw OHLCV history on a business key

Use this pattern when the feed can deliver both new and corrected historical rows, and the landing table has a real uniqueness boundary such as (symbol, trade_date). It is typically triggered by daily price-history refreshes or backfills from an external market-data source. The demo uses a temporary table with a composite primary key. Its purpose is to show the PostgreSQL-native bronze history pattern: INSERT ... ON CONFLICT DO UPDATE.

FieldSourceTypeMeaning
symboltemp demo business keytextInstrument symbol.
trade_datetemp demo business keydateHistorical date boundary.
closetemp demo payloadnumericClosing price to land.
volumetemp demo payloadbigintDaily volume to land.

This demo corrects one existing historical row and inserts one new one through ON CONFLICT.

CREATE TEMP TABLE bronze_ohlcv_demo
(
    symbol text,
    trade_date date,
    close numeric(10, 2),
    volume bigint,
    PRIMARY KEY (symbol, trade_date)
);
 
INSERT INTO bronze_ohlcv_demo
VALUES
    ('AAPL', DATE '2026-04-07', 253.50, 60820961),
    ('AMD', DATE '2026-04-07', 221.53, 24796216);
 
INSERT INTO bronze_ohlcv_demo (symbol, trade_date, close, volume)
VALUES
    ('AMD', DATE '2026-04-07', 222.00, 25000000),
    ('AMZN', DATE '2026-04-07', 213.77, 26704731)
ON CONFLICT (symbol, trade_date) DO UPDATE
SET
    close = EXCLUDED.close,
    volume = EXCLUDED.volume;
 
SELECT
    symbol,
    trade_date,
    close,
    volume
FROM bronze_ohlcv_demo
ORDER BY symbol, trade_date;
symboltrade_dateclosevolume
AAPL2026-04-07253.5060820961
AMD2026-04-07222.0025000000
AMZN2026-04-07213.7726704731

This is the PostgreSQL-native equivalent of the bronze-history merge boundary. It works well when the target table has the right business key. Without that key, the loader has no safe definition of what counts as “the same” historical row.

Operational Guidance

The safest bronze defaults for this PostgreSQL chapter are straightforward.

Default habits for the bronze layer

  • Keep bronze tables source-faithful and ingestion-aware. Preserve _index and _ingested_at instead of normalizing them away.
  • Use PostgreSQL-native idempotent DDL such as CREATE SCHEMA IF NOT EXISTS during setup.
  • Prefer snapshot replacement for complete slice feeds and ON CONFLICT only for true append-and-correct history.
  • Add real business-key indexes or constraints if bronze loaders will depend on conflict-aware landing or slice-targeted maintenance.