Databricks DLT → Lakehouse Migration Guide: Apparel Retail Streaming Pipeline
If your data pipeline runs on Databricks Delta Live Tables, the core migration effort to Singdata Lakehouse is lower than you might expect — most PySpark DataFrame code can be reused directly. DLT uses decorators (@dlt.table, @dlt.expect_or_drop) to wrap Python functions into pipeline nodes. ZettaPark removes the decorators and replaces them with df.write.saveAsTable() — business logic is unchanged, line for line.
This article validates this with a real project: a Databricks DLT-based apparel retail streaming pipeline (Bronze ingestion → Silver SCD2 cleansing → Gold aggregation analytics) fully migrated to Singdata Lakehouse, passing all 16 automated validations.
Full code on GitHub: databricks2lakehouse-dlt-apparel
Source Project
databricks2lakehouse-dlt-apparel is forked from jrlasak/databricks_apparel_streaming (⭐45). The original tech stack is Databricks DLT + Auto Loader + Delta Lake. The project implements a complete data pipeline for an apparel retailer across 4 dimensions (customers, products, stores, transactions), covering streaming ingestion, SCD Type 2 history tracking, data quality constraints, and Gold layer aggregation analytics.
Migrated code is in the 03_lakehouse/ directory, with three available approaches that can be compared file-by-file with 01_source/dlt/.
Conclusion First
Option C (Dynamic Table) is the closest equivalent for DLT pipelines — declarative definition + automatic scheduled refresh, consistent with DLT's "define and execute" philosophy. If the existing team is comfortable with PySpark DataFrame, Option A (ZettaPark) requires only 5 mechanical substitutions with all business logic fully preserved.
| Option | Files | DLT Equivalent | Characteristics |
|---|---|---|---|
| A. ZettaPark Python | 03_lakehouse/*.py | @dlt.table → df.write.saveAsTable() | Minimal code changes, retains PySpark skills |
| B. Pure SQL | 03_lakehouse/sql/ | SQL equivalent implementation | SQL-first teams |
| C. Dynamic Table (GIC) | 03_lakehouse/dynamic_tables/ | Native equivalent of @dlt.table | Declarative + auto-refresh, closest to DLT semantics |
Option A change list (ZettaPark main path):
| Change | Effort | Notes |
|---|---|---|
import dlt / from pyspark... | Very low | from clickzetta.zettapark import functions as F, replace package name |
spark global | Very low | session = Session.builder.configs({}).create() |
@dlt.table(name=X) | Very low | Add df.write.mode("overwrite").saveAsTable(X) as last line of function |
@dlt.expect_or_drop("msg","cond") | Very low | df.filter(F.col(...)...), semantically equivalent |
dlt.read_stream("LIVE.X") / dlt.read("LIVE.X") | Very low | session.table("X"), same as PySpark |
dlt.create_auto_cdc_flow(scd_type=2) | Low | F.lead().over(Window.partitionBy(key).orderBy(seq)), ZettaPark Window is identical to PySpark |
F.window("event_time","1 day") | Very low | F.to_date(F.col("event_time")), ZettaPark has no F.window |
| Auto Loader → | Low | session.read.csv("vol://...") batch; use Pipe for streaming in production |
JOIN logic, aggregation functions (F.sum/count/avg), F.when/coalesce/lead/lag, Window — all require no changes, fully consistent with PySpark API.
Tech Stack Comparison
| Databricks DLT | ZettaPark (after migration) | |
|---|---|---|
| Pipeline definition | Python decorator @dlt.table | df.write.mode("overwrite").saveAsTable(X) |
| Data quality constraints | @dlt.expect_or_drop("msg","cond") | df.filter(condition) (same semantics) |
| Streaming read | dlt.read_stream("LIVE.X") | session.table("X") (DT auto-incremental) |
| Static read | dlt.read("LIVE.X") | session.table("X") |
| SCD Type 2 | dlt.create_auto_cdc_flow(stored_as_scd_type=2) | F.lead().over(Window.partitionBy(key).orderBy(seq)) |
| Time window aggregation | F.window("event_time","1 day") | F.to_date(F.col("event_time")) |
| File ingestion | Auto Loader (cloudFiles) | session.read.csv("vol://...") / Pipe (streaming) |
| DataFrame API | PySpark | Fully consistent (F.col/when/lead/Window etc.) |
Project Background
Apparel retailer with 4 data streams + Bronze/Silver/Gold three-layer architecture:
| Data Domain | Raw Events | Row Count | Notes |
|---|---|---|---|
| Customers | raw_customers | 150 | Includes SCD2 update records (30 historical) |
| Products | raw_products | 50 | Includes category/brand/size/color |
| Stores | raw_stores | 5 | 5 stores |
| Transactions | raw_sales | 500 | Includes discount, tax, payment method |
DLT pipeline file structure:
Migration Steps
Step 1: @dlt.table / Auto Loader → ZettaPark
DLT uses decorators to declare tables + spark.readStream for streaming ingestion. ZettaPark uses session.read.csv("vol://...") to load files, with df.write.saveAsTable() replacing decorators:
Step 2: @dlt.expect_or_drop → df.filter()
DLT data quality constraints translate directly to ZettaPark .filter() — semantically equivalent, API consistent:
Step 3: SCD Type 2 — dlt.create_auto_cdc_flow → LEAD() Window Function
create_auto_cdc_flow(stored_as_scd_type=2) is fundamentally a LEAD window function. ZettaPark implements the equivalent logic directly — F.lead() and Window APIs are fully consistent between the two:
Result: 150 raw records → 150 rows (including 30 historical) → 120 current snapshot records (__is_current = TRUE).
Step 4: Time Window Aggregation — F.window() → F.to_date()
F.window("event_time","1 day") is not implemented in ZettaPark. Use F.to_date() instead (functionally equivalent, results consistent):
Pure SQL Alternative
If the team prefers SQL, 03_lakehouse/sql/ provides equivalent SQL scripts:
| DLT Python | SQL Equivalent |
|---|---|
@dlt.expect_or_drop | WHERE condition |
create_auto_cdc_flow(scd=2) | LEAD() OVER (PARTITION BY key ORDER BY seq) |
F.window("event_time","1 day") | DATE_TRUNC('day', event_time) |
ZettaPark is the recommended primary path (minimal code changes, retains PySpark skills); SQL is suitable for SQL-first teams or rapid validation.
Option C: Dynamic Table (GIC) — Native Equivalent of DLT
Dynamic Table is the most direct equivalent of DLT's @dlt.table — declare SQL declaratively, and the platform automatically refreshes on schedule. DLT triggers on new data automatically; Dynamic Table refreshes on a REFRESH INTERVAL schedule. Logically equivalent.
| DLT | Dynamic Table | Notes |
|---|---|---|
@dlt.table(name=X) | CREATE DYNAMIC TABLE X ... AS SELECT ... | Direct equivalent |
| Auto-refresh on new data | REFRESH INTERVAL N MINUTE | Different scheduling method, semantically equivalent |
F.window("1 day") | CAST(event_time AS DATE) | DT uses SQL |
create_auto_cdc_flow(scd=2) | LEAD() OVER Window embedded in DT definition | Standard window function |
| DLT pipeline DAG | DT dependencies (downstream DT references upstream DT) | Declarative dependencies |
Tested in AWS Singapore: 4 Dynamic Tables created and refreshed, all passed e2e validation: dt_customers_current (150 rows / 120 current), dt_daily_sales_by_store (367), dt_product_performance (50), dt_customer_lifetime_value (96).
About Pipe (Auto Loader Equivalent)
Pipe is the Databricks Auto Loader equivalent — continuously monitors object storage (OSS/S3/COS) for new files, automatically triggering COPY INTO ingestion. Tested in AWS Singapore: after uploading a new CSV to S3, Pipe auto-ingested within 15 seconds.
E2E Validation Results
Tested on AWS Singapore instance (aws_singapore_prod), 20/20 all passed (including Dynamic Table validation):
| Check | Expected | Result |
|---|---|---|
| bronze.raw_customers | 150 | ✅ |
| bronze.raw_products | 50 | ✅ |
| bronze.raw_stores | 5 | ✅ |
| bronze.raw_sales | 500 | ✅ |
| silver_customers (including history) | 150 | ✅ |
| silver_products | 50 | ✅ |
| silver_stores | 5 | ✅ |
| silver_sales_transactions | 500 | ✅ |
| silver_customers_current | 120 | ✅ |
| silver_products_current | 50 | ✅ |
| silver_stores_current | 5 | ✅ |
| gold.denormalized_sales_facts | 500 | ✅ |
| gold.gold_product_performance | 50 | ✅ |
| Total sales amount | 281,490 | ✅ |
| Customers with purchases | 96 | ✅ |
| SCD2 historical records | 30 | ✅ |
| dt_customers_current (Dynamic Table) | 150 | ✅ |
| dt_daily_sales_by_store (Dynamic Table) | ≥100 | ✅ 367 |
| dt_product_performance (Dynamic Table) | 50 | ✅ |
| dt_customer_lifetime_value (Dynamic Table) | 96 | ✅ |
| SCD2 historical records | 30 | ✅ |
Notes
- Streaming vs batch: Auto Loader continuously monitors new files; COPY INTO is a one-time batch. Use Lakehouse Pipe instead of Auto Loader in production — automatically ingests new files after configuration, no manual triggering required.
@dlt.expectwarn semantics:expect(warn level) in DLT counts but does not drop records. When migrating to SQL, you can choose to not filter (ignore entirely) or add aWHEREfilter (equivalent to upgrading toexpect_or_drop). Decide based on business requirements.- DT auto-incremental: Lakehouse Dynamic Tables automatically refresh incrementally after definition (similar to DLT streaming updates), without needing to explicitly write
read_stream. - SCD2
__is_currentfield:LEAD()returning NULL means there is no subsequent version, i.e., this is the current record.COALESCE(__end_at, '9999-12-31')is the standard SCD2 convention, with identical behavior on both sides.
Related Documentation
Dynamic Table (GIC)
- Dynamic Table Overview: Declarative incremental computation principles and architecture
- Dynamic Table SQL Reference: Full CREATE DYNAMIC TABLE syntax
- Dynamic Table Usage Guide: Unified batch-streaming pipeline examples
