<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:taxo="http://purl.org/rss/1.0/modules/taxonomy/" version="2.0">
  <channel>
    <title>topic SAP Medallion Architecture Patterns: What Bronze, Silver and Gold Have to Do Differently in Community Articles</title>
    <link>https://community.databricks.com/t5/community-articles/sap-medallion-architecture-patterns-what-bronze-silver-and-gold/m-p/168596#M1560</link>
    <description>&lt;H2 id="answer-first"&gt;Answer first&lt;/H2&gt;&lt;P&gt;A medallion architecture over SAP data is the same three layers everyone draws, with three jobs that are specific to the source. Bronze has to preserve deletes. Silver has to rebuild a business document that lives in five tables. Gold has to translate an ERP dictionary into words a human reads. Skip any of the three and the layers are decoration.&lt;/P&gt;&lt;P&gt;If you have SAP tables landing in Unity Catalog and somebody has just asked you for a gold layer over them, this is written for you. It assumes you have built a medallion before and that this is your first one over an ERP, which is exactly the combination where the usual advice stops working.&lt;/P&gt;&lt;P&gt;I have built this pipeline over the same SAP SD tables on Databricks, on Snowflake, on Flink and with dbt. What follows is the five patterns that survived every one of them, written here as Lakeflow Declarative Pipelines code with the modern&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dp&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;API. They are not a framework and there is nothing to install: they are five places to put the SAP knowledge you are about to need.&lt;/P&gt;&lt;P&gt;That the same five held across four engines with four different primitive sets is the reason I think they are worth writing down. The parts that changed were the syntax. The parts that did not were the parts SAP forced.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;Environment.&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;SAP ECC and SAP S/4HANA, SD module, sales order tables landing into Unity Catalog as Delta tables. Databricks side: Lakeflow Declarative Pipelines, Python,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;from pyspark import pipelines as dp. Catalog and schema names below are illustrative.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;Disclosure.&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;I work at Onibex, an SAP and Confluent partner that builds SAP-to-lakehouse pipelines, so the examples reflect one implementation I have worked on. This post is about the engineering patterns, not a product, and it recommends no vendor. Code is illustrative and sanitized.&lt;/P&gt;&lt;H2 id="why-does-sap-break-the-tutorial-version-of-the-medallion"&gt;Why does SAP break the tutorial version of the medallion?&lt;/H2&gt;&lt;P&gt;Because every medallion tutorial starts from a source that already behaves. Clean column names, dates that are dates, one row per thing. SAP is a transactional ERP that spent decades optimizing for fast writes, and it owes your lakehouse nothing.&lt;/P&gt;&lt;P&gt;The first time I opened an SAP table in Databricks I thought I was looking at a corrupted dump. The columns were&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBELN,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;KWMENG,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VDATU. Dates arrived as text in character fields. Amounts arrived as&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;FLOAT. One row had a delivery date of&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;0000-00-00, which is not a date, it is SAP saying "I do not know yet".&lt;/P&gt;&lt;P&gt;And the shape is wrong too. A single sales order is not a row, it is a join:&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBAK&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;the header,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBAP&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;the line items,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBPA&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;the partners,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBFA&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;the document flow,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBKD&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;the business data. SAP's own descriptions for those five are Sales Document Header Data, Item Data, Partner, Document Flow and Business Data.&lt;/P&gt;&lt;P&gt;So the medallion still applies. What changes is what each layer is responsible for.&lt;BR /&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-inline" image-alt="01_layers.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31098i91D5D1B78079CD8B/image-size/large?v=v2&amp;amp;px=999" role="button" title="01_layers.png" alt="01_layers.png" /&gt;&lt;/span&gt;&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="what-does-bronze-actually-have-to-guarantee"&gt;What does bronze actually have to guarantee?&lt;/H2&gt;&lt;P&gt;One thing, and it is not cleanliness. Bronze has to be source faithful&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;&lt;STRONG&gt;and it has to carry deletes&lt;/STRONG&gt;, because in an ERP a deleted sales order is information, not an absence.&lt;/P&gt;&lt;P&gt;That means the Change Data Feed has to be on before anything downstream can work:&lt;/P&gt;&lt;DIV class=""&gt;&lt;PRE&gt;&lt;SPAN&gt;TBLPROPERTIES (&lt;/SPAN&gt;
&lt;SPAN&gt;  &lt;SPAN class=""&gt;'delta.enableChangeDataFeed'&lt;/SPAN&gt; &lt;SPAN class=""&gt;=&lt;/SPAN&gt; &lt;SPAN class=""&gt;'true'&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;  &lt;SPAN class=""&gt;'delta.enableDeletionVectors'&lt;/SPAN&gt; &lt;SPAN class=""&gt;=&lt;/SPAN&gt; &lt;SPAN class=""&gt;'true'&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;  &lt;SPAN class=""&gt;'delta.enableRowTracking'&lt;/SPAN&gt;     &lt;SPAN class=""&gt;=&lt;/SPAN&gt; &lt;SPAN class=""&gt;'true'&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;)&lt;/SPAN&gt;&lt;/PRE&gt;&lt;/DIV&gt;&lt;P&gt;enableChangeDataFeed&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;is the load bearing one. It makes Delta Lake record every insert, update and above all every delete into a feed the silver can read. Whatever lands your bronze, batch extract or streaming connector, check this property first. If it is off, everything below is a pattern you cannot implement, and you will not find out until the first deletion in production.&lt;/P&gt;&lt;P&gt;One thing bronze should not do is enforce a strict schema on an ERP source. Schema enforcement belongs further up, once you have decided what each field is for, and the last pattern in this post explains why.&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="what-does-the-silver-have-to-do-that-ordinary-cdc-does-not"&gt;What does the silver have to do that ordinary CDC does not?&lt;/H2&gt;&lt;P&gt;Keep a current-state snapshot per table, keyed on SAP's real business keys, with deletes applied. An ordinary pipeline would do an upsert and stop there. This one has to do an upsert that can also remove a row, which is the part a plain merge into a target table quietly skips.&lt;/P&gt;&lt;P&gt;The pattern is one temporary view over the change feed, one streaming table, one auto CDC flow. Here it is for&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBAK, the order header:&lt;/P&gt;&lt;DIV class=""&gt;&lt;PRE&gt;&lt;SPAN&gt;&lt;SPAN class=""&gt;from&lt;/SPAN&gt; pyspark &lt;SPAN class=""&gt;import&lt;/SPAN&gt; pipelines &lt;SPAN class=""&gt;as&lt;/SPAN&gt; dp&lt;/SPAN&gt;
&lt;SPAN&gt;&lt;SPAN class=""&gt;from&lt;/SPAN&gt; pyspark.sql &lt;SPAN class=""&gt;import&lt;/SPAN&gt; functions &lt;SPAN class=""&gt;as&lt;/SPAN&gt; F&lt;/SPAN&gt;

&lt;SPAN&gt;&lt;SPAN class=""&gt;@dp.temporary_view&lt;/SPAN&gt;(name&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"vbak_cdf"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;&lt;SPAN class=""&gt;def&lt;/SPAN&gt; vbak_cdf():&lt;/SPAN&gt;
&lt;SPAN&gt;    &lt;SPAN class=""&gt;return&lt;/SPAN&gt; (&lt;/SPAN&gt;
&lt;SPAN&gt;        spark.readStream.&lt;SPAN class=""&gt;format&lt;/SPAN&gt;(&lt;SPAN class=""&gt;"delta"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .option(&lt;SPAN class=""&gt;"readChangeFeed"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"true"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .option(&lt;SPAN class=""&gt;"startingVersion"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"0"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .table(&lt;SPAN class=""&gt;"sap.bronze.vbak"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .select(&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"string"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"string"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"kunnr"&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"string"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"kunnr"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"vdatu"&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"string"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vdatu"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"gbstk"&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"string"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"gbstk"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            &lt;SPAN class=""&gt;# the rest of the columns, all cast to string, see the last pattern&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"event_ts"&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"timestamp"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"event_ts"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"_change_type"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"_commit_version"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"_commit_timestamp"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;        )&lt;/SPAN&gt;
&lt;SPAN&gt;    )&lt;/SPAN&gt;

&lt;SPAN&gt;dp.create_streaming_table(&lt;/SPAN&gt;
&lt;SPAN&gt;    name&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"vbak_cur"&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;    comment&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"Type 1 snapshot of VBAK, SAP sales order header"&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;)&lt;/SPAN&gt;

&lt;SPAN&gt;dp.create_auto_cdc_flow(&lt;/SPAN&gt;
&lt;SPAN&gt;    target&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"vbak_cur"&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;    source&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"vbak_cdf"&lt;/SPAN&gt;,                             &lt;SPAN class=""&gt;# a name, never a DataFrame&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;    keys&lt;SPAN class=""&gt;=&lt;/SPAN&gt;[&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;],                       &lt;SPAN class=""&gt;# SAP's real primary key, client included&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;    sequence_by&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"_commit_timestamp"&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;    apply_as_deletes&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"_change_type = 'delete'"&lt;/SPAN&gt;,    &lt;SPAN class=""&gt;# the delete propagates&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;    except_column_list&lt;SPAN class=""&gt;=&lt;/SPAN&gt;[&lt;SPAN class=""&gt;"_change_type"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"_commit_version"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"_commit_timestamp"&lt;/SPAN&gt;],&lt;/SPAN&gt;
&lt;SPAN&gt;    ignore_null_updates&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;False&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;)&lt;/SPAN&gt;&lt;/PRE&gt;&lt;/DIV&gt;&lt;P&gt;Three things in that block are worth stealing.&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;mandt&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;belongs in the key, because the client is part of SAP's identity and leaving it out works right up until somebody opens a second client. The source of an auto CDC flow is a string identifier, so the change feed is wrapped in a temporary view rather than passed as a DataFrame. And&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;apply_as_deletes&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;is the whole reason bronze needed the change feed: without it, an order deleted in SAP lives forever in your dashboards.&lt;/P&gt;&lt;P&gt;The other four tables follow the same shape with their own keys. On&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBPA&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;I also filter&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;parvw = 'WE'&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;to keep only the ship-to partner, because that table packs every partner role into the same rows.&lt;/P&gt;&lt;P&gt;If you are porting older code:&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dp.create_auto_cdc_flow&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;is what&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dlt.apply_changes&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;became, it is called at top level, and it does not return a value.&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="how-do-you-rebuild-a-document-that-lives-in-five-tables"&gt;How do you rebuild a document that lives in five tables?&lt;/H2&gt;&lt;P&gt;Join the snapshots into one consolidated row, and decide two things most examples skip: what the columns are called, and what "fresh" means for a row assembled from five sources.&lt;/P&gt;&lt;DIV class=""&gt;&lt;PRE&gt;&lt;SPAN&gt;&lt;SPAN class=""&gt;@dp.materialized_view&lt;/SPAN&gt;(&lt;/SPAN&gt;
&lt;SPAN&gt;    name&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"silver_sd_sales_order"&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;    comment&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"Consolidated SAP SD sales order: VBAK x VBAP + VBPA(WE) + VBFA + VBKD"&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;&lt;SPAN class=""&gt;def&lt;/SPAN&gt; silver_sd_sales_order():&lt;/SPAN&gt;
&lt;SPAN&gt;    vbak &lt;SPAN class=""&gt;=&lt;/SPAN&gt; spark.read.table(&lt;SPAN class=""&gt;"vbak_cur"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbak"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;    vbap &lt;SPAN class=""&gt;=&lt;/SPAN&gt; spark.read.table(&lt;SPAN class=""&gt;"vbap_cur"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbap"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;    vbpa &lt;SPAN class=""&gt;=&lt;/SPAN&gt; spark.read.table(&lt;SPAN class=""&gt;"vbpa_we_cur"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbpa"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;    vbfa &lt;SPAN class=""&gt;=&lt;/SPAN&gt; spark.read.table(&lt;SPAN class=""&gt;"vbfa_cur"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbfa"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;    vbkd &lt;SPAN class=""&gt;=&lt;/SPAN&gt; spark.read.table(&lt;SPAN class=""&gt;"vbkd_cur"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbkd"&lt;/SPAN&gt;)&lt;/SPAN&gt;

&lt;SPAN&gt;    &lt;SPAN class=""&gt;return&lt;/SPAN&gt; (&lt;/SPAN&gt;
&lt;SPAN&gt;        vbak&lt;/SPAN&gt;
&lt;SPAN&gt;        .join(vbap, [&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;], &lt;SPAN class=""&gt;"inner"&lt;/SPAN&gt;)   &lt;SPAN class=""&gt;# no header without items&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;        .join(vbpa, [&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"posnr"&lt;/SPAN&gt;], &lt;SPAN class=""&gt;"left"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .join(vbfa, [&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"posnr"&lt;/SPAN&gt;], &lt;SPAN class=""&gt;"left"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .join(vbkd, [&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"posnr"&lt;/SPAN&gt;], &lt;SPAN class=""&gt;"left"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .select(&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"vbak.vbeln"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbeln_vbak"&lt;/SPAN&gt;),   &lt;SPAN class=""&gt;# &amp;lt;col&amp;gt;_&amp;lt;table&amp;gt;, on purpose&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"vbap.posnr"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"posnr_vbap"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"vbap.matnr"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"matnr_vbap"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"vbak.kunnr"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"kunnr_vbak"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            &lt;SPAN class=""&gt;# the freshness of the row is the freshest of its sources&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;            F.greatest(&lt;/SPAN&gt;
&lt;SPAN&gt;                F.col(&lt;SPAN class=""&gt;"vbak.event_ts"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;                F.coalesce(F.col(&lt;SPAN class=""&gt;"vbap.event_ts"&lt;/SPAN&gt;), F.col(&lt;SPAN class=""&gt;"vbak.event_ts"&lt;/SPAN&gt;)),&lt;/SPAN&gt;
&lt;SPAN&gt;                F.coalesce(F.col(&lt;SPAN class=""&gt;"vbpa.event_ts"&lt;/SPAN&gt;), F.col(&lt;SPAN class=""&gt;"vbak.event_ts"&lt;/SPAN&gt;)),&lt;/SPAN&gt;
&lt;SPAN&gt;                F.coalesce(F.col(&lt;SPAN class=""&gt;"vbfa.event_ts"&lt;/SPAN&gt;), F.col(&lt;SPAN class=""&gt;"vbak.event_ts"&lt;/SPAN&gt;)),&lt;/SPAN&gt;
&lt;SPAN&gt;                F.coalesce(F.col(&lt;SPAN class=""&gt;"vbkd.event_ts"&lt;/SPAN&gt;), F.col(&lt;SPAN class=""&gt;"vbak.event_ts"&lt;/SPAN&gt;)),&lt;/SPAN&gt;
&lt;SPAN&gt;            ).alias(&lt;SPAN class=""&gt;"event_ts"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;        )&lt;/SPAN&gt;
&lt;SPAN&gt;    )&lt;/SPAN&gt;&lt;/PRE&gt;&lt;/DIV&gt;&lt;P&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-inline" image-alt="02_document_rebuild.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31099iBF08238BB957F2CA/image-size/large?v=v2&amp;amp;px=999" role="button" title="02_document_rebuild.png" alt="02_document_rebuild.png" /&gt;&lt;/span&gt;&lt;/P&gt;&lt;P&gt;Inner join on items, left join on everything else, so an order with no partner or no flow does not vanish. The&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;&amp;lt;col&amp;gt;_&amp;lt;table&amp;gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;convention is verbose and it pays for itself the first time two columns called&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;netwr&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;disagree and you need to know which one came from the header. The silver stays faithful to the source, the makeup happens in gold.&lt;/P&gt;&lt;P&gt;The&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;greatest&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;over coalesced timestamps answers a question a single-table pipeline never has to ask. If a row is five rows, its freshness is the most recent of them.&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="what-is-the-gold-layer-actually-adding"&gt;What is the gold layer actually adding?&lt;/H2&gt;&lt;P&gt;Meaning, not data. Three operations, and two of them are SAP specific.&lt;/P&gt;&lt;DIV class=""&gt;&lt;PRE&gt;&lt;SPAN&gt;&lt;SPAN class=""&gt;@dp.materialized_view&lt;/SPAN&gt;(name&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"gold_sd_open_order_tracker"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;&lt;SPAN class=""&gt;def&lt;/SPAN&gt; gold_sd_open_order_tracker():&lt;/SPAN&gt;
&lt;SPAN&gt;    sso &lt;SPAN class=""&gt;=&lt;/SPAN&gt; spark.read.table(&lt;SPAN class=""&gt;"sap.silver.silver_sd_sales_order"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"sso"&lt;/SPAN&gt;)&lt;/SPAN&gt;

&lt;SPAN&gt;    &lt;SPAN class=""&gt;# SAP text tables are per language. Without the filter you multiply rows.&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;    makt &lt;SPAN class=""&gt;=&lt;/SPAN&gt; (spark.read.table(&lt;SPAN class=""&gt;"sap.bronze.makt"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;            .where(F.col(&lt;SPAN class=""&gt;"spras"&lt;/SPAN&gt;) &lt;SPAN class=""&gt;==&lt;/SPAN&gt; &lt;SPAN class=""&gt;"E"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;            .select(F.col(&lt;SPAN class=""&gt;"matnr"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"matnr_makt"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;                    F.col(&lt;SPAN class=""&gt;"maktg"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"material_text"&lt;/SPAN&gt;)).alias(&lt;SPAN class=""&gt;"makt"&lt;/SPAN&gt;))&lt;/SPAN&gt;

&lt;SPAN&gt;    &lt;SPAN class=""&gt;# "0000-00-00" is not a date, it is "not yet". It becomes NULL here or it breaks later.&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;    delivery_date &lt;SPAN class=""&gt;=&lt;/SPAN&gt; F.when(&lt;/SPAN&gt;
&lt;SPAN&gt;        F.col(&lt;SPAN class=""&gt;"sso.vdatu_vbak"&lt;/SPAN&gt;).isNull() &lt;SPAN class=""&gt;|&lt;/SPAN&gt; (F.col(&lt;SPAN class=""&gt;"sso.vdatu_vbak"&lt;/SPAN&gt;) &lt;SPAN class=""&gt;==&lt;/SPAN&gt; &lt;SPAN class=""&gt;"0000-00-00"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;        F.lit(&lt;SPAN class=""&gt;None&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"timestamp"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;    ).otherwise(F.to_timestamp(F.col(&lt;SPAN class=""&gt;"sso.vdatu_vbak"&lt;/SPAN&gt;), &lt;SPAN class=""&gt;"yyyy-MM-dd"&lt;/SPAN&gt;))&lt;/SPAN&gt;

&lt;SPAN&gt;    &lt;SPAN class=""&gt;return&lt;/SPAN&gt; (&lt;/SPAN&gt;
&lt;SPAN&gt;        sso.join(makt, F.col(&lt;SPAN class=""&gt;"makt.matnr_makt"&lt;/SPAN&gt;) &lt;SPAN class=""&gt;==&lt;/SPAN&gt; F.col(&lt;SPAN class=""&gt;"sso.matnr_vbap"&lt;/SPAN&gt;), &lt;SPAN class=""&gt;"left"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .select(&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"sso.vbeln_vbak"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"sales_order"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"sso.posnr_vbap"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"item"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"makt.material_text"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"material"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            delivery_date.alias(&lt;SPAN class=""&gt;"delivery_date_ts"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.when(F.col(&lt;SPAN class=""&gt;"sso.gbstk_vbak"&lt;/SPAN&gt;) &lt;SPAN class=""&gt;==&lt;/SPAN&gt; &lt;SPAN class=""&gt;"C"&lt;/SPAN&gt;, F.lit(&lt;SPAN class=""&gt;"CLOSE"&lt;/SPAN&gt;))&lt;/SPAN&gt;
&lt;SPAN&gt;             .otherwise(F.lit(&lt;SPAN class=""&gt;"OPEN"&lt;/SPAN&gt;)).alias(&lt;SPAN class=""&gt;"order_status"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;        )&lt;/SPAN&gt;
&lt;SPAN&gt;    )&lt;/SPAN&gt;&lt;/PRE&gt;&lt;/DIV&gt;&lt;P&gt;Look at the contrast between input and output. In:&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;vbeln,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;matnr,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;gbstk,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;0000-00-00. Out:&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;sales_order,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;material,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;order_status, a real timestamp or a NULL. Same information, and now a dashboard or a person can read it without an SAP dictionary open in the next tab.&lt;/P&gt;&lt;P&gt;The language filter is the one that silently ruins numbers. Text tables carry a&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;spras&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;column, so an unfiltered join multiplies every row by the number of configured languages, and your line count looks fine until somebody sums an amount.&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="who-owns-a-shared-silver-table"&gt;Who owns a shared silver table?&lt;/H2&gt;&lt;P&gt;Exactly one pipeline, and this is the decision I see skipped most often. A pipeline owns the state of the tables it declares: the checkpoints, the versioning, the lifecycle of the Delta table. Two pipelines declaring the same&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;silver_sd_sales_order&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;is a design you do not want, because each one believes it manages that table.&lt;BR /&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-inline" image-alt="03_pipeline_ownership.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31100iA31943B9A03B10EF/image-size/large?v=v2&amp;amp;px=999" role="button" title="03_pipeline_ownership.png" alt="03_pipeline_ownership.png" /&gt;&lt;/span&gt;&lt;/P&gt;&lt;P&gt;For a single domain, one pipeline is the simpler answer and the current default. The split becomes worth it when N business domains need the same consolidated sales order. Then the shape is one shared silver pipeline that owns the snapshots and the consolidation, plus one gold pipeline per domain that reads the silver as an external table and owns only its own outputs.&lt;/P&gt;&lt;P&gt;You build the truth once, each domain iterates without breaking what is shared, and the CDC logic exists in one place.&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="which-primitive-belongs-in-which-layer"&gt;Which primitive belongs in which layer?&lt;/H2&gt;&lt;P&gt;Layer Primitive Why this one&lt;/P&gt;&lt;TABLE&gt;&lt;TBODY&gt;&lt;TR&gt;&lt;TD&gt;Bronze&lt;/TD&gt;&lt;TD&gt;Delta table with Change Data Feed on&lt;/TD&gt;&lt;TD&gt;The layer has one job, preserve what happened, deletes included&lt;/TD&gt;&lt;/TR&gt;&lt;TR&gt;&lt;TD&gt;Silver, per table&lt;/TD&gt;&lt;TD&gt;dp.create_streaming_table&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;plus&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dp.create_auto_cdc_flow&lt;/TD&gt;&lt;TD&gt;Incremental, stateful, delete aware, keyed on SAP business keys&lt;/TD&gt;&lt;/TR&gt;&lt;TR&gt;&lt;TD&gt;Silver, consolidated&lt;/TD&gt;&lt;TD&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/25059"&gt;@DP&lt;/a&gt;.materialized_view&lt;/TD&gt;&lt;TD&gt;It is a join over snapshots, recomputed, not a stream&lt;/TD&gt;&lt;/TR&gt;&lt;TR&gt;&lt;TD&gt;Gold&lt;/TD&gt;&lt;TD&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/25059"&gt;@DP&lt;/a&gt;.materialized_view&lt;/TD&gt;&lt;TD&gt;Enrichment and renaming, read by dashboards, recomputed on refresh&lt;/TD&gt;&lt;/TR&gt;&lt;TR&gt;&lt;TD&gt;Across pipelines&lt;/TD&gt;&lt;TD&gt;spark.read.table&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;on a fully qualified name&lt;/TD&gt;&lt;TD&gt;Reading something another pipeline owns&lt;/TD&gt;&lt;/TR&gt;&lt;/TBODY&gt;&lt;/TABLE&gt;&lt;HR /&gt;&lt;H2 id="what-do-these-patterns-not-solve"&gt;What do these patterns not solve?&lt;/H2&gt;&lt;P&gt;They do not make the data correct. They make it traceable and delete aware, which is a different and more honest claim. If&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;parvw&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;was configured differently in your system, a correct pipeline gives you a confidently wrong ship-to.&lt;/P&gt;&lt;P&gt;They do not remove SAP domain knowledge. Knowing that&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;WE&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;is ship-to, that&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;spras&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;has to be filtered and that&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;0000-00-00&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;means "not yet" is not something a framework supplies. That knowledge is the actual work, and every pattern above is just a place to put it.&lt;/P&gt;&lt;P&gt;And they do not cover schema drift by themselves. Normalizing everything to string in silver and casting in gold is my answer to that, and it is a trade: you get stability when SAP changes a type or a length, you pay by deferring every cast and all real schema enforcement to the layer above. Data quality expectations,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/25059"&gt;@DP&lt;/a&gt;.expect_or_drop&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;and its siblings, are where I put the checks that used to live in strict types, because a dropped row with a metric attached is easier to explain on a Monday than a pipeline that stopped.&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="faq"&gt;FAQ&lt;/H2&gt;&lt;P&gt;&lt;STRONG&gt;Do I need one pipeline or several for an SAP medallion?&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;Start with one. Split into a shared silver pipeline plus one gold pipeline per domain when a second business domain needs the same consolidated document, because a table should have a single owning pipeline and duplicating the CDC logic per domain is worse than the extra pipeline.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;Why cast everything to string in the silver?&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;Schema stability. SAP occasionally changes a type or a length, and a silver full of strict types turns that into a broken pipeline. Casting in gold means you decide the type where you already know what the field is for.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;How do I handle deletes from SAP?&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;Enable&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;delta.enableChangeDataFeed&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;on the bronze, read it with&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;readChangeFeed, and pass&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;apply_as_deletes="_change_type = 'delete'"&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;to&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dp.create_auto_cdc_flow. Without the change feed there is nothing to apply.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;What do I do with&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;0000-00-00&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dates?&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;Intercept them before parsing and turn them into NULL. SAP uses that value for "not known yet" in character date fields, and parsing it directly either fails or produces a date nobody meant.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;Why is&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;mandt&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;in the primary key?&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;Because the client is part of identity in SAP. A pipeline keyed only on the document number works until a second client exists, and then it silently merges two different orders.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;Is this specific to Lakeflow Declarative Pipelines?&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;The primitives are. The five patterns are not: I have built the same shape on Snowflake, on Flink and with dbt, and bronze preserving deletes, silver rebuilding the document and gold carrying the vocabulary stayed the same on all of them. What changed each time was the syntax for expressing them.&lt;/P&gt;&lt;HR /&gt;&lt;P&gt;&lt;EM&gt;Notes on the API: this post uses the modern&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dp&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;API for Lakeflow Declarative Pipelines, which is what Delta Live Tables was renamed to. If you are reading older material,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;import dlt&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;maps to&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;from pyspark import pipelines as dp,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/97035"&gt;@Dlt&lt;/a&gt;.view&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;to&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/25059"&gt;@DP&lt;/a&gt;.temporary_view,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dlt.apply_changes&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;to&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dp.create_auto_cdc_flow, and&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dlt.read&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;to&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;spark.read.table. The legacy names still parse.&lt;/EM&gt;&lt;/P&gt;&lt;P&gt;&lt;EM&gt;SAP, SAP ECC and SAP S/4HANA are trademarks or registered trademarks of SAP SE in Germany and other countries. Apache and Apache Spark are trademarks of The Apache Software Foundation. Databricks, Delta Lake, Lakeflow and Unity Catalog are trademarks of Databricks, Inc.&lt;/EM&gt;&lt;/P&gt;</description>
    <pubDate>Mon, 14 Sep 2026 22:45:35 GMT</pubDate>
    <dc:creator>AFDev</dc:creator>
    <dc:date>2026-09-14T22:45:35Z</dc:date>
    <item>
      <title>SAP Medallion Architecture Patterns: What Bronze, Silver and Gold Have to Do Differently</title>
      <link>https://community.databricks.com/t5/community-articles/sap-medallion-architecture-patterns-what-bronze-silver-and-gold/m-p/168596#M1560</link>
      <description>&lt;H2 id="answer-first"&gt;Answer first&lt;/H2&gt;&lt;P&gt;A medallion architecture over SAP data is the same three layers everyone draws, with three jobs that are specific to the source. Bronze has to preserve deletes. Silver has to rebuild a business document that lives in five tables. Gold has to translate an ERP dictionary into words a human reads. Skip any of the three and the layers are decoration.&lt;/P&gt;&lt;P&gt;If you have SAP tables landing in Unity Catalog and somebody has just asked you for a gold layer over them, this is written for you. It assumes you have built a medallion before and that this is your first one over an ERP, which is exactly the combination where the usual advice stops working.&lt;/P&gt;&lt;P&gt;I have built this pipeline over the same SAP SD tables on Databricks, on Snowflake, on Flink and with dbt. What follows is the five patterns that survived every one of them, written here as Lakeflow Declarative Pipelines code with the modern&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dp&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;API. They are not a framework and there is nothing to install: they are five places to put the SAP knowledge you are about to need.&lt;/P&gt;&lt;P&gt;That the same five held across four engines with four different primitive sets is the reason I think they are worth writing down. The parts that changed were the syntax. The parts that did not were the parts SAP forced.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;Environment.&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;SAP ECC and SAP S/4HANA, SD module, sales order tables landing into Unity Catalog as Delta tables. Databricks side: Lakeflow Declarative Pipelines, Python,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;from pyspark import pipelines as dp. Catalog and schema names below are illustrative.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;Disclosure.&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;I work at Onibex, an SAP and Confluent partner that builds SAP-to-lakehouse pipelines, so the examples reflect one implementation I have worked on. This post is about the engineering patterns, not a product, and it recommends no vendor. Code is illustrative and sanitized.&lt;/P&gt;&lt;H2 id="why-does-sap-break-the-tutorial-version-of-the-medallion"&gt;Why does SAP break the tutorial version of the medallion?&lt;/H2&gt;&lt;P&gt;Because every medallion tutorial starts from a source that already behaves. Clean column names, dates that are dates, one row per thing. SAP is a transactional ERP that spent decades optimizing for fast writes, and it owes your lakehouse nothing.&lt;/P&gt;&lt;P&gt;The first time I opened an SAP table in Databricks I thought I was looking at a corrupted dump. The columns were&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBELN,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;KWMENG,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VDATU. Dates arrived as text in character fields. Amounts arrived as&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;FLOAT. One row had a delivery date of&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;0000-00-00, which is not a date, it is SAP saying "I do not know yet".&lt;/P&gt;&lt;P&gt;And the shape is wrong too. A single sales order is not a row, it is a join:&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBAK&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;the header,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBAP&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;the line items,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBPA&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;the partners,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBFA&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;the document flow,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBKD&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;the business data. SAP's own descriptions for those five are Sales Document Header Data, Item Data, Partner, Document Flow and Business Data.&lt;/P&gt;&lt;P&gt;So the medallion still applies. What changes is what each layer is responsible for.&lt;BR /&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-inline" image-alt="01_layers.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31098i91D5D1B78079CD8B/image-size/large?v=v2&amp;amp;px=999" role="button" title="01_layers.png" alt="01_layers.png" /&gt;&lt;/span&gt;&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="what-does-bronze-actually-have-to-guarantee"&gt;What does bronze actually have to guarantee?&lt;/H2&gt;&lt;P&gt;One thing, and it is not cleanliness. Bronze has to be source faithful&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;&lt;STRONG&gt;and it has to carry deletes&lt;/STRONG&gt;, because in an ERP a deleted sales order is information, not an absence.&lt;/P&gt;&lt;P&gt;That means the Change Data Feed has to be on before anything downstream can work:&lt;/P&gt;&lt;DIV class=""&gt;&lt;PRE&gt;&lt;SPAN&gt;TBLPROPERTIES (&lt;/SPAN&gt;
&lt;SPAN&gt;  &lt;SPAN class=""&gt;'delta.enableChangeDataFeed'&lt;/SPAN&gt; &lt;SPAN class=""&gt;=&lt;/SPAN&gt; &lt;SPAN class=""&gt;'true'&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;  &lt;SPAN class=""&gt;'delta.enableDeletionVectors'&lt;/SPAN&gt; &lt;SPAN class=""&gt;=&lt;/SPAN&gt; &lt;SPAN class=""&gt;'true'&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;  &lt;SPAN class=""&gt;'delta.enableRowTracking'&lt;/SPAN&gt;     &lt;SPAN class=""&gt;=&lt;/SPAN&gt; &lt;SPAN class=""&gt;'true'&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;)&lt;/SPAN&gt;&lt;/PRE&gt;&lt;/DIV&gt;&lt;P&gt;enableChangeDataFeed&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;is the load bearing one. It makes Delta Lake record every insert, update and above all every delete into a feed the silver can read. Whatever lands your bronze, batch extract or streaming connector, check this property first. If it is off, everything below is a pattern you cannot implement, and you will not find out until the first deletion in production.&lt;/P&gt;&lt;P&gt;One thing bronze should not do is enforce a strict schema on an ERP source. Schema enforcement belongs further up, once you have decided what each field is for, and the last pattern in this post explains why.&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="what-does-the-silver-have-to-do-that-ordinary-cdc-does-not"&gt;What does the silver have to do that ordinary CDC does not?&lt;/H2&gt;&lt;P&gt;Keep a current-state snapshot per table, keyed on SAP's real business keys, with deletes applied. An ordinary pipeline would do an upsert and stop there. This one has to do an upsert that can also remove a row, which is the part a plain merge into a target table quietly skips.&lt;/P&gt;&lt;P&gt;The pattern is one temporary view over the change feed, one streaming table, one auto CDC flow. Here it is for&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBAK, the order header:&lt;/P&gt;&lt;DIV class=""&gt;&lt;PRE&gt;&lt;SPAN&gt;&lt;SPAN class=""&gt;from&lt;/SPAN&gt; pyspark &lt;SPAN class=""&gt;import&lt;/SPAN&gt; pipelines &lt;SPAN class=""&gt;as&lt;/SPAN&gt; dp&lt;/SPAN&gt;
&lt;SPAN&gt;&lt;SPAN class=""&gt;from&lt;/SPAN&gt; pyspark.sql &lt;SPAN class=""&gt;import&lt;/SPAN&gt; functions &lt;SPAN class=""&gt;as&lt;/SPAN&gt; F&lt;/SPAN&gt;

&lt;SPAN&gt;&lt;SPAN class=""&gt;@dp.temporary_view&lt;/SPAN&gt;(name&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"vbak_cdf"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;&lt;SPAN class=""&gt;def&lt;/SPAN&gt; vbak_cdf():&lt;/SPAN&gt;
&lt;SPAN&gt;    &lt;SPAN class=""&gt;return&lt;/SPAN&gt; (&lt;/SPAN&gt;
&lt;SPAN&gt;        spark.readStream.&lt;SPAN class=""&gt;format&lt;/SPAN&gt;(&lt;SPAN class=""&gt;"delta"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .option(&lt;SPAN class=""&gt;"readChangeFeed"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"true"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .option(&lt;SPAN class=""&gt;"startingVersion"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"0"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .table(&lt;SPAN class=""&gt;"sap.bronze.vbak"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .select(&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"string"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"string"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"kunnr"&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"string"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"kunnr"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"vdatu"&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"string"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vdatu"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"gbstk"&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"string"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"gbstk"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            &lt;SPAN class=""&gt;# the rest of the columns, all cast to string, see the last pattern&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"event_ts"&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"timestamp"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"event_ts"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"_change_type"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"_commit_version"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"_commit_timestamp"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;        )&lt;/SPAN&gt;
&lt;SPAN&gt;    )&lt;/SPAN&gt;

&lt;SPAN&gt;dp.create_streaming_table(&lt;/SPAN&gt;
&lt;SPAN&gt;    name&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"vbak_cur"&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;    comment&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"Type 1 snapshot of VBAK, SAP sales order header"&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;)&lt;/SPAN&gt;

&lt;SPAN&gt;dp.create_auto_cdc_flow(&lt;/SPAN&gt;
&lt;SPAN&gt;    target&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"vbak_cur"&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;    source&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"vbak_cdf"&lt;/SPAN&gt;,                             &lt;SPAN class=""&gt;# a name, never a DataFrame&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;    keys&lt;SPAN class=""&gt;=&lt;/SPAN&gt;[&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;],                       &lt;SPAN class=""&gt;# SAP's real primary key, client included&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;    sequence_by&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"_commit_timestamp"&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;    apply_as_deletes&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"_change_type = 'delete'"&lt;/SPAN&gt;,    &lt;SPAN class=""&gt;# the delete propagates&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;    except_column_list&lt;SPAN class=""&gt;=&lt;/SPAN&gt;[&lt;SPAN class=""&gt;"_change_type"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"_commit_version"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"_commit_timestamp"&lt;/SPAN&gt;],&lt;/SPAN&gt;
&lt;SPAN&gt;    ignore_null_updates&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;False&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;)&lt;/SPAN&gt;&lt;/PRE&gt;&lt;/DIV&gt;&lt;P&gt;Three things in that block are worth stealing.&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;mandt&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;belongs in the key, because the client is part of SAP's identity and leaving it out works right up until somebody opens a second client. The source of an auto CDC flow is a string identifier, so the change feed is wrapped in a temporary view rather than passed as a DataFrame. And&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;apply_as_deletes&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;is the whole reason bronze needed the change feed: without it, an order deleted in SAP lives forever in your dashboards.&lt;/P&gt;&lt;P&gt;The other four tables follow the same shape with their own keys. On&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;VBPA&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;I also filter&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;parvw = 'WE'&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;to keep only the ship-to partner, because that table packs every partner role into the same rows.&lt;/P&gt;&lt;P&gt;If you are porting older code:&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dp.create_auto_cdc_flow&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;is what&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dlt.apply_changes&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;became, it is called at top level, and it does not return a value.&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="how-do-you-rebuild-a-document-that-lives-in-five-tables"&gt;How do you rebuild a document that lives in five tables?&lt;/H2&gt;&lt;P&gt;Join the snapshots into one consolidated row, and decide two things most examples skip: what the columns are called, and what "fresh" means for a row assembled from five sources.&lt;/P&gt;&lt;DIV class=""&gt;&lt;PRE&gt;&lt;SPAN&gt;&lt;SPAN class=""&gt;@dp.materialized_view&lt;/SPAN&gt;(&lt;/SPAN&gt;
&lt;SPAN&gt;    name&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"silver_sd_sales_order"&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;    comment&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"Consolidated SAP SD sales order: VBAK x VBAP + VBPA(WE) + VBFA + VBKD"&lt;/SPAN&gt;,&lt;/SPAN&gt;
&lt;SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;&lt;SPAN class=""&gt;def&lt;/SPAN&gt; silver_sd_sales_order():&lt;/SPAN&gt;
&lt;SPAN&gt;    vbak &lt;SPAN class=""&gt;=&lt;/SPAN&gt; spark.read.table(&lt;SPAN class=""&gt;"vbak_cur"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbak"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;    vbap &lt;SPAN class=""&gt;=&lt;/SPAN&gt; spark.read.table(&lt;SPAN class=""&gt;"vbap_cur"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbap"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;    vbpa &lt;SPAN class=""&gt;=&lt;/SPAN&gt; spark.read.table(&lt;SPAN class=""&gt;"vbpa_we_cur"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbpa"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;    vbfa &lt;SPAN class=""&gt;=&lt;/SPAN&gt; spark.read.table(&lt;SPAN class=""&gt;"vbfa_cur"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbfa"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;    vbkd &lt;SPAN class=""&gt;=&lt;/SPAN&gt; spark.read.table(&lt;SPAN class=""&gt;"vbkd_cur"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbkd"&lt;/SPAN&gt;)&lt;/SPAN&gt;

&lt;SPAN&gt;    &lt;SPAN class=""&gt;return&lt;/SPAN&gt; (&lt;/SPAN&gt;
&lt;SPAN&gt;        vbak&lt;/SPAN&gt;
&lt;SPAN&gt;        .join(vbap, [&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;], &lt;SPAN class=""&gt;"inner"&lt;/SPAN&gt;)   &lt;SPAN class=""&gt;# no header without items&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;        .join(vbpa, [&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"posnr"&lt;/SPAN&gt;], &lt;SPAN class=""&gt;"left"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .join(vbfa, [&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"posnr"&lt;/SPAN&gt;], &lt;SPAN class=""&gt;"left"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .join(vbkd, [&lt;SPAN class=""&gt;"mandt"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"vbeln"&lt;/SPAN&gt;, &lt;SPAN class=""&gt;"posnr"&lt;/SPAN&gt;], &lt;SPAN class=""&gt;"left"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .select(&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"vbak.vbeln"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"vbeln_vbak"&lt;/SPAN&gt;),   &lt;SPAN class=""&gt;# &amp;lt;col&amp;gt;_&amp;lt;table&amp;gt;, on purpose&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"vbap.posnr"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"posnr_vbap"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"vbap.matnr"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"matnr_vbap"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"vbak.kunnr"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"kunnr_vbak"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            &lt;SPAN class=""&gt;# the freshness of the row is the freshest of its sources&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;            F.greatest(&lt;/SPAN&gt;
&lt;SPAN&gt;                F.col(&lt;SPAN class=""&gt;"vbak.event_ts"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;                F.coalesce(F.col(&lt;SPAN class=""&gt;"vbap.event_ts"&lt;/SPAN&gt;), F.col(&lt;SPAN class=""&gt;"vbak.event_ts"&lt;/SPAN&gt;)),&lt;/SPAN&gt;
&lt;SPAN&gt;                F.coalesce(F.col(&lt;SPAN class=""&gt;"vbpa.event_ts"&lt;/SPAN&gt;), F.col(&lt;SPAN class=""&gt;"vbak.event_ts"&lt;/SPAN&gt;)),&lt;/SPAN&gt;
&lt;SPAN&gt;                F.coalesce(F.col(&lt;SPAN class=""&gt;"vbfa.event_ts"&lt;/SPAN&gt;), F.col(&lt;SPAN class=""&gt;"vbak.event_ts"&lt;/SPAN&gt;)),&lt;/SPAN&gt;
&lt;SPAN&gt;                F.coalesce(F.col(&lt;SPAN class=""&gt;"vbkd.event_ts"&lt;/SPAN&gt;), F.col(&lt;SPAN class=""&gt;"vbak.event_ts"&lt;/SPAN&gt;)),&lt;/SPAN&gt;
&lt;SPAN&gt;            ).alias(&lt;SPAN class=""&gt;"event_ts"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;        )&lt;/SPAN&gt;
&lt;SPAN&gt;    )&lt;/SPAN&gt;&lt;/PRE&gt;&lt;/DIV&gt;&lt;P&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-inline" image-alt="02_document_rebuild.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31099iBF08238BB957F2CA/image-size/large?v=v2&amp;amp;px=999" role="button" title="02_document_rebuild.png" alt="02_document_rebuild.png" /&gt;&lt;/span&gt;&lt;/P&gt;&lt;P&gt;Inner join on items, left join on everything else, so an order with no partner or no flow does not vanish. The&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;&amp;lt;col&amp;gt;_&amp;lt;table&amp;gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;convention is verbose and it pays for itself the first time two columns called&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;netwr&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;disagree and you need to know which one came from the header. The silver stays faithful to the source, the makeup happens in gold.&lt;/P&gt;&lt;P&gt;The&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;greatest&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;over coalesced timestamps answers a question a single-table pipeline never has to ask. If a row is five rows, its freshness is the most recent of them.&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="what-is-the-gold-layer-actually-adding"&gt;What is the gold layer actually adding?&lt;/H2&gt;&lt;P&gt;Meaning, not data. Three operations, and two of them are SAP specific.&lt;/P&gt;&lt;DIV class=""&gt;&lt;PRE&gt;&lt;SPAN&gt;&lt;SPAN class=""&gt;@dp.materialized_view&lt;/SPAN&gt;(name&lt;SPAN class=""&gt;=&lt;/SPAN&gt;&lt;SPAN class=""&gt;"gold_sd_open_order_tracker"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;&lt;SPAN class=""&gt;def&lt;/SPAN&gt; gold_sd_open_order_tracker():&lt;/SPAN&gt;
&lt;SPAN&gt;    sso &lt;SPAN class=""&gt;=&lt;/SPAN&gt; spark.read.table(&lt;SPAN class=""&gt;"sap.silver.silver_sd_sales_order"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"sso"&lt;/SPAN&gt;)&lt;/SPAN&gt;

&lt;SPAN&gt;    &lt;SPAN class=""&gt;# SAP text tables are per language. Without the filter you multiply rows.&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;    makt &lt;SPAN class=""&gt;=&lt;/SPAN&gt; (spark.read.table(&lt;SPAN class=""&gt;"sap.bronze.makt"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;            .where(F.col(&lt;SPAN class=""&gt;"spras"&lt;/SPAN&gt;) &lt;SPAN class=""&gt;==&lt;/SPAN&gt; &lt;SPAN class=""&gt;"E"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;            .select(F.col(&lt;SPAN class=""&gt;"matnr"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"matnr_makt"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;                    F.col(&lt;SPAN class=""&gt;"maktg"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"material_text"&lt;/SPAN&gt;)).alias(&lt;SPAN class=""&gt;"makt"&lt;/SPAN&gt;))&lt;/SPAN&gt;

&lt;SPAN&gt;    &lt;SPAN class=""&gt;# "0000-00-00" is not a date, it is "not yet". It becomes NULL here or it breaks later.&lt;/SPAN&gt;&lt;/SPAN&gt;
&lt;SPAN&gt;    delivery_date &lt;SPAN class=""&gt;=&lt;/SPAN&gt; F.when(&lt;/SPAN&gt;
&lt;SPAN&gt;        F.col(&lt;SPAN class=""&gt;"sso.vdatu_vbak"&lt;/SPAN&gt;).isNull() &lt;SPAN class=""&gt;|&lt;/SPAN&gt; (F.col(&lt;SPAN class=""&gt;"sso.vdatu_vbak"&lt;/SPAN&gt;) &lt;SPAN class=""&gt;==&lt;/SPAN&gt; &lt;SPAN class=""&gt;"0000-00-00"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;        F.lit(&lt;SPAN class=""&gt;None&lt;/SPAN&gt;).cast(&lt;SPAN class=""&gt;"timestamp"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;    ).otherwise(F.to_timestamp(F.col(&lt;SPAN class=""&gt;"sso.vdatu_vbak"&lt;/SPAN&gt;), &lt;SPAN class=""&gt;"yyyy-MM-dd"&lt;/SPAN&gt;))&lt;/SPAN&gt;

&lt;SPAN&gt;    &lt;SPAN class=""&gt;return&lt;/SPAN&gt; (&lt;/SPAN&gt;
&lt;SPAN&gt;        sso.join(makt, F.col(&lt;SPAN class=""&gt;"makt.matnr_makt"&lt;/SPAN&gt;) &lt;SPAN class=""&gt;==&lt;/SPAN&gt; F.col(&lt;SPAN class=""&gt;"sso.matnr_vbap"&lt;/SPAN&gt;), &lt;SPAN class=""&gt;"left"&lt;/SPAN&gt;)&lt;/SPAN&gt;
&lt;SPAN&gt;        .select(&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"sso.vbeln_vbak"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"sales_order"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"sso.posnr_vbap"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"item"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.col(&lt;SPAN class=""&gt;"makt.material_text"&lt;/SPAN&gt;).alias(&lt;SPAN class=""&gt;"material"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            delivery_date.alias(&lt;SPAN class=""&gt;"delivery_date_ts"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;            F.when(F.col(&lt;SPAN class=""&gt;"sso.gbstk_vbak"&lt;/SPAN&gt;) &lt;SPAN class=""&gt;==&lt;/SPAN&gt; &lt;SPAN class=""&gt;"C"&lt;/SPAN&gt;, F.lit(&lt;SPAN class=""&gt;"CLOSE"&lt;/SPAN&gt;))&lt;/SPAN&gt;
&lt;SPAN&gt;             .otherwise(F.lit(&lt;SPAN class=""&gt;"OPEN"&lt;/SPAN&gt;)).alias(&lt;SPAN class=""&gt;"order_status"&lt;/SPAN&gt;),&lt;/SPAN&gt;
&lt;SPAN&gt;        )&lt;/SPAN&gt;
&lt;SPAN&gt;    )&lt;/SPAN&gt;&lt;/PRE&gt;&lt;/DIV&gt;&lt;P&gt;Look at the contrast between input and output. In:&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;vbeln,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;matnr,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;gbstk,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;0000-00-00. Out:&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;sales_order,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;material,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;order_status, a real timestamp or a NULL. Same information, and now a dashboard or a person can read it without an SAP dictionary open in the next tab.&lt;/P&gt;&lt;P&gt;The language filter is the one that silently ruins numbers. Text tables carry a&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;spras&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;column, so an unfiltered join multiplies every row by the number of configured languages, and your line count looks fine until somebody sums an amount.&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="who-owns-a-shared-silver-table"&gt;Who owns a shared silver table?&lt;/H2&gt;&lt;P&gt;Exactly one pipeline, and this is the decision I see skipped most often. A pipeline owns the state of the tables it declares: the checkpoints, the versioning, the lifecycle of the Delta table. Two pipelines declaring the same&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;silver_sd_sales_order&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;is a design you do not want, because each one believes it manages that table.&lt;BR /&gt;&lt;span class="lia-inline-image-display-wrapper lia-image-align-inline" image-alt="03_pipeline_ownership.png" style="width: 999px;"&gt;&lt;img src="https://community.databricks.com/t5/image/serverpage/image-id/31100iA31943B9A03B10EF/image-size/large?v=v2&amp;amp;px=999" role="button" title="03_pipeline_ownership.png" alt="03_pipeline_ownership.png" /&gt;&lt;/span&gt;&lt;/P&gt;&lt;P&gt;For a single domain, one pipeline is the simpler answer and the current default. The split becomes worth it when N business domains need the same consolidated sales order. Then the shape is one shared silver pipeline that owns the snapshots and the consolidation, plus one gold pipeline per domain that reads the silver as an external table and owns only its own outputs.&lt;/P&gt;&lt;P&gt;You build the truth once, each domain iterates without breaking what is shared, and the CDC logic exists in one place.&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="which-primitive-belongs-in-which-layer"&gt;Which primitive belongs in which layer?&lt;/H2&gt;&lt;P&gt;Layer Primitive Why this one&lt;/P&gt;&lt;TABLE&gt;&lt;TBODY&gt;&lt;TR&gt;&lt;TD&gt;Bronze&lt;/TD&gt;&lt;TD&gt;Delta table with Change Data Feed on&lt;/TD&gt;&lt;TD&gt;The layer has one job, preserve what happened, deletes included&lt;/TD&gt;&lt;/TR&gt;&lt;TR&gt;&lt;TD&gt;Silver, per table&lt;/TD&gt;&lt;TD&gt;dp.create_streaming_table&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;plus&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dp.create_auto_cdc_flow&lt;/TD&gt;&lt;TD&gt;Incremental, stateful, delete aware, keyed on SAP business keys&lt;/TD&gt;&lt;/TR&gt;&lt;TR&gt;&lt;TD&gt;Silver, consolidated&lt;/TD&gt;&lt;TD&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/25059"&gt;@DP&lt;/a&gt;.materialized_view&lt;/TD&gt;&lt;TD&gt;It is a join over snapshots, recomputed, not a stream&lt;/TD&gt;&lt;/TR&gt;&lt;TR&gt;&lt;TD&gt;Gold&lt;/TD&gt;&lt;TD&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/25059"&gt;@DP&lt;/a&gt;.materialized_view&lt;/TD&gt;&lt;TD&gt;Enrichment and renaming, read by dashboards, recomputed on refresh&lt;/TD&gt;&lt;/TR&gt;&lt;TR&gt;&lt;TD&gt;Across pipelines&lt;/TD&gt;&lt;TD&gt;spark.read.table&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;on a fully qualified name&lt;/TD&gt;&lt;TD&gt;Reading something another pipeline owns&lt;/TD&gt;&lt;/TR&gt;&lt;/TBODY&gt;&lt;/TABLE&gt;&lt;HR /&gt;&lt;H2 id="what-do-these-patterns-not-solve"&gt;What do these patterns not solve?&lt;/H2&gt;&lt;P&gt;They do not make the data correct. They make it traceable and delete aware, which is a different and more honest claim. If&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;parvw&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;was configured differently in your system, a correct pipeline gives you a confidently wrong ship-to.&lt;/P&gt;&lt;P&gt;They do not remove SAP domain knowledge. Knowing that&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;WE&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;is ship-to, that&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;spras&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;has to be filtered and that&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;0000-00-00&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;means "not yet" is not something a framework supplies. That knowledge is the actual work, and every pattern above is just a place to put it.&lt;/P&gt;&lt;P&gt;And they do not cover schema drift by themselves. Normalizing everything to string in silver and casting in gold is my answer to that, and it is a trade: you get stability when SAP changes a type or a length, you pay by deferring every cast and all real schema enforcement to the layer above. Data quality expectations,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/25059"&gt;@DP&lt;/a&gt;.expect_or_drop&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;and its siblings, are where I put the checks that used to live in strict types, because a dropped row with a metric attached is easier to explain on a Monday than a pipeline that stopped.&lt;/P&gt;&lt;HR /&gt;&lt;H2 id="faq"&gt;FAQ&lt;/H2&gt;&lt;P&gt;&lt;STRONG&gt;Do I need one pipeline or several for an SAP medallion?&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;Start with one. Split into a shared silver pipeline plus one gold pipeline per domain when a second business domain needs the same consolidated document, because a table should have a single owning pipeline and duplicating the CDC logic per domain is worse than the extra pipeline.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;Why cast everything to string in the silver?&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;Schema stability. SAP occasionally changes a type or a length, and a silver full of strict types turns that into a broken pipeline. Casting in gold means you decide the type where you already know what the field is for.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;How do I handle deletes from SAP?&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;Enable&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;delta.enableChangeDataFeed&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;on the bronze, read it with&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;readChangeFeed, and pass&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;apply_as_deletes="_change_type = 'delete'"&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;to&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dp.create_auto_cdc_flow. Without the change feed there is nothing to apply.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;What do I do with&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;0000-00-00&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dates?&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;Intercept them before parsing and turn them into NULL. SAP uses that value for "not known yet" in character date fields, and parsing it directly either fails or produces a date nobody meant.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;Why is&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;mandt&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;in the primary key?&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;Because the client is part of identity in SAP. A pipeline keyed only on the document number works until a second client exists, and then it silently merges two different orders.&lt;/P&gt;&lt;P&gt;&lt;STRONG&gt;Is this specific to Lakeflow Declarative Pipelines?&lt;/STRONG&gt;&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;The primitives are. The five patterns are not: I have built the same shape on Snowflake, on Flink and with dbt, and bronze preserving deletes, silver rebuilding the document and gold carrying the vocabulary stayed the same on all of them. What changed each time was the syntax for expressing them.&lt;/P&gt;&lt;HR /&gt;&lt;P&gt;&lt;EM&gt;Notes on the API: this post uses the modern&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dp&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;API for Lakeflow Declarative Pipelines, which is what Delta Live Tables was renamed to. If you are reading older material,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;import dlt&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;maps to&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;from pyspark import pipelines as dp,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/97035"&gt;@Dlt&lt;/a&gt;.view&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;to&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;&lt;a href="https://community.databricks.com/t5/user/viewprofilepage/user-id/25059"&gt;@DP&lt;/a&gt;.temporary_view,&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dlt.apply_changes&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;to&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dp.create_auto_cdc_flow, and&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;dlt.read&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;to&lt;SPAN&gt;&amp;nbsp;&lt;/SPAN&gt;spark.read.table. The legacy names still parse.&lt;/EM&gt;&lt;/P&gt;&lt;P&gt;&lt;EM&gt;SAP, SAP ECC and SAP S/4HANA are trademarks or registered trademarks of SAP SE in Germany and other countries. Apache and Apache Spark are trademarks of The Apache Software Foundation. Databricks, Delta Lake, Lakeflow and Unity Catalog are trademarks of Databricks, Inc.&lt;/EM&gt;&lt;/P&gt;</description>
      <pubDate>Mon, 14 Sep 2026 22:45:35 GMT</pubDate>
      <guid>https://community.databricks.com/t5/community-articles/sap-medallion-architecture-patterns-what-bronze-silver-and-gold/m-p/168596#M1560</guid>
      <dc:creator>AFDev</dc:creator>
      <dc:date>2026-09-14T22:45:35Z</dc:date>
    </item>
  </channel>
</rss>

