Validate attributes on assignment¶
The question. You want to stop an invalid value getting onto an instance — not just at construction, but every time somebody writes to the attribute. The classic example is a Rectangle whose width should always be positive; you want r.width = -1 to fail, not quietly corrupt the state.
The three tools for this are @property (single-field, every assignment), __post_init__ (dataclass-only, once at construction), and __setattr__ (cross-field invariants). For the common case — "check this single field on every write" — @property is the answer.
class Rectangle:
def __init__(self, width, height):
self.width = width # triggers the setter below
self.height = height # triggers the setter below
@property
def width(self):
return self._width
@width.setter
def width(self, value):
if value <= 0:
raise ValueError('width must be positive')
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
if value <= 0:
raise ValueError('height must be positive')
self._height = value
def __repr__(self):
return f'Rectangle({self._width}, {self._height})'
r = Rectangle(3, 4)
print(r)
try:
r.width = -1 # setter raises — state stays valid
except ValueError as e:
print(f'{type(e).__name__}: {e}')
try:
Rectangle(-1, 4) # __init__ assignment also runs the setter
except ValueError as e:
print(f'{type(e).__name__}: {e}')
# Variant: one-time validation with @dataclass __post_init__
from dataclasses import dataclass
@dataclass
class RectangleDC:
width: float
height: float
def __post_init__(self):
if self.width <= 0 or self.height <= 0:
raise ValueError('sides must be positive')
print(RectangleDC(3, 4))
try:
RectangleDC(-1, 4)
except ValueError as e:
print(f'{type(e).__name__}: {e}')
# Note: this guards construction, not later assignment.
# r = RectangleDC(3, 4); r.width = -1 # won't be caught
# Variant: cross-field invariants via __setattr__
class DateRange:
def __init__(self, start, end):
self.start = start
self.end = end
def __setattr__(self, name, value):
if name in ('start', 'end'):
other = 'end' if name == 'start' else 'start'
other_value = getattr(self, other, None)
if other_value is not None:
if name == 'start' and value > other_value:
raise ValueError('start cannot be after end')
if name == 'end' and value < other_value:
raise ValueError('end cannot be before start')
super().__setattr__(name, value)
dr = DateRange(1, 5)
try:
dr.end = 0
except ValueError as e:
print(f'{type(e).__name__}: {e}')
Why it works¶
@property redefines attribute access as method calls. Reading r.width runs the getter; writing r.width = 3 runs the setter. The real value lives under the underscored name self._width, which is an ordinary attribute — the setter is the only place that touches it, and the check happens in exactly one spot.
Crucially, __init__ assigning self.width = width goes through the setter too, so construction is validated by the same code as later assignment. No duplicated logic between "check the inputs" and "check on every write".
__post_init__ is the dataclass-only shortcut: it runs once, after the generated __init__, and is enough when the value type is mostly read-only after construction. __setattr__ is the heavy-hammer option — it intercepts every attribute assignment, which is exactly what you want for cross-field invariants but a sledgehammer for single-field rules.
Trade-offs¶
Each field gets its own getter/setter pair. For a class with a dozen validated fields, that's a lot of boilerplate. At that size, __setattr__ with a dispatch table starts to look cleaner, or — more often — you should be using Pydantic or a dataclass with __post_init__ that re-validates.
__setattr__ catches everything, including your own writes. Inside __setattr__, self.x = value would recurse forever — that's why the pattern ends with super().__setattr__(name, value) to actually store. A misplaced self.x = ... or a typo that skips the super() call turns the class into a silent black hole.
@property with no behaviour is noise. A getter that just returns self._width and a setter that just stores the value isn't adding anything — delete both and use a plain attribute. You can always promote to a property later; Python's attribute access is uniform, so callers won't notice.
Consider Pydantic. pydantic.BaseModel declares fields with types and constraints (x: int = Field(gt=0)) and handles all of the above out of the box — including type coercion and serialisation. If you're already using Pydantic for API data, use it here too.
Related reading¶
- Choose between @dataclass, NamedTuple, and a plain class — because the "do I even need a plain class here?" question often comes first.
- Avoid common class mistakes — including the "
@propertywraps nothing" antipattern. - Validate function arguments — the same check-and-raise pattern, on the function side.