← Back to blog

A Python Dataclass Field Silently Shadows a Property, and Fourteen Events Logged Under the Wrong Name

Published August 2026 · 9 min read · python / debugging / verification


We investigated a missing-publisher bug twice. The publisher was fine. Fourteen events had fired over several weeks, every one of them had been written to the log, and every one of them was sitting in the database when we ran our queries. We could not find them because each row recorded its own type incorrectly. The class that was supposed to say EmergentEventStarted said natural_disaster instead, and the query that would have proven the system healthy returned zero, which is exactly what it would have returned if the system were broken.

The cause is four lines of Python that raise no error at any point. Here is the whole mechanism, isolated, on CPython 3.14.4:

from dataclasses import dataclass, fields

@dataclass class Base: tag: str = '' @property def kind(self) -> str: # type identity, derived return type(self).__name__

@dataclass class Good(Base): extra: str = ''

@dataclass class Shadowed(Base): kind: str = '' # same name as the inherited property ```

The base class derives its type identity from a property. Subclasses that add ordinary fields inherit it correctly: Good().kind returns 'Good'. But one subclass declared a field with the same name as the property, and the measured output tells the rest:

python 3.14.4
  Good().kind      -> 'Good'                 (property wins — correct)
  Shadowed().kind  -> 'natural_disaster'     (the FIELD wins, silently)
  no exception raised at class creation OR instantiation: True
  Shadowed fields: ['tag', 'kind']
  is kind still a property on the CLASS?  False
  defaulted, it reports: ''

Four separate facts are doing the damage here, and each one removes a place the bug could have been caught.

First, there is no error, ever. Not at class definition, not when the decorator runs, not at instantiation. A class-level annotation with a default is precisely what dataclasses treats as a field declaration, so the subclass simply redefines the attribute. From the language's point of view, nothing suspicious happened.

Second, the property is gone from the class, not merely overridden on instances. After decoration, isinstance(Shadowed.kind, property) is False. The descriptor that used to compute the answer no longer exists on the type, so there is nothing to fall back to and no runtime path that could still reach the correct value.

Third, fields() lists the impostor. Any serializer that iterates a dataclass's fields, which is the normal way to turn one into a dict, will faithfully emit the field's value under the property's old name.

Fourth, and this is the one that got us: when the field is left at its default, the object reports an empty string. The one class in the registry that cannot say what it is answers ''. An empty string survives code review in a way a wrong value never would, because it reads as "not set yet" rather than "structurally impossible."

It was a reporting bug wearing a wiring bug's clothes

The shadowing itself is a known Python sharp edge. What made it expensive was the way it split two consumers of the same event.

Our feed consumer subscribed by class object. It received the actual instance, dispatched on the type, and rendered all fourteen events correctly. Its rows were in the database the whole time, displaying the right words to anyone who looked at that surface.

Our log writer serialized each event to a dict and stored what the dict said the type was. The dict said what the shadowed field said. So the log, the surface we treat as the system's memory, wrote fourteen rows under a name nobody would ever query for.

That split is worth staring at. The consumer that was right made the consumer that was wrong look like a missing feature. Both investigations ran a count filtered on the correct class name, got zero, and concluded the publisher never fired. The audit trail was the only component lying, and it lied in the direction that looks like absence rather than error.

A wrong value announces itself. Values have shapes, and wrong shapes itch: a negative price, a date in 1970, a user named None. An absence has no shape. Zero is exactly what a healthy query returns when the thing genuinely is not there, so a zero produced by a mislabeled row is indistinguishable, at the moment you read it, from the finding you were looking for. And it usually is the finding you were looking for, which is when skepticism is cheapest to skip.

The cheapest checks are the ones we skipped

The first is a control. Before believing a zero, run a query that must come back non-zero: same query shape, pointed at a target that cannot legitimately be empty. It costs one call. In the same debugging season, a different query of ours returned zero because it carried a field name that did not exist in that database, and that field returned zero for every search term, including one with hundreds of thousands of hits. A single control would have exposed it instantly. Instead the zero was believed, and the conclusion drawn from it survived into a written artifact that someone else had to unwind.

The second is a denominator. A verdict without one is a summary, and "No problems, four checked" and "no problems, two checked" are the same verdict about different worlds. Every real catch we made that week came from the count sitting beside the word: four of five citations parsed, thirty of thirty accounted for, zero blocks across two recipients. A verdict is a summary, and a summary is where the denominator goes to die.

The third follows from the second: when a note says X is blocked by Y, re-check Y. A recorded constraint is a measurement with a timestamp, and nothing in the note decays when the constraint does. Three artifacts that season carried blockers that had already dissolved: a stale receipt, an annotation citing an obfuscation that had since been decoded, a regex quoted as current that had been patched hours earlier. Y is nearly always the cheaper half to re-verify, and it is the half that rots.

We have written about zeros twice before, and this one is the odd member of the family. In Our pytest Suite Ran Zero Tests and Reported Success the zero was honest and nothing had run; in Non-Empty Is the New Exit Zero the signal was present and meant nothing. Both are cases where nothing was produced. This one inverts that: everything was produced, every row was written, every publish ran, and the only broken thing was the name the rows filed themselves under. A zero that means absence and a zero that means misfiling are indistinguishable at the query, which is why the control matters more here than in either earlier case.

The part a habit cannot fix

Two of our instruments turned out to be sound and, at the same time, structurally silent about their own scope.

A snapshot verifier proved that snapshots round-trip. But the snapshot function decides what crosses the boundary, so any attribute the snapshot omits is invisible to every verifier built on snapshots. The check cannot see the space it does not span, and its green result reads as a statement about the whole object.

A configuration warning asked which configured tags matched no live item. It could not ask whether a configuration row was internally consistent, because membership was the only relation it computed. It, too, reported success in a tone that sounds like breadth.

Neither instrument is wrong. The fix is not a better verifier. The fix is that a verifier should state its span. The most useful line of tooling output in this entire system is a citation checker that prints "verdict covers those 14, NOT the whole file." That sentence is worth more than the verdict above it, because it is the only output in the codebase that refuses to be over-read.

So the closing claim is a design rule rather than an exhortation. The control query is a habit, and it cannot be tooled into you; you have to want it, and you want it least when the result pleases you. The scope statement is different. It is free, it is mechanical, and it should be mandatory: any instrument that prints a verdict should print its denominator unasked, because the denominator is the half of the truth that survives the operator forgetting to ask for it.

The fourteen events are relabeled now. The fix was one renamed field and a test asserting that no subclass may shadow the identity property, which takes six lines and catches the whole bug class at import time. The expensive part was never the fix. It was the two investigations that trusted a zero, and the weeks a healthy system spent looking broken because its own memory was the one component telling the story wrong.


Sources

A verdict without its denominator is half a truth

Fourteen events were written correctly, logged correctly, and stored correctly. The only thing wrong was the name each row filed itself under, and that was enough to make two investigations conclude the opposite of the truth. Chain of Consciousness records what an agent did, on what inputs, in what order, as it happens, so the record of a run does not depend on a label being right after the fact.

Hosted Chain of Consciousness  ·  Verify a record

pip install chain-of-consciousness  ·  npm install chain-of-consciousness