01Architecture
This route decides three things that are painful to reverse once tables exist: what each layer is allowed to contain, what objects are called, and how environments are separated. None of them is technically difficult. All of them are expensive to change in month six, because by then there are dashboards pointed at the names.
The layer contract
Bronze, silver and gold are the least controversial diagram in data engineering and the most frequently misread. The contract below is what stops the diagram from being decoration.
#Medallion is not a data model
bronze.sap_vbap one row per order-item CHANGE EVENT (CDC)
keys: whatever SAP sent
history: everything, forever, append-only
silver.sales_order_line one row per ORDER LINE, current state
keys: order_id + line_number (business keys)
history: SCD1 — overwritten
gold.fct_order_line one row per ORDER LINE, analysis-ready
keys: order_line_sk (surrogate)
+ customer_sk, product_sk, date_sk, region_sk
history: none itself; it points at SCD2 dimensions
currency: converted, amount_eur materialisedbronze → silver → gold decided none of that.Doing it
- Write the grain sentence — 'one row per …' — before you create any table. If it takes more than one sentence, the grain is wrong.
- Put that sentence in the table COMMENT, deployed in DDL, not typed into the UI.
- Give every gold table surrogate keys; leave business keys visible alongside them.
- Conform dimensions in gold. Silver stays source-shaped and honest.
- Enforce the read direction: gold reads silver, silver reads bronze, nothing skips.
Defaults
- One sentence of grain per table, in the COMMENT, deployed as code.
- Surrogate keys are a gold concern. Silver keeps business keys.
- gold reads only silver; silver reads only bronze. No diagonal arrows.
- If a gold table is a renamed silver table, delete it and let BI read silver directly — an honest shortcut beats a dishonest layer.
Gotchas
- Gold tables that are silver with a prefix. You find them in seconds: no column ends in _sk. If the whole layer looks like that, the project has a medallion diagram and no data model.
- Skipping silver because 'bronze is clean enough'. It is, for exactly one source. When the webshop arrives there is nowhere to conform customer identity, and the fix is now a rewrite of everything downstream instead of a new silver table.
- Inventing a fourth layer — platinum, or an extra mart schema — is almost always a symptom that gold was never modelled, and the new layer is where the modelling is quietly being done instead.
- Letting a dashboard read silver 'just for now'. It becomes permanent, and it pins silver's shape forever: you can no longer refactor silver without breaking a report nobody told you about.
#The contract itself
| bronze | silver | gold | |
|---|---|---|---|
| May enter | raw source output, unmodified | bronze only | silver only |
| Grain | as delivered by the source | one row per business entity or event | declared per table; facts at the finest useful grain |
| Transformations allowed | none — add technical columns only | typing, cleansing, deduplication, conformance, historisation | surrogate keys, joins to dimensions, aggregation, currency conversion |
| History | everything, append-only, never deleted | SCD1 or SCD2 per table, declared | dimensions carry SCD2 intervals; facts are insert/upsert |
| Who reads it | engineers only | engineers; analysts by exception | everyone — BI, Genie, metric views |
| Forbidden | business logic of any kind | surrogate keys, aggregation | reading bronze; source-specific column names |
_tech_loaded_at TIMESTAMP -- when this row landed
_tech_source_file STRING -- which file or extract it came from
_tech_batch_id STRING -- which run wrote itDoing it
- Agree the table in one session, in week one, before there are tables to argue about.
- Put it in the repository next to the code, not in a slide deck.
- Add the three technical columns to bronze in the ingestion framework, so no table can forget them.
- Add a CI check that fails a gold model importing from bronze — the contract that is only prose gets violated within a month.
Defaults
- The contract lives in the repo and changes by pull request like anything else.
- Technical columns are added by the framework, never hand-written per table.
- Encode the read-direction rule as a CI check, not as a paragraph.
- Exceptions are allowed but must be written down with an expiry date.
Gotchas
- A contract with no enforcement is a wish. The read-direction rule in particular is violated within weeks unless CI checks it, because reading bronze directly is always the fastest way to close today's ticket.
- Making the contract too detailed. If it specifies naming for every column type it stops being read at all. One page, six rows.
- Writing 'analysts may not read silver' and then giving analysts SELECT on the whole catalog. The contract and the grants must say the same thing — see the governance routes.
Naming and identifiers
Names are the cheapest documentation available and the only kind that is always up to date. Two rules — a prefix that carries the contract, and an identifier standard — cover almost everything.
#Prefixes that carry the contract
| Prefix | Means | Example | Rule |
|---|---|---|---|
| dim_ | dimension | gold.dim_customer | has a _sk; may be SCD2 |
| fct_ | fact | gold.fct_order_line | declared grain; FKs to dimensions only |
| agg_ | aggregate | gold.agg_revenue_month | derived from a fct_; never a source |
| brg_ | bridge | gold.brg_product_group | resolves many-to-many; no measures |
| stg_ | staging | stg_customer_delta | transient; dropped after the run |
| mv_ | metric view | gold.mv_sales | semantic layer; read with MEASURE() |
| _tech_ | technical column | _tech_valid_from | never business meaning; never exposed to BI |
gold.agg_revenue_month knows immediately that it is derived, that it must not be a source for anything else, and that if the number is wrong the bug is upstream in fct_order_line. That is three facts obtained from a name, and it is why a report writer stops asking you where numbers come from._tech_ prefix earns its place separately: it is the single filter that separates plumbing from business meaning. Everything a BI tool should never see starts with it.Doing it
- Apply the prefix at creation. Renaming a table that a dashboard reads is a migration, not a tidy-up.
- Filter _tech_ columns out of the gold views exposed to BI, mechanically rather than by hand-listing columns.
- Use stg_ for anything transient and actually drop it — a staging table that survives the run is a table someone will start depending on.
Defaults
- Seven prefixes, no more. A scheme people can recite is a scheme people apply.
- The prefix is part of the contract — CI can check that anything named fct_ has a declared grain comment.
- Never prefix with the source system. gold.sap_customer means the conformance never happened.
Gotchas
mv_is a genuine collision on this platform: Databricks has materialized views AND Unity Catalog metric views, and they are different objects with different refresh semantics and different costs. Pick one meaning and write it down. This handbook uses mv_ for metric views and spells out 'materialized view' in full every time it means the other thing.- Leaving a fact table unprefixed 'because it's obvious'. Then the aggregate is indistinguishable from the fact in an autocomplete list, and someone builds a report on the aggregate thinking it is the detail.
- Prefixing by source (sap_, shop_) in gold. It reads as tidy and it means every downstream consumer has to know which system a customer came from — which is precisely the knowledge gold exists to remove.
#Identifier language and the ASCII rule
-- yes
CREATE TABLE gold.dim_customer (
customer_sk STRING COMMENT 'Surrogate key, sha2 of customer_id',
customer_segment STRING COMMENT 'Kundengruppe: A | B | C',
postal_code STRING COMMENT 'PLZ'
)
COMMENT 'One row per customer per validity interval (SCD2).';
-- no
CREATE TABLE gold.dim_kunde (
kunden_schlüssel STRING, -- umlaut in an identifier
Kundengruppe STRING, -- mixed case
PLZ STRING -- upper case
);kunden_schlüssel. That is not the same as it being safe.ö can be encoded as one code point (NFC, U+00F6) or as o plus a combining diaeresis (NFD, U+006F U+0308). They render identically in every editor and terminal. macOS filesystems historically hand back NFD; Windows and most Linux tooling produce NFC.column not found naming a column you can see in the table. Engineers lose a day to this, and they lose it while doubting their own eyes.Doing it
- Set the rule in week one and enforce it in CI with a regex over DDL — ^[a-z][a-z0-9_]*$ is the whole check.
- Put the local-language term in the COMMENT, where it helps the business reader and breaks nothing.
- Use the metric view's display metadata for anything a BI user sees, so the German label and the ASCII identifier can coexist.
- Keep SAP's own table names (KNA1, VBAK, VBAP) in bronze — those are the literal source names and renaming them destroys traceability.
Defaults
- ^[a-z][a-z0-9_]*$ for every identifier, checked in CI.
- Local-language business terms live in COMMENT and in metric-view labels.
- Source table names preserved verbatim in bronze; conformed English names from silver onward.
- No abbreviations that are not already universal in the domain — cust_seg costs more in confusion than customer_segment costs in typing.
Gotchas
- Unicode normalisation, as above: two identifiers that render identically, compare unequal, and produce a 'column not found' error naming a column you can plainly see.
- Reserved words — but check which ones, because the intuitive answer is wrong here. Databricks does not formally disallow any literal as an identifier, and the ANSI keyword list is explicitly informational and unenforced. What actually needs backticks is a short list of 17 words used as a table alias:
ANTI, CROSS, EXCEPT, FULL, INNER, INTERSECT, JOIN, LATERAL, LEFT, MASK, MINUS, NATURAL, ON, RIGHT, SEMI, UNION, USING. Alias a subqueryleftand the parser reads a join; the error names the join syntax, not your alias, so the fix is not where the message points. - Assuming ANSI reserved words are unusable. They are usable, which is a trap in the other direction:
orderis a legal Databricks table name. This handbook still calls the tablesales_order—FROM orderbesideORDER BYis genuinely hard to read, and engines you may federate to do reserve it — but that is a readability and portability argument, not a platform constraint. Anyone who tells a client ORDER is reserved on Databricks will be corrected live. - Mixed case identifiers. Delta is case-insensitive for resolution but case-PRESERVING in the metadata, so Kundengruppe and kundengruppe resolve to the same column while tools that compare schema strings report a difference. Schema-drift monitors then alarm on a change that did not happen.
- Renaming identifiers after BI is connected. The rename is one statement; the coordination with every report, every metric view and every downstream extract is a fortnight.
Environments and catalogs
Three environments, three catalogs, and one binding rule that makes 'could dev read production?' answerable with a yes or a no rather than with a discussion.
#Catalog per environment
dwh_dev / dwh_test / dwh_prod
|-- bronze raw, append-only, technical metadata added
|-- silver cleansed, conformed, historised
|-- gold star schema + aggregates + metric views
|-- fixtures seeded test data (dwh_test only)
'-- ci Volume for JUnit reports (dwh_test only)
per-CI-run isolation: dwh_test.run_<job_run_id>def run(spark, catalog: str) -> int:
"""Rebuild silver.customer from bronze. Returns rows written."""
source = f"{catalog}.bronze.sap_kna1"
target = f"{catalog}.silver.customer"
df = clean_customer(spark.read.table(source))
df.write.mode("overwrite").saveAsTable(target)
return df.count()dwh_test.run_<job_run_id> — so two pipelines running concurrently on two branches cannot see each other's tables. Without that, the second developer to push gets a failure caused by the first developer's data, and spends the afternoon debugging their own correct code.dwh.prod_gold, dwh.dev_gold. It looks equivalent and is not, for one reason: grants are given at catalog and schema level. In the single-catalog design, every grant is one careless wildcard away from spanning environments, and there is no structural boundary to stop it.dwh_prod is not merely ungranted but not reachable. 'Could someone in dev read production?' becomes a configuration you can show an auditor, rather than a review of every grant ever issued.Doing it
- Create three catalogs before any table exists. Retrofitting this means moving every table and re-pointing every report.
- Bind each workspace to the catalogs it may see; do not rely on grants alone.
- Pass the catalog in as a bundle variable per target — never a literal, never an environment sniff at runtime.
- Give each CI run its own schema and drop it at the end of the run, in a step that runs even when the tests failed.
Defaults
- Catalog per environment; layers as schemas; one metastore per region.
- Workspace-catalog binding on top of grants, not instead of them.
- Catalog as a deployment parameter, resolved at deploy time.
- Production objects owned by groups, never by individuals — covered in the Unity Catalog route.
- CI schemas are per-run and dropped in an always-runs step.
Gotchas
- Hardcoded catalog names. They work in every environment the author tested and fail in the one they did not — and the failure mode is not an error, it is a dev job quietly writing into dwh_prod because the string was right there in the notebook.
- Retrofitting catalog-per-environment after go-live. Every table moves, every grant is reissued, every report is re-pointed, and there is no partial rollout. Cost this at the start, when it is free.
- CI runs sharing one schema. Two branches collide, the failure looks like a data bug, and the developer debugs their own correct transformation for an afternoon before anyone suspects the harness.
- Assuming binding is inherited. Binding is configured per workspace; a new workspace created later starts unbound, which is the moment the boundary you demonstrated in month one silently stops being true.
Straight to the documentation
The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.
- Unity Catalog best practices — the platform-level guidance on metastores, catalogs and isolation
- What is the medallion architecture? — the canonical description — read it, then read the contract above for what it does not decide
- Workspace-catalog binding — how to make dev structurally unable to reach prod
- Databricks SQL reserved words — check before naming anything; ORDER is the one that catches warehouse teams