Software failure is loud: a service falls over, an alert fires, someone gets paged.
Data failure is quiet. The pipeline exits zero. The warehouse accepts the rows. The dashboard renders on schedule, in the right colours, with a number in every tile. Nothing anywhere in the stack believes anything is wrong. The numbers are simply not true any more, and they will keep not being true until a human happens to look at one and frown.
Almost every silent break falls into one of six classes. Knowing them by name is useful, because each has a different shape in the warehouse and each has a cheap query that would have caught it.
Why nothing crashes
A warehouse has no opinion about whether your data is correct. It enforces types, sometimes a primary key, occasionally a not-null constraint. Everything past that — the right number of rows, the same units as yesterday, a join that stayed one-to-one — is business logic that lives nowhere.
Meanwhile every layer above it is built to be resilient. BI tools render whatever they are handed. A SUM over half the rows is still a valid SUM. The entire stack is designed to keep working, which means the entire stack is designed to hide this class of problem.
Absence: freshness and volume
Freshness — the table stopped receiving data. An ingestion job’s credentials expire on a Friday. The scheduler marks the run failed, sends the failure to an inbox nobody reads, and moves on. Monday’s revenue dashboard still shows a healthy figure, because it defaults to a trailing 30-day window and 27 of those days are fine.
Nobody notices because absence renders as normality. There is no gap in the chart when the chart aggregates. The one-line check:
select max(loaded_at) as latest, now() - max(loaded_at) as staleness from analytics.orders;
If that staleness is materially larger than usual for this hour, the table is not loading. A table that loads weekly is not broken on a Wednesday, which is why fixed thresholds generate so much noise here.
Volume — the table is loading, but not all of it. An upstream partner adds pagination to their export API. Your extractor takes the first page and stops. Rows keep arriving every night, so freshness is perfectly healthy; there are just 200 of them instead of the usual 10,000.
This is the failure that survives longest, because a partial load looks exactly like a quiet week. Someone eventually decides business is soft.
select date_trunc('day', created_at) as day, count(*)
from analytics.orders
where created_at > now() - interval '30 days'
group by 1 order by 1 desc;
The break is obvious to a human reading down that column, and invisible to everything else.
Distortion: out-of-range and null-rate
Out-of-range — the values are wrong, but plausible. A payments provider ships a version bump. The amount field, which had always been a decimal in pounds, is now an integer in pence. Nothing errors: it is a number, it fits the column, it loads. Average order value is suddenly a hundred times what it was. Finance sees a record month, celebrates for two days, then asks why the bank balance disagrees.
Unit changes, timezone shifts and sentinel values (-1, 9999) all land here. Nobody notices because the value is structurally valid; only its magnitude is absurd, and magnitude is what dashboards display without judgement.
select date_trunc('day', created_at) as day, avg(amount), max(amount)
from analytics.orders
where created_at > now() - interval '14 days'
group by 1 order by 1 desc;
Null-rate — a column quietly emptied. An upstream service renames email_address to email in its payload. Your loader maps by name, finds nothing, and writes null. The column still exists. The rows still arrive. It is 2% null historically; today it is 40%.
Nobody notices because null-handling is invisible by design. count(*) is unaffected. Averages skip nulls. Joins on that column just return fewer rows. The only people who find out are the ones who ran a campaign to a segment that had silently shrunk.
select count(*) filter (where email is null)::numeric / nullif(count(*), 0) as null_share
from analytics.customers
where created_at > now() - interval '1 day';
Field renames are the most common cause, and they are usually announced in a changelog nobody on the data team subscribes to.
Corruption: format and duplicates
Format — the shape of the value changed. A date column typed as text has held ISO dates for two years. A new source system starts sending 14/08/2026. Both are strings, so both load. Downstream, a cast either fails on those rows — usually wrapped in a try_cast that returns null, folding it into the null-rate problem — or succeeds and reads the day as the month.
The same class covers a numeric column that starts receiving strings, currency codes that arrive lowercase, and identifiers that gain or lose a prefix.
select count(*) from analytics.signups
where signup_date !~ '^\d{4}-\d{2}-\d{2}$';
Nobody notices because the ambiguous cases are the dangerous ones. 14/08 fails loudly on any strict parse; 08/07 converts cleanly to the wrong day and stays wrong forever.
Duplicates — the join fanned out. Someone adds a dim_customer table with slowly changing dimensions, so a customer now has one row per address change. The existing model joins orders to customers on customer_id and has always been one-to-one. Now a customer who moved twice contributes three rows per order. Revenue goes up. Everyone is pleased.
This is the most flattering failure mode, and therefore the least likely to be questioned. It is also the easiest to detect:
select order_id, count(*) from analytics.orders_enriched
group by 1 having count(*) > 1 limit 10;
If that returns anything, the grain of your table is not what your SQL assumes.
The detection mechanism is a person, days later
None of those six queries is difficult. Every data team could run all six against every important table. Nobody does, and the reason is not laziness: these checks are only valuable when run unprompted. The moment you suspect something is wrong, you already know to look — the value of a freshness check is entirely in the days when you had no reason to run it. Manual checking inverts that: you run the query after the stakeholder emails, which is exactly when it has stopped being useful.
So the default detection mechanism is a person noticing a weird number. That is slow — usually days, sometimes a full reporting cycle. It is biased towards the visible: nobody notices a table that only feeds a monthly model. And it is expensive in trust, because every error found by a stakeholder makes the next number slightly less believed.
What a structural alternative looks like
The fix is not better queries. It is moving detection off the critical path of someone’s attention:
- Run on a schedule, not on suspicion. Every monitored table, every night, after the loads finish.
- Compare against the table’s own history, not a fixed rule. “Fewer than 5,000 rows” is wrong for a table with weekend dips or a weekly load. The baseline has to be learned per table.
- Deliver results whether or not there is bad news. A report that only arrives when something is broken is indistinguishable from a report that is itself broken.
- Attach evidence to every finding. A claim without the query that produced it and a handful of offending values costs more time to verify than to have found by hand.
- Give it a way to be told “that’s expected”. Any check that cannot learn from being wrong gets muted entirely within a month.
How Sentry handles it
Sentry runs these six checks — freshness, volume, out-of-range, null-rate, format and duplicates — as specialised agents, nightly, against the tables you choose.
- Baselines are learned, not configured. Sentry profiles each table’s history to work out its normal behaviour, including weekend dips and weekly load schedules. There are no thresholds to set.
- The scan runs after your data has finished loading, at a time you set — 5:00 AM in your organisation’s timezone by default, configurable on every plan.
- One digest each morning: what was scanned, what is healthy, and any findings ordered by severity, each deep-linking into the dashboard.
- Every finding carries evidence — the queries run, sample offending values, and the historical baseline it was judged against — so you can confirm or dismiss it in seconds.
- Noise is controllable. Mark a finding expected and it folds into the baseline; mute a single check on a single table for as long as you want.
- Read-only, and it never writes. Sentry diagnoses. What to do about a fanned-out join stays with the person who understands the pipeline. Plans and limits are on the pricing page.
These six failures are not hard to find. Finding them just should not depend on somebody being suspicious on the right morning.
Pick a plan and get started — read-only credentials, first digest tomorrow morning.
