SDP API - Flow types, Destination Types, and Syntax

Estimated read: 24 min

The complete syntax reference for everything the previous lecture introduced conceptually – tables, views, materialized views, every flow type, and expectations, in both Python and SQL. This is the lecture to bookmark and return to while building the hands-on bronze/silver/gold pipeline in the lectures that follow.

Streaming tables: Python and SQL

import dlt as dp

@dp.table(
    name="bronze_orders",
    comment="Raw orders landed from cloud storage, minimally transformed"
)
def bronze_orders():
    return (spark.readStream
        .format("cloudFiles")
        .option("cloudFiles.format", "json")
        .load("/Volumes/main/landing/orders/"))
CREATE OR REFRESH STREAMING TABLE bronze_orders
COMMENT "Raw orders landed from cloud storage, minimally transformed"
AS SELECT * FROM STREAM read_files(
  '/Volumes/main/landing/orders/',
  format => 'json'
);

Both produce the same destination type – a streaming table, incrementally processed. SQL syntax is often more concise for straightforward cases; Python is preferable once you need real conditional logic, loops generating multiple similar tables, or integration with other Python code.

Materialized views: Python and SQL

@dp.materialized_view(
    name="gold_daily_revenue",
    comment="Daily revenue aggregated by customer tier"
)
def gold_daily_revenue():
    return (spark.read.table("silver_orders")
        .groupBy("order_date")
        .agg({"order_total": "sum"}))
CREATE OR REFRESH MATERIALIZED VIEW gold_daily_revenue
COMMENT "Daily revenue aggregated by customer tier"
AS SELECT order_date, sum(order_total) AS total_revenue
FROM silver_orders
GROUP BY order_date;

Append flows: adding data incrementally into an existing destination

Beyond the implicit append behavior of a simple streaming table read, @dp.append_flow lets multiple, independent sources feed the same destination table – useful when several upstream sources logically belong in one bronze table:

@dp.table(name="bronze_orders")
def bronze_orders_base():
    return spark.readStream.table("LIVE.orders_source_a")

@dp.append_flow(target="bronze_orders")
def orders_from_source_b():
    return spark.readStream.table("LIVE.orders_source_b")

Both flows append into the same bronze_orders destination, each independently, incrementally tracked.

AUTO CDC: SCD Type 1 and Type 2

AUTO CDC automates upsert logic from a change feed – the declarative equivalent of the MERGE INTO patterns from Section 5, without hand-writing the MERGE statement:

dp.create_streaming_table("silver_customers")

dp.create_auto_cdc_flow(
    target="silver_customers",
    source="bronze_customers_cdc",
    keys=["customer_id"],
    sequence_by="updated_at",
    stored_as_scd_type=1   # overwrite in place -- only current state retained
)
dp.create_auto_cdc_flow(
    target="silver_customers_history",
    source="bronze_customers_cdc",
    keys=["customer_id"],
    sequence_by="updated_at",
    stored_as_scd_type=2   # full history -- __START_AT / __END_AT columns track validity windows
)
  SCD Type 1 SCD Type 2
Historical versions Overwritten – only current state Preserved – every version tracked
Extra columns None __START_AT, __END_AT
Use when You only ever need current state You need “what did this look like on date X”

Key term: sequence_by is what tells AUTO CDC which incoming change is actually the latest when multiple changes for the same key arrive close together or out of order – almost always a reliable timestamp or monotonic version column from the source, never wall-clock arrival time (arrival order and true change order can differ, especially across retries or backfills).

AUTO CDC FROM SNAPSHOT: when there’s no change feed at all

For sources that only expose periodic full snapshots (no CDC feed available at all), a snapshot-comparison variant detects changes by diffing consecutive snapshots:

dp.create_auto_cdc_from_snapshot_flow(
    target="silver_products",
    source=lambda version: spark.read.table(f"bronze_products_snapshot_v{version}"),
    keys=["product_id"],
    stored_as_scd_type=2
)

This is the SDP-native answer to a source that genuinely can’t provide row-level change events – common for legacy systems exporting periodic full dumps rather than a real change stream.

Expectations, in full

@dp.table
@dp.expect("valid_email", "email IS NOT NULL AND email LIKE '%@%'")
@dp.expect_or_drop("valid_order_total", "order_total >= 0")
@dp.expect_or_fail("valid_order_id", "order_id IS NOT NULL")
def silver_orders():
    return spark.readStream.table("bronze_orders")
Expectation Violating rows Use when
expect Kept, flagged in pipeline metrics Report-only, non-critical issues
expect_or_drop Silently excluded from this destination Quarantine-equivalent, moderate-severity issues
expect_or_fail Entire pipeline run fails Critical invariants that should never be violated

This maps directly onto Section 7’s report-only vs. quarantine strategies, plus a third tier (expect_or_fail) for genuinely non-negotiable invariants – a null primary key, for instance, where continuing to process would be actively harmful rather than merely imprecise.

Referencing other tables within the same pipeline

@dp.table
def silver_orders():
    return spark.readStream.table("LIVE.bronze_orders")
    # or, in newer syntax without the LIVE. prefix, depending on pipeline configuration:
    # return spark.readStream.table("bronze_orders")

Tables within the same pipeline reference each other by name – this is what lets SDP infer the dependency graph automatically: silver_orders reading from bronze_orders tells the framework bronze_orders must run first, without you writing any explicit ordering logic.

Putting several concepts together

import dlt as dp

@dp.table(comment="Raw orders, minimally transformed")
def bronze_orders():
    return (spark.readStream
        .format("cloudFiles")
        .option("cloudFiles.format", "json")
        .load("/Volumes/main/landing/orders/"))

@dp.table(comment="Validated orders, bad rows quarantined")
@dp.expect_or_drop("valid_total", "order_total >= 0")
@dp.expect_or_fail("has_order_id", "order_id IS NOT NULL")
def silver_orders():
    return spark.readStream.table("bronze_orders")

@dp.materialized_view(comment="Daily revenue for the finance dashboard")
def gold_daily_revenue():
    return (spark.read.table("silver_orders")
        .groupBy("order_date")
        .agg({"order_total": "sum"}))

Three destinations, one dependency chain, inferred automatically from the table references – no checkpoint paths, no explicit run bronze then silver then gold ordering, no separate quality- check code outside the table definition itself. This is the complete syntax vocabulary the next three lectures build a real pipeline with.


| ← Previous: Core concepts - Pipelines, Flows, Destinations, and the Python API | Next: Building the Bronze layer - Auto Loader as a Streaming Table → | |:—|—:|


Back to top

Independent, self-authored Databricks companion notes.

This site uses Just the Docs, a documentation theme for Jekyll.