9.6 Data Profiling: Summary Statistics & Distribution Assessment
Key Takeaways
- Profiling precedes cleansing: you cannot choose a data type, a null strategy, a clustering key, or a constraint threshold before you know the cardinality, null rate, and distribution of each column.
- dbutils.data.summarize(df) and the notebook Data Profile tab generate per-column statistics and distribution plots without writing aggregation SQL by hand.
- df.describe() returns count, mean, standard deviation, min, and max, while df.summary() adds the 25th, 50th, and 75th percentiles and accepts explicit percentile arguments.
- ANALYZE TABLE ... COMPUTE STATISTICS collects table and column statistics that the cost-based optimizer uses for join ordering, which is a different purpose from ad hoc profiling output.
- DESCRIBE DETAIL exposes physical facts a distribution query cannot show - file count, table size, partition columns, and clustering columns - which is how you diagnose the small file problem.
9.6 Data Profiling: Summary Statistics & Distribution Assessment
DP-750 Exam Focus: The blueprint places "Profile data to generate summary statistics and assess data distributions" as the first bullet of Cleanse, transform, and load data into Unity Catalog - before choosing column data types, before resolving duplicates and nulls, before any transformation. The ordering is the lesson: profiling is what makes every later decision defensible.
1. Why Profiling Comes First
Every decision in Sections 9.2 through 9.5 and Chapter 8 depends on a number you can only get by profiling:
| Downstream decision | Profiling input it needs |
|---|---|
| Column data type (Section 9.2) | Actual min/max range, decimal precision, string length distribution |
| Null handling strategy (Section 9.2) | Null rate per column - 0.1% is a data error, 60% is an optional attribute |
NOT NULL invariant (Section 9.5) | Whether the column is ever null in history |
CHECK constraint bounds (Section 9.5) | Observed value range, plus legitimate outliers |
| Liquid clustering keys (Section 8.3) | Column cardinality and how often it appears in filters |
| Broadcast join eligibility (Section 13.3) | Table size in bytes |
| Skew remediation (Section 13.2) | Frequency distribution of the join key |
| Deduplication key (Section 9.2) | Distinct count versus row count |
2. Notebook Data Profiles and dbutils.data.summarize
The fastest path is the built-in profiler. Running display() on a DataFrame gives a Data Profile tab alongside the table view, and dbutils.data.summarize() produces the same report programmatically:
df = spark.read.table("prod_retail.bronze.orders_raw")
# Interactive: renders a Table tab and a Data Profile tab
display(df)
# Programmatic equivalent
dbutils.data.summarize(df)
The profile reports, per column, the count and percentage of missing values, the number of distinct values, and type-appropriate statistics - mean, standard deviation, min, max, and quantiles for numeric and temporal columns, and the most frequent values for categorical ones - plus a distribution plot. Some statistics on very large datasets are computed with approximation algorithms, so treat distinct counts as estimates unless you compute them exactly.
3. describe() Versus summary()
Both are DataFrame methods and they are not interchangeable:
# describe(): count, mean, stddev, min, max
df.describe("net_amount", "quantity").show()
# summary(): adds the 25%, 50%, and 75% percentiles by default
df.summary().show()
# summary() also accepts explicit statistics, including custom percentiles
df.summary("count", "min", "5%", "50%", "95%", "max").show()
| Method | Returns |
|---|---|
describe() | count, mean, stddev, min, max |
summary() | the above plus 25th, 50th, 75th percentiles by default, and any statistics you name |
When a scenario asks for the median or an arbitrary percentile, summary() is the method; describe() cannot produce one.
4. SQL Profiling Patterns
For repeatable profiling that runs inside a pipeline, write it as SQL.
Null Rate and Cardinality Sweep
SELECT
count(*) AS row_count,
count(customer_id) AS customer_id_non_null,
count(*) - count(customer_id) AS customer_id_nulls,
round(100.0 * (count(*) - count(customer_id)) / count(*), 3) AS customer_id_null_pct,
approx_count_distinct(customer_id) AS customer_id_distinct_approx,
count(DISTINCT customer_id) AS customer_id_distinct_exact
FROM prod_retail.bronze.orders_raw;
approx_count_distinct() uses HyperLogLog and is dramatically cheaper than an exact COUNT(DISTINCT ...) on a wide table - use it for the sweep, and reserve the exact count for the one or two columns that turn out to matter.
Distribution and Outliers
SELECT
min(net_amount) AS min_amount,
percentile_approx(net_amount, 0.05) AS p05,
percentile_approx(net_amount, 0.50) AS median,
percentile_approx(net_amount, 0.95) AS p95,
max(net_amount) AS max_amount,
sum(CASE WHEN net_amount < 0 THEN 1 ELSE 0 END) AS negative_rows
FROM prod_retail.silver.orders;
A median far from the mean signals a long tail; a max orders of magnitude above p95 signals either an outlier or a unit error (cents recorded as dollars).
Skew Detection Before It Becomes a Spark Problem
-- The join key values that will create straggler tasks (Section 13.2)
SELECT store_id, count(*) AS row_count
FROM prod_retail.silver.orders
GROUP BY store_id
ORDER BY row_count DESC
LIMIT 20;
If the top key holds orders of magnitude more rows than the median key, you have found the skew that would otherwise appear as one task running for an hour in the Spark UI.
Duplicate Detection
SELECT order_id, count(*) AS occurrences
FROM prod_retail.bronze.orders_raw
GROUP BY order_id
HAVING count(*) > 1
ORDER BY occurrences DESC;
5. Table-Level Profiling: DESCRIBE and ANALYZE TABLE
Column statistics tell you about values. These tell you about the table.
-- Physical facts: file count, size in bytes, partition and clustering columns
DESCRIBE DETAIL prod_retail.silver.orders;
-- Schema, comments, table properties, location, provider
DESCRIBE EXTENDED prod_retail.silver.orders;
-- Operation history: who wrote what, when, and with which operation metrics
DESCRIBE HISTORY prod_retail.silver.orders;
DESCRIBE DETAIL is how you diagnose the small file problem from Section 8.3: a numFiles in the tens of thousands against a modest sizeInBytes means the table needs OPTIMIZE. It is also where you read the current clusteringColumns and partitionColumns.
ANALYZE TABLE Is for the Optimizer, Not for You
-- Table-level statistics (row count, size) for the cost-based optimizer
ANALYZE TABLE prod_retail.silver.orders COMPUTE STATISTICS;
-- Column-level statistics: distinct counts, min/max, null counts
ANALYZE TABLE prod_retail.silver.orders COMPUTE STATISTICS FOR COLUMNS store_id, order_ts;
-- All columns
ANALYZE TABLE prod_retail.silver.orders COMPUTE STATISTICS FOR ALL COLUMNS;
The distinction the exam tests: dbutils.data.summarize and summary() produce a report a human reads; ANALYZE TABLE persists statistics the cost-based optimizer reads to choose join order and join strategy. Stale statistics on a table that has grown ten times produce bad plans, which is one root cause of a query that "suddenly got slow" without any code change.
6. Turning Profiling Into Enforcement
Profiling is a one-time investigation; the findings belong in permanent enforcement so the same defect cannot recur silently:
| Finding | Enforcement |
|---|---|
order_id is never null in 400M rows | ALTER TABLE ... ALTER COLUMN order_id SET NOT NULL |
net_amount is never negative except for refunds flagged separately | ALTER TABLE ... ADD CONSTRAINT amt_non_neg CHECK (net_amount >= 0 OR is_refund) |
0.4% of rows arrive with a null customer_id | Lakeflow expectation expect_or_drop (Section 10.2) plus a monitoring alert |
store_id has 12,000 distinct values and appears in most filters | Liquid clustering key (Section 8.3) |
| Dimension table is 40 MB | Broadcast join hint (Section 13.3) |
7. Exam Traps
describe()cannot give you a median.summary()can.ANALYZE TABLEdoes not clean or transform anything. It only computes statistics for the optimizer.- Approximate is not wrong.
approx_count_distinctandpercentile_approxare the correct choice for profiling sweeps at scale; scenarios that reject them purely for being approximate are usually distractors. DESCRIBE HISTORYis notDESCRIBE DETAIL. History shows operations over time; detail shows the current physical layout.
Before setting a CHECK constraint on a monetary column, an engineer needs the 5th percentile, the median, and the 95th percentile of that column from a DataFrame. Which approach returns all three directly?
A nightly aggregation query that ran in four minutes now takes forty, with no change to its SQL. The underlying fact table has grown roughly tenfold. The Spark UI shows a shuffle-heavy join where a broadcast would previously have been chosen. What profiling action addresses the root cause?
A profiling sweep must report the approximate number of distinct values for eighty columns across a multi-billion-row bronze table, cheaply. Which function is the appropriate choice?