Intuition The one core idea
A property is a method that wears the costume of an attribute : you write obj.x (looks like plain data), but a hidden function runs and can check, compute, or block the operation. To understand it you only need to see what happens the instant you type obj.x — and that is a small chain of lookups we will build one link at a time.
Before you can appreciate properties, you must be fluent in the little pieces of vocabulary the parent note quietly assumes. Below we build every one from absolute zero — plain words, then a picture, then why the topic needs it.
An object is a bundle of data that lives in memory. Think of it as a labelled box . In Python you make one from a class (a blueprint): t = Temperature(25) builds a box t.
An attribute is a named thing stored on (or reachable through) an object. We write it with a dot : t.celsius means "the thing called celsius belonging to box t". The dot . is read as "'s" — t.celsius = "t's celsius".
Read Figure 1: the blue box is the object t; the two lines inside it are attributes it holds. The orange label t.celsius on the left, with the arrow diving into the box, is the picture of the dot: it says "start at box t, then walk in to find the thing named celsius." Keep this arrow-into-a-box image in your head — everything below is just what that arrow does on the way in .
Why the topic needs it: properties exist only to change what the dot does. If you don't picture the dot as "go find a thing named X on this box", nothing later makes sense.
self
Inside a method, ==self== is the name Python gives to "the very object this method was called on." If you call t.celsius, then inside that method self is t. So self.celsius means the same as t.celsius — it's the box talking about itself.
Definition Leading underscore
_x
A name that starts with an underscore, like ==_celsius==, is a plain attribute that carries a social signpost : "I am internal — outsiders please don't touch me directly." Python does not enforce this; it is a convention (see Encapsulation and Access Modifiers ).
celsius and _celsius?
The property lives under the public name celsius. The actual stored number lives under the private name _celsius. They must differ, otherwise storing the value would re-trigger the property forever (the infinite-recursion trap the parent warns about).
Read Figure 2: the blue box on the left is the public name celsius (the guard); the green box on the right is the private name _celsius (where the number actually lives). The gray arrow "stores into" shows the guard handing the value to the private box. The red panel at the bottom is the trap: if the guard tries to store into itself (self.celsius = value) the arrow would loop back to the guard forever — that is the RecursionError the parent warns about.
A method is a function written inside a class . When called as obj.f(), Python secretly passes obj as the first argument — that's the self you see. So def celsius(self): ... is a function whose job depends on which box asked.
Why the topic needs it: a property is built out of methods . @property takes the celsius method and repackages it so you can drop the () and just write t.celsius.
The @name line written directly above a def is a decorator . It means: "take the function I'm about to define, feed it to name, and keep whatever name hands back — under the same function's name." In one line:
@ property
def celsius (self): ...
is exactly celsius = property(celsius).
fget, fset, fdel — the three slots inside a property
A property object carries three named slots , each holding one function (or nothing):
==fget== — the f unction that runs on get (when you read obj.x). Filled by @property.
==fset== — the f unction that runs on set (when you write obj.x = v). Filled by @x.setter.
==fdel== — the f unction that runs on del ete (when you do del obj.x). Filled by @x.deleter.
If a slot is empty, the matching operation is blocked (read/write/delete raises AttributeError).
Intuition A decorator is a wrapper, not magic
Picture a plain gift (your function). @property is the wrapping paper: same gift inside, but now it looks and behaves differently from outside. property(...) is the machine that does the wrapping — it drops your function into the fget slot.
Read Figure 3: on the left is your plain function celsius(self); the orange middle box property( ) is the wrapping machine; the blue box on the right is the finished property object. The two arrows show the flow: function → wrapper → property. The big orange @property label reminds you that this whole three-step flow is what that one line triggers. The finished object is what holds the fget/fset/fdel slots from the definition above.
Why the @x.setter reuses the name x: @property first makes an object named x with fget filled. Then x.setter(func) returns a copy of that object with the fset slot now filled too , and the name x is rebound to the copy. @x.deleter does the same for fdel. All three slots accumulate into one object. (Compare Getters and Setters in Java vs Python — Java needs no decorator because it never had the plain-attribute syntax to protect.)
A descriptor is any object that defines at least one of the special methods __get__, __set__, __delete__. When such an object is stored on a class , Python reroutes obj.thatname through those methods instead of just handing the object back. The full mechanics live in Descriptors __get__ __set__ __delete__ .
Definition Data descriptor vs non-data descriptor
Data descriptor = defines __set__ or __delete__. It is bossy : it beats the instance's own storage.
Non-data descriptor = defines only __get__. It is polite : the instance's own storage beats it.
A property is always a data descriptor (it always defines __set__ and __delete__, even when fset/fdel are empty — an empty slot just makes those raise AttributeError), which is why your setter is guaranteed to run .
Why the topic needs it: the parent's whole "properties beat instance attributes" guarantee is just this one fact — data descriptors win.
obj.__dict__
Every object carries a tiny dictionary, ==obj.__dict__==, mapping attribute names to values — the object's personal storage drawer .
__mro__ (Method Resolution Order)
A class and its parents form an ordered search list called the ==__mro__==: "look in this class first, then its parent, then the grandparent…". Class-level names (methods, class variables, properties) are all found by walking this list in order and stopping at the first class that has the name.
__mro__ concretely drives one lookup
Say t is a Temperature, and Temperature inherits from Base. Evaluating t.celsius does this:
Build the class list: [Temperature, Base, object] — that's type(t).__mro__.
Walk it left to right , asking each class "do you have celsius in your __dict__?" Stop at the first yes → that is the class-level candidate .
Then Python decides whether that candidate, or t's own __dict__, wins (the priority rule below).
So __mro__ fixes which celsius we found; the priority rule fixes whether it beats the instance's own copy.
Intuition WHY this exact order (deriving the rule, not memorising it)
Why data descriptors (step 1) beat the instance dict (step 2): a data descriptor is the class's promise "I control writes to this name" — it defines __set__. If the instance's own copy could shadow it, that promise would be breakable by anyone who writes into obj.__dict__. Python resolves the conflict in favour of the class's controlling descriptor, so your setter can never be bypassed . That is the whole point of a property.
Why non-data descriptors (step 3) are deferred below the instance dict: a non-data descriptor (only __get__) makes no claim over writes. The classic case is a normal method: it lives on the class, but if you deliberately store obj.method = something, you clearly meant to override it per-instance. Since the descriptor never promised to guard the name, the instance's copy is allowed to win.
Why plain class attributes (step 4) come last among class-level things: they are just shared default values; anything more specific (a descriptor, or an instance's own value) should naturally take priority over a class-wide default.
Read Figure 4: the five stacked boxes are the priority ladder, checked top to bottom; the arrows show Python falling through to the next rung only when the current one has no match. The green top rung is where a property sits (data descriptor) — above the blue instance-dict rung, which is the visual reason the setter always runs. The bottom red rung is the "nothing matched" outcome, AttributeError.
del obj.x
del is the delete operation. On a normal attribute, del obj.x removes the key x from obj.__dict__. On a property it does not touch the dict directly — it runs the property's fdel slot instead.
Worked example Case A — property with only
@x.deleter (only fdel supplied)
class Account :
def __init__ (self, bal): self ._balance = bal
@ property
def balance (self): return self ._balance
@balance.deleter
def balance (self):
print ( "closing account" )
self ._balance = 0
del acc.balance runs fdel: it prints and sets self._balance = 0. Notice it edits the private backing field _balance, not the public balance — the property has no key in obj.__dict__ to remove, so "delete" here means "run my cleanup," a controlled decommission.
del obj.x to erase instance storage
del acc.balance does not remove anything from acc.__dict__ (the property isn't stored there — it lives on the class ). It only calls fdel. If fdel is missing, del acc.balance raises AttributeError: can't delete attribute, because the delete slot is empty and the class-level property still shadows any instance value.
Worked example Case B — property with an empty
fset (read-only)
A property made by @property alone still is a data descriptor: its __set__ exists but, finding fset empty, immediately raises AttributeError: can't set attribute. So acc.balance = 5 is blocked even though you never wrote a setter — the empty slot is an active "no," not a gap that falls through to the instance dict.
Intuition How to read this map
Read arrows as "is needed before." Two parallel chains run downward and then merge:
the syntax chain (top, A→C→D→E) teaches you how the @property code is built, and the
engine chain (bottom, F/H→G→I) teaches you the lookup machinery underneath. They meet at
the final node Controlled access — the topic itself. The letters map to the numbered sections:
A = §0, B = §1, C = §2, D–E = §3, F–G = §4, H–I = §5, J = §6/§Invariant.
self and underscore names
Method is a function on a class
Decorator wraps a function
property object with fget fset fdel
Descriptor get set delete
Data vs non-data descriptor
obj dict and mro lookup order
Property wins over instance dict
Invariant a rule that must hold
Controlled access via property
An invariant is a rule that must always stay true for an object to be valid, e.g. "temperature ≥ −273.15" or "balance ≥ 0." Properties are the guard-post that enforces invariants on every write. Deep dive: Invariants and Class Design .
Intuition The whole point in one line
Plain attributes let anyone break your invariants (account.balance = -9999). A property inserts a checkpoint so every assignment must pass inspection — while the caller still types the clean obj.x.
Test yourself — cover the right side.
The dot in t.celsius means "the thing named celsius belonging to object t" — go find attribute celsius on t.
Inside a method, self refers to the exact object the method was called on (e.g. t in t.celsius).
Why store the value in self._celsius not self.celsius assigning to celsius would re-trigger the property setter → infinite recursion; _celsius is a separate backing field.
@property above def celsius is shorthand forcelsius = property(celsius) — the function is wrapped into a property object with fget filled.
What are fget, fset, fdel the three slots of a property holding the functions run on get, set, and delete respectively.
A descriptor is an object that defines at least one of __get__, __set__, __delete__.
A data descriptor differs from a non-data descriptor because it defines __set__ or __delete__, so it wins over the instance's __dict__.
The full attribute-lookup priority order is data descriptor > instance __dict__ > non-data descriptor > plain class attribute > AttributeError.
Why does a data descriptor beat the instance dict it defines __set__, a promise to control writes; letting the instance shadow it would break that guarantee, so the class descriptor wins.
Why is a non-data descriptor deferred below the instance dict it makes no claim over writes (only __get__), so a deliberately stored instance value is allowed to override it.
What does __mro__ decide vs what does the priority rule decide __mro__ picks which class-level x is found; the priority rule decides whether it beats the instance's own copy.
What does del acc.balance do when only @balance.deleter is defined it runs fdel (cleanup), it does not remove anything from acc.__dict__.
What happens on acc.balance = 5 when no setter is defined AttributeError: can't set attribute — the empty fset actively blocks it.
See the parent: Properties (Hinglish) .