Ways to use Delta Engine¶
Delta Engine is a reconciliation library rather than a deployment system or background controller. A sync runs only when your application, workflow, or deployment process calls it with a collection of table declarations.
This makes Delta Engine flexible about where it fits. A team can reconcile table contracts as part of a release, make table readiness a prerequisite of an ETL workflow, or manage governance metadata around tables whose structure is owned elsewhere.
The patterns below are not mutually exclusive. A project might plan changes in CI, apply them through a dedicated release job, and use a separate restricted deployment for centrally managed annotations.
Choose a pattern¶
Pattern |
Best suited to |
Where Delta Engine runs |
|---|---|---|
Controlled production releases and continuing table estates |
A dedicated job after the application bundle is deployed |
|
ETL applications that own a single target table |
Application startup before transformations or writes |
|
ETL applications that use declared schema and row identity at write time |
Transformation and write code |
|
ETL applications that test schemas, required values, and relationships |
Unit tests |
|
Split ownership between data-product, platform, and governance teams |
A separate restricted-scope deployment |
Pattern 1: Release table contracts with the application¶
For most production applications, the clearest pattern is to treat table contracts as part of the release.
flowchart LR
PR[Pull request] --> Review[Plan and review changes]
Review --> Merge[Merge]
Merge --> Release[Deploy and reconcile tables]
Release --> Data[Run data workloads]
The declarations, pipeline code, and deployment configuration are versioned and released together. The deployment creates or updates the Databricks resources, then runs a dedicated job that reconciles the table declarations before the new data-producing workload is allowed to run.
This keeps responsibilities clear:
the bundle deploys jobs, pipelines, libraries, and configuration;
the reconciliation job manages the declared table state;
the ETL workloads produce and write rows.
Keep an explicit table registry¶
Expose the tables owned by the release through one explicit, ordered collection:
# myproject/table_registry.py
from myproject.tables.customers import customers
from myproject.tables.order_items import order_items
from myproject.tables.orders import orders
ALL_TABLES = (
customers,
orders,
order_items,
)
An explicit registry makes the release boundary visible in code review: adding a table to the collection is an intentional decision to include it in the deployment.
Add a reconciliation entry point¶
Package the reconciliation logic with the application:
# myproject/sync_tables.py
from delta_engine import render_report
from delta_engine.databricks import build_spark_engine
from pyspark.sql import SparkSession
from myproject.table_registry import ALL_TABLES
def main() -> None:
"""Reconcile every table owned by this release."""
spark = SparkSession.builder.getOrCreate()
engine = build_spark_engine(spark)
report = engine.sync(*ALL_TABLES)
print(render_report(report))
The job succeeds only when the sync succeeds. If reading, planning, dependency
resolution, or execution fails, Delta Engine raises SyncFailedError, and the
job should fail rather than allowing the release to continue silently.
Deploy and run it through a bundle¶
A Declarative Automation Bundle can deploy the application wheel and configure the reconciliation entry point as a dedicated Python wheel task.
The release pipeline can then validate the bundle, deploy it, and run the reconciliation job:
databricks bundle validate --target production
databricks bundle deploy --target production
databricks bundle run --target production reconcile_tables
Bundle deployment and table reconciliation are separate operations. If the
bundle is deployed but the reconciliation job fails, the new job definitions
and application artifacts may already exist in the workspace. The release
process should stop, expose the failed SyncReport, and prevent the updated
data workload from starting until the declaration or catalog problem is
resolved.
Plan the same registry in CI¶
The same registry can be planned during a pull request through the read-only CLI:
delta-engine plan myproject.table_registry:ALL_TABLES
This reads live Unity Catalog state, validates the declarations, and prints the semantic differences and exact planned DDL without applying them.
A real sync does not replay the dry-run output as a saved plan. It reads the catalog again and derives a fresh plan from the state present at deployment time. See How to report schema plans in CI for a complete read-only workflow.
Pattern 2: Reconcile a target table before its ETL runs¶
When an ETL application owns a single target table, it can reconcile that table once at startup before performing transformations or writing any rows:
ETL job starts
↓
reconcile target table
↓
run transformations
↓
write rows
Keep the table declaration alongside the ETL code and import it into the application entry point:
# myproject/main.py
from delta_engine.databricks import build_spark_engine
from pyspark.sql import SparkSession
from myproject.pipeline import run_pipeline
from myproject.tables.customers import customers
def main() -> None:
"""Reconcile the target table, then run the ETL pipeline."""
spark = SparkSession.builder.getOrCreate()
engine = build_spark_engine(spark)
engine.sync(customers)
run_pipeline(spark)
If the table is missing, Delta Engine creates it. If it has safe, supported drift, Delta Engine reconciles it. If it already matches the declaration, the sync applies no DDL and the ETL continues normally.
If the declaration is invalid, the live table cannot be read, the proposed
transition is unsafe, or execution fails, SyncFailedError propagates and the
ETL stops before run_pipeline() begins.
This pattern gives the ETL application a clear startup precondition:
The target table must exist and match its declared contract before the application writes any data.
Synchronize dependencies together¶
An ETL that owns one independent target normally needs to synchronize only that table:
engine.sync(customers)
Pass multiple declarations when the target has cross-table dependencies that
Delta Engine must validate and order. For example, if orders declares a
foreign key to customers, synchronize both declarations in the same run:
engine.sync(customers, orders)
Delta Engine validates the relationship, places the parent before the dependent,
and prevents orders from executing if customers cannot reach its desired
state.
There is no need to synchronize unrelated tables together merely because they belong to the same repository. Each ETL can reconcile its own target, while related tables are grouped only where their declarations depend on one another.
When this pattern works well¶
Use this pattern when:
one ETL application clearly owns one target table;
the application should be able to start in a new environment where the table does not yet exist;
table validity should be checked before every ETL run;
the job runs at a frequency where the additional catalog read and planning step are acceptable;
the runtime identity is permitted both to reconcile the table and write its data.
It is particularly straightforward for batch applications because the table check becomes part of the application’s normal startup sequence.
Pattern 3: Reuse table declarations throughout ETL code¶
A DeltaTable declaration can also be imported throughout the ETL that
produces it. Table names, schemas, keys, and column lists then stay in one
place rather than being repeated across the pipeline.
Conform output to the declared schema¶
Use the declaration to cast and order the final DataFrame before writing it:
result = transform(source).to(to_spark_schema(customers))
Build merges from the declared primary key¶
Use the same primary key declared on the table to construct the Delta merge condition. This handles composite keys without maintaining a separate join predicate:
from functools import reduce
from operator import and_
from pyspark.sql import functions as F
condition = reduce(
and_,
(
F.col(f"target.{column}") == F.col(f"source.{column}")
for column in customers.primary_key
),
)
(
target.alias("target")
.merge(result.alias("source"), condition)
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.execute()
)
Derive update columns from the declaration¶
When a merge should not update key columns, derive the mutable columns instead of maintaining another list:
update_columns = [
column.name
for column in customers.columns
if column.name not in customers.primary_key
]
updates = {
column: F.col(f"source.{column}")
for column in update_columns
}
(
target.alias("target")
.merge(result.alias("source"), condition)
.whenMatchedUpdate(set=updates)
.whenNotMatchedInsertAll()
.execute()
)
Reuse the key wherever row identity matters¶
The declared primary key can also drive deduplication, windowing, and other key-based ETL logic. For example, retain the latest event for each customer:
from pyspark.sql import Window, functions as F
latest = Window.partitionBy(*customers.primary_key).orderBy(
F.col("event_timestamp").desc()
)
result = result.withColumn("_row_number", F.row_number().over(latest))
Pattern 4: Use table declarations in unit tests¶
The same DeltaTable used by production code can define the schemas, keys,
and relationships expected by ETL tests. Tests can therefore work from the
real table contract instead of maintaining separate schemas and column lists.
Create test DataFrames from declared schemas¶
Use the declared Spark schema when creating test fixtures:
source = spark.createDataFrame(
[
(1, "Alice"),
(2, "Bob"),
],
schema=to_spark_schema(customers),
)
Assert the ETL produces the declared schema¶
Compare the final DataFrame with the same table contract:
def test_produces_the_declared_schema(source) -> None:
result = transform(source)
assert result.schema == to_spark_schema(customers)
A schema change now changes the production contract and the test expectation in one place.
Test non-nullable columns¶
Use the declaration to derive which columns must always contain a value:
def test_produces_no_nulls_in_required_columns(source) -> None:
result = transform(source)
required_columns = [
column.name
for column in customers.columns
if not column.nullable
]
for column in required_columns:
assert result.filter(F.col(column).isNull()).isEmpty()
Test primary-key uniqueness¶
The declared primary key can drive data-level key checks:
def test_produces_unique_primary_keys(source) -> None:
result = transform(source)
duplicates = (
result
.groupBy(*customers.primary_key)
.count()
.filter(F.col("count") > 1)
)
assert duplicates.isEmpty()
Test foreign-key relationships¶
Related table declarations can also drive referential-integrity tests. For a foreign key whose local and referenced columns have the same names:
def test_produces_valid_customer_references(
orders_result,
customers_result,
) -> None:
missing_customers = (
orders_result
.join(
customers_result,
on=list(orders.foreign_keys[0].columns),
how="left_anti",
)
)
assert missing_customers.isEmpty()
Pattern 5: Add governance without taking over the pipeline¶
Table ownership is not always all-or-nothing. A data-product team may own the schema and row production while a platform or governance team owns comments, classifications, and ownership tags.
Restricted scopes let those responsibilities remain separate:
flowchart TB
Pipeline[Pipeline or dbt project] -->|owns schema and rows| Table[Unity Catalog table]
Governance[Delta Engine governance deployment] -->|owns comments and tags| Table
For example, a pipeline can continue to define and populate an orders table
while a separate declaration manages its annotations:
from delta_engine.schema import Column, DeltaTable, Long, String
orders_annotations = DeltaTable(
catalog="production",
schema="sales",
name="orders",
columns=[
Column(
"order_id",
Long(),
nullable=False,
comment="Stable identifier for an order.",
tags={"classification": "identifier"},
),
Column(
"customer_email",
String(),
comment="Email supplied when the order was placed.",
tags={"classification": "personal"},
),
],
comment="Customer orders accepted by the commerce platform.",
tags={
"domain": "sales",
"owner": "commerce-platform",
},
scope="annotations",
)
With scope="annotations", Delta Engine can reconcile table and column comments
and tags, but it cannot add, remove, or alter columns, properties, layouts, or
constraints.
Other available scopes include:
Scope |
Managed state |
|---|---|
|
Complete table definition |
|
Comments, tags, and key constraints |
|
Comments and tags |
|
Tags only |
See How to deploy metadata only for the exact scope semantics.
Mirror the table state you do not own¶
A restricted declaration still describes the live table accurately. Its columns, layout, and any keys must match the state owned by the producing system, even though Delta Engine is not allowed to alter those aspects.
If the pipeline changes the schema and the governance declaration is not updated, the next sync fails rather than applying metadata against a table it no longer understands. This makes the ownership boundary safe, but it also means governance declarations must follow intentional changes made by the structural owner.
Use restricted scopes for streaming tables¶
Databricks streaming tables are structurally owned by their defining pipeline.
Delta Engine can manage their externally alterable comments and tags through
scope="annotations" or scope="tags", but broader scopes are rejected.
Do not declare the same comment or tag in both the pipeline definition and Delta Engine. A pipeline refresh can reassert its own declaration, creating recurring drift between two competing owners.
Operational boundaries¶
Whichever pattern you use:
Keep one write-capable sync per table at a time.
Treat a dry run as a live preview, not as an immutable saved plan.
Do not allow another tool to evolve an aspect that Delta Engine manages.
Do not run reconciliation inside every streaming micro-batch.
Prefer explicit table registries over implicit package crawling.
Do not describe a multi-statement table plan or multi-table run as transactionally atomic.
Inspect the returned
SyncReport; successful process startup is not a substitute for checking the reconciliation result.
See Getting started for a complete first sync, How a sync works for the execution model, and Capabilities and limitations before using write-capable synchronization in an unattended production workflow.