
Primitive Obsession: When a Float Isn't a Temperature
July 16, 2026
313 min readA float can hold any number, which is exactly the problem. Primitive obsession is why the same validation keeps reappearing, and value objects are the fix.
Most bugs I have chased for more than an hour had the same shape underneath: a value arrived somewhere it had no business being, and the type system waved it through because the value was technically a float. Not a temperature. A float. The compiler, the linter and the IDE were all perfectly happy. Only the greenhouse was unhappy.
This is primitive obsession, and it is easy to miss because the code looks fine. You reach for a str to hold an email, an int to hold a duration, a float to hold a temperature. Those are the obvious types. They are also the types that let you pass an email address where a postcode goes, or minutes where seconds go, and never hear a word about it.
The problem is not that primitives are bad. It's that a primitive is wider than the idea you're storing in it. There are far more floats than there are valid temperatures, and every float you didn't mean is a bug waiting for the right afternoon.
It always starts clean
Here's a controller for a greenhouse climate system. Each zone has a target temperature, and we can nudge it up or down.
class ClimateController:
def __init__(self):
self.targets = {}
def set_target(self, zone, temperature):
self.targets[zone] = temperature
def nudge(self, zone, delta):
self.targets[zone] += delta
def is_frost_risk(self, zone):
return self.targets[zone] < 2.0Honestly, this is nice code. Three short methods, no nesting, no cleverness. Nobody is failing a code review over this. zone is a string, temperature and delta are floats, and that all seems to be exactly what those things are.
Then it goes to production, and it stays fine for about four months.
The exact moment it breaks
The company sells to a customer in the US, and their sensors report Fahrenheit. Someone writes the integration:
controller.set_target("north-bay", reading.value) # reading.value is 68.0 FNothing raises. Nothing logs. set_target wanted a float and got a float, and 68.0 is an extremely valid float. The greenhouse is now heating to 68 degrees Celsius and the tomatoes are being cooked in a way the product spec did not anticipate.
You might say: fine, validate it. So we add a check. And because temperature arrives from three places - the sensor integration, the admin UI, and a nightly schedule import - we add it in three places:
def set_target(self, zone, temperature):
if not -10 <= temperature <= 45:
raise ValueError("temperature out of range")
self.targets[zone] = temperatureNow watch what that actually bought us. nudge still takes a raw float and can walk the target straight past 45 without a word. The admin UI has its own copy of -10 <= t <= 45, which drifted to -10 <= t <= 40 last spring and nobody noticed. And the real bug is still there: 68.0 passes the range check fine when the range is Fahrenheit-shaped. We validated the number. We never established what the number meant.
That's the tell for primitive obsession. The same validation keeps reappearing in more places, it keeps disagreeing with itself, and it still can't catch the thing you care about - because a float does not know whether it's Celsius, and no amount of checking downstream will teach it.
The fix: give the idea a type
Make the concept a real thing in the code.
from dataclasses import dataclass
@dataclass(frozen=True)
class ZoneId:
value: str
def __post_init__(self):
if not self.value:
raise ValueError("zone id cannot be empty")
@dataclass(frozen=True)
class TemperatureDelta:
degrees: float
@dataclass(frozen=True, order=True)
class Celsius:
degrees: float
def __post_init__(self):
if not -10 <= self.degrees <= 45:
raise ValueError(f"{self.degrees}C is outside greenhouse range")
@classmethod
def from_fahrenheit(cls, f):
return cls((f - 32) * 5 / 9)
def plus(self, delta: TemperatureDelta):
return Celsius(self.degrees + delta.degrees)TemperatureDelta is not a mistake or a duplicate. A reading of -19C is nonsense in a greenhouse, but cooling by 19 degrees is an ordinary Tuesday, so a temperature and a change in temperature obey different rules and are not the same idea. Modelling them as one type is the original sin in a smaller font. Once they're separate, plus is the only bridge between them, and it returns a Celsius - which means the constructor runs, which means nudge can no longer walk the target past 45. The hole I pointed at earlier closes on its own, without a single new if.
And the controller stops checking anything:
class ClimateController:
def __init__(self):
self.targets = {}
def set_target(self, zone: ZoneId, temperature: Celsius):
self.targets[zone] = temperature
def nudge(self, zone: ZoneId, delta: TemperatureDelta):
self.targets[zone] = self.targets[zone].plus(delta)
def is_frost_risk(self, zone: ZoneId):
return self.targets[zone] < Celsius(2.0)The Fahrenheit integration now reads Celsius.from_fahrenheit(reading.value), and 68F becomes 20C at the boundary, once, where the unit is actually known. If someone passes a bare 68.0 instead, it isn't a subtle miscalibration - it's a type error at the door.
Notice what left the controller: every range check. Celsius cannot exist in an invalid state, so anything holding one is free to just use it. The validation didn't get deleted, it got relocated to the single place that can enforce it.
Why a type beats a check
Validation happens once, at the edge. This is guard clauses taken one level up: instead of every function guarding its inputs, the constructor guards, and the rest of the code trusts. A Celsius in your hand is a proof that the check already ran.
The type checker catches the swap. set_target(zone, temperature) with the arguments reversed is invisible when both are primitives - Python will happily use a temperature as a dict key and tell you nothing. Once one is a ZoneId and the other a Celsius, mypy flags it before the code ever runs. Two str parameters sitting next to each other are a coin flip you re-toss on every call.
Behaviour has somewhere to live. from_fahrenheit belongs with temperature, not scattered across three integrations. The conversion, the range, and the frost threshold all have one obvious home - which is the single responsibility point: one reason to change, one place it changes.
It stays easy to test. Celsius is frozen and takes no collaborators, so testing it is a constructor call and an assertion. That's the same reason pure functions are pleasant to test - no setup, no mocks, no world to arrange.
When you don't need it
Primitive obsession is a real smell, but "wrap everything" is a worse codebase than the one you started with.
When the primitive is genuinely the whole idea. A loop counter is an int. A log message is a str. There's no invariant, no unit, no confusion possible. class LoopIndex is not domain modelling, it's costume.
When nothing can be confused with it. The value of a type is largely that it excludes things. If a function takes one string and there is no second string it could be mixed up with, and no rule about what's in it, a wrapper buys you very little.
When it's a script. Twenty lines you'll run once and delete don't need a domain model. The cost of a value object is paid back over months of other people misusing the primitive. No months, no payback.
The honest signal is repetition: when you find yourself writing the same if about the same primitive in a third file, that primitive is asking to become a type.
The takeaway
A type isn't just a container for a value - it's a claim about which values are allowed to exist. float claims almost nothing, so every function receiving one has to do the claiming itself, forever, in slightly different words. The moment you notice the same validation appearing twice, or two parameters of the same primitive sitting next to each other, you've found the spot. Name the idea, put the rule in its constructor, and let every function downstream stop asking.




