The goal: know that gold.sales.orders arrived with 40% null customer_ids, or half the usual row count, before a dashboard user tells you. Pipeline expectations catch rules you already know. Data profiling catches the changes you didn't think to write a rule for: null rates creeping up, a category disappearing, a numeric column shifting.
This post sets up a profile on a table with the Python SDK, reads the metric tables, and wires alerts on-call will actually see.
How it works
- A monitor (data profile) is attached to one Unity Catalog Delta table. Only one profile per table per metastore.
- Each refresh runs on serverless compute and writes two tables to an output schema:
<table>_profile_metrics: per window, per column:count,num_nulls,percent_null,distinct_count,min,max,avg,quantiles,frequent_itemsand more.<table>_drift_metrics: change between windows (CONSECUTIVE) or against a baseline table (BASELINE):count_delta,percent_null_delta,ks_test,js_distance,population_stability_indexand more.
- A dashboard is generated in the
assets_diryou choose.
| Profile type | Use for | Notes |
|---|---|---|
TimeSeries | Fact and event tables with a timestamp column | Metrics per time window; first run looks back only 30 days |
Snapshot | Dimension and reference tables | Recomputes over the whole table each refresh; max table size 4 TB |
InferenceLog | Model prediction logs | Adds model quality metrics |
Note
The feature was called Lakehouse Monitoring and the SDK class was
w.quality_monitors. That class is deprecated in favour ofw.data_quality(REST/api/data-quality/v1/monitors). Names in the UI and docs vary by release; check your SDK version if an import fails.
Prerequisites and checks
- Unity Catalog table (managed or external Delta) and serverless compute enabled in the workspace.
- An output schema for the metric tables, for example
ops.dq. - The person or service principal creating the monitor needs to manage the table (owner, or
MANAGEplusUSE CATALOG/USE SCHEMA) andCREATE TABLEon the output schema. - For
TimeSeries, turn on change data feed so refreshes process only new data:
CREATE SCHEMA IF NOT EXISTS ops.dq;
ALTER TABLE gold.sales.orders SET TBLPROPERTIES (delta.enableChangeDataFeed = true);
Create a time series profile
Run in a notebook or as a job task:
%pip install "databricks-sdk>=0.68.0"
dbutils.library.restartPython()
from databricks.sdk import WorkspaceClient
from databricks.sdk.service.dataquality import (
Monitor, DataProfilingConfig, TimeSeriesConfig, AggregationGranularity,
)
w = WorkspaceClient()
table = w.tables.get(full_name="gold.sales.orders")
out_schema = w.schemas.get(full_name="ops.dq")
config = DataProfilingConfig(
output_schema_id=out_schema.schema_id,
assets_dir="/Workspace/Shared/dq/gold.sales.orders",
time_series=TimeSeriesConfig(
timestamp_column="order_ts",
granularities=[AggregationGranularity.AGGREGATION_GRANULARITY_1_DAY],
),
slicing_exprs=["region", "channel"],
)
monitor = w.data_quality.create_monitor(
monitor=Monitor(object_type="table", object_id=table.table_id,
data_profiling_config=config)
)
print(monitor)
For a dimension table, replace time_series=... with snapshot=SnapshotConfig() (import SnapshotConfig from the same module).
Tip
Keep
slicing_exprsto a few low-cardinality columns. Every slice value gets its own set of metrics, so slicing oncustomer_idmultiplies refresh cost and metric table size.
Refresh after each load, not on a fixed clock
A schedule works, but a refresh right after the load means metrics describe the data you just wrote. Add this as the last task of the load job:
from databricks.sdk import WorkspaceClient
from databricks.sdk.service.dataquality import Refresh
w = WorkspaceClient()
tid = w.tables.get(full_name="gold.sales.orders").table_id
run = w.data_quality.create_refresh(
object_type="table", object_id=tid,
refresh=Refresh(object_type="table", object_id=tid),
)
print(run)
Check recent refreshes and their state:
for r in w.data_quality.list_refresh(object_type="table", object_id=tid):
print(r.refresh_id, r.state, r.start_time_ms, r.end_time_ms)
Field names on the refresh object can differ by SDK version; print one object to see what yours returns.
Query the metric tables
Table-level row counts per day (column_name = ':table', whole table = slice_key IS NULL):
SELECT window.start AS day, count AS row_count
FROM ops.dq.orders_profile_metrics
WHERE column_name = ':table'
AND slice_key IS NULL
AND log_type = 'INPUT'
ORDER BY day DESC
LIMIT 14;
Columns whose null rate jumped against the previous window:
SELECT window.start AS day, column_name, percent_null_delta
FROM ops.dq.orders_drift_metrics
WHERE drift_type = 'CONSECUTIVE'
AND slice_key IS NULL
AND column_name <> ':table'
AND percent_null_delta > 5
ORDER BY day DESC, percent_null_delta DESC;
Numeric distribution drift (KS test p-value) and categorical drift (JS distance):
SELECT window.start AS day, column_name,
ks_test.pvalue AS ks_pvalue,
js_distance
FROM ops.dq.orders_drift_metrics
WHERE drift_type = 'CONSECUTIVE'
AND slice_key IS NULL
AND (ks_test.pvalue < 0.01 OR js_distance > 0.2)
ORDER BY day DESC;
The thresholds are starting points. Tune them per table after a couple of weeks of history, or you'll get daily false alarms.
Turn metrics into on-call alerts
Create a Databricks SQL query that returns one row per problem, then a SQL alert on it.
-- dq_orders_today: returns rows only when something is wrong
WITH today AS (
SELECT count AS rows_today FROM ops.dq.orders_profile_metrics
WHERE column_name = ':table' AND slice_key IS NULL AND log_type = 'INPUT'
ORDER BY window.start DESC LIMIT 1
),
baseline AS (
SELECT avg(count) AS rows_avg FROM (
SELECT count FROM ops.dq.orders_profile_metrics
WHERE column_name = ':table' AND slice_key IS NULL AND log_type = 'INPUT'
ORDER BY window.start DESC LIMIT 8 OFFSET 1)
)
SELECT 'row_count_drop' AS issue, rows_today, round(rows_avg) AS rows_avg
FROM today, baseline
WHERE rows_today < 0.5 * rows_avg;
Set the alert condition to "row count > 0", schedule it after the refresh task, and send it to a notification destination wired to your paging tool (Teams, PagerDuty or a webhook) rather than a personal mailbox. Add a second alert on refresh failures using NotificationSettings(on_failure=...) in the profile config, or by failing the job task when create_refresh ends in a failed state.
Symptom → cause → fix
| Symptom | Likely cause | Fix |
|---|---|---|
create_monitor fails with permission error | Caller doesn't manage the table or can't create tables in the output schema | Grant MANAGE (or ownership) on the table and CREATE TABLE on ops.dq |
ImportError for databricks.sdk.service.dataquality | Old SDK on the cluster | %pip install "databricks-sdk>=0.68.0" and restart Python |
| Metric tables empty after first refresh | Timestamps older than 30 days, or refresh still running | Check refresh state; only recent data is profiled at creation |
| Refreshes slow and expensive | Many slices, CDF off, very wide table | Reduce slices, enable CDF, profile a narrower view or fewer key tables |
| Snapshot monitor fails on a large table | Over the 4 TB snapshot limit | Use a TimeSeries profile instead |
| Drift table missing | No baseline and only one window so far | Wait for a second window or set a baseline table |
| Users can't see the dashboard numbers | Metric tables are owned by the creator | GRANT SELECT on ops.dq tables to the reader group |
Removing a monitor
w.data_quality.delete_monitor(object_type="table", object_id=tid)
This doesn't drop the metric tables or the dashboard. Drop them yourself once you no longer need the history:
DROP TABLE IF EXISTS ops.dq.orders_profile_metrics;
DROP TABLE IF EXISTS ops.dq.orders_drift_metrics;
Prevent it next time
- Profile the 10-20 gold tables that feed decisions, not every bronze table.
- Create monitors from code (a job or Asset Bundle task) so they're recreated in each environment, and run them as a service principal.
- Trigger refreshes from the load job and alert on both data problems and refresh failures.
- Review thresholds monthly and keep pipeline expectations for known rules. Profiling complements expectations; it doesn't replace them.