06Standards & CI/CD
This route decides how code is laid out, how it reaches a workspace, and what has to be true before a pull request is merged. The three testability rules are the load-bearing part — they are what make every later testing route possible. A warehouse that skips them can still be shipped. It just cannot be proven, and it cannot be handed over.
Code structure
Almost all untestable Databricks code is untestable for one of three reasons, and all three are layout decisions rather than testing decisions. Fix the layout and the tests become ordinary.
#The three testability rules
spark.read.table inside it, no table name, no catalog, no clock. Two: IO lives at the edges, in a thin caller the tests never import. Three: nothing sits between the logic and the test — no decorator, no notebook cell, no %run. A test imports the function the same way production does.from pyspark.sql import DataFrame, functions as F
# Business rules 1, 2, 5 and 6 as a plain dict — the pipeline and the
# test both import THIS, so they can never disagree about the rules.
# Rule 3 (valid countries DE/AT/CH/NL) is deliberately absent: an order
# line carries no country, so that rule is enforced in the customer
# transform. An expectation naming a column the table does not have
# fails the pipeline at analysis, not at runtime.
EXPECTATIONS = {
"amount_positive": "amount_eur > 0 OR status = 'CANCELLED'",
"known_currency": "currency IN ('EUR', 'CHF')",
"fx_rate_resolved": "fx_rate IS NOT NULL",
"order_not_ancient": "order_ts >= TIMESTAMP '2020-01-01'",
"status_domain": "status IN ('OPEN', 'SHIPPED', 'CANCELLED')",
}
def to_silver_sales_order(lines: DataFrame, rates: DataFrame) -> DataFrame:
"""lines: bronze.sap_vbap shape. rates: silver.fx_rate (currency, rate_date, fx_rate)."""
return (
lines.withColumn("rate_date", F.to_date("order_ts"))
.join(rates, on=["currency", "rate_date"], how="left")
# EUR is the base currency and is absent from the ECB file, so it is 1
# by definition. Any OTHER currency with no rate stays NULL on purpose:
# fx_rate_resolved then drops the row and the event log counts it. The
# literal is cast, because a Python float would promote the whole
# DECIMAL chain to DOUBLE.
.withColumn(
"fx_rate",
F.when(F.col("currency") == "EUR", F.lit(1).cast("decimal(18,6)"))
.otherwise(F.col("fx_rate")),
)
.withColumn(
"amount_eur",
F.round(F.col("quantity") * F.col("unit_price") * F.col("fx_rate"), 2),
)
.drop("rate_date")
)from pyspark import pipelines as dp
from dwh.pipelines.transforms.silver_sales_order import EXPECTATIONS, to_silver_sales_order
@dp.materialized_view(name="sales_order_line")
@dp.expect_all_or_drop(EXPECTATIONS)
def sales_order_line():
return to_silver_sales_order(
spark.read.table("bronze.sap_vbap"),
spark.read.table("silver.fx_rate"),
)amount_eur = ROUND(quantity * unit_price * fx_rate, 2) — is four lines and runs on a laptop in under a second. Without it the same test costs a pipeline start, a seeded table and ninety seconds, so nobody writes it, so the rule is only checked when someone notices a wrong number in a dashboard.Doing it
- Split each transform into transforms/
.py (pure) and a caller. Do the split mechanically first and improve the logic second — two commits, never one. - Move EXPECTATIONS out of the decorator call into a module-level dict in the transform file.
- Pass anything non-deterministic in as an argument: as_of timestamps, run ids, the catalog. A transform calling current_timestamp() cannot be asserted against a fixed expected DataFrame.
- Keep the wrapper under ten lines. If it grows logic, that logic has escaped the testable half.
Defaults
- transforms/ holds pure functions; pipelines/ and jobs/ hold IO. Tests import only transforms/.
- EXPECTATIONS is a dict in the transform module, imported by both the pipeline and the test.
- Clock, catalog and run id are arguments, never module-level reads.
- The wrapper is glue thin enough that nobody wants to test it.
Gotchas
- spark.read.table() inside the transform. Every test then needs a real table, so the suite needs a warehouse, so it moves to nightly, so it stops running before merge.
- A transform calling F.current_timestamp(). The expected DataFrame changes every run, the developer 'fixes' it by asserting only row counts, and the test now passes on wrong values.
- Expectations written inline in the decorator. The test asserting a rule re-types the same SQL string, the two drift after the first edit, and the test keeps passing against a rule production no longer runs.
- dbutils.widgets.get() inside a transform. It works in a notebook and raises outside one, so the function is importable only from Databricks — which is the problem you were trying to leave.
- coalesce(fx_rate, 1.0) as a 'safe' default for a missing rate. A CHF order is then booked at 1:1, revenue is overstated by roughly a tenth, and nothing fails — no expectation fires, no job goes red. Default the base currency by name and let every other missing rate stay NULL so an expectation can count it.
- An expectation naming a column the transform does not produce — country on an order line is the classic. Lakeflow rejects it when the pipeline is analysed, so it fails on the first deploy rather than in review, and the fix is usually to move the rule to the table that owns the column.
#Workspace files, not a wheel
src/ as workspace files inside the bundle and import from it. Do not build a wheel, upload it to a Volume, install it on a cluster and restart.sys.path — or structured as Python packages — are automatically distributed to all executors. On 12.2 LTS and below they are not, and the symptom is precise: the same import works on the driver and raises ModuleNotFoundError inside a UDF.import sys
# Both are job parameters set by the bundle, not relative paths and not
# an environment sniff.
source_root = dbutils.widgets.get("source_root")
catalog = dbutils.widgets.get("catalog")
if source_root not in sys.path:
sys.path.insert(0, source_root)
from dwh.silver.customer import clean_customer # noqa: E402
def run(spark, source: str, target: str) -> int:
df = clean_customer(spark.read.table(source))
df.write.mode("overwrite").saveAsTable(target)
return df.count()
# The entry point is the only place a table name is assembled.
rows = run(spark, f"{catalog}.bronze.sap_kna1", f"{catalog}.silver.customer")
print(f"silver.customer: {rows} rows written")resources:
jobs:
silver_customer:
name: dwh-silver-customer
tasks:
- task_key: silver_customer
notebook_task:
notebook_path: ../src/dwh/jobs/silver_customer.py
source: WORKSPACE
base_parameters:
source_root: ${workspace.file_path}/src
catalog: ${var.catalog}%run ./helpers is replaced by from dwh.common.keys import make_sk. The difference is not stylistic: %run executes a notebook into the current namespace, so nothing it defines can be imported by pytest, static analysis cannot see it, and two notebooks that both %run the same helper get two copies with independent state.Doing it
- Put the whole package under src/dwh/ with __init__.py files and deploy it as workspace files via the bundle.
- Bootstrap sys.path exactly once, in the entry-point file — never inside a transform or a library module.
- Pass the source root as a job parameter resolved from ${workspace.file_path}. Do not compute it from a relative path or from os.getcwd().
- Grep for %run and convert each one to an import in its own commit, so a revert is cheap. Check the runtime version on every cluster and pipeline first — 12.2 LTS is still in the wild.
Defaults
- src/dwh/ deployed as workspace files by the bundle. No wheel, no Volume, no cluster library.
- One sys.path bootstrap per entry point, fed by a job parameter.
- DBR 13.3 LTS or newer everywhere, so imported modules reach executors.
- %run does not appear in the repository, and local pytest imports the identical package from src/ — no shim, no path hackery in conftest.py.
Gotchas
- DBR 12.2 LTS or older: an import that works on the driver raises ModuleNotFoundError inside a UDF. It is intermittent by nature — the failure only appears once someone writes the first UDF, weeks after the layout was agreed.
- Relative sys.path entries such as sys.path.append('../..'). DBR 14.0 changed the default working directory to the directory of the notebook being run, so a path written against 13.3 resolves somewhere else after an upgrade and fails in production only.
- sys.path.append inside a library module. It runs on every import, the list grows, and import order starts deciding which copy of a module you get — a bug that reproduces on one cluster and not another.
- A stale wheel still installed as a cluster library after the migration. It shadows the workspace files, so you debug the new code while the old code runs.
#Characterization test first, then refactor
CREATE TABLE dwh_test.fixtures.char_fct_order_line_20260729
COMMENT 'Characterization snapshot of gold.fct_order_line, June 2026, taken before the transform refactor. Drop when the refactor is merged.'
AS
SELECT order_line_sk, order_id, line_number,
customer_sk, product_sk, date_sk, region_sk,
order_ts, quantity, unit_price, currency, fx_rate, amount_eur, status
-- _tech_loaded_at is deliberately NOT projected. It changes on every
-- run, so including it makes every row differ and the test useless.
FROM dwh_prod.gold.fct_order_line
WHERE order_ts >= DATE '2026-06-01'
AND order_ts < DATE '2026-07-01';-- Same projection AND same window on both sides, or every row outside
-- June is reported as a difference and the test can never return zero.
WITH new_rows AS (
SELECT order_line_sk, order_id, line_number,
customer_sk, product_sk, date_sk, region_sk,
order_ts, quantity, unit_price, currency, fx_rate, amount_eur, status
FROM dwh_test.gold.fct_order_line
WHERE order_ts >= DATE '2026-06-01'
AND order_ts < DATE '2026-07-01'
)
SELECT 'new_not_in_old' AS side, d.* FROM (
SELECT * FROM new_rows
EXCEPT ALL
SELECT * FROM dwh_test.fixtures.char_fct_order_line_20260729
) d
UNION ALL
SELECT 'old_not_in_new' AS side, d.* FROM (
SELECT * FROM dwh_test.fixtures.char_fct_order_line_20260729
EXCEPT ALL
SELECT * FROM new_rows
) d;Doing it
- Take the snapshot from production output, not from a re-run of the old code — you are pinning what the business currently sees.
- Choose a window wide enough to contain the awkward cases: a month with cancellations, at least one CHF order, at least one line hitting the UNKNOWN member.
- Run EXCEPT ALL in both directions, with the identical column list and the identical window on both sides. One direction catches only half the differences, and EXCEPT without ALL hides duplicate-count changes entirely.
- Keep the refactor commit behaviour-neutral, put an expiry in the snapshot's COMMENT, and actually drop it when the refactor merges.
Defaults
- Snapshot production output, never a re-run of the code under change.
- Symmetric EXCEPT ALL, both directions, in one query with a side label.
- One refactor commit, behaviour-neutral, reviewed on its own.
- The snapshot table carries a COMMENT saying what it is for and when it dies.
Gotchas
- Comparing the whole rebuilt table against a windowed snapshot. Every row outside the window comes back as new_not_in_old, the query can never return zero, and the team concludes the pattern does not work rather than that the filter is missing on one side.
- Snapshotting a re-run of the old code instead of production output. You pin a behaviour the business has never seen, and a real production-versus-code drift stays invisible.
- EXCEPT instead of EXCEPT ALL. EXCEPT deduplicates, so a refactor that doubles every row returns zero differences and passes.
- Including _tech_loaded_at in the compared columns. Every row differs, the test is declared useless, and the team abandons the pattern on day one. Project the business columns explicitly.
- Keeping the snapshot after the merge. Six months later it is a fixture nobody can explain, still failing CI whenever a legitimate change lands.
Bundles and CI
One bundle, three targets, one command per CI step. The interesting parts are the test target and the single bundle limitation that has a workaround worth knowing.
#The bundle and its test target
src/ workspace files. The catalog is a variable per target, which is what lets the same code run against dwh_dev, dwh_test and dwh_prod without a single environment check at runtime.bundle:
name: dwh
include:
- resources/*.yml
variables:
catalog:
description: Unity Catalog catalog this target writes to.
deploy_sp:
description: Application ID of the OAuth service principal that owns prod runs.
targets:
dev:
mode: development
default: true
variables:
catalog: dwh_dev
test:
variables:
catalog: dwh_test
resources:
jobs:
ci_assert: &ci_assert
name: dwh-ci-assert-${bundle.target}
tasks:
- task_key: seed
notebook_task:
notebook_path: ../src/dwh/ci/seed.py
source: WORKSPACE
base_parameters:
source_root: ${workspace.file_path}/src
catalog: ${var.catalog}
- task_key: pipeline
depends_on:
- task_key: seed
pipeline_task:
pipeline_id: ${resources.pipelines.silver.id}
full_refresh: true
- task_key: assert
depends_on:
- task_key: pipeline
notebook_task:
notebook_path: ../src/dwh/ci/assert_expectations.py
source: WORKSPACE
base_parameters:
source_root: ${workspace.file_path}/src
catalog: ${var.catalog}
sandbox:
variables:
catalog: dwh_dev
resources:
jobs:
ci_assert: *ci_assert # referenced, not redefined
prod:
mode: production
variables:
catalog: dwh_prod
run_as:
service_principal_name: ${var.deploy_sp}
# ci_assert is simply ABSENT here. That is the workaround.ci_assert as a top-level resource and then exclude it from prod — excluding a top-level bundle resource for a single target is not supported. So the seed/run/assert job is defined inside the first target that needs it, anchored with &ci_assert and referenced with *ci_assert from any other target that wants it. Targets that should not have it say nothing, which is the only reliable way to subtract.Doing it
- Define the catalog as a bundle variable resolved per target. No literal catalog names anywhere in src/.
- Anchor any resource that belongs to some targets but not others, and define it in the first target that uses it. Keep anchors in databricks.yml itself — they do not cross include: file boundaries.
- Pin the Databricks CLI version in CI. Python bundle configuration requires 0.275.0 or newer, and an older CLI fails confusingly rather than with a version error.
- Run databricks bundle validate -t prod locally before every release and read the resolved configuration it prints — variables are already substituted there, which is where a wrong catalog is visible before anything is deployed.
Defaults
- One bundle, one repository, one databricks.yml, resources split into resources/*.yml — anchors excepted.
- Catalog per target as a variable; mode: production on prod so the CLI enforces production guardrails.
- Subtract by omission and YAML anchors, never by trying to exclude a top-level resource.
- Pin the CLI version in CI and in the developer setup instructions; prod runs as a service principal via run_as.
Gotchas
- Trying to exclude a top-level resource for one target. It is not supported, so the CI job you wrote for test also lands in prod, where it seeds fixture data into the production catalog.
- An anchor defined in resources/jobs.yml and referenced in databricks.yml. Anchors are per-document, so it never resolves and the message points at YAML syntax rather than at the include boundary.
- An anchor referenced before it is defined. YAML resolves in document order, so reordering targets breaks a deploy that worked yesterday with no code change.
- environment_key on a serverless notebook task is reported to be rejected. Unverified — but if a serverless task fails to start for no visible reason, this is the first thing to try removing.
- verifiedDatabricks Asset Bundles are now Declarative Automation Bundles. The rename is non-breaking: the bundle CLI command and all existing configuration are unchanged.Stand 29.07.2026
- verifiedPython configuration for bundles (the databricks-bundles package) left experimental in Databricks CLI 0.275.0, which is also the minimum version required to use it.Stand 29.07.2026
- check firstExcluding a top-level bundle resource for a single target is not supported.Stand 29.07.2026
- check firstServerless notebook tasks are reported to reject environment_key.Stand 29.07.2026
#The CI pipeline and its identity
databricks bundle validate is the first step, before the linter and before pytest. It is the only step that catches a malformed bundle and it takes two seconds — running it after a ninety-second test suite wastes ninety seconds every time the YAML is wrong.name: dwh
on:
pull_request:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
env:
DATABRICKS_HOST: ${{ secrets.DATABRICKS_HOST }}
DATABRICKS_CLIENT_ID: ${{ secrets.DATABRICKS_CI_CLIENT_ID }}
DATABRICKS_CLIENT_SECRET: ${{ secrets.DATABRICKS_CI_CLIENT_SECRET }}
steps:
- uses: actions/checkout@v4
- uses: databricks/setup-cli@main
with:
version: 0.275.0 # pinned; see the bundles-python fact
- name: bundle validate
run: databricks bundle validate -t test
# Catches uppercase AND non-ASCII in one pass, and only in the table
# name — a COMMENT holding a German term is untouched.
- name: identifier rule
run: |
if grep -rEn 'CREATE (OR REPLACE )?TABLE (IF NOT EXISTS )?[^ (]*[^a-z0-9._ (]' src/; then
echo "table names must match ^[a-z][a-z0-9_]*$ and be ASCII" >&2
exit 1
fi
- name: unit tests
run: pytest tests/unit --junitxml=reports/unit.xml
- name: deploy to test
run: databricks bundle deploy -t test
- name: seed, run, assert
run: databricks bundle run -t test ci_assertDATABRICKS_CLIENT_ID and DATABRICKS_CLIENT_SECRET, which the CLI exchanges for a short-lived token on every invocation. Personal access tokens do not appear in this repository, this pipeline, or anyone's .databrickscfg on a shared machine.| Personal access token | OAuth service principal | |
|---|---|---|
| Belongs to | a person | the pipeline |
| When that person leaves | prod deploys stop | nothing happens |
| In system.access.audit | their name, on every automated run | the service principal, distinguishable from humans |
| Lifetime | long-lived secret at rest | short-lived token minted per call |
| Scope | everything the person can do | exactly the grants the SP was given |
system.access.audit cannot separate what a human did from what a pipeline did — so the standing access review has no signal, and 'who read the PII table' is unanswerable for the exact identity that touches everything.Doing it
- Create one service principal per environment, grant it only what its target needs, and store its secret in the CI secret store — never in the repository, never as a bundle variable default.
- Revoke every personal access token used for deployment in week one and confirm with the token API that none remain.
- Make bundle validate the first step and let it fail the build. A warning nobody reads is not a check.
- Encode the identifier and read-direction rules from the architecture route as grep steps here, and emit JUnit XML so failures surface in the pull request rather than in a log nobody opens.
Defaults
- bundle validate first, static checks second, unit tests third, workspace steps last.
- OAuth M2M service principals for every automated identity; zero personal access tokens.
- One service principal per environment, granted per target, secret in the CI secret store.
- Pinned CLI version, JUnit XML published to the pull request, and every agreed standard that can be a grep is a grep.
Gotchas
- CI running as a personal access token belonging to whoever set it up. It works until that person leaves or the token expires, and then production deployment is blocked by an account nobody can access.
- bundle validate after the test suite. Every YAML typo costs a full test run before you learn about it, which teaches people to push and wait rather than validate locally.
- A service principal granted ALL PRIVILEGES on the metastore 'to unblock CI'. It never gets narrowed, and the identity with the widest grants in the warehouse is the one whose secret sits in a CI configuration.
- Deploying to test from a branch without an isolated schema. Two pull requests deploy the same job name, the second overwrites the first, and both builds report failures caused by each other.
- verifiedDatabricks Asset Bundles are now Declarative Automation Bundles. The rename is non-breaking: the bundle CLI command and all existing configuration are unchanged.Stand 29.07.2026
- verifiedPython configuration for bundles (the databricks-bundles package) left experimental in Databricks CLI 0.275.0, which is also the minimum version required to use it.Stand 29.07.2026
Definition of done
A definition of done is only useful if a reviewer can check every line of it in under a minute without asking the author anything.
#A definition of done that bites
| Done means | Reviewer checks |
|---|---|
| Grain is declared | the table COMMENT contains a 'one row per …' sentence, in the DDL in this diff |
| The transform is importable | a test imports it directly, with no pipeline and no table |
| Business rules are asserted, not described | each rule touched by the diff maps to an EXPECTATIONS entry or a .sql assertion returning zero rows |
| Behaviour-neutral changes are proven neutral | a symmetric EXCEPT ALL result is linked in the pull request |
| The bundle still validates | bundle validate is green for every target, not only test |
| No literals that vary by environment | grep the diff for dwh_dev / dwh_prod; the catalog arrives as a variable |
| Identifiers pass the rule | ^[a-z][a-z0-9_]*$ — the CI identifier step is green and no new identifier in the diff needs backticks |
| Ownership is real | the object is owned by a group; no individual appears as owner in the DDL |
| The re-run question is answered | the runbook says whether this job is safe to re-run blind, updated here if the answer changed |
| PII is classified | any new column carrying personal data has its governed tag applied in the DDL, not clicked in the UI |
Doing it
- Put the ten rows in the pull-request template, so the reviewer reads them at the moment they matter.
- Automate every row that can be automated — identifiers, environment literals, bundle validate — and leave the rest as explicit reviewer questions.
- Add a row only when a real defect escaped that it would have caught, and name that defect in the commit message.
- Delete a row that has never once failed, and timebox review: if checking the list takes more than five minutes, the pull request is too big and that is the finding.
Defaults
- Ten rows or fewer, each binary, each visible in a diff or a build.
- Anything automatable is automated; the manual rows are the ones needing judgement.
- The list lives in the pull-request template in the repository, versioned like code.
- Rows are added in response to escaped defects and removed when they stop firing.
Gotchas
- Rows that cannot be checked — 'code is clean', 'documentation updated'. They train reviewers to tick boxes without looking, which devalues the rows that do matter.
- A twenty-row list. Review becomes a chore, people tick everything, and the list now provides false assurance rather than none.
- Enforcing it only on the consultant's pull requests. The standard has to bind everyone from week one or it reads as an outsider's rule and dies at handover.
- No written exception path. There will be an emergency fix; without one carrying an expiry date, the emergency becomes the precedent.
Straight to the documentation
The generic platform material lives in the official docs and is not restated here. These are the pages worth having open while you work through this route.
- What are Declarative Automation Bundles? — start here for the renamed product; the CLI commands are unchanged from the Asset Bundles you already know
- Bundle settings and targets — the full schema for variables, targets, run_as and mode — read it before inventing a convention of your own
- Bundle configuration in Python — if the YAML starts generating itself; check the CLI 0.275.0 minimum before committing to it
- Work with Python and R modules — the authority on workspace files, sys.path, and which runtime distributes modules to executors
- OAuth machine-to-machine authentication — how to create the CI service principal and its secret — follow this instead of issuing a token