Reference architecture

Medallion lakehouse

Batch analytics in three layers. Bronze keeps what arrived, exactly as it arrived. Silver turns it into conformed business entities. Gold answers specific questions. Every layer after Bronze is derived, which means every mistake is reversible.

Batch Databricks Delta Lake Unity Catalog Last updated October 2026

The problem

The first data pipeline anyone writes reads from a source and writes the thing the dashboard needs. It works. The second one does the same, with a slightly different definition of "active customer". By the tenth, nobody can explain why finance and sales report different revenue, the only way to fix a parsing bug from three months ago is to ask the source system for the data again — and the source system no longer has it.

Three failures are hiding in that story, and they are the three the medallion pattern is designed to prevent:

The fix is a deliberate constraint: data moves in one direction through named layers, and each layer has a contract about what it guarantees.

How it works

Medallion lakehouse: Bronze, Silver and Gold layers on Databricks Source systems, files and events are ingested incrementally by Auto Loader into a Bronze layer of raw append-only Delta tables. Silver tables clean, type and conform that data into business entities. Gold tables aggregate it into purpose-built marts consumed by BI, machine learning and operational applications. Silver and Gold can always be rebuilt from Bronze. 1 Sources 2 Ingest 3 Bronze 4 Silver 5 Gold 6 Consume ERP / CRM JDBC · connector Files CSV · JSON · Parquet SaaS APIs exports · webhooks Auto Loader incremental file discovery checkpointed Bronze raw, as it landed append-only + source · file · time bronze orders_raw Silver typed · validated deduplicated conformed keys silver orders Gold aggregates · marts agreed metrics one per question gold revenue_daily BI dashboards · SQL ML features · training Applications exports · reverse ETL rebuild from Bronze 7 Silver and Gold are derived — drop them and recompute at any time. Bronze is the only layer you cannot recreate, so it is the only one you must never edit.
One direction, three contracts. The value of the pattern is step 7: everything downstream of Bronze is disposable.

The flow reads left to right, and the arrow that matters most points backwards:

  1. Sources hand over data however they can — a JDBC pull, a nightly file drop, a SaaS export.
  2. Ingestion lands it incrementally. On Databricks this is Auto Loader for files and a managed connector for systems that have one. It tracks what it has already seen so a rerun is not a reload.
  3. Bronze stores it unchanged, append-only, with provenance columns attached.
  4. Silver parses, types, validates, deduplicates and conforms — one row per real-world thing, with a key the rest of the business recognises.
  5. Gold aggregates into tables shaped for a specific question, with metric definitions agreed once.
  6. Consumers read Gold, and read Silver when they need to ask something Gold does not answer.
  7. Rebuilds run forward from Bronze whenever logic changes — which it will.
The real payoff

The layers are not about tidiness. They exist so that a logic bug costs a recompute instead of a data loss. If you can drop Silver and Gold on a Friday afternoon and have them back by Saturday morning, the architecture is working.

What each layer guarantees

Write this table down before you write any pipeline code, and then hold the line on it. Most medallion implementations fail not because the layers are wrong but because nobody agreed what belongs in each.

BronzeSilverGold
ShapeThe source's shapeThe business's shapeThe question's shape
GrainOne row per record receivedOne row per entity (or per version)Whatever the metric needs
SchemaPermissive — strings are fineStrict and typedStrict, and treated as a public API
DuplicatesExpectedRemovedImpossible by construction
JoinsNoneOnly to resolve keysFreely
Business logicNone at allDefinitional only (what is a customer)Interpretive (what counts as active)
Who may read itEngineers, and reprocessing jobsAnalysts, data scientistsEveryone
Rebuild costImpossible — this is the recordMinutes to hoursMinutes
The most common mistake

Filtering in Bronze. Someone drops test orders, or rows with a null customer ID, "because they are obviously wrong". Six months later those rows turn out to carry a real case, and the only copy is gone. Bronze filters nothing. If a row is unusable, Silver is where you say so — and where you keep it in a quarantine table.

Getting data in

Ingestion is the part that decides whether reruns are cheap. The rule is that every landing job must be able to run twice without producing twice the data, and must be able to resume without reprocessing everything it has already seen.

Auto Loader (the cloudFiles source) is the default for anything that arrives as files. It keeps its own record of which files it has processed, so new files are picked up and old ones are not:

(spark.readStream
   .format("cloudFiles")
   .option("cloudFiles.format", "json")
   .option("cloudFiles.schemaLocation", f"{chk}/schema")
   .option("cloudFiles.schemaEvolutionMode", "addNewColumns")
   .option("rescuedDataColumn", "_rescued_data")   # nothing is silently dropped
   .load("abfss://landing@acct.dfs.core.windows.net/erp/orders/")
   .selectExpr(
       "*",
       "_metadata.file_path  as _source_file",
       "_metadata.file_modification_time as _source_modified_at",
       "current_timestamp()  as _ingested_at")
   .writeStream
   .option("checkpointLocation", f"{chk}/bronze_orders")
   .trigger(availableNow=True)          # batch semantics, streaming bookkeeping
   .toTable("bronze.erp.orders_raw"))

Two details in there carry most of the weight. trigger(availableNow=True) gives you a batch job — it starts, consumes everything outstanding, and stops — while keeping the checkpointing and exactly-once guarantees of Structured Streaming. And rescuedDataColumn catches fields that do not match the expected schema instead of discarding them, which is the file-ingestion equivalent of not filtering in Bronze.

For other sources, in rough order of preference:

Pair this with an event-driven platform

If the organisation already runs an integration platform, the data platform is simply one more subscriber: it receives the reference event and reads the record back into Bronze. One ingestion mechanism, shared with every other consumer, instead of a bespoke extract per system.

The three layers, concretely

Bronze — the record of what arrived

Append-only. No deletes, no updates, no filtering, no renaming. Store the payload in whatever type keeps it faithful — a JSON string column is a perfectly good Bronze schema — and attach provenance: source system, source file or offset, ingest timestamp, and the batch or run identifier that produced the row.

Retention is a real decision, not a default. Bronze is the thing that makes reprocessing possible, so keeping ninety days of it is keeping ninety days of the ability to fix mistakes. Keeping seven years of it is usually just paying for storage you will never read. Decide per source, write it in the table comment, and enforce it.

Silver — the business's shape

This is where the work is, and it is unglamorous: parse, cast, trim, deduplicate, resolve keys, apply referential rules, and decide what to do with rows that fail. Silver is where a data engineer earns their salary and where most of the pipeline's test coverage should live.

CREATE OR REFRESH TABLE silver.sales.orders
  (CONSTRAINT valid_order_id  EXPECT (order_id IS NOT NULL) ON VIOLATION DROP ROW,
   CONSTRAINT valid_amount    EXPECT (amount_eur >= 0),
   CONSTRAINT known_customer  EXPECT (customer_key IS NOT NULL))
  COMMENT 'One row per order, latest version. Grain: order_id.'
AS
WITH typed AS (
  SELECT
    CAST(payload:orderId        AS STRING)    AS order_id,
    CAST(payload:customerNumber AS STRING)    AS customer_number,
    CAST(payload:total          AS DECIMAL(18,2)) AS amount_eur,
    CAST(payload:orderedAt      AS TIMESTAMP) AS ordered_at,
    _ingested_at,
    _source_file
  FROM STREAM(bronze.erp.orders_raw)
),
deduplicated AS (
  SELECT * EXCEPT (rn) FROM (
    SELECT *, ROW_NUMBER() OVER (
        PARTITION BY order_id ORDER BY _ingested_at DESC) AS rn
    FROM typed)
  WHERE rn = 1
)
SELECT d.*, c.customer_key
FROM deduplicated d
LEFT JOIN silver.sales.customers c
       ON c.customer_number = d.customer_number;

Note what is not here: no revenue definition, no "active customer" rule, no currency conversion to a reporting currency, no fiscal calendar. Those are interpretations, and interpretations belong in Gold where they can be argued about in one place.

Slowly changing dimensions live here too. Decide per entity whether Silver holds the current state (SCD type 1) or the full version history (SCD type 2); do not let it vary by whoever wrote the pipeline. In Lakeflow Declarative Pipelines this is a declaration rather than a merge you hand-write:

CREATE FLOW customers_scd AS AUTO CDC INTO silver.sales.customers
FROM STREAM(bronze.crm.customers_raw)
KEYS (customer_number)
APPLY AS DELETE WHEN operation = 'DELETE'
SEQUENCE BY _ingested_at
STORED AS SCD TYPE 2;

Gold — one table per question

Gold tables are read by people and by tools that people point at things. They should be wide, denormalised, named in business language, and documented. A star schema is a good default if the consumers are BI tools; a single flat aggregate is a better one if the consumer is a specific dashboard with a specific refresh budget.

The discipline that matters here is that a metric has exactly one definition and it lives in exactly one place. If "net revenue" appears in four Gold tables with four slightly different filters, the layering has bought you nothing. Either compute it once and join, or express it once in a metric view and let the tooling expand it.

Incremental processing

The naive implementation recomputes every layer from scratch on every run. That is genuinely the right choice while the data is small — it is simple, it is always correct, and it costs almost nothing. Switch when the recompute no longer fits the window, and switch one table at a time.

ApproachUse whenCost of getting it wrong
Full refreshTable recomputes in minutes; logic changes oftenNone — always consistent
MERGE INTO on a keyUpdates arrive for existing rowsDuplicates if the key is not actually unique
Append-only with watermarkFacts that never change once writtenSilent data loss when late rows arrive
Streaming table from STREAM(...)Bronze is append-only and the transform is row-wiseFull reload when the source gets a non-append change
Change Data Feed on the upstream tableDownstream needs the delta, not the stateCDF must be enabled before the changes you want
Materialized viewAggregate over a slowly changing inputFalls back to full recompute, quietly, when it cannot refresh incrementally
Incremental is not free

Every incremental table is a piece of state you now have to reason about: a checkpoint, a watermark, a merge key. A full refresh that takes four minutes and costs a euro is better engineering than an incremental pipeline that takes forty seconds and produces a wrong number twice a year. Measure before you optimise.

Data quality and quarantine

Expectations belong on Silver tables, where you first claim the data is usable. Three enforcement levels, and the choice between them is a business decision rather than a technical one:

Quarantine tables are the part people skip, and they are what turns "the pipeline is green" into something you can believe. A nightly check on quarantine row counts per source finds broken upstream changes days before a dashboard does.

Alongside expectations, keep a short list of assertions that hold across tables — row counts reconciled against the source, no orphaned foreign keys in Silver, every Gold metric within a plausible band of yesterday's. These catch the failures expectations cannot see, because they are about the shape of the whole dataset rather than any single row.

Orchestration

Layers imply dependencies, and dependencies want a scheduler rather than a stack of cron entries. The structure that holds up:

For pipelines you are writing from scratch, declarative pipelines (Lakeflow Declarative Pipelines, previously Delta Live Tables) are worth the constraint: you declare the tables and their inputs, and the dependency graph, incremental bookkeeping and quality metrics come with it. For pipelines that need imperative control, plain jobs with notebook or Python tasks remain the right tool. Mixing both is normal.

Governance with Unity Catalog

Layers are only a contract if the contract is enforceable, and that means the layer boundary has to be a permission boundary.

Performance and cost

Most lakehouse cost problems come from a small number of causes, and they are all avoidable:

Where it goes wrong

SymptomWhat actually happenedFix
Analysts query BronzeSilver was too slow to deliver, so people routed around itTreat it as a backlog signal, then revoke the grant
Gold has 200 tablesEvery dashboard got its own table instead of a shared oneConsolidate to a star schema; one fact table serves many dashboards
Silver contains revenue logicSomeone needed it in two places and pushed it downMove it to Gold, or make it an explicit shared intermediate
A fourth layer appears ("platinum")Gold was doing two jobs: conformed facts and presentationUsually right to split — but name it for what it does, not for a metal
Reprocessing is never actually doneNobody has tested it, so nobody trusts itRebuild one Silver table from Bronze every month, on purpose
Everything is one pipelineLayers were modelled as job stages, not as tables with contractsSplit per domain; the layer is a property of the table, not the schedule

When to use it — and when not to

Good fit

  • Analytics and reporting where hours-old data is fine
  • More than a handful of sources, or more than a handful of consumers
  • Business logic that is contested and therefore needs one agreed home
  • Source systems with short retention, where you need your own copy of history
  • Regulated reporting that must be reproducible and explainable
  • Machine learning that needs consistent features for training and inference
  • Teams who will still be maintaining this in three years

Poor fit

  • Decisions that must be made in seconds — see streaming analytics
  • One source, one dashboard, one person: the layers are pure overhead
  • Transactional workloads — point reads and row updates belong in a database
  • Sub-second lookups from an application
  • A genuinely exploratory, throwaway analysis
  • Organisations unwilling to agree on definitions; the pattern will not create that agreement, only expose its absence

The honest middle case: a two-layer version (Bronze and Gold, skipping Silver) is the right answer more often than the literature admits — specifically when there is one source domain and no cross-system conformance to do. Add Silver when you have a second source that needs to join to the first.

Implementation checklist

  1. Write down the layer contract table above, adapted to your organisation, and get the data team to agree to it.
  2. Pick the catalog and schema naming convention before the first table, including the environment prefix. Renaming catalogs later is painful.
  3. Land Bronze with Auto Loader or a managed connector, with provenance columns and a rescued-data column on every table.
  4. Decide Bronze retention per source and record it in the table comment.
  5. Build Silver with expectations and a quarantine table from the first table onwards, not the tenth.
  6. Decide SCD type per entity, centrally.
  7. Start with full refreshes. Move individual tables to incremental only when the runtime justifies it.
  8. Define each Gold metric once, and keep the definition next to the table.
  9. Make every task idempotent and ship a parameterised backfill job alongside the first pipeline.
  10. Alert on freshness and on quarantine volume, not only on task failure.
  11. Revoke Bronze access from everyone who does not maintain pipelines.
  12. Schedule a rebuild drill. An untested reprocessing path is not a reprocessing path.
Build it with a skill

This checklist exists as a skill — medallion-pipeline scaffolds the catalogs, the Auto Loader ingestion, the Silver expectations and quarantine, and the backfill job, and refuses to put business logic in the wrong layer.

← All reference architectures