LEARN · MODERN DATA ENGINEERING
A data pipeline can have bronze, silver, and gold tables, beautifully organized schemas, dozens of scheduled jobs, and still produce numbers nobody trusts.
The problem is rarely the absence of layers. It is the absence of clear contracts between layers.
Medallion architecture offers a useful way to organize data engineering systems: preserve source information, transform it into reliable data, and publish business-ready datasets. But adopting three layers without understanding their responsibilities can introduce unnecessary storage, latency, operational complexity, and duplicated business logic.
The right question is not “How do we implement bronze, silver, and gold?” It is:
What reliability or usability problem does each additional layer solve?
This tutorial answers that question from both an architectural and implementation perspective.
We will build a working retail order pipeline using DuckDB and Python, deliberately introduce corrupted and duplicate records, enforce contracts, calculate business metrics, and examine the situations where three layers are genuinely valuable—or simply bureaucracy.
1. Understanding the medallion architecture
Medallion architecture organizes data processing into stages of increasing quality and business usefulness.
The conventional flow is straightforward:
BRONZE -> raw source records, ingestion metadata, replayability
|
v
SILVER -> validated, typed, deduplicated, reusable data
|
v
GOLD -> business metrics, reporting models, consumer-facing datasets
The important distinction is that these are logical quality boundaries, not necessarily three storage systems, three catalogs, or even three physically materialized tables.
The Databricks medallion architecture documentation, updated in September 2026, describes the approach as a recommended design pattern rather than an architectural requirement.
That distinction matters. A small DuckDB database, a managed warehouse, and a distributed lakehouse can all apply the same concepts without implementing them identically.
Bronze: preserve what happened
Bronze is the ingestion and recovery layer.
Its responsibility is to preserve source records with enough information to understand where they came from and replay them when necessary.
Typical bronze information includes:
-
The original event payload or source columns.
-
Source file, topic, partition, or offset.
-
Ingestion timestamp and batch identifier.
-
Source-system identifiers.
-
Schema version, when available.
Bronze should avoid transformations that destroy information.
If an incoming record contains an invalid price, silently dropping the record during ingestion removes the evidence needed to investigate the failure.
A useful principle is: bronze prioritizes fidelity over cleanliness.
Silver: make records trustworthy
Silver transforms raw data into reusable, validated representations.
Typical responsibilities include:
-
Parsing and enforcing data types.
-
Validating identifiers and required fields.
-
Deduplicating retried events.
-
Standardizing timestamps and currencies.
-
Normalizing values across source systems.
-
Separating invalid records for investigation.
-
Applying consistent entity definitions.
Silver is often the most valuable layer in a larger data platform because multiple downstream consumers can reuse its work.
Without a shared silver representation, several teams may independently implement slightly different definitions of the same customer, order, or transaction.
Gold: make data useful to consumers
Gold turns validated data into explicitly defined products.
A gold dataset might provide daily sales, order conversion metrics, customer segments, inventory availability, or model-ready features.
Its central responsibility is not aggregation alone. It is semantic consistency.
For example, a column called revenue requires answers to questions such as:
-
Are cancelled orders excluded?
-
Are refunds represented?
-
Are taxes and shipping included?
-
Which currency is used?
-
Does the metric represent booked, paid, or recognized revenue?
-
Can historical values change when late events arrive?
A gold table without those definitions may be convenient to query but still unreliable.
2. Contracts matter more than layer names
Before writing SQL, define what each boundary guarantees.
For our example, the source is a JSON Lines file containing retail order events.
| Layer | Data grain | Contract | Failure behavior |
|---|---|---|---|
| Bronze | One received source line | Preserve original payload and source position | Retain malformed records |
| Silver | One unique valid order event | Valid identifiers, timestamp, status, and nonnegative amount | Quarantine invalid rows; audit duplicates |
| Gold | One order-creation date | Deterministic order counts and retained order value | Fail publication if assertions fail |
The phrase data grain deserves attention.
Grain specifies what one row represents.
Bronze contains received source records. Silver contains unique business events. Gold contains daily summaries.
Confusing these grains causes subtle bugs.
If a downstream query counts silver events as orders, an order that was placed and later cancelled might count twice. If it counts bronze records, network retries could inflate the number even further.
A strong architecture states grain explicitly for every published dataset.
3. Our runnable DuckDB pipeline
We’ll implement the full flow with DuckDB’s Python client and SQL.
DuckDB is particularly useful for this exercise because it supports persistent databases, JSON processing, analytical window functions, transactions, and Parquet interoperability without needing a distributed compute cluster.
The example uses the current stable DuckDB 1.5 series and standard APIs documented in the DuckDB Python client reference.
Step 1: Set up the environment
Run these commands in a Bash-compatible shell:
mkdir -p medallion-demo
cd medallion-demo
python -m venv .venv
source .venv/bin/activate
python -m pip install "duckdb==1.5.5"
The directory will contain our Python pipeline, synthetic JSON Lines input, and persistent DuckDB database.
No external dataset or cloud credentials are required.
Step 2: Implement the complete pipeline
Create a file named pipeline.py and add the following code.
This is one self-contained implementation. It generates its own input data on the first run, preserves raw records, rebuilds the downstream tables, and checks expected results.
import json
from pathlib import Path
import duckdb
ROOT = Path(__file__).resolve().parent
SOURCE = ROOT / "orders.jsonl"
DATABASE = ROOT / "shop.duckdb"
# Synthetic retail order events.
# Includes valid events, a replay, and invalid values.
sample = [
("e001", "o100", "2026-10-01T10:00:00Z", "placed", "40.00"),
("e002", "o101", "2026-10-01T11:00:00Z", "placed", "25.50"),
("e003", "o102", "2026-10-01T12:00:00Z", "placed", "12.00"),
("e004", "o101", "2026-10-02T08:00:00Z", "cancelled", "25.50"),
("e005", "o103", "2026-10-02T09:00:00Z", "placed", "80.00"),
("e005", "o103", "2026-10-02T09:00:00Z", "placed", "80.00"),
("e006", "o104", "2026-10-02T10:00:00Z", "placed", "-5.00"),
("e007", "o105", "2026-10-02T10:30:00Z", "placed", "not-a-number"),
("e008", "o106", "2026-10-02T11:00:00Z", "shipped", "34.00"),
("e009", "o107", "2026-10-02T12:00:00Z", "placed", "15.00"),
("e010", "o108", "2026-10-03T12:00:00Z", "placed", "5.00"),
]
# Create immutable sample input only if it does not exist.
if not SOURCE.exists():
rows = [
dict(
event_id=event_id,
order_id=order_id,
occurred_at=occurred_at,
status=status,
amount=amount,
)
for event_id, order_id, occurred_at, status, amount in sample
]
lines = [json.dumps(row, sort_keys=True) for row in rows]
# One deliberately malformed JSON line.
lines.append('{"event_id": "broken"')
SOURCE.write_text(
"\n".join(lines) + "\n",
encoding="utf-8",
)
con = duckdb.connect(str(DATABASE))
con.execute("SET TimeZone = 'UTC'")
try:
con.execute("BEGIN TRANSACTION")
# -----------------------------------------
# BRONZE: preserve raw source records
# -----------------------------------------
con.execute("""
CREATE TABLE IF NOT EXISTS bronze_orders (
source_file VARCHAR NOT NULL,
source_line BIGINT NOT NULL,
payload VARCHAR NOT NULL,
ingested_at TIMESTAMPTZ NOT NULL
DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (source_file, source_line)
)
""")
source_rows = [
(SOURCE.name, line_no, payload)
for line_no, payload in enumerate(
SOURCE.read_text(encoding="utf-8").splitlines(),
start=1,
)
]
# The source-file/line key makes re-ingestion
# of this immutable input idempotent.
con.executemany("""
INSERT INTO bronze_orders (
source_file, source_line, payload
)
VALUES (?, ?, ?)
ON CONFLICT DO NOTHING
""", source_rows)
# -----------------------------------------
# SILVER STAGING: parse and validate
# -----------------------------------------
con.execute("""
CREATE OR REPLACE TEMP VIEW staged_events AS
WITH decoded AS (
SELECT
source_file,
source_line,
payload,
TRY_CAST(payload AS JSON) AS raw_json
FROM bronze_orders
),
typed AS (
SELECT
source_file,
source_line,
payload,
raw_json,
json_extract_string(
raw_json, '$.event_id'
) AS event_id,
json_extract_string(
raw_json, '$.order_id'
) AS order_id,
TRY_CAST(
json_extract_string(
raw_json, '$.occurred_at'
) AS TIMESTAMPTZ
) AS occurred_at,
json_extract_string(
raw_json, '$.status'
) AS status,
TRY_CAST(
json_extract_string(
raw_json, '$.amount'
) AS DECIMAL(12, 2)
) AS amount
FROM decoded
)
SELECT *,
CASE
WHEN raw_json IS NULL
THEN 'invalid_json'
WHEN event_id IS NULL
OR trim(event_id) = ''
THEN 'missing_event_id'
WHEN order_id IS NULL
OR trim(order_id) = ''
THEN 'missing_order_id'
WHEN occurred_at IS NULL
THEN 'invalid_timestamp'
WHEN status IS NULL
OR status NOT IN (
'placed', 'cancelled'
)
THEN 'invalid_status'
WHEN amount IS NULL OR amount < 0
THEN 'invalid_amount'
ELSE NULL
END AS reject_reason
FROM typed
""")
# Store invalid records for investigation.
con.execute("""
CREATE OR REPLACE TABLE silver_rejects AS
SELECT
source_file,
source_line,
reject_reason,
payload
FROM staged_events
WHERE reject_reason IS NOT NULL
""")
# Rank valid events for deterministic deduplication.
con.execute("""
CREATE OR REPLACE TEMP VIEW numbered_valid_events AS
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY event_id
ORDER BY source_file, source_line
) AS duplicate_rank
FROM staged_events
WHERE reject_reason IS NULL
""")
# Canonical, typed events.
con.execute("""
CREATE OR REPLACE TABLE silver_order_events AS
SELECT
event_id,
order_id,
occurred_at,
status,
amount,
source_file,
source_line
FROM numbered_valid_events
WHERE duplicate_rank = 1
""")
# Preserve evidence of replayed event identifiers.
con.execute("""
CREATE OR REPLACE TABLE silver_duplicate_events AS
SELECT
event_id,
source_file,
source_line,
payload
FROM numbered_valid_events
WHERE duplicate_rank > 1
""")
# -----------------------------------------
# GOLD: business-ready daily summary
# -----------------------------------------
con.execute("""
CREATE OR REPLACE TABLE gold_daily_order_summary AS
WITH first_placements AS (
SELECT
order_id,
MIN(occurred_at) AS placed_at
FROM silver_order_events
WHERE status = 'placed'
GROUP BY order_id
),
ranked_events AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY occurred_at DESC, event_id DESC
) AS latest_rank
FROM silver_order_events
),
current_orders AS (
SELECT
CAST(p.placed_at AS DATE) AS order_date,
e.status AS current_status,
e.amount AS latest_amount
FROM ranked_events e
JOIN first_placements p USING (order_id)
WHERE e.latest_rank = 1
)
SELECT
order_date,
COUNT(*) AS orders_created,
COUNT(*) FILTER (
WHERE current_status = 'cancelled'
) AS cancelled_orders,
COUNT(*) FILTER (
WHERE current_status = 'placed'
) AS active_orders,
CAST(
SUM(
CASE
WHEN current_status = 'placed'
THEN latest_amount
ELSE 0
END
) AS DECIMAL(18, 2)
) AS retained_order_value
FROM current_orders
GROUP BY order_date
""")
# -----------------------------------------
# DATA CONTRACT TESTS
# -----------------------------------------
counts = tuple(
con.execute(
f"SELECT COUNT(*) FROM {table}"
).fetchone()[0]
for table in (
"bronze_orders",
"silver_order_events",
"silver_rejects",
"silver_duplicate_events",
)
)
assert counts == (12, 7, 4, 1), counts
actual = con.execute("""
SELECT
CAST(order_date AS VARCHAR),
orders_created,
cancelled_orders,
active_orders,
retained_order_value
FROM gold_daily_order_summary
ORDER BY order_date
""").fetchall()
expected = [
("2026-10-01", 3, 1, 2, 52),
("2026-10-02", 2, 0, 2, 95),
("2026-10-03", 1, 0, 1, 5),
]
assert actual == expected, actual
con.execute("COMMIT")
except Exception:
con.execute("ROLLBACK")
raise
print("bronze, silver, rejected, replayed:", counts)
for row in actual:
print(*row, sep=" | ")
con.close()
Step 3: Execute and rerun it
python pipeline.py
python pipeline.py
Both executions should produce the same logical results:
bronze, silver, rejected, replayed: (12, 7, 4, 1)
2026-10-01 | 3 | 1 | 2 | 52.00
2026-10-02 | 2 | 0 | 2 | 95.00
2026-10-03 | 1 | 0 | 1 | 5.00
The second execution is important.
The pipeline does not ingest the original 12 lines again as new bronze records. Its primary key identifies the same immutable source positions, while the silver and gold tables are deterministically rebuilt.
This is a small but meaningful demonstration of idempotence: rerunning a processing job does not multiply its business results.
The sample assertions are intended for this exact synthetic dataset. Production pipelines should replace hard-coded counts with general invariants and monitored expectations.
4. Why the bronze implementation works
Our bronze table deliberately stores the input payload as a string.
That choice prevents malformed JSON, unknown source fields, and failed type conversions from destroying the evidence.
Consider the record containing a negative order amount. It stays in bronze exactly as received.
The silver transformation rejects it, but data engineers can still inspect its source position and original value.
The important bronze design decisions are:
Raw payload retention. We keep the original line instead of reconstructing it from parsed columns. This supports later investigations and transformation changes.
Ingestion metadata. The file name and line number identify the source position; the ingestion timestamp records when that row entered the database.
Idempotent ingestion. Repeated processing of the same immutable source file does not create additional bronze rows.
Minimal semantic validation. Bronze does not decide which business statuses or amounts are acceptable.
There is an important limitation in our demonstration: a file name and line number work as a stable ingestion key only when source files are immutable.
If a producer overwrites a file in place, the same source position could contain different data. Our conflict-handling logic would retain the earlier payload rather than notice the mutation.
A production design should enforce immutable source objects or use stronger source identity, such as versioned object identifiers, content hashes, or message offsets.
Likewise, a production bronze layer might store original object bytes externally and maintain pointers inside the database instead of copying everything into a relational table.
The contract remains the same: preserve an independently recoverable source representation.
5. Silver is where correctness becomes enforceable
The silver stage performs several different tasks that are easy to confuse.
Parsing is not validation
A JSON object can be syntactically correct and still be invalid for the business.
For example, a status of “shipped” may be valid in another application, but our example accepts only “placed” and “cancelled”.
That is a domain-specific contract, not a JSON parsing rule.
Our staging view distinguishes these concerns by extracting values, converting them into expected types, and assigning rejection reasons.
DuckDB’s TRY_CAST behavior is useful here. Failed conversions return null rather than throwing an exception that aborts the whole transformation.
That allows malformed timestamps and amounts to become inspectable rejects.
However, using TRY_CAST without checking the result would be dangerous. A failed conversion could silently turn into a missing value that downstream aggregates ignore.
The pattern should always be:
-
Attempt the conversion.
-
Determine whether the result satisfies the contract.
-
Accept, reject, or explicitly default the record.
Quarantine is better than silent deletion
The example creates a separate table for invalid records with a rejection reason and original payload.
Four records are rejected:
-
A negative amount.
-
A nonnumeric amount.
-
An unsupported status.
-
Malformed JSON.
Those records are not available in the canonical silver event table, but they remain recoverable from bronze and visible in the rejection table.
In a larger pipeline, rejection reasons should also become metrics. A sudden increase in invalid timestamps may indicate a broken upstream deployment rather than an isolated bad record.
Deduplication requires identity
Our sample includes two identical events with the same event identifier.
Silver retains one canonical event and records the other in a duplicate audit table.
Why deduplicate by event identifier rather than order identifier?
Because a single order can legitimately have multiple events.
Order o101, for example, was placed and later cancelled. Those two records must remain separate in the event history.
Deduplicating by order identifier would lose an important business change.
There is another production consideration: the example uses deterministic first-arrival selection when identifiers are repeated.
That is appropriate for the exact duplicate replay in our sample. It is not sufficient for conflicting payloads that share the same event identifier.
A hardened implementation should distinguish exact replays from identity collisions, flag conflicting event contents, and apply a defined remediation policy.
6. Gold is where business semantics become explicit
The gold calculation contains a subtle but important design decision.
We do not simply group events by their event timestamps.
Instead, we derive the date when each order was first placed, identify its latest valid status, and aggregate the resulting order states by their creation dates.
This handles a common event-data problem.
On October 1, order o101 was placed for 25.50. On October 2, it was cancelled.
If we summed only placement events for October 1, we would report 77.50 for that day:
40.00 + 25.50 + 12.00 = 77.50
But our business metric is the currently retained value of those original orders. The cancelled order no longer contributes.
Therefore, the October 1 value becomes:
40.00 + 12.00 = 52.00
The daily summary retains three created orders but reports one cancellation and two active orders.
This is why gold requires more than a convenient SQL aggregation. It requires a clear definition of what a metric means.
Why we call it retained order value
Our table deliberately avoids naming the amount revenue.
An order being placed does not necessarily mean it has been paid, fulfilled, or recognized as revenue.
The example has no payment ledger, tax adjustments, refund settlements, or accounting recognition rules.
Calling the amount revenue would imply guarantees the data cannot provide.
Good metric naming is part of data quality.
A related consequence is that this gold table represents the latest known state, not an immutable historical snapshot.
If another cancellation for an October 1 order arrives later, a subsequent refresh may change the previously published October 1 total.
That can be desirable for an operational dashboard, but a financial report requiring reproducible as-of figures needs versioned snapshots or explicitly defined cutoff times.
7. Testing the boundaries, not just the SQL
The most important tests in this example validate architectural invariants rather than implementation details.
The fixed dataset establishes expected facts:
| Check | Expected result |
|---|---|
| Source records preserved in bronze | 12 |
| Unique valid events in silver | 7 |
| Rejected records | 4 |
| Duplicate replay records | 1 |
| Distinct valid orders | 6 |
| Orders currently cancelled | 1 |
| Total retained order value | 152.00 |
Tests like these catch bugs that can survive ordinary schema validation.
A column may have the correct numeric type while containing a duplicated business event.
A gold table may have the expected columns while accidentally calculating order counts from event counts.
A production test suite should cover several categories:
-
Schema tests: Column presence, types, nullability, and valid enumerations.
-
Identity tests: Unique event identifiers and legitimate entity relationships.
-
Reconciliation tests: Accepted, rejected, and duplicate records account for input volumes.
-
Semantic tests: Business measures match known scenarios, including cancellations and corrections.
-
Freshness tests: Published data reflects source changes within the agreed latency.
-
Replay tests: Retrying the same batch does not change results unexpectedly.
Quality thresholds also require judgment.
A critical finance dataset may need publication blocked when a source reconciliation fails.
A telemetry dataset may tolerate a small invalid-record rate while separately alerting on quality degradation.
Not every rejection needs to stop all consumers, but the behavior should be intentional.
8. Late-arriving data, CDC, and the limits of full rebuilds
Our implementation fully rebuilds silver and gold from bronze on every run.
For a small DuckDB project, this is an attractive choice. It is straightforward, reproducible, and avoids the complexity of incremental state management.
For hundreds of millions of records arriving continuously, repeatedly scanning the complete history could become unnecessarily expensive.
At that scale, the architecture needs additional mechanisms.
Incremental ingestion and processing
Production pipelines often record processing checkpoints so that previously processed source records do not need to be scanned repeatedly.
Incremental processing becomes harder when events update old business entities.
For example, a cancellation arriving today might affect an order created several months earlier.
Updating only today’s gold partition would miss the change.
A correct incremental pipeline must identify the affected business keys and refresh the relevant aggregates, even if their dates are outside the newest ingestion window.
Event time versus ingestion time
These are different concepts.
Event time describes when a business event occurred. Ingestion time describes when the data platform received it.
A delayed event can have an old event timestamp but a recent ingestion timestamp.
The silver contract should define whether business ordering relies on event time, source version, sequence number, or another authoritative ordering mechanism.
Our demonstration orders events by timestamp and then event identifier to produce deterministic results. For a real event stream, a lexicographic event identifier should not be mistaken for a guaranteed causal ordering sequence.
Change data capture and historical states
For database change streams, silver may need to interpret inserts, updates, deletes, and source transaction ordering rather than simply deduplicate events.
If consumers require historical entity states, one current row per entity is not enough.
A slowly changing dimension, versioned event history, or temporal model may be appropriate.
The key design choice is whether consumers need the latest known state, the state at a historical point, or both.
Medallion architecture does not answer that question automatically. Data modeling does.
9. When three layers earn their keep
Three distinct layers become valuable when they reduce duplicated work, protect recoverability, and establish useful ownership boundaries.
Consider an e-commerce platform with several upstream systems.
Orders arrive from a transactional database, payment events come from a payment service, and refunds are reported by another provider.
Data engineering must reconcile identifiers, currencies, timestamps, corrections, and delivery delays.
Meanwhile, finance, operations, analytics, and machine learning teams need different representations of the same information.
In that environment, the layers provide real benefits.
Bronze offers a stable replay point, silver handles reusable source integration and validation, and gold publishes metrics tailored to different stakeholders.
The added boundaries reduce the risk that every downstream team interprets raw data differently.
Three layers are especially defensible when:
-
Multiple independent source systems feed the same business entities.
-
Different consumer teams need a shared cleaned representation.
-
Data must be replayed after corrections or rule changes.
-
Source schemas evolve frequently.
-
Governance and auditability justify separate access boundaries.
-
Business-facing datasets require stable, documented contracts.
-
Expensive transformations are reused by several consumers.
But these benefits should be measurable in reduced rework, improved reliability, easier recovery, or predictable consumer interfaces—not merely the number of tables created.
10. Cherry on the cake: three layers can be two too many
Here is the surprising part: adding layers can make a data product less trustworthy when it introduces unnecessary synchronization boundaries.
Imagine a small system with one already validated Parquet source and one internal report.
The source is updated every hour, and the report contains a handful of straightforward aggregations.
A team decides to introduce an elaborate medallion pipeline.
It copies the source into bronze, copies most of those records into silver, then materializes the final report in gold.
The three-table design appears architecturally mature.
But what has actually improved?
Perhaps nothing.
The source was already validated. No other consumer needs silver. The report’s logic is simple enough for a documented view.
Now consider a cancellation that arrives at 10:05.
Silver refreshes at 10:06, but gold was refreshed at 10:00 and will not refresh again until 11:00.
A direct query against the validated source could see the cancellation immediately after source publication. The materialized gold report remains stale.
The extra layer has created a new freshness problem.
This is not an argument against gold materialization generally. Materialization can dramatically improve performance, isolate workloads, and stabilize consumer interfaces.
It is an argument against adding materialization without a specific reason.
For this particular system, a single canonical table and a reporting view may deliver better results with fewer failure modes.
Even the bronze copy might be unnecessary if the original files are durably retained, immutable, and directly replayable.
The honest engineering decision is to keep the fewest boundaries that satisfy the contracts.
A practical decision framework
| Situation | Reasonable architecture |
|---|---|
| One validated source, one simple report | One table plus a view |
| Raw events requiring validation, one consumer | Raw landing plus curated model |
| Several sources requiring shared conformance | Bronze + silver + consumer views |
| Multiple consumers with expensive reusable aggregates | Bronze + silver + materialized gold |
| Regulated or complex historical processing | Versioned raw, canonical models, governed serving products |
These are starting points rather than rigid rules.
A small dataset can justify three layers because of audit requirements. A large but simple dataset may need only one durable source and a carefully designed serving model.
The number of layers should follow the contracts, not precede them.
11. Production hardening: what the demo deliberately leaves out
Our DuckDB example demonstrates the logical boundaries, but several features need strengthening before using the same design for a critical production workload.
Atomic publication and recovery
DuckDB supports ACID transactions, including explicit begin, commit, and rollback behavior, as described in its transaction documentation.
Our example wraps ingestion and downstream rebuilding in one database transaction. If an assertion fails, the transaction rolls back rather than committing a partially updated pipeline.
However, this protects operations inside that DuckDB transaction only.
If production processing spans object storage, external warehouses, message queues, and independent jobs, a DuckDB transaction cannot make those systems globally atomic.
You may instead need staged outputs, atomic table replacement, versioned publication, or a coordinated workflow that exposes only completed datasets.
Explicit ownership and access controls
Not every consumer should be able to read every stage.
Raw source data can contain sensitive attributes or operational identifiers that are unnecessary in published analytics.
An enterprise design should define access controls appropriate to the platform and data classification.
A useful ownership model distinguishes who can modify ingestion logic, who approves canonical business definitions, and who owns published metrics.
Governance should protect real boundaries, not merely decorate schema names.
Observability and service expectations
A production job needs more than a success exit code.
Capture source arrival time, processing duration, validation failures, duplicate counts, output volumes, and the latest successfully published data version.
Freshness should be defined relative to the source and consumer requirements.
A daily finance report and a near-real-time operational dashboard have very different tolerances for delay.
Schema evolution and source changes
New source fields are not necessarily errors. Incompatible type changes and altered field meanings may be.
Bronze should preserve incoming information so that parser changes can be investigated and replayed.
Silver should make schema changes deliberately, with compatibility tests for downstream consumers.
Gold changes require particular care because a familiar column name with changed semantics can break trust even if its SQL type stays identical.
Efficient physical storage
Logical layer names do not determine optimal file layouts.
For larger datasets, physical design may include Parquet storage, appropriately sized files, partitioning or clustering, and selective materialization.
Avoid partitioning every table by several high-cardinality dimensions simply because the platform supports it. Excessive partition counts and tiny files can make reads and maintenance worse.
In DuckDB, Parquet can be queried directly, so not every intermediate transformation needs to be copied into a native database table.
Handling duplicate conflicts
Production-grade deduplication should distinguish:
-
Exact event replays.
-
Different events for the same entity.
-
Conflicting payloads sharing an event identifier.
-
Corrected records with explicit source versions.
-
Late events that modify already published aggregates.
Our example handles the first two and audits repeated identifiers. It intentionally does not implement a complete conflict-resolution or change-data-capture framework.
Those rules belong in the silver contract before the pipeline becomes a critical source of truth.
12. How to decide whether to materialize a layer
An important architectural distinction is the difference between a logical boundary and a stored table.
You can maintain a raw ingestion table and express a silver transformation as a view, provided the underlying data and performance requirements permit that approach.
Similarly, a gold reporting view can expose stable business semantics without storing another full copy of the dataset.
Materialization is justified when it solves a concrete problem.
| Criterion | Prefer a view when | Prefer a materialized table when |
|---|---|---|
| Compute cost | Transformation is cheap | Repeated queries are expensive |
| Consumer concurrency | Workload is light | Many consumers reuse results |
| Freshness | Source changes should appear directly | Controlled publication is desirable |
| Recovery | Recalculation is practical | Rebuilds are expensive |
| Historical consistency | Current source state is sufficient | Published versions must be retained |
| Operational overhead | Simplicity is important | Maintenance cost is justified |
Notice that neither option is universally superior.
A view improves freshness relative to its underlying data but shifts compute work to query time.
A materialized table improves predictable query performance but requires explicit refresh and publication policies.
The correct decision depends on workload characteristics, service expectations, and failure modes.
13. Five architectural mistakes worth avoiding
Mistake 1: Treating bronze as a cleaned source table
If ingestion removes malformed rows before preserving them, replay and forensic analysis become difficult.
Keep a durable raw representation first. Make intentional cleaning decisions downstream.
Mistake 2: Building silver as a duplicate of bronze
A silver table that merely renames columns without enforcing a meaningful contract adds storage without much reliability.
If no independent validation or normalization is necessary, reconsider whether a separate physical table is justified.
Mistake 3: Calling every aggregation gold
A quick analytical summary is not automatically a governed business data product.
A production gold model should have an owner, defined grain, documented measures, and freshness expectations.
Mistake 4: Confusing successful execution with correct data
A transformation can run without errors while double-counting retries, omitting late events, or silently changing metric semantics.
Test business invariants and reconciliation, not only whether SQL executes.
Mistake 5: Turning layer boundaries into organizational bottlenecks
A team may need to wait for three different owners to approve a small correction if responsibilities are fragmented poorly.
Good governance establishes authority and accountability. Bad governance forces unnecessary handoffs.
An architecture that makes safe changes harder without reducing measurable risk has become bureaucracy.
14. A checklist for reviewing an existing medallion pipeline
Before approving a new table or additional processing hop, ask these questions.
-
Source fidelity: Can we recover the original records after a transformation bug?
-
Replayability: Can we rebuild downstream datasets without asking upstream systems to resend everything?
-
Silver correctness: Are types, uniqueness, validation rules, and entity grain explicitly defined?
-
Reject visibility: Can we investigate records that fail validation?
-
Gold semantics: Do consumers understand precisely what every metric represents?
-
Freshness: Is the publication delay necessary and observable?
-
Incrementality: Can late corrections affect previously published periods correctly?
-
Ownership: Is somebody accountable for each important contract?
-
Cost: Does an additional stored copy save more operational or query cost than it creates?
-
Simplicity: Could a view or one reusable transformation satisfy the same requirement?
A convincing answer to these questions is much stronger architectural evidence than compliance with a three-layer naming convention.
15. Final takeaways
Medallion architecture is useful because it separates distinct responsibilities: preserving source data, enforcing canonical data quality, and publishing consumer-ready information.
Its strongest benefit is not the bronze-silver-gold vocabulary. It is the ability to establish explicit, testable contracts and make transformations reproducible.
Our DuckDB implementation demonstrated those ideas without needing a distributed platform:
-
Bronze preserved all 12 source records, including malformed input.
-
Silver accepted seven unique valid events, quarantined four invalid records, and audited one duplicate replay.
-
Gold calculated business metrics using the latest known order states.
-
Assertions checked both data quality and expected business results.
-
Rerunning the pipeline did not duplicate ingested source records.
We also saw why a working demonstration is only a foundation. Real systems need policies for source immutability, event ordering, late data, schema evolution, conflict resolution, publication consistency, and operational monitoring.
Most importantly, we established that an architecture is not better merely because it has more layers.
The goal is not to build three stages. The goal is to build the smallest reliable system that makes data trustworthy, recoverable, and useful.
Put it into practice
Run the DuckDB pipeline twice, inspect the rejected records, and then extend the sample with a late cancellation that changes an earlier day’s retained order value. Verify that the expected gold totals change correctly.
Next, take one real pipeline from your environment and document its grain, validation rules, replay guarantees, freshness contract, and consumer ownership.
Finally, challenge every materialized boundary: if removing this layer would not weaken reliability, correctness, governance, or performance, why are we maintaining it?
That is how medallion architecture becomes an engineering best practice instead of an expensive naming convention.