Did It Say No, or Did It Decline to Say?
Somewhere in a company there is a system that takes a large batch of records, writes them into an Oracle database, and reports how many it handled. Ten thousand rows go in. It reports ten thousand.
There is a procedure: a person opens the input file, a spreadsheet, and reads how many rows it has. Then the same person opens the output file, another spreadsheet, and reads how many rows it has. If the two numbers match, the run was good.
They matched for years.
Then the codebase was dragged from PHP 7.4 to 8.2, one of those ports where you add nothing and only try to arrive on the other side with the behavior you left with. In the course of it, the comparison that decided whether a record had actually changed got repaired.
The next run reported three thousand.
Seven thousand records were already correct and needed nothing done to them. The number was, probably for the first time, true.
The procedure declared it broken.
What was the test, exactly?
Look at where that instrument is pointed. It measures the two ends of a pipeline. Does it measure the pipeline? Between those two spreadsheets sat the entire mechanism, the writes, the comparisons, the decision about which record deserved to be touched at all. How many of those produced a number that anyone ever read?
So point that procedure at something that goes wrong in the middle and still comes out symmetrical. What does it report?
That procedure has two verdicts available. Same, so it worked. Different, so it broke. Two, for a mechanism that can fail in a hundred ways. How many does the database underneath it have?
What happens when only some of the rows change?
An insert is easy to count. Ten thousand new rows are ten thousand new rows, and the number that goes in is the number that comes back out.
An update is a different question, and the honest version of it is not “how many rows did you send” but “how many rows are now different from what they were.” Those are almost never the same number.
One of those two numbers describes the work that was done. The system had been reporting the other one for years, and nothing in the procedure was built to notice.
What does a database do when it cannot tell?
Now the part underneath, because the defect in that comparison was not a typo. It was a translation error, and it happens at a boundary you cross fifty times a day without slowing down.
Your database does not have two answers. It has three.
A condition in SQL evaluates to true, or false, or unknown. That third one is not a bug and not an edge case, it is in the standard, and it exists because a database is asked to reason about values it does not have. Oracle puts it plainly in its own documentation: a null cannot be equal or unequal to any value, or to another null, and if the result of a condition depends on the value of that null, the result is unknown. Which is why the only way to ask about one is IS NULL. Every other comparison against it evaluates to unknown.
So the database reasons in three values. Then the WHERE clause keeps the rows that evaluated to true, and discards everything else, and here is the part worth sitting with: false and unknown are discarded identically. The row that was definitively not a match and the row the database could not evaluate leave through the same door, in silence, indistinguishable.
So which of the two was missing from your result set this morning? Did those rows fail your condition? Or did the database decline to say whether they met it?
You received the same result either way. Which one did you assume?
Who decided that nothing means no?
The value has to travel up the stack, and every layer it passes through had a chance to preserve that distinction.
Oracle treats a character value of zero length as null. The same null, not something resembling it. And Oracle’s own documentation, aware of exactly how much rests on this, includes something close to an apology: the database currently treats a zero-length character value as null, this may not continue to be true in future releases, and Oracle recommends you do not treat empty strings the same as nulls. That is a vendor warning you, in its own manual, not to build anything on the behavior it currently provides.
From there the value has to reach PHP, and PHP has two ways to talk to Oracle.
Through the OCI8 extension, when a column is null, oci_fetch_array does not put null in your row. It does not create the element at all. The column is not empty in your result, the column is absent, and if you want to be told it was ever there you have to pass a flag named OCI_RETURN_NULLS and ask. Consider what that means for the code above it. Every isset in that codebase, every array key check, every count of the fields that came back: they are all reading a deletion and calling it an answer.
Through PDO, there is a setting. It is called PDO::ATTR_ORACLE_NULLS, and its documented job is to determine “if and how null and empty strings should be converted.” You may choose no conversion, or empty strings become null, or null becomes an empty string. The manual notes, almost in passing, that this attribute is available with all drivers, not just Oracle.
Read that again. There is a configuration knob for what nothing means, it ships in the standard library, and its default value is the one nobody on your team has ever discussed.
So go looking for whoever decided that unknown collapses into no. Was it Oracle, which put the warning in its own manual? The standard, which defined a third value on purpose? A driver author choosing how to represent an absent column in an array, twenty years ago, for reasons that were probably about memory? The developer who accepted a default they never knew existed?
Which desk does that decision sit on?
No desk. The rule is in production anyway, assembled in the gaps between four reasonable choices, and it has been deciding what your data means since before anyone currently on the team arrived.
What moved while nobody was looking?
There is one more layer, and it is the one specific to a migration.
In PHP 7, 0 == "" was true. In PHP 8, it is false. This is documented, deliberate, and by any reasonable measure an improvement, the result of an RFC that fixed a decade of surprising comparisons.
That change lands on top of everything above. Oracle does not distinguish an empty string from a null. One driver deletes the column, the other offers you three policies. And halfway through a port from 7.4 to 8.2, the language itself revised whether zero and empty are the same thing.
Every one of those decisions is correct in isolation. Every one of them is defensible by the people who made it. Does that tell you anything about whether the composition is correct?
Four defensible decisions, and a wrong number at the end of them. The fault has no owner, which is a large part of why it lasted so long.
Where did the third answer go?
There is a place that kept the third answer for three hundred years, and it just gave up.
Scotland ran a criminal justice system with three verdicts, not two. Guilty. Not guilty. And not proven, which meant, roughly, that the case against you had not been made. From 1 January 2026, new criminal trials stopped using not proven. The Victims, Witnesses, and Justice Reform (Scotland) Act 2025 removed the verdict, leaving Scotland with two verdicts like everybody else.
The argument that killed it is the one that should interest you. Not proven had exactly the same legal effect as not guilty. The accused walked free, identically, either way. And nothing in law ever defined the difference between them.
So here was a third state that was real in the deliberation, that jurors genuinely reached for, that meant something specific about the quality of the evidence, and that was completely invisible at the exit. Two different findings, one observable outcome. It survived three centuries and was finally retired on the grounds that nobody could reliably read it.
Does that shape look familiar?
The analogy only goes so far. SQL’s UNKNOWN has precise, documented semantics. Scotland’s not proven never had an equivalent definition, and a jury’s uncertainty is not a database operation. But both expose the same problem at the boundary: a distinction that matters inside the decision disappears from the result delivered downstream.
A third state, real in the engine, indistinguishable at the output, and eventually treated as the second one because that is the only thing the people downstream had a name for. Scotland took three hundred years and an act of parliament to collapse its third verdict, in public, with a commencement date. Your WHERE clause does it on every query, today, without asking.
What was the count actually measuring?
Go back to the two spreadsheets.
Nobody in that story ever verified an update. What they verified was that a number at one end equaled a number at the other, and the mechanism produced a number that made them equal, because the comparison inside it could not separate “this row is unchanged” from “this row cannot be evaluated.”
The exact line that produced that arithmetic is gone now, and so are most of the people who wrote it. What survives is the shape, and the shape is enough: a comparison that could not tell those two apart, sitting on a stack where four layers had already agreed they were the same thing.
So what was that count measuring? The work? Or the only definition of the work anybody had?
There was no written requirement. There rarely is, on a system that old. And when there is no specification, something always volunteers to be one, and it is usually whatever number is easiest to look at. Ten thousand in, ten thousand out, green, for years.
Wrong and quiet is survivable. How long has your codebase been surviving that way?
So why is the right number in production today?
When the mechanism reported three thousand, nobody defended the ten thousand. Nobody could. Arguing the merits would have meant holding four things at once: that Oracle folds empty into null, that one driver deletes the column while the other offers you a policy, that a WHERE clause hands the same fate to “no” and to “cannot say”, and that an update count and a row count were never the same question. Who in that room was holding all four?
So the three thousand went in. Not because the room evaluated the claim. Because it evaluated the claimant.
The wrong number was believed because it matched. The right number was believed because the person saying it sounded like someone who would know. Between those two moments, what was verified?
And if the fix had been wrong, what in that process would have caught it?
What the room was actually running on was a private ranking of who is credible about databases. That is an instrument too. When was it last calibrated? It agrees with reality often enough that nobody thinks to check it, and when it fails it fails silently, in the direction of whoever speaks with the least hedging.
So try it on your own last release. Was the number correct, or was it asserted by somebody plausible? Which of those two did the green check actually confirm? And sitting where you sit, reading what you were sent, how would you separate them?
Is that not the same collapse, one floor up? Proven, and merely never contradicted, arriving at your desk through the same door.
What is your count in the middle?
You have a number in front of you somewhere. A dashboard tile, a report, a row count at the end of a job, something green that told you this morning that a thing had worked.
What did it compare? Does it measure the mechanism, or does it measure agreement between two ends that were always going to agree? Where is the count in the middle, the one that would notice if the pipeline started lying symmetrically? And if that number went from ten thousand to three thousand tomorrow, which way would your organization move: toward finding out, or toward putting it back?
Ten thousand rows. Ten thousand reported. Everybody comfortable, for years, because the two ends matched.
That number was never checked against the work. It was only ever checked against itself.
So when your system tells you no, how do you know it is not telling you that it cannot say? And if nothing that reaches you can tell the difference between those two sentences… which one have you been acting on all this time?