Databricks

SQL, Spark, pipelines, and lakehouse patterns

Musfira Jamil

2026-09-27

Notes on SQL, Spark, pipelines, dimensional modelling, analytics queries, and Chemprop for drug discovery on Databricks.

These chapters started as learning notes and primers.

Notes library

AI Drug Discovery Made Easy: Your Complete Guide to Chemprop on Databricks

Notes on using Chemprop, a directed message-passing graph neural network for molecular property prediction, as a managed workflow on Databricks, from loading pretrained models to training multi-task ADMET regressors.

These are my notes on a Databricks Community technical blog post, “AI Drug Discovery Made Easy: Your Complete Guide to Chemprop on Databricks.” All credit for the original workflow and code goes to the Databricks authors, see the link for the full walkthrough and runnable notebooks.

Computational drug discovery got a well-known proof point in 2020, when a graph neural network flagged halicin, a compound already sitting in a screening library, as a candidate antibiotic with a structure unlike anything in existing drug classes. That’s the pitch for pairing a chemistry-aware model like Chemprop with a platform like Databricks: search chemical space faster, and do it on infrastructure that also handles the data engineering and deployment side.

What Chemprop actually models

Chemprop is a directed message-passing neural network (D-MPNN) built specifically for molecules. Instead of hand-engineered chemical descriptors, it treats a molecule as a graph, atoms as nodes, bonds as edges, and runs several rounds of message passing so that information about neighboring atoms and bonds propagates through the structure. The result is a learned molecular fingerprint that feeds into a feed-forward network for the actual prediction task, whether that’s a classification (toxic / non-toxic) or a regression (solubility, binding affinity, an ADMET property).

Why run it on Databricks specifically

The argument isn’t that Databricks changes the modeling, it’s that it removes friction around everything adjacent to modeling. Unity Catalog gives you a governed, discoverable place to keep datasets and registered models so a chemistry team and a data engineering team aren’t passing CSVs around. MLflow handles experiment tracking and model registry, so a trained Chemprop model can be logged, versioned, and served without a bespoke deployment pipeline. For a workflow that moves from raw SMILES strings to a served prediction endpoint, having compute, governance, and serving in one place is the practical win.

The four workflows the guide walks through

The Databricks post runs these against Unity Catalog data and MLflow’s registry. To show what’s actually happening underneath, the snippets below are trimmed straight from Chemprop’s own example notebooks (Chemprop is MIT-licensed), Databricks just wraps this with governed data access and managed serving.

1. Inference with a pretrained model, loading an existing Chemprop checkpoint and scoring new molecules for a property like aqueous solubility, with no training step at all.

from pathlib import Path
import numpy as np
import pandas as pd
import torch
from lightning import pytorch as pl
from chemprop import data, featurizers, models

chemprop_dir = Path.cwd().parent
checkpoint_path = chemprop_dir / "tests" / "data" / "example_model_v2_regression_mol.ckpt"
mpnn = models.MPNN.load_from_checkpoint(checkpoint_path)

test_path = chemprop_dir / "tests" / "data" / "regression" / "mol" / "mol.csv"
df_test = pd.read_csv(test_path)
smis = df_test["smiles"]

test_data = [data.MoleculeDatapoint.from_smi(smi) for smi in smis]
featurizer = featurizers.SimpleMoleculeMolGraphFeaturizer()
test_dset = data.MoleculeDataset(test_data, featurizer=featurizer)
test_loader = data.build_dataloader(test_dset, shuffle=False)

with torch.inference_mode():
 trainer = pl.Trainer(logger=None, accelerator="cpu", devices=1)
 test_preds = trainer.predict(mpnn, test_loader)

df_test["pred"] = np.concatenate(test_preds, axis=0)

2. Training a single-task classifier, fine-tuning Chemprop on a labeled dataset for a binary property such as toxicity.

from pathlib import Path
import pandas as pd
from lightning import pytorch as pl
from chemprop import data, featurizers, models, nn

input_path = Path.cwd().parent / "tests" / "data" / "classification" / "mol.csv"
target_columns = ["NR-AhR", "NR-ER", "SR-ARE", "SR-MMP"]

df_input = pd.read_csv(input_path)
smis = df_input.loc[:, "smiles"].values
ys = df_input.loc[:, target_columns].values
all_data = [data.MoleculeDatapoint.from_smi(smi, y) for smi, y in zip(smis, ys)]

mols = [d.mol for d in all_data]
train_indices, val_indices, test_indices = data.make_split_indices(mols, "random", (0.8, 0.1, 0.1))
train_data, val_data, test_data = data.split_data_by_indices(all_data, train_indices, val_indices, test_indices)

featurizer = featurizers.SimpleMoleculeMolGraphFeaturizer()
train_dset = data.MoleculeDataset(train_data[0], featurizer)
val_dset = data.MoleculeDataset(val_data[0], featurizer)
test_dset = data.MoleculeDataset(test_data[0], featurizer)

train_loader = data.build_dataloader(train_dset)
val_loader = data.build_dataloader(val_dset, shuffle=False)
test_loader = data.build_dataloader(test_dset, shuffle=False)

mp = nn.BondMessagePassing()
agg = nn.MeanAggregation()
ffn = nn.BinaryClassificationFFN(n_tasks=len(target_columns))
mpnn = models.MPNN(mp, agg, ffn, batch_norm=False)

trainer = pl.Trainer(
 accelerator="cpu", devices=1, max_epochs=20, enable_checkpointing=True,
)
trainer.fit(mpnn, train_loader, val_loader)
results = trainer.test(mpnn, test_loader)

3. Serving via MLflow’s registry, pulling a registered model back out of Unity Catalog/MLflow for inference, which is the pattern you’d actually use once a model is production-bound rather than sitting in a notebook. This part is Databricks/MLflow plumbing rather than Chemprop code, so see the original post for that snippet.

4. Multi-task ADMET regression, training one model to predict several absorption/distribution/metabolism/excretion/toxicity endpoints simultaneously, which is closer to how a real drug-candidate triage pipeline would be structured than any single-property demo.

from pathlib import Path
import torch
import pandas as pd
from lightning import pytorch as pl
from chemprop import data, models, nn

input_path = Path.cwd().parent / "tests" / "data" / "regression" / "mol_multitask.csv"
target_columns = ["mu", "alpha", "homo", "lumo", "gap", "r2", "zpve", "cv", "u0", "u298", "h298", "g298"]

df_input = pd.read_csv(input_path)
smis = df_input.loc[:, "smiles"].values
ys = df_input.loc[:, target_columns].values
datapoints = [data.MoleculeDatapoint.from_smi(smi, y) for smi, y in zip(smis, ys)]

split_indices = data.make_split_indices(datapoints)
train_data, val_data, test_data = data.split_data_by_indices(datapoints, *split_indices)
train_dset = data.MoleculeDataset(train_data[0])
val_dset = data.MoleculeDataset(val_data[0])
test_dset = data.MoleculeDataset(test_data[0])

output_scaler = train_dset.normalize_targets()
val_dset.normalize_targets(output_scaler)
train_loader = data.build_dataloader(train_dset)
val_loader = data.build_dataloader(val_dset)
test_loader = data.build_dataloader(test_dset)

output_transform = nn.transforms.UnscaleTransform.from_standard_scaler(output_scaler)
ffn = nn.RegressionFFN(n_tasks=len(target_columns), output_transform=output_transform)
chemprop_model = models.MPNN(nn.BondMessagePassing(), nn.MeanAggregation(), ffn)

trainer = pl.Trainer(logger=False, enable_checkpointing=False, max_epochs=1)
trainer.fit(chemprop_model, train_loader, val_loader)

preds = trainer.predict(chemprop_model, test_loader)
preds = torch.concat(preds, axis=1)

Takeaway

The interesting part isn’t that Chemprop works, message-passing GNNs for molecular property prediction are well established, it’s the integration story: a specialized chemistry model dropped into a general-purpose data platform’s governance and serving layer, so the same infrastructure that tracks a churn model can track a toxicity classifier. Worth reading the full post for the actual notebook code and the ADMET multi-task setup in detail.

3 Common Data Modeling Techniques

A practical tour of flat, relational, and dimensional modelling, with Databricks SQL examples so you can choose the right shape for analytics and pipelines.

Data modelling is how you decide what your tables mean and how they relate. In Databricks you will often meet three styles: flat, relational, and dimensional. None is universally best. Each trades simplicity against flexibility, and each fits different stages of a lakehouse pipeline.

Flat models

A flat model is a single wide table: one row per event or entity, with many columns and little or no joining. Think of a CSV of sales with customer name, product name, and region all denormalised onto the same row. Flat models are easy to load, easy to query for beginners, and common for landing zones or feature tables.

The downside is redundancy. The same customer details repeat on every order row. Updates become painful, and storage grows. Flat tables also encourage analytics that are hard to reuse across teams.

CREATE OR REPLACE TABLE sales_flat AS
SELECT
 order_id,
 order_date,
 customer_id,
 customer_name,
 product_id,
 product_name,
 quantity,
 unit_price,
 quantity * unit_price AS line_amount
FROM bronze.orders_raw;

Use flat models for exploration, machine learning feature stores, or narrow reporting extracts. Avoid them as the long-term system of record when many teams need consistent definitions.

Relational models

Relational modelling normalises entities into related tables: customers, products, orders, order lines. Keys link them. This is the classic OLTP and enterprise warehouse style. In Databricks it often appears in silver layers where you clean and conform source systems.

CREATE OR REPLACE TABLE dim_customer_rel (
 customer_id STRING PRIMARY KEY,
 customer_name STRING,
 email STRING,
 city STRING
);

CREATE OR REPLACE TABLE fact_order_line_rel (
 order_line_id STRING PRIMARY KEY,
 order_id STRING,
 customer_id STRING,
 product_id STRING,
 quantity INT,
 unit_price DECIMAL(12,2)
);

Queries then join on keys:

SELECT c.customer_name, SUM(f.quantity * f.unit_price) AS revenue
FROM fact_order_line_rel f
JOIN dim_customer_rel c ON f.customer_id = c.customer_id
GROUP BY c.customer_name;

Relational models reduce duplication and protect integrity. They can be slower for heavy analytics if you join many large tables without good partitioning and clustering. They also ask analysts to understand the schema deeply.

Dimensional models

Dimensional modelling organises data for analysis: facts hold measures (sales amount, quantity), and dimensions hold descriptive context (date, customer, product). The classic star schema puts one fact table at the centre with dimensions around it.

CREATE OR REPLACE TABLE fact_sales (
 date_key INT,
 customer_key BIGINT,
 product_key BIGINT,
 quantity INT,
 sales_amount DECIMAL(14,2)
);

CREATE OR REPLACE TABLE dim_product (
 product_key BIGINT,
 product_id STRING,
 product_name STRING,
 category STRING
);

Dimensional models shine for BI tools and repeated business questions: revenue by category, orders by month, churn by segment. They are usually built in gold layers after you have cleaned relational or semi-structured sources.

Choosing in Databricks

Start from the questions people ask. If analysts need flexible drill-downs across many attributes, lean dimensional. If you are integrating operational systems, lean relational. If you need a fast one-off extract, a flat table is fine, just do not pretend it is a warehouse.

Good modelling is less about dogma and more about clarity: every column should have a clear grain, a clear owner, and a clear path into the next layer of your lakehouse.

Dimensional Modeling and Kimball Architecture

How Kimball-style dimensional modelling maps onto a Databricks lakehouse: facts, dimensions, conformed keys, and practical design habits.

Kimball dimensional modelling is a practical way to design analytics data so business questions are easy to ask and hard to get wrong. In Databricks, the same ideas sit comfortably in a medallion lakehouse: cleaned silver entities feed gold stars that BI tools and SQL notebooks consume.

Core ideas

A fact table records measurable events at a declared grain, for example one row per order line per day. A dimension table describes the who, what, where, and when. Surrogate keys (integers or bigints you generate) link facts to dimensions so natural keys from source systems can change without breaking history.

Star schema shape

In a star, the fact sits in the middle. Dimensions hang off it with few joins. Snowflakes normalise dimensions further (product to category to department). Stars are usually simpler for analysts; snowflakes can reduce duplication but add join complexity.

CREATE OR REPLACE TABLE gold.fact_orders (
 order_date_key INT NOT NULL,
 customer_key BIGINT NOT NULL,
 product_key BIGINT NOT NULL,
 order_id STRING NOT NULL,
 quantity INT,
 net_amount DECIMAL(14,2),
 CONSTRAINT pk_fact_orders PRIMARY KEY (order_id, product_key)
);

CREATE OR REPLACE TABLE gold.dim_date (
 date_key INT PRIMARY KEY,
 full_date DATE,
 year INT,
 month_name STRING,
 fiscal_quarter STRING
);

Slowly changing dimensions

Customer attributes change. Kimball Type 1 overwrites the current value. Type 2 keeps history with effective dates or version rows. In Databricks, Delta tables make Type 2 practical: merge new versions, expire old ones, and point facts at the correct surrogate key for that point in time.

-- Conceptual Type 2 pattern
MERGE INTO gold.dim_customer t
USING staging.customer_changes s
ON t.customer_id = s.customer_id AND t.is_current = true
WHEN MATCHED AND t.email <> s.email THEN UPDATE SET
 t.is_current = false,
 t.valid_to = current_date()
WHEN NOT MATCHED THEN INSERT *;

Keep SCD logic in one place (a notebook job or dbt model) so every mart inherits the same history rules.

Kimball on a lakehouse

Classic Kimball assumed a relational warehouse bus. On Databricks you still want a bus: shared conformed dimensions published once, then reused. What changes is storage and compute, Delta Lake, Unity Catalog, and SQL warehouses, not the modelling discipline.

  1. Define business processes and grains with stakeholders.
  2. Build conformed dimensions in gold (date, customer, product).
  3. Load facts with surrogate keys and declared additive measures.
  4. Document grain, sources, and refresh cadence in the catalogue.

Practical habits

Name tables clearly (fact_ and dim_). Avoid putting descriptive text on facts. Prefer integer date keys for pruning. Partition large facts by date when queries filter that way, and liquid-cluster or Z-order high-cardinality join keys when needed.

Kimball is not ceremony for its own sake. It is a shared language: when someone says “the grain is one shipment per day”, everyone knows what a duplicate means and which dashboard is trustworthy. That clarity is what makes dimensional modelling worth learning in Databricks.

DataBricks Series - SQL Fundamentals

The core SQL patterns worth mastering first, filtering, aggregation, joins, CTEs, window functions, and indexing, with runnable examples against a students table.

SQL (Structured Query Language) is a standardized programming language used to manage, query, and manipulate relational databases. It allows users to retrieve, update, insert, and delete data efficiently.

If you’re learning SQL, I’d recommend focusing on the following topics:

We will focus on the most-used concepts, which upon mastery will allow you to accomplish 80% of typical query tasks.

  1. Basic Querying (SELECT, FROM, WHERE)

Let’s assume we are tasked with loading data from a students table for students who have achieved grades > 85.

SELECT *
FROM students
WHERE grade >= 85;

In this query, SELECT * tells the SQL engine to load all available columns in the table students and apply the filter to the grade column.

  1. Filtering Data (WHERE, AND, OR, IN, LIKE, BETWEEN)
SELECT * FROM students
WHERE grade BETWEEN 80 AND 90
AND department IN ('Computer Science', 'Math');
  1. Sorting and Limiting Results (ORDER BY, LIMIT)
SELECT * FROM students
ORDER BY grade DESC
LIMIT 10;
  1. Aggregation (COUNT, SUM, AVG, MIN, MAX, GROUP BY, HAVING)
SELECT department, AVG(grade) AS avg_grade
FROM students
GROUP BY department
HAVING AVG(grade) > 80;

Here we can replace AVG with any of the following:

5. Joins (INNER, LEFT, RIGHT, FULL)

SELECT s.first_name, s.last_name, c.course_name
FROM students s
INNER JOIN enrollments e ON s.student_id = e.student_id
INNER JOIN courses c ON e.course_id = c.course_id;
  1. Subqueries (Nested Queries)
SELECT first_name, last_name
FROM students
WHERE student_id IN (
 SELECT student_id FROM enrollments WHERE course_id = 101
);
  1. Common Table Expressions (CTEs)
WITH top_students AS (
 SELECT student_id, grade FROM students WHERE grade > 90
)
SELECT s.first_name, s.last_name, t.grade
FROM top_students t
JOIN students s ON s.student_id = t.student_id;

WITH defines temporary query results.

  1. Window Functions (OVER, PARTITION BY)
SELECT student_id, grade,
 RANK() OVER (PARTITION BY course_id ORDER BY grade DESC) AS rank
FROM enrollments;

Concepts to know:

  1. Updating Data (INSERT, UPDATE, DELETE)
UPDATE students
SET grade = 95
WHERE student_id = 101;

INSERT INTO students (first_name, last_name, grade)
VALUES ('John', 'Doe', 88);

DELETE FROM students WHERE grade < 50;

Extensions that are super important to know:

Note: Always use WHERE with UPDATE and DELETE to prevent accidental changes!

  1. Indexing for Performance (INDEX, EXPLAIN)
CREATE INDEX idx_students ON students (last_name);

Concepts to know:

Common Analytics Query Patterns

Reusable SQL patterns for analytics on Databricks: filters, aggregations, window functions, cohort logic, and star-schema joins.

Most analytics work is a small set of patterns repeated with different columns. Learn the patterns once, and Databricks SQL (or Spark SQL) becomes much faster to write, and easier to review.

Filter then aggregate

Start with a clear grain, filter early, then group. Push predicates that prune partitions (especially date) as close to the base table as you can.

SELECT
 product_category,
 SUM(net_amount) AS revenue,
 COUNT(DISTINCT order_id) AS orders
FROM gold.fact_orders f
JOIN gold.dim_product p ON f.product_key = p.product_key
JOIN gold.dim_date d ON f.order_date_key = d.date_key
WHERE d.full_date BETWEEN DATE '2026-01-01' AND DATE '2026-03-31'
GROUP BY product_category
ORDER BY revenue DESC;

Time series and period comparisons

Window functions compare a row to its neighbours without collapsing the grain. Use them for running totals, previous-period values, and moving averages.

SELECT
 full_date,
 daily_revenue,
 LAG(daily_revenue, 7) OVER (ORDER BY full_date) AS revenue_7d_ago,
 AVG(daily_revenue) OVER (
 ORDER BY full_date
 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
 ) AS moving_avg_7d
FROM daily_sales;

Ranking and top-N

Rank within a partition when stakeholders ask for “top products per region”. Prefer ROW_NUMBER when you need a deterministic single winner, and DENSE_RANK when ties should share a rank.

WITH ranked AS (
 SELECT
 region,
 product_name,
 revenue,
 ROW_NUMBER() OVER (
 PARTITION BY region
 ORDER BY revenue DESC
 ) AS rn
 FROM region_product_revenue
)
SELECT * FROM ranked WHERE rn <= 5;

Cohorts and retention sketches

Cohort analysis groups users by first activity month, then tracks return activity. Keep the cohort assignment in a reusable view so dashboards stay consistent.

WITH first_order AS (
 SELECT customer_key, MIN(full_date) AS cohort_date
 FROM gold.fact_orders f
 JOIN gold.dim_date d ON f.order_date_key = d.date_key
 GROUP BY customer_key
)
SELECT
 date_trunc('MONTH', fo.cohort_date) AS cohort_month,
 date_trunc('MONTH', d.full_date) AS activity_month,
 COUNT(DISTINCT f.customer_key) AS active_customers
FROM gold.fact_orders f
JOIN gold.dim_date d ON f.order_date_key = d.date_key
JOIN first_order fo ON f.customer_key = fo.customer_key
GROUP BY 1, 2;

Star joins and bridge tables

Join facts to dimensions on surrogate keys. When a fact relates to many values of one dimension (for example an order with many promotions), use a bridge table rather than exploding the fact grain silently.

Semi-additive and careful measures

Not everything sums. Account balances and inventory snapshots are semi-additive: you may sum across products but not across days. Document that in the catalogue and encode it in metrics views so tools do not invent wrong totals.

Make patterns reusable

Wrap stable logic in views or metric layers. Keep notebooks for exploration, not as the only definition of “monthly revenue”. When a pattern shows up three times, promote it. That is how analytics teams stay organised as the lakehouse grows.

Processing Big Data with Apache Spark in Databricks

How Apache Spark powers Databricks workloads: DataFrames, transformations, partitioning, and practical habits for reliable large-scale processing.

Apache Spark is the distributed engine underneath most Databricks data engineering. You write transformations against DataFrames (or SQL), and Spark plans them across a cluster. Understanding a few core ideas, lazy evaluation, partitions, shuffle, and caching, pays off quickly when jobs slow down or spill to disk.

DataFrames, not rows in a loop

Spark works best when you describe what you want in bulk operations: filter, select, join, aggregate. Avoid collecting large results to the driver or writing Python loops over partitions unless you truly need custom logic via pandas UDFs or mapInPandas.

from pyspark.sql import functions as F

df = spark.table("silver.orders")
clean = (
 df
 .filter(F.col("order_date") >= "2026-01-01")
 .withColumn("line_amount", F.col("quantity") * F.col("unit_price"))
 .groupBy("customer_id")
 .agg(F.sum("line_amount").alias("revenue"))
)
clean.write.mode("overwrite").saveAsTable("gold.customer_revenue")

Lazy plans and actions

Transformations build a logical plan. Actions such as count, collect, show, or a write trigger execution. That laziness lets Catalyst optimise the whole pipeline. Chain filters before wide joins when you can, and prefer SQL or DataFrame APIs over opaque UDFs so the optimiser can see your intent.

Partitions and shuffle

Data is split into partitions processed in parallel. A shuffle redistributes data for joins and group-bys. Shuffles are expensive: they move bytes across the network and can create skew when one key dominates.

from pyspark.sql.functions import broadcast

facts = spark.table("gold.fact_orders")
dim_prod = spark.table("gold.dim_product")
joined = facts.join(broadcast(dim_prod), "product_key")

Delta Lake and Spark together

In Databricks you usually read and write Delta tables. ACID transactions, time travel, and MERGE make incremental pipelines safer than raw Parquet folders. Combine Spark transformations with Delta features: optimise layout, vacuum old files carefully, and use Change Data Feed when downstream jobs need only the deltas.

Cluster and job habits

  1. Use job clusters for scheduled work; keep interactive clusters for development.
  2. Size for the shuffle-heavy stages, not only the scan.
  3. Prefer Photon-accelerated SQL warehouses for BI-shaped queries when available.
  4. Log metrics: rows in, rows out, duration, and data skipped by partition pruning.

When Spark is the right tool

Spark shines for large batch and micro-batch ETL, feature engineering, and joins that no longer fit on a single machine. For lightweight transforms on small tables, a SQL warehouse query may be simpler and cheaper. Match the engine to the data volume and latency, not to habit.

Think in DataFrames, respect the shuffle, and keep tables in Delta with clear layers. That combination is the everyday craft of processing big data in Databricks.

Building Data Pipelines in Databricks

A practical guide to Databricks pipelines: medallion layers, incremental loads, orchestration, quality checks, and operational habits that keep data trustworthy.

A data pipeline moves information from sources to something people can trust, tables, dashboards, or models, on a schedule and with clear ownership. In Databricks that usually means notebooks or workflows reading raw files or CDC streams, writing Delta tables through bronze, silver, and gold layers, and publishing into Unity Catalog.

Medallion layers as a contract

Treat each layer as a contract. Downstream jobs should not reach into bronze for business logic. That separation keeps reprocessing and debugging organised when a source changes shape.

Incremental over full reload

Prefer incremental processing: read only new files, new partitions, or Change Data Feed rows, then merge into silver. Full reloads are fine for small dimensions; they become costly and slow for large facts.

-- Merge daily increments into silver
MERGE INTO silver.orders t
USING staging.orders_day s
ON t.order_id = s.order_id
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *;

Idempotency matters. Re-running yesterday’s job should not double-count. Use deterministic keys, merge logic, and partition overlays so retries are safe.

Orchestration with Workflows

Databricks Workflows (jobs) chain tasks: ingest, transform, test, and notify. Keep tasks small and explicit, one responsibility per task, so failures are easy to isolate. Pass parameters for run dates rather than hard-coding paths inside notebooks.

  1. Ingest to bronze (Auto Loader or batch copy).
  2. Transform to silver with validation.
  3. Build gold marts.
  4. Run data quality checks and refresh downstream caches.

Quality and observability

Pipelines without checks eventually lie. Add expectations at silver: non-null keys, valid ranges, referential integrity samples. Quarantine bad rows instead of failing silently. Emit row counts and freshness metrics so on-call engineers can see whether gold is stale before stakeholders do.

from pyspark.sql import functions as F

silver = spark.table("silver.orders")
assert silver.filter(F.col("order_id").isNull()).count() == 0
freshness_hours = (
 spark.sql(
 "SELECT (unix_timestamp() - unix_timestamp(MAX(_ingested_at))) / 3600 "
 "FROM silver.orders"
 ).first()[0]
)

Governance and environments

Use Unity Catalog for permissions and lineage. Separate dev, staging, and prod catalogues or schemas. Promote code through repos and job definitions, not by editing production notebooks by hand. Secrets belong in secret scopes, never in cells.

Design for change

Sources evolve. Version schemas thoughtfully, prefer additive columns, and document breaking changes. Keep visualisation and BI tools pointed at gold views so you can rearrange physical tables without breaking every dashboard.

Good Databricks pipelines are boring in the best way: incremental, tested, observable, and layered. When those habits are in place, modelling and analytics work can move quickly without sacrificing trust.