In many object-oriented languages, the standard advice is "never expose a field directly". Wrap every value in getX() and setX(value) methods, even if those methods do nothing, just in case you need to add validation later. Python takes a different stance: start with a plain attribute, and add accessors only when you have a reason. This lesson covers why the Java-style pattern is an anti-pattern in Python, when you do need to intercept reads and writes, how to evolve a plain attribute into a property without breaking callers, and the small set of advanced hooks that exist for the unusual cases.
In a language where the syntax for "read a field" (obj.field) is permanently different from the syntax for "call a method" (obj.getField()), once a class exposes a public field, anyone who touches it has hard-coded the field-access form into their code. If you later need to add validation, you have to change every call site too. So the defensive habit is to write the getter and setter from day one, just in case.
Python doesn't have this problem because the syntax for reading an attribute and the syntax for reading a property are identical. Both are obj.field. The difference between "field" and "method-backed property" is hidden behind the dot. Adding validation later costs zero changes at the call site.
A minimal product class:
Three weeks later, someone wants to reject negative prices. In Java, if setPrice(value) had been written from the start, you'd add the check inside that method and be done. Otherwise, you'd be renaming product.price = ... to product.setPrice(...) at every call site, which is a depressing afternoon.
In Python, you don't have to make that defensive choice up front. Keep the plain attribute version of Product, ship it, and only when the validation requirement appears do you swap the attribute for a property. The caller's code doesn't change:
That outer code is the same in both snippets. The class internals differ (the second version routes every read through a getter and every write through a setter), but the difference isn't visible from the outside. That's why Python doesn't preempt with getter/setter ceremony: the upgrade path is invisible to the caller.
This is the design pattern people are calling out when they say "premature getters and setters are an anti-pattern in Python". The pattern isn't wrong because accessors are bad; it's wrong because writing set_price before it's needed commits to a noisier API for no benefit. Start with the simplest form. Add machinery when something requires it.
The diagram shows the decision tree. Most classes never leave the green branch. When a real requirement appears, the teal branch handles it without any change to the public interface. The call site never has to know which branch you took.
The Pythonic position is not "never write accessors". It's "write accessors when they do real work". A handful of situations call for going through @property instead of a plain attribute.
The four common reasons are validation, transformation, computation, and immutability. Each one represents a concrete behavior that a plain attribute can't provide.
A frequent reason. The setter is the natural place to enforce rules that should hold for every valid instance.
The setter runs on every assignment, including the one inside __init__. So Product("Wireless Mouse", -5) would also fail, which is the goal: no way for a Product to exist with a price that violates the rule.
Sometimes the value that comes in isn't quite the value you want stored. Trim whitespace, round to two decimal places, lowercase an email. The setter is a clean place to do that work once, in one place, so every other part of the code sees the cleaned value.
The caller passed a messy string with mixed case and leading whitespace. The setter normalized it once during construction, and every later read returns the clean form. If the caller writes alice.email = " Alice@Algomaster.IO ", the same cleanup happens automatically.
A read-only property with no underlying storage is what a getter does in Python. Use it when a value is a function of other attributes and you don't want to keep the two in sync by hand.
Nobody told total to refresh. It doesn't need to, because it isn't stored anywhere; each read recomputes from the current items. No risk of a stale total drifting out of sync with the actual contents.
A getter without a setter is read-only. The pattern fits fields that should be set once in __init__ and never change afterward, like a customer ID or an order number.
Reading works. Writing fails with AttributeError because there's no setter to call. The order ID is set once during construction and frozen for the life of the object.
| Reason | When to use it | Decorator combination |
|---|---|---|
| Validation | Reject bad values, enforce invariants | @property + @<name>.setter |
| Transformation | Clean, normalize, or coerce incoming values | @property + @<name>.setter |
| Computation | Value derived from other attributes | @property only |
| Immutability | Field set once, never changed | @property only |
The full mechanics of these decorators belong in the @property lesson. Each row of the table represents real work a plain attribute can't do, which is what justifies the extra code.
@property Without a SetterThe "immutability" use case is worth a closer look because it shows how Python handles a common need with minimal ceremony. A property with only a getter behaves like a read-only attribute. Callers can read it normally; any attempt to assign raises AttributeError.
The convention is to store the real value in a private attribute (leading underscore) and expose it through the property:
name is a plain attribute and accepts reassignment. customer_id is a read-only property and refuses. The leading underscore on _customer_id is a "this is the storage; don't touch it directly" signal, but Python doesn't enforce privacy: a determined caller could still write alice._customer_id = 9999. The underscore communicates intent, not enforcement.
Read-only properties pair well with two patterns. The first is identity fields that should never change after construction: customer ID, order number, account number, anything that represents identity in the domain. The second is derived values you want to expose but never accept writes for: a cart total, an order's shipping fee, a product's discounted price. Both share the same shape: a getter, no setter, and the value is settled either at construction time or computed fresh on every read.
A caveat: if you make a field read-only and the storage is mutable (a list or a dict), the field itself can't be reassigned, but its contents can still be modified through the existing reference. A read-only items property that returns self._items doesn't prevent cart.items.append(...) from mutating the underlying list. To prevent that, return a copy or wrap the value in something immutable like a tuple.
The setter is the natural place for any rule that should hold for every valid instance. The rules go inside the setter because that way they cover both the initial assignment in __init__ and every later assignment from outside.
A fuller example with both type and value checks:
The setter ran four times in this snippet: once for the good review's constructor, once for the failed Review(101, 7) call (the constructor's self.rating = 7 triggered the setter, which raised before any attributes were set), once for Review(101, "five"), and once for the late good.rating = 0 assignment. In every case, the same validation rules applied. The only way to bypass them is writing directly to self._rating.
The convention for exception types:
TypeError for wrong type ("rating must be an int").ValueError for right type but bad value ("rating must be between 1 and 5")."got 7" is much easier to debug than "invalid rating".When the setter raises, the assignment never completes, so the instance is left in whatever state it was in before. For __init__, that means no Review exists at all because the constructor itself raised. For a later assignment like good.rating = 0, it means good.rating is still the previous value (5).
A setter call has the same overhead as any other method call, which is small but non-zero. In normal code this is irrelevant. In tight loops that read or write the same property millions of times, the overhead might show up in a profile; the workaround is to read the underlying value once outside the loop. This is a micro-optimization, not a daily concern.
Python lets you ship a class with a plain attribute today, and later swap that attribute for a property without breaking any caller. The refactor, concretely:
Day one, the class is as simple as possible:
Day fifteen, a teammate notices that an admin tool set a negative price and charged a customer the wrong amount. The fix is to reject negatives. The class becomes:
The caller code, written on day one against the plain-attribute version, still looks the same:
Every line of that caller code keeps working without any edit. The constructor call, the read, the write: all of them go through the property now, but the syntax at the call site is the same. The only visible difference is that an admin tool that tries to write -5.00 to mouse.price now gets a ValueError instead of succeeding, which is the point of the refactor.
The mechanics that make this work are spelled out in the @property lesson: the underlying value moves from self.price to self._price (with the underscore) so the setter doesn't recurse into itself, and the property object on the class intercepts both reads and writes. The bigger picture: you don't have to choose between "ship a clean API now" and "leave room for validation later". You can do both, because the refactor is invisible to callers.
This is sometimes called the uniform access principle: from the outside, reading a stored value and reading a computed value should look the same. Python implements it through the property mechanism, which is why the upgrade is painless.
The bigger context for this lesson is a phrase that comes up in Python circles: "we're all consenting adults". Python doesn't enforce true privacy on attributes. A leading underscore means "treat this as private, please", not "you literally cannot access it". A double leading underscore enables a naming-mangling trick that makes accidental clashes less likely in subclasses, but it doesn't lock anything down either.
The language assumes you trust the people who use your code to read the conventions and respect them. If someone reaches into your class and writes to _internal_state, that's their problem to manage, not yours to prevent. The philosophy trades hard guarantees for simpler code and easier debugging.
Three consequences fall out of this:
obj._underlying = value. The first kind of code is who you're designing for.This is a different mental model from languages that have private keywords and field-level visibility. Once the conventions are familiar, the code ends up with less ceremony and more substance. The classes that ship are smaller, the public APIs are clearer, and the time that would have gone to getter/setter boilerplate goes into actual logic.
__getattr__ and __setattr__ for the Edge Cases@property handles the common needs: a specific attribute with specific rules. For the rare cases where you want to intercept every attribute access on a class, two dunder methods exist: __getattr__ for reads and __setattr__ for writes. This is the heavy machinery, and most code never needs it.
__getattr__ is called only when normal attribute lookup fails. If the attribute exists on the instance or the class, Python doesn't call __getattr__ at all. The method runs as a fallback, which makes it useful for proxying attribute access to some underlying store:
config.theme first tries the normal lookup (instance dict, then class), finds nothing, and falls back to __getattr__, which checks the _data dict and returns the value. Same for page_size. config.missing also misses, the dict doesn't have a missing key, and the method raises AttributeError to signal "this attribute doesn't exist".
__setattr__ is the opposite, and it's much trickier because it runs on every attribute assignment, including the ones inside __init__. Forgetting that fact leads to immediate infinite recursion:
The line self.log = f"set {name} = {value}" inside __setattr__ is itself an attribute assignment, which calls __setattr__ again, which tries to set self.log again, and so on. Python raises RecursionError after about a thousand levels. The fix is to use super().__setattr__ or object.__setattr__ to bypass the override:
Every assignment now goes through __setattr__, which records the event and then delegates to the default mechanism via super().__setattr__. The _log attribute is itself set via super().__setattr__ to avoid the recursion trap.
These two methods are powerful but easy to misuse. The general advice: use @property first. Only when you need to intercept attributes whose names aren't known in advance (proxies, lazy loaders, configuration objects, ORM-like classes), __getattr__ and __setattr__ fit. For everything else, named properties are simpler, more readable, and less error-prone.
__setattr__ runs on every write. A class with __setattr__ defined pays the method-call overhead on every assignment, including the ones inside __init__. That's usually fine, but it's measurable in tight loops and is a reason to keep the override minimal.
10 quizzes