In a nightly report generator a computed @property re-reads the clock on every access, so two fields in one row disagree — when is a property the wrong tool?
answer
- Parentheses tell the reader work happens
- Two reads, two answers
- Debuggers and reprs read attributes too
- Freeze the moment, pass it down
- Cheap, repeatable, exception-free state
basics
~20 sA property promises that reading is cheap, repeatable and side-effect free. Anything volatile, expensive or fallible belongs in a method, where the call site shows it. Freeze the clock once per run and pass that value in.
solid answer
~50 sAttribute syntax hides that a function ran. Every read of a volatile getter re-runs it, so a value derived from `time.monotonic()` differs between the row header and the row footer — the clock-skew artefact is the property advertising a stability it never had. The rule I apply: a getter may derive from **stored state**, cheaply, without I/O and without raising. A clock read, a network call, or anything that can throw belongs in a method, where the parentheses tell the reader work is happening. The fix is to bind the timestamp once — `self._as_of` at construction, or one `as_of` threaded through the render — and compute from that frozen input. Cost matters too: at a 1,200-request-per-minute peak an innocuous `row.total` inside a loop multiplies invisibly, and every logger, `repr` and debugger watch triggers it as well.
code
python · 19 linesimport time
class Row:
def __init__(self, started):
self._started = started
@property
def volatile_age(self): # re-reads the clock on every access
return time.monotonic() - self._started
def age_at(self, as_of): # explicit frozen input, repeatable
return as_of - self._started
row = Row(time.monotonic())
print(row.volatile_age == row.volatile_age) # False: two reads, two clocks
as_of = time.monotonic()
print(row.age_at(as_of) == row.age_at(as_of)) # Truego deeper
Remember that a property runs a function every time you read it, so reading twice can give two answers and can cost real time. If work is happening, calling a method makes that visible to whoever reads the code.
Be able to name the four disqualifiers — volatile, expensive, fallible, side-effecting — and explain why hasattr on a getter that raises AttributeError silently reports the attribute as missing.
Show you have debugged this: freeze the volatile input at the top of the run and thread it down, find hidden recomputation by profiling call counts or by diffing two runs over identical input, and keep getters out of anything a debugger would trigger.
Own it as an API convention: publish where your codebase draws the line between attribute and method, and make determinism a testable property of batch output rather than a habit. A same-input-twice byte diff in CI catches an entire class of these before they reach a report.
### The contract a property signs `obj.total` and `obj.total()` are the same amount of work to the interpreter and very different promises to the reader. Parentheses say *something happens here*; a bare attribute says *this is a value the object has*. Every convention built on top of Python assumes the second reading: debuggers evaluate attributes to populate a variables pane, `repr` implementations touch them, serializers and template engines walk them, and `hasattr` calls them speculatively. A property that violates the promise does not fail at its definition — it fails scattered across the callers who trusted it. The nightly report is the canonical shape. A getter written as `return time.monotonic() - self._started` is correct in isolation and wrong in aggregate: the header and the footer of a single row each read it, get two different clock samples, and the report shows an internally inconsistent row. Nobody wrote a bug; the attribute simply is not a value, it is a measurement, and measurements need an explicit moment. ### The four properties that are the wrong tool **Volatile.** The getter depends on something outside the object's stored state — the wall clock, a monotonic clock, `random`, a mutable global, an environment variable, the contents of a file. Two reads legitimately differ, and any caller that reads twice gets an inconsistency it cannot see in the source. Fix by freezing the volatile input: capture it once, store it (`self._as_of`), and let the getter be a pure function of stored state. If the moment belongs to the *operation* rather than the object, pass it as an argument to a method — `row.age_at(as_of)` — so the caller owns the reference point and all rows in a report share one. **Expensive.** The getter loops over a large collection, walks a graph, formats a document. This is invisible at the call site, so it lands inside other people's loops. Under a 1,200-request-per-minute peak, one hidden recomputation per field per row becomes a profile you cannot explain from reading the calling code. Two escapes: promote it to a method with a verb name (`compute_totals()`) so cost shows, or — if the value truly is stable for the object's lifetime — store it once with a caching descriptor such as `functools.cached_property`, accepting the invalidation problem that comes with it. **Fallible.** A getter that raises breaks things quietly. `hasattr(obj, "x")` catches only `AttributeError`, so a getter that raises `AttributeError` internally — including from a typo inside it — makes `hasattr` return `False` and the caller conclude the attribute does not exist. Any other exception propagates out of `hasattr` instead, which surprises callers the other way. Worse, a raising getter makes objects hostile to debuggers and `repr`: you cannot inspect the object without triggering the failure. Anything that can fail meaningfully should be a method that raises a documented exception at an explicit call. **Side-effecting.** A getter that mutates, logs, increments a counter, or lazily opens a connection turns *reading* into *doing*. Debugger inspection then changes program state — the worst class of heisenbug, because the act of looking is the act of changing. ### What I would actually change in this report Bind one `as_of` at the top of the run and thread it down: rows compute against a value they were given, so every field in every row of one report shares a single reference point, and re-rendering a row produces identical output. That also makes the whole thing testable — pass a fixed `as_of` and assert exact numbers, with no clock patching and no tolerance windows. Keep `age_seconds`-style attributes only where the underlying inputs are stored on the object; where the answer depends on *when you ask*, make the asking explicit. ### Diagnosing it in an existing codebase Profiling finds these: a deterministic profiler attributes time to the getter function by name and file line, so a getter high in the profile with a call count far above the object count is a hidden recomputation. The other signal is a diff in output between two runs over identical input — for a report generator, running the same input twice and comparing bytes catches every volatile getter in one shot, which is worth wiring into a test. ### The rule, compressed Use a property when the value is a cheap, repeatable, exception-free function of state the object already holds, and when there is a real reason not to expose a plain attribute — validation, a derived view, a name you want to keep stable while the storage changes. Use a method the moment reading costs something, might fail, might change, or depends on when you asked.
- How would you keep attribute syntax and still make the report deterministic?Freeze the volatile input into the object: capture `as_of` once when the run starts, store it on each row as `self._as_of`, and let the getter compute purely from stored state. The attribute stays an attribute, two reads agree, and tests can construct a row with a fixed `as_of` and assert exact values with no clock patching.
- What specifically breaks when a getter behind a property raises?`hasattr` catches only `AttributeError`, so a getter that raises one — even from a typo inside it — reports the attribute as missing and hides the real failure; any other exception escapes `hasattr` entirely. Debuggers, `repr` and serializers all touch attributes, so a raising getter makes the object hostile to inspection. Fallible work belongs in a method with a documented exception.
- How do you find hidden recomputation in a running service?Profile: a deterministic profiler attributes time to the getter function by name and line, and a call count far larger than the number of objects is the tell. For a batch generator, the cheaper check is running the same input twice and diffing the output — every clock- or randomness-dependent getter shows up as a difference, and that diff is worth keeping as a test.
- Is there ever a case for an expensive property?Yes, when the value is stable for the object's lifetime and the cost is paid once — that is what a caching descriptor is for — or when the object exists specifically as a lazy view and callers are told so in its documentation. The condition is that the cost is bounded and predictable. An unbounded recomputation on every read is never the right shape behind attribute syntax.
A property is the price printed on a shelf label; a method is the till. Nobody expects the label to change while they walk to the checkout, and nobody expects reading it to charge them.
saying these in an interview costs you the question
- Says properties are free because they look like attributes
- Puts network or database I/O behind a getter
- Assumes two reads of a property must agree
- Lets a getter raise and expects hasattr to surface it
- Adds retries or logging inside a getter
- Patches the clock in tests instead of injecting the moment