None Is Not a Fact: Stop Letting ‘Empty’ Drive Your State Machine

It is 2 a.m. Your nightly job fetches a partner feed, gets a 200, parses zero items, and exits cleanly. The scheduler writes one small row: no new content. Nobody gets paged. Three months later a customer asks why their data stopped flowing in on March 4th. The answer is sitting in that log table, repeated 92 times, in a format that looks exactly like success.
A recent DEV Community write-up called this out with a line worth stealing: empty is not a state. It is not a value, either. It is a hole where five different facts should be, and most production code pours all five into the same variable and calls it None.
The fix is not better logging. It is control flow. The moment an empty result is allowed to drive a durable state change, you have thrown away the information you need to act correctly.
One empty result is at least five different facts
Take a scheduled job that polls an external source. The next time it comes back empty, it could be any of these:
- Genuinely nothing new. The request succeeded, the parser succeeded, and the source simply has no item newer than the last one you processed. This is boring and correct. You update the last-successful-check timestamp and poll again tomorrow.
- Temporary unavailability. DNS failed, the connection was refused, the server returned a 503. This needs exponential backoff and, if it persists, an alert. It is not evidence that the source is finished or empty.
- Contract failure. The server returned 200 and 48 KB of something your parser no longer understands. Maybe the XML changed shape. Maybe a CDN started serving an HTML challenge page. Maybe the API version you pinned was retired. Retrying this every fifteen minutes forever will not repair it, and it will never throw an exception your current code catches.
- Access limited. A 401, 403 or 429 describes your ability to retrieve the content, not the content itself. The fix is new credentials, a slower request rate, or a conversation about quota. It is not a retry loop.
- Undetermined. The response is ambiguous, the source has no history with you, or the signals contradict each other. Most production code refuses to admit this state exists, which is precisely why it ends up silently mislabeled as one of the other four.
All five of those can arrive at your scheduler as the same value. That is the design smell. A temporary outage can permanently stop monitoring a healthy feed. A parser regression can look like a quiet week. An expired credential can trigger endless retries against an endpoint that is working perfectly for everyone else.
The two lines that do the damage
It usually looks something like this: fetch the source, and if the result is falsy, mark the source as done or idle or synced. Two lines. No exceptions raised, no alarms triggered, no distinction preserved.
The problem is not that the code is lazy. It is that the fetcher has been put in charge of deciding what the fetch means, and the fetcher has no idea. It does not know the baseline. It does not know whether 42 KB of HTML from this particular host is normal. It does not know whether the business cares about this source this quarter. It only knows what came back this one time.
Here is the honest trade-off: separating these layers costs you more code and more schema. If you are polling one feed for a hobby bot, a boolean really is fine. Use this pattern where a wrong durable write is customer-visible, financially material, or hard to reverse — which in practice means every ingestion pipeline that feeds a product, a report, or a billing system.
Rule 1: The fetcher records, it does not interpret
The boundary component — the thing that talks to the outside world — should produce a structured record of what it observed, every single attempt, including the failed ones. Roughly: source identifier, timestamp, HTTP status, transport error, content type, bytes received, items parsed, parse error, newest item date, and the ETag if there was one.
The important detail is that items parsed: 0 and items parsed: not run are different fields, not the same field with the same value. A timeout is data. A 200 response containing 48 KB of HTML is data. A parser that never executed is a completely different fact from a parser that ran successfully and found nothing.
Exception-driven code erases all of that in one line: try to fetch and parse, except return None. By the time the scheduler sees the result, the useful information is already gone. There is no debugging to do later, because the evidence was destroyed at the source.
Rule 2: Empty only means something against a baseline
A single observation is often not enough to reach a conclusion. A 200 response with zero parsed items can be perfectly normal for one source and a screaming regression signal for another.
So keep a baseline per source: last successful check, newest item date, typical item count, expected content type, last known ETag. Now run the scenario. A feed has returned between 15 and 25 items on every poll for six months. Today it returns 200, 42 KB, content type text/html, and zero parsed items. Calling that no new content requires an impressive amount of optimism. Calling it a contract failure is the only defensible reading.
Now take the identical response from a source you have never seen before. It might be broken. It might not be a feed at all. You cannot claim a contract regression because you have no known contract to compare against. Same bytes, different evidence, different verdict. That is why history is not decorative metadata — it changes what you are allowed to conclude.
Rule 3: A verdict is not a boolean
The layer that combines observation and baseline should emit something richer than true or false. In practice, a small enumerated set of outcomes: items found, no new items, unavailable, contract failure, access limited, endpoint gone, undetermined. Attach the observation, the baseline, and the reasons that led to the verdict.
Then you can express the thing your scheduler actually needs: which verdicts are settled and which are not. Only items found and no new items should be allowed to move durable state forward. Everything else retries, backs off, or escalates to a human — and the reason travels with it, so the person who gets paged at 2 a.m. knows whether to fix credentials or fix a parser.
This is a small change with a large blast radius. You replace a done flag with a three-way decision: settled, retryable, or needs-a-human. Most teams find between five and twenty places in a mature codebase where an empty value currently drives a permanent write.
Rule 4: Do not fake confidence
It is tempting to bolt a confidence score onto the verdict — something like 0.83, decimal and therefore scientific. Resist it. Unless those numbers are calibrated against labeled historical outcomes, confidence is intuition wearing a decimal point. You will tune the threshold until it stops paging you, and you will be back where you started.
An explicit undetermined outcome with a list of reasons is more useful and more honest. Add calibrated confidence later, when you can show that verdicts emitted at 0.8 were actually correct about 80 percent of the time.
Where this bites hardest in 2026
- Webhook receivers. A delivery that never arrived and a delivery that arrived and was rejected both show up downstream as nothing happened. If your reconciliation job treats both as empty, you will quietly drop events forever.
- Change-data-capture and replication. A table with no changes and a connector that silently stopped reading produce the same row count.
- Scrapers and RAG corpus refreshes. A documentation page that now serves a consent interstitial parses to zero chunks. Your index simply stops updating, and every dashboard stays green because no error was ever raised.
- Agentic pipelines. This is the newest and worst case. An agent calls a tool, receives an empty result, and reports that no data is available — then writes that conclusion into a customer-facing summary. The agent has no baseline, no notion of a contract, and no vocabulary for uncertainty. Empty strings and empty arrays are indistinguishable from the answer is nothing. If you are shipping agents against your own internal tools, the observation-verdict split is not optional. It is the difference between an agent that says I could not check and one that confidently makes things up.
A 90-minute starting point
- Grep for every place a falsy or empty result drives a durable write, a status transition, or a done flag. Write the list down. It is usually longer than expected.
- For each one, ask the diagnostic question: if this happened today, could I tell temporary unavailability from a contract failure from genuine absence using only what I log? If the answer is no, that is the gap.
- Fix the highest-cost one first. Emit an observation on every attempt, and require a settled verdict before any state changes.
- Add a weekly baseline job. Typical item counts, expected content type, last success timestamp, current ETag.
What you get back is an alert that reads contract failure: text/html where text/xml was expected, 0 of roughly 20 items, six months of history — instead of silence that you discover during a customer escalation. The empty result was never the problem. Treating it as a fact was.
Key Takeaways
- An empty result is not a value. It is the missing distinction between absence, failure, uncertainty, access limits, and a broken parser.
- Never let an empty or falsy value drive a durable state transition. That single rule prevents the most expensive class of silent pipeline failure.
- Split the work into three layers: the fetcher records observations, an adjudicator compares them against a baseline, and a verdict decides what the scheduler is allowed to do.
- Empty only means something against history. Log typical item counts and expected content types per source, or you cannot tell a quiet week from a regression.
- Verdicts should be a small enumeration with an explicit undetermined outcome, not a boolean and not an invented confidence score.
- Agents amplify this bug. A tool that returns empty gives an agent no way to say I could not check, so it says something confident instead.