Field note · July 21, 2026
57.8% of churned_at is missing
1,444 blanks in one column, and the one-line check that decides whether dropping them is free.
churned_at on saas-subscriptions is null in 1,444 of 2,500 rows.
57.8% missing is enough to matter and small enough to ignore, which is the dangerous range. Nothing errors. The aggregate returns. The number is wrong by an amount nobody can see.
The question is never "how much is missing" — that is one line and it tells you nothing. It is whether the rows with a blank differ from the rows without one:
missing = df["churned_at"].isna()
df.groupby(missing)["plan"] \
.value_counts(normalize=True) \
.unstack(fill_value=0) \
.round(3)If the two rows of that table look alike, dropping the blanks costs you sample size and nothing else. If they look different, the missingness is carrying information, and every average computed over the survivors is biased in a direction you can usually name.
Here the blank is not missing data at all — it means the account has not churned yet. Treating it as unknown loses the censoring, and a survival question answered without the censoring is answered wrong.
"How much is missing" is a completeness metric. "Who is missing" is the finding. The second question costs one more line of code and is the one worth asking.