11.5 Reverse-ETL: publish the Gold layer out¶
Concept¶
Everything so far pulled data into the lakehouse — Bronze ingest, Silver cleaning, Gold marts. Reverse-ETL runs the arrow the other way: it takes a curated Gold table and pushes it back out to wherever the business actually consumes it — a file drop someone downloads, an operational app's database, or a SaaS tool (a CRM, a marketing platform). The lakehouse computes the answer once; reverse-ETL delivers that answer to the systems people already live in.
A destination you write to is called a sink (the mirror of a source, which you read from). This recipe publishes one small Gold-style report to two sinks: a CSV file drop, and a serving table an app reads.
flowchart LR
G["🥇 my.gold.*<br/>country_report"] --> RE[Spark write]
RE -->|CSV| S3[(RustFS<br/>exports/country_report/)]
RE -->|writeTo| ST[(Serving table<br/>my.serving.country_report)]
S3 --> BU[📥 Business users<br/>download the CSV]
ST --> APP[🖥️ App / dashboard<br/>reads it over SQL]
Why Gold is what you publish: the same rule from Unit 10.1 — you serve the small, safe, business-ready Gold layer, never raw Bronze/Silver. Gold is already the finished metric (revenue by country), so there's nothing left to explain to whoever receives it.
Reverse-ETL is just ETL with the arrow reversed
Ordinary ETL moves data from operational systems into your warehouse/lakehouse.
Reverse-ETL moves curated data out of the lakehouse back into operational systems. Same
machinery (spark.write), opposite direction.
Lab¶
Assume the spark session from 4.1 — the object named spark is your
handle to the cluster. We'll build a tiny Gold report, then publish it to two sinks.
Cell A — a small Gold report¶
In real life you'd spark.read an existing mart (my.gold.daily_sales, country_report, …).
To keep the recipe self-contained we hand-build a three-row stand-in:
# a Gold report — in real life you'd read my.gold.daily_sales etc.
report = spark.createDataFrame(
[('US', 3940000), ('UK', 2510000), ('DE', 1330000)],
['country', 'revenue'])
Read it step by step:
spark.createDataFrame([...], [...])— build a DataFrame directly from Python values. The first argument is the rows (a list of tuples), the second is the column names.[('US', 3940000), …]— three rows: one per country, with its total revenue. This is exactly the shape a real Gold mart has — pre-aggregated, one row per business entity.['country', 'revenue']— names the two columns.countryis a category (a dimension),revenueis the number (a metric).
Produces: report, a 3-row DataFrame. Grain (what one row means): one country and its revenue.
Cell B — publish as a downloadable CSV in object storage¶
The first sink is a file drop: write the report as a CSV into RustFS, where a business user can grab it from the bucket (or a link) and open it in Excel — no lakehouse access required.
(report.coalesce(1).write.mode("overwrite").option("header", True)
.csv("s3a://demouser-lake/files/exports/country_report/"))
Read it step by step:
report.coalesce(1)— collapse the DataFrame to a single partition so Spark writes one output file instead of manypart-00000,part-00001, … pieces. A business user wants one CSV to download, not a folder of fragments. (Spark is parallel by default, so each partition normally writes its own file —coalesce(1)funnels them together first.).write— the entry point for writing a DataFrame out (the counterpart tospark.read)..mode("overwrite")— if the target folder already has an export, replace it. Re-running the cell always leaves exactly one fresh export (idempotent). The alternative,"append", would add another file alongside the old ones — see the Challenge..option("header", True)— write the column names (country,revenue) as the first line, so the CSV is self-describing when someone opens it..csv("s3a://demouser-lake/files/exports/country_report/")— the action: write CSV files to that path.s3a://is Spark's connector for S3-compatible object storage (here, RustFS — inside the stack it's reached athttp://storage:9000). Note the path is a folder — Spark writes the single part-file inside it.
Produces: one CSV file under s3a://demouser-lake/files/exports/country_report/, with a header row and
one line per country — ready to hand to anyone.
Why a folder, not a file
Even with coalesce(1), Spark writes to a directory (country_report/) containing one
part-*.csv plus small _SUCCESS/commit markers — that's just how distributed writers work.
Business users are usually handed a link to that single part-file, or a tiny downstream step
renames it to country_report.csv. The coalesce(1) is what guarantees there's only one
part-file to point at.
Cell C — a serving table the app reads¶
The second sink is a table an application or dashboard reads directly over SQL: a small serving table in its own namespace, holding just what the consumer needs.
spark.sql("CREATE NAMESPACE IF NOT EXISTS my.serving")
report.writeTo("my.serving.country_report").createOrReplace()
Read it step by step:
CREATE NAMESPACE IF NOT EXISTS my.serving: a separate namespace (schema) in your own lakehouse for tables you publish to consumers, apart from your workingbronze/silver/gold.report.writeTo("my.serving.country_report"): the target table. Readers get a stable name to point their app at, whatever you change in your pipeline behind it..createOrReplace(): replaces the table's contents each run, so re-running always leaves exactly one fresh copy (idempotent)..append()would add rows instead (the Challenge does this).
Produces: my.serving.country_report with three rows. An app or dashboard reads it with
plain SQL, for example in SQLPad: SELECT * FROM demouser_lake.serving.country_report.
Why not straight into the shop's Postgres?
Classic reverse-ETL often writes into an operational app's database over JDBC (the mirror of
the JDBC read in Unit 4.2): report.write.format("jdbc")…save().
But ShopFlow's Postgres is our production database, and data engineers only get a
read-only login there (try it: the write fails with cannot execute CREATE TABLE in a
read-only transaction). In a real company the app team creates a dedicated table and a
separate service account that may write only to it, and the pipeline uses that account, never
a personal login.
Publish as Excel, not just CSV
Some recipients want a real .xlsx, not a CSV. Since Gold reports are small, pull the DataFrame
to pandas on the driver and let it write the workbook — the reverse of the
Excel ingest recipe:
import os
report.toPandas().to_excel(
"s3://demouser-lake/files/exports/report.xlsx", index=False,
storage_options={"key": os.environ["AWS_ACCESS_KEY_ID"], # your own key
"secret": os.environ["AWS_SECRET_ACCESS_KEY"],
"client_kwargs": {"endpoint_url": "http://storage:9000"}})
report.toPandas() collects the small result into a pandas DataFrame; .to_excel(...)
writes a formatted .xlsx; storage_options={...} hands pandas your own storage key and
endpoint so it can write straight to object storage. index=False drops pandas' row numbers.
Challenge¶
The report is published fresh each run today (overwrite). Make it a daily history instead:
stamp each run with its date and append to a history table,
my.serving.country_report_history, so it accumulates one batch per day. As a stretch, also write the CSV partitioned by country so each country gets its own
folder of exports.
The idea: append, don't overwrite
overwrite keeps only the latest snapshot; append adds rows, building history. Add a run_date
column so you can tell the batches apart, then switch the write mode. For the CSV, partitionBy
splits the output into one folder per value — the same trick you used for date-partitioned Parquet
in 4.2.
Solution
from pyspark.sql import functions as F
# stamp each run with today's date
dated = report.withColumn("run_date", F.current_date())
# (1) append daily history into its own table instead of overwriting
hist = "my.serving.country_report_history"
if spark.catalog.tableExists(hist):
dated.writeTo(hist).append()
else:
dated.writeTo(hist).create()
# (2) write CSV partitioned by country — one folder per country
(report.write.mode("overwrite").option("header", True)
.partitionBy("country")
.csv("s3a://demouser-lake/files/exports/country_report_by_country/"))
.withColumn("run_date", F.current_date())— add arun_datecolumn set to today's date, so each daily batch is distinguishable once they pile up in the same table..append()— add the new rows instead of replacing them (the first run creates the table). Run it three days running and you get three dated batches — a growing history. It's a separate table because the history has an extrarun_datecolumn — its own shape..partitionBy("country")— split the CSV output into one folder per country (country=US/,country=UK/,country=DE/). A consumer who only wants one country reads just that folder. Note there's nocoalesce(1)here — partitioning is the split you want.
Append builds an audit trail; overwrite keeps only "now". Pick append when downstream needs the daily history, overwrite when it only ever wants the latest snapshot.
🎯 The same reverse-ETL on Azure Data Factory, Databricks & Fabric
What you just did: published a Gold table out to two sinks — a CSV file drop and a
serving table — over the same spark.write you'd use anywhere.
- Azure Data Factory — a Copy activity with your Gold table as source and a SQL / Blob sink as destination; the "sink" concept is ADF's own word for it.
- Azure Databricks —
df.writeto JDBC or files runs unchanged; or expose Gold to external consumers with Delta Sharing (no copy at all). - Microsoft Fabric — a Data Factory copy into the destination, or Data Activator to trigger an action when a Gold metric crosses a threshold.
- Dedicated tools — Census and Hightouch are purpose-built reverse-ETL products that sync warehouse tables into SaaS apps (Salesforce, HubSpot) without you writing the pipe.
Different logos, identical shape: read the governed Gold layer, write it out to a sink.
Key terms, at a glance¶
| Term | Plain meaning |
|---|---|
| Reverse-ETL | Push curated Gold back out to where it's consumed (file, app DB, SaaS) |
| Sink | A destination you write to — the mirror of a source |
.write |
Entry point for writing a DataFrame out (counterpart to spark.read) |
coalesce(1) |
Collapse to one partition → one output file, not many part-files |
.mode("overwrite") |
Replace the target's contents each run (idempotent) |
.mode("append") |
Add rows to what's already there — builds history |
| Serving table | A small published table with a stable name that apps / dashboards read |
| JDBC write | write.format("jdbc") into an app database, with a service account that may write there |
partitionBy(col) |
Split output into one folder per value of that column |
| Why publish Gold | Small, safe, business-ready — never publish Bronze/Silver |
You can now…¶
- Explain reverse-ETL — pushing curated Gold back out to a sink (file, app database, SaaS)
- Publish a Gold report as a single downloadable CSV in object storage with
coalesce(1) - Publish a Gold table as a serving table, and explain why pipelines don't write to production with personal logins
- Choose
overwritevsappend, and partition the output by column for per-value file drops