Ever seen @something sitting just above a function or class in Python , Those are decorators — one of Python's most powerful features. Think of a decorator like a gift wrapper : the gift inside (your function or class) stays exactly the same, but the wrapper adds something extra on the outside. Today, we'll master five built-in decorators that every Python developer uses in real projects. Let's go step by step!
Quick Navigation
What is a Decorator? (The Core Idea)
Before diving into each decorator, let's understand the concept with a simple mental model.
A simple way to think about it: Imagine you write a function that makes tea . A decorator is like a tea-making robot that automatically adds milk and sugar every time — without you changing the original tea-making recipe at all. You just stick a label (@add_milk_sugar) above your recipe, and the robot takes care of the rest.
In code, a decorator sits directly above a function or class, using the @ symbol:
@ symbol tells Python to pass my_function itself into some_decorator before anything else happens, and whatever some_decorator returns is what actually gets bound to the name my_function afterward.@some_decorator
def my_function():
...
Python sees this and automatically "wraps" my_function with whatever some_decorator does. Now let's look at five decorators Python gives you for free!
Decorator 1: @property — Access Methods Like Attributes
The @property decorator lets you call a method without parentheses, making it feel like a simple variable. This is useful when a value needs to be calculated from other data, but you want it to look and feel like a plain attribute to anyone using your class.
A practical analogy: Think of your car's speedometer . You don't call the speedometer — you just look at it. But behind the dashboard, complex machinery is computing your speed. @property lets you build that hidden machinery while keeping a clean "just look at it" interface.
Step 1: Basic @property — A Computed Attribute
radius and area are accessed below this class — both look identical from the outside (c.radius, c.area, no parentheses on either), but only area actually runs a calculation each time you read it, because of the @property decorator sitting just above its def line.class Circle:
def __init__(self, radius):
self.radius = radius # plain attribute
@property
def area(self):
# Runs like a method, but accessed like an attribute!
return 3.14159 * self.radius ** 2
c = Circle(5)
print(c.area) # No parentheses! Feels like c.area, not c.area()
print(c.radius) # Also no parentheses — this is a plain attribute
Output:
78.53975
5
See the difference? c.radius is a plain stored value. c.area looks the same, but Python quietly runs a calculation behind the scenes every time you read it!
Step 2: @property Setter — Controlling What Gets Stored
You can also control what happens when someone assigns a value using @property_name.setter. This is where validation lives:
balance, decorated differently — @property makes the first one run whenever you read acc.balance, and @balance.setter makes the second one run whenever you write acc.balance = value. Follow the three calls at the bottom top to bottom to see the getter fire, then the setter succeed, then the setter reject a negative value before it's ever stored.class BankAccount:
def __init__(self, balance):
self._balance = balance # underscore = "private" by convention
@property
def balance(self):
return self._balance # getter: called when you READ balance
@balance.setter
def balance(self, value):
# setter: called when you WRITE balance
if value < 0:
raise ValueError("Balance cannot go negative!")
self._balance = value
acc = BankAccount(1000)
print(acc.balance) # 1000
acc.balance = 500 # calls the setter — clean as any variable assignment!
print(acc.balance) # 500
acc.balance = -50 # raises ValueError — setter blocked it!
Output:
1000
500
ValueError: Balance cannot go negative!
You're protecting your data without making the interface complicated. Anyone using your class just writes acc.balance = 500 — they don't even know validation is happening underneath.
@property when a value is derived from other data (like area, full_name, total_price). Use a @setter whenever a value has constraints — must be positive, must be a valid email, and so on.
c.area(). Since @property already makes it act like an attribute, adding () causes a TypeError — Python tries to call the returned value as a function!
Decorator 2: @classmethod — Alternative Ways to Create Objects
A regular method receives self — a reference to the specific object you created. A @classmethod instead receives cls — a reference to the class itself. This makes it perfect for providing multiple, friendly ways to create objects from different input formats.
A practical analogy: Imagine a cookie factory . A regular method says "put sprinkles on this cookie." A @classmethod says "let me create a fresh batch of cookies from a recipe card" — it operates at the factory level, building new objects in different ways.
Step 1: Creating Objects from Different Input Formats
Student object — notice from_string() and from_dict() both build and return a new object using cls(...) instead of Student(...), which is exactly what makes them work correctly even if someone later creates a subclass of Student.class Student:
def __init__(self, name, age, grade):
self.name = name
self.age = age
self.grade = grade
@classmethod
def from_string(cls, student_str):
# Parses "Alice,20,A" and builds a Student for you
name, age, grade = student_str.split(",")
return cls(name, int(age), grade) # cls() is the same as Student()
@classmethod
def from_dict(cls, data: dict):
# Builds a Student from a dictionary (e.g., data from a JSON API)
return cls(data["name"], data["age"], data["grade"])
def __repr__(self):
return f"Student({self.name}, age={self.age}, grade={self.grade})"
# Three different ways to create the same kind of object
s1 = Student("Alice", 20, "A") # normal
s2 = Student.from_string("Bob,22,B") # from CSV
s3 = Student.from_dict({"name": "Eva", "age": 21, "grade": "A"}) # from dict
print(s1)
print(s2)
print(s3)
Output:
Student(Alice, age=20, grade=A)
Student(Bob, age=22, grade=B)
Student(Eva, age=21, grade=A)
All three produce the same type of object, but each accepts a different input format. This pattern is called a named constructor — it makes your classes dramatically more flexible and readable!
cls (not the class name directly) inside a @classmethod. This ensures the method still works correctly if someone inherits your class — a small habit with big payoffs.
from_string() or from_csv() are far more readable than stuffing a single __init__ with many optional parameters. The caller instantly knows what input format is expected.
Decorator 3: @staticmethod — Utility Functions That Belong to a Class
Sometimes a function logically belongs with a class, but doesn't actually need access to either an instance (self) or the class (cls). That's the job for @staticmethod. It's essentially a plain function that lives inside a class purely for organisational reasons.
A practical analogy: Imagine a Calculator class . Most methods use the calculator's stored settings. But a helper like "is this a valid number?" doesn't need any settings at all — it's a simple, standalone check. A @staticmethod is that helper: self-contained, no dependencies on the object or class.
Step 1: A Helper That Logically Belongs Inside a Class
is_strong() is called directly on the class itself (PasswordManager.is_strong(...)) with no object created first — that's only possible because @staticmethod doesn't expect a self or cls argument at all, it behaves like a plain function that just happens to live inside the class.class PasswordManager:
def __init__(self, username):
self.username = username
@staticmethod
def is_strong(password):
# Pure logic — no self, no cls needed at all
if len(password) < 8:
return False
if not any(c.isdigit() for c in password):
return False
if not any(c.isupper() for c in password):
return False
return True
def set_password(self, password):
if not PasswordManager.is_strong(password):
raise ValueError("Too weak! Needs 8+ chars, a digit, and uppercase.")
self.password = password
print(f" Password set for {self.username}")
# Call a @staticmethod without creating any object!
print(PasswordManager.is_strong("abc")) # False
print(PasswordManager.is_strong("Secure99!")) # True
user = PasswordManager("alice")
user.set_password("Secure99!")
Output:
False
True
Password set for alice
Notice is_strong() can be called directly on the class — no object needed! But it still lives inside PasswordManager because that's where it logically belongs.
When to use which?
- Use a regular method → when you need to read or change the object's own data (
self) - Use
@classmethod→ when you need to create a new object or work with the class as a whole (cls) - Use
@staticmethod→ when the logic is related to the class but doesn't need eitherselforcls
self or cls to a @staticmethod. Adding self accidentally won't cause an immediate error — but Python will silently pass the object as the first argument, breaking your function logic in a very hard-to-debug way.
Decorator 4: @dataclass — Stop Writing Boring Boilerplate
Writing a class that mostly stores data is incredibly repetitive. You write __init__, then __repr__, then maybe __eq__... all doing obvious, mechanical work. The @dataclass decorator (Python 3.7+) auto-generates all of that from your type-annotated fields. It's one of the most practical additions to modern Python.
Think of it like: A pre-filled form template . Instead of writing every field label and input box yourself, you just list your column headers and Python fills in all the standard paperwork automatically.
Step 1: Before and After — The Boilerplate Problem
@dataclass version right below it.# THE OLD WAY — 14 lines just to hold 3 fields!
class Product:
def __init__(self, name: str, price: float, in_stock: bool):
self.name = name
self.price = price
self.in_stock = in_stock
def __repr__(self):
return f"Product(name='{self.name}', price={self.price}, in_stock={self.in_stock})"
def __eq__(self, other):
return (self.name, self.price) == (other.name, other.price)
@dataclass reads the type-annotated fields (name, price, in_stock) and automatically writes __init__, __repr__, and __eq__ for you behind the scenes. See the accuracy note right after this block for one subtle way it's not 100% identical to the manual version above.# THE MODERN WAY — 4 lines, exact same result!
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
in_stock: bool = True # default value!
__eq__ compares only (self.name, self.price), while @dataclass's auto-generated __eq__ compares all annotated fields, including in_stock. So two products with the same name and price but different in_stock values would be considered equal under the manual version, but not equal under the dataclass version. In this article's specific example the values happen to match, so the printed output below is unaffected — but it's worth knowing this difference exists before assuming the two approaches always behave identically.
Step 2: Using the Dataclass
Product objects get created and printed — notice you never wrote a __repr__ method anywhere, yet print(laptop) produces a clean, readable line on its own. The final line tests equality between two separately created objects that happen to share the same field values.laptop = Product("Laptop", 999.99)
phone = Product("Phone", 599.00, in_stock=False)
print(laptop) # __repr__ auto-generated!
print(phone)
print(laptop == Product("Laptop", 999.99)) # __eq__ auto-generated!
Output:
Product(name='Laptop', price=999.99, in_stock=True)
Product(name='Phone', price=599.0, in_stock=False)
True
Python wrote the boring parts — you just describe the fields your class has!
Step 3: The Most Common Mistake — Mutable Defaults
Here's a trap that catches almost every developer at least once:
Classroom twice on purpose — the second definition overwrites the first, so only the field(default_factory=list) version is actually active by the time c1 and c2 get created below. Mentally run the WRONG version instead and you'd see c1.students and c2.students print the exact same list, which is the bug this whole example exists to demonstrate.from dataclasses import dataclass, field
# WRONG — All instances share the SAME list object!
@dataclass
class Classroom:
students: list = [] # This will cause mysterious bugs!
# CORRECT — Each instance gets its OWN fresh list
@dataclass
class Classroom:
students: list = field(default_factory=list) # Always do this for lists/dicts
c1 = Classroom()
c2 = Classroom()
c1.students.append("Alice")
print(c1.students) # ['Alice'] ← correct
print(c2.students) # [] ← also correct (its own separate list)
items: list = [] in a dataclass. All instances will secretly share the exact same list — so adding to one modifies all others! Always use field(default_factory=list) for any mutable default value (lists, dicts, sets).
@dataclass(frozen=True) to make your object immutable (no one can change its fields after creation). Use @dataclass(order=True) to automatically enable sorting and comparison operators (<, >) between instances.
Decorator 5: @abstractmethod — Enforcing Rules on Subclasses
The @abstractmethod decorator lets you design a template class — one that says "any subclass of me must implement these specific methods, or Python will refuse to create objects of that subclass." It's used together with ABC (Abstract Base Class) from Python's built-in abc module.
A practical analogy: Think of a job contract . Before you can officially start, you must fill in your emergency contact and sign the code of conduct — no exceptions, no shortcuts. If you don't, HR blocks your start date. @abstractmethod is that mandatory checklist for your subclasses.
Step 1: Define the Template (Abstract Class)
PaymentGateway() and Python refuses, because it inherits from ABC and has two @abstractmethod-decorated methods with no real implementation. Notice log() has no @abstractmethod above it though — that's a normal, fully working method, automatically inherited and ready to use by every subclass without being rewritten.from abc import ABC, abstractmethod
class PaymentGateway(ABC): # ABC = Abstract Base Class — cannot be created on its own
@abstractmethod
def pay(self, amount: float) -> bool:
... # No body needed — subclasses MUST write their own version
@abstractmethod
def refund(self, amount: float) -> bool:
...
def log(self, amount):
# This is a regular method — shared by all subclasses, ready to use as-is
print(f" Transaction recorded: ${amount:.2f}")
Step 2: Create Real Implementations
pay() and refund() to satisfy the contract set by PaymentGateway. Notice both call self.log(amount) — that method was never redefined here, it's the exact same shared method inherited straight from the parent class.class StripePayment(PaymentGateway):
def pay(self, amount):
print(f" Stripe: Charging ${amount:.2f}")
self.log(amount) # inherited from PaymentGateway!
return True
def refund(self, amount):
print(f" Stripe: Refunding ${amount:.2f}")
return True
class PayPalPayment(PaymentGateway):
def pay(self, amount):
print(f"🅿 PayPal: Processing ${amount:.2f}")
self.log(amount)
return True
def refund(self, amount):
print(f" PayPal: Reversing ${amount:.2f}")
return True
stripe = StripePayment()
stripe.pay(49.99)
Output:
Stripe: Charging $49.99
Transaction recorded: $49.99
Step 3: What Happens If You Forget an Abstract Method?
pay() and never implements refund() — watch what happens the instant BrokenPayment() tries to run at the bottom. Python checks whether every @abstractmethod has been overridden before it allows object creation at all, and refuses here with a clear error rather than letting a half-finished class slip through.class BrokenPayment(PaymentGateway):
def pay(self, amount): # implemented pay()...
print("Paying!")
# forgot to implement refund()!
b = BrokenPayment() # TypeError: Can't instantiate abstract class BrokenPayment
# because it doesn't implement abstract method 'refund'
Python catches this the moment you try to create the object — before any business logic runs! This is how you write bulletproof APIs that other developers (or your future self) cannot accidentally break.
@abstractmethod when designing a "plugin" system where multiple implementations need to follow the same contract — payment providers, notification senders, data exporters, report generators. It's your safety net.
ABC. If you write class PaymentGateway: instead of class PaymentGateway(ABC):, Python silently ignores all your @abstractmethod decorators — no error is raised, no contract is enforced.
Putting It All Together — A Real-World Example
The real power comes from combining these decorators. Here's a pattern you'll see in production code — a dataclass with a computed property and a classmethod factory, all working together:
average and grade (each a @property, with grade even calling the other property), and finally from_csv (a @classmethod acting as a friendly alternate constructor) — trace how Gradebook.from_csv(...) at the bottom flows through all three.from dataclasses import dataclass, field
from typing import List
@dataclass
class Gradebook:
student: str
scores: List[float] = field(default_factory=list)
@property
def average(self):
return sum(self.scores) / len(self.scores) if self.scores else 0.0
@property
def grade(self):
avg = self.average
if avg >= 90: return "A"
if avg >= 80: return "B"
if avg >= 70: return "C"
return "D"
@classmethod
def from_csv(cls, line: str):
# Parses "Alice,85,90,92" into a Gradebook object
parts = line.split(",")
name = parts[0]
scores = [float(s) for s in parts[1:]]
return cls(student=name, scores=scores)
gb = Gradebook.from_csv("Alice,85,90,92,88")
print(gb) # @dataclass handles the __repr__
print(gb.average) # @property computes on demand
print(gb.grade) # @property derives from another @property
Output:
Gradebook(student='Alice', scores=[85.0, 90.0, 92.0, 88.0])
88.75
B
See how naturally they combine? @dataclass handles storage. @classmethod provides a friendly factory. @property computes derived values on demand. Each decorator does exactly one job — and together they produce clean, readable, professional code.
Common Beginner Mistakes
Watch out for these — they're easy to make and can be tricky to debug:
- @property with parentheses → Writing
obj.area()instead ofobj.areacauses aTypeErrorbecause Python tries to call the returned value as a function. - @classmethod with self → The first parameter must be
cls, notself. Usingselfwon't crash but misleads every reader of your code. - @staticmethod with self → Adding
selfaccidentally passes the object as the first argument, breaking your logic without a clear error message. - @dataclass mutable defaults → Writing
items: list = []causes all instances to share the same list. Always usefield(default_factory=list). - @abstractmethod without ABC → Forgetting
(ABC)in the class definition means Python never enforces anything — your contract becomes a polite suggestion that anyone can ignore.
Quick Summary
What we learned today:
- @property → Access a method like an attribute. Add
@setterto validate assignments. Never call with parentheses. - @classmethod → Receives
clsinstead ofself. Perfect for named constructors likefrom_string()orfrom_csv(). - @staticmethod → A pure helper function inside a class. No
self, nocls. Can be called without creating an object. - @dataclass → Auto-generates
__init__,__repr__, and__eq__. Always usefield(default_factory=list)for mutable defaults. - @abstractmethod → Forces subclasses to implement specific methods. Requires
class MyClass(ABC). Catches missing implementations immediately.
Happy coding!
Frequently Asked Questions
Yes, technically — a property access goes through a function call and Python's descriptor protocol under the hood, while a plain attribute access is a direct dictionary lookup. This is measurably slower if called millions of times in a tight loop, though usually negligible for realistic use, like computing a total once per web request. For a value that's genuinely expensive to compute and doesn't change afterward, functools.cached_property is the better tool than a plain @property, since it only runs the calculation once.
cached_property computes the value once on first access, then stores the result directly in the instance's own __dict__, effectively replacing the property descriptor for that instance going forward — every access after the first becomes a fast, plain attribute lookup instead of rerunning the calculation. This is the right choice when computing the value is expensive and the underlying data won't change after the object is created — parsing a large file once, for example. A plain @property should still be used whenever the value must reflect current, possibly-changing state, like the BankAccount.balance example in this article, where caching would silently return stale data after a deposit or withdrawal.
The key distinction interviewers look for: a @classmethod's cls parameter automatically refers to whichever subclass actually called it, so a factory method like from_string() defined once on a base class correctly returns instances of the calling subclass, not always the base class. A @staticmethod has no awareness of the calling class at all, since it receives neither self nor cls, and behaves identically no matter which subclass calls it. Understanding this difference is what separates someone who's memorized the syntax from someone who understands why the two exist as separate tools.
A single-level ABC with a handful of abstract methods, exactly like PaymentGateway here, is a well-regarded, common production pattern for interchangeable implementations — payment providers, notification channels, storage backends. Problems tend to appear when ABCs get stacked several layers deep, where a subclass several levels down may need to trace back through multiple ancestors just to know what contract it's actually required to fulfill. Most production teams favor one flat ABC per "family of interchangeable implementations" and reach for composition instead of additional inheritance layers beyond that.
When a dataclass inherits from another dataclass, Python correctly regenerates __init__ to cover the full combined set of fields, and any field using field(default_factory=list) is still freshly created per-instance regardless of how many inheritance layers are involved. The mutable-default bug this article warns about is only triggered by writing a raw mutable literal (like []) directly as a field's default value — that mistake is exactly as dangerous whether or not inheritance is involved, since the danger comes from Python evaluating that default expression once at class-definition time, not from the inheritance structure itself.
Unit tests frequently create only one instance of a class per test case, so the shared-list bug never actually surfaces — a single Classroom() object behaves completely normally on its own. The bug only becomes visible once two or more instances genuinely coexist and one of them appends to what it believes is its own private list, which is exactly the kind of scenario that shows up under real concurrent production traffic rather than an isolated test. Static analysis tools like ruff and pylint specifically flag mutable default values for this reason — the mistake is common and easy to miss through manual review alone.
Yes — all four decorators covered in this article (@property, @classmethod, @staticmethod, @dataclass) have first-class support in mypy. A @property's return type is correctly inferred as the underlying value's type, not a method, and @dataclass fields get an exact, type-checked __init__ signature generated automatically. The real risk area is custom, hand-written decorators — without careful typing using typing.ParamSpec and TypeVar, a custom decorator can silently erase the original function's type signature from mypy's view, which is why well-typed decorator libraries take explicit care to preserve it.
A custom decorator typically works by defining an inner wrapper function that replaces the original — and without extra care, that wrapper silently takes over the original function's name, docstring, and other metadata. Without @functools.wraps(original_function) applied to your inner wrapper, calling help(decorated_function) or checking decorated_function.__name__ shows the wrapper's own generic name and an empty docstring instead of the real function's — breaking debugging output, auto-generated documentation, and any tooling that introspects functions by name. It's a one-line fix that trips up nearly every developer the first time they write a custom decorator from scratch.
Comments
Post a Comment