Null rate: how to calculate and alert on it in SQL
Null rate is the percentage of rows where a column has no value. Compute it in SQL as the number of NULLs divided by the total rows: 100.0 * count(*) FILTER (WHERE col IS NULL) / count(*). The common mistake is count(col), which skips NULLs, so count(col) / count(*) gives the fill rate and count(col) / count(col) is always 100 percent. A jump in null rate usually means a changed upstream field, a broken join, or a partial load.
The formula
Null rate is nulls divided by rows. The one thing to get right is the denominator.
count(*) counts every row. count(col) counts only rows where col is not NULL. So:
count(*) - count(col)is the number of NULLs.count(col) / count(*)is the fill rate.count(col) / count(col)is always 1, which is the trap. A null rate built on it reads 0 forever.
SELECT count(*) AS total,
count(email) AS non_null,
count(*) - count(email) AS nulls,
round(100.0 * count(*) FILTER (WHERE email IS NULL)
/ nullif(count(*), 0), 2) AS null_pct
FROM users;
nullif(count(*), 0) avoids a divide-by-zero on an empty table, where the result becomes NULL instead of an error. That NULL is worth knowing about: a monitor that reads a non-numeric value cannot compare it. In arcodash such a monitor sits in the unknown state, per the alerts overview, so an empty table shows as unknown, not as ok.
Alert on a recent window
Whole-table null rate moves slowly. A pipeline that breaks today barely shifts a number built on five years of rows. Measure the recent window instead, and require a minimum row count so a quiet hour does not fire.
SELECT round(100.0 * count(*) FILTER (WHERE email IS NULL)
/ count(*), 1) AS null_pct,
count(*) AS rows_checked
FROM signups
WHERE created_at >= now() - interval '24 hours'
HAVING count(*) >= 20;
This returns one row with null_pct, which is the shape a monitor needs. arcodash reads the first row of the query, takes one named column, and compares it to your threshold, so point a monitor at null_pct with > and a value. The data observability monitors page has the same recipe for a whole table. If the HAVING filters the single row out, the result is empty and the monitor goes to unknown rather than triggered.
Pick the level from the column’s history
To choose a threshold, look at what the column normally does. Per day:
SELECT created_at::date AS day,
count(*) AS n,
round(100.0 * count(*) FILTER (WHERE email IS NULL)
/ count(*), 1) AS null_pct
FROM signups
WHERE created_at >= now() - interval '3 days'
GROUP BY 1
ORDER BY 1;
Note the partial first day in a rolling window: a day with one row can show 100 percent. Use whole days (created_at >= current_date - 3 AND created_at < current_date) when you chart this. For how to set the level once you can see the history, read alert thresholds.
Where null rate checks go wrong
- Wrong denominator. Covered above. Use
count(*). - Placeholders that are not NULL. Empty strings,
'unknown',0and1970-01-01all hide missing data. Count them explicitly. - Intentional nulls. A
cancelled_atcolumn is NULL for every active subscription. Alert on change, not on presence. - Joins. A left join that stops matching fills the joined columns with NULL. If one column’s null rate spikes, check the join key before the source.
A null check is one kind of validity check; scheduled SQL checks shows it running on a schedule.
The SQL data check generator writes a null rate check for the columns you list, in your database’s dialect.
Questions
What is a good null rate?
It depends on the column. A primary key should be at 0. An optional field like a phone number may sit at 40 percent forever. Alert on a change from that column's normal level, not on a universal number.
Why does my null rate look like 0 when I know there are NULLs?
Usually the denominator is wrong. count(col) skips NULLs in both the numerator and the denominator, so the ratio comes out at 100 percent fill. Divide by count(*) instead.
Does an empty string count as a NULL?
No. IS NULL only matches NULL. Some pipelines write empty strings or placeholders like 'N/A' instead, so check for those too with nullif(col, '') if your data does that.