Skip to main content

Python Decorators Explained

Calculating read time…

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!

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:

Code walkthrough: This isn't runnable code by itself — it's just showing the syntax pattern. The @ 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

Code walkthrough: Watch the difference in how 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:

Code walkthrough: Two methods share the exact same name, 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.

DO: Use @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.
DON'T: Never call a property with parentheses like 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

Code walkthrough: Three completely different ways to end up with the same kind of 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!

DO: Use 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.
TIP: Named constructors like 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

Code walkthrough: Notice 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 either self or cls
DON'T: Never add 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

Code walkthrough: This is the manual, no-decorator version — 14 lines just to store three fields and give the class a readable print output and a basic equality check. Keep this in mind as you read the @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)
Code walkthrough: Four lines producing roughly the same class as the 14-line version above — @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!
Accuracy Note: The comment above calls this the "exact same result" as the manual version, and it's extremely close — but not identical in one subtle way. The manual class's __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

Code walkthrough: Two 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:

Code walkthrough: This file defines 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)
Important: Never write 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).
Additional tip: Use @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)

Code walkthrough: This class can never be created directly — try 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

Code walkthrough: Two separate subclasses, each required to write their own 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?

Code walkthrough: This class only implements 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.

DO: Use @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.
DON'T: Forget to inherit from 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:

Code walkthrough: The biggest example in this article — three different decorators working on the same class at once. Read the field definitions at the top first, then 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 of obj.area causes a TypeError because Python tries to call the returned value as a function.
  • @classmethod with self → The first parameter must be cls, not self. Using self won't crash but misleads every reader of your code.
  • @staticmethod with self → Adding self accidentally 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 use field(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 @setter to validate assignments. Never call with parentheses.
  • @classmethod → Receives cls instead of self. Perfect for named constructors like from_string() or from_csv().
  • @staticmethod → A pure helper function inside a class. No self, no cls. Can be called without creating an object.
  • @dataclass → Auto-generates __init__, __repr__, and __eq__. Always use field(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

Does using @property introduce meaningful performance overhead compared to a plain attribute, especially in a hot loop?

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.

When should you reach for functools.cached_property instead of a regular @property?

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.

What's actually being tested when an interview asks about the difference between @classmethod and @staticmethod with inheritance?

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.

Is a single ABC like PaymentGateway a healthy pattern, or does it risk becoming an over-engineered hierarchy?

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.

Does the mutable-default bug shown with Classroom still apply when dataclasses inherit from each other?

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.

Why does the mutable-default dataclass bug often slip past code review and unit tests, only to surface in production?

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.

Do type checkers like mypy understand @property, @classmethod, and @dataclass correctly out of the box?

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.

If I write my own custom decorator (not one of the five built-in ones here), what does functools.wraps actually protect against?

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