datarekha

Classes

class syntax, __init__, methods, properties — the building blocks of object-oriented Python, without the over-engineering.

9 min read Beginner Python Lesson 25 of 41

What you'll learn

  • class syntax and __init__, the constructor
  • Instance attributes vs class attributes (and the shared-mutable bug)
  • Methods, @classmethod, and @staticmethod
  • @property and setters — computed attributes that read like data

Before you start

A class bundles some data together with the behaviour that operates on it. In Python a class is far lighter than in Java — there are no public/private keywords, no obligatory getters and setters, no interfaces you must implement first. You will write fewer classes than you might expect, because dictionaries, dataclasses, and plain modules already cover a great deal of ground. But the classes you do write deserve to be clean, so let us build one up carefully.

The minimum viable class

class User:
    def __init__(self, name, email):
        # 'self' is the instance being built. Attach attributes to it.
        self.name = name
        self.email = email

    def greet(self):
        # Methods take 'self' as their first parameter.
        return f"Hi, I'm {self.name}"

u = User("Aarav", "aarav@example.com")
print(u.name)
print(u.greet())
Aarav
Hi, I'm Aarav

Three things are worth fixing in your mind. __init__ is the constructor — Python calls it for you when you write User(...), and you never call it directly. self is the instance, and Python makes you list it explicitly because methods are stored on the class, not on each object: when you write u.greet(), Python quietly rewrites it as User.greet(u) and passes the instance in as the first argument. And attributes are simply assigned on self — there is no separate declaration block.

class Usergreet(self): …domain = “datarekha.com”methods + class attrs live hereu (an instance)name = “Aarav”email = “aarav@…“instance attrs live hereis au.greet() ≡ User.greet(u) → self = u

Instance vs class attributes

class User:
    domain = "datarekha.com"          # class attribute — shared by all User objects

    def __init__(self, name):
        self.name = name              # instance attribute — unique to each object

A class attribute lives on the class object itself, and every instance reads the same value; an instance attribute is stored on the specific object that __init__ built. Class attributes are handy for constants and defaults — but there is a trap, and it is the same shape as the mutable-default-argument bug. Never use a mutable class attribute as per-instance state, because all instances will quietly share the one object:

# BUG: a mutable class attribute is SHARED across every instance.
class CartBad:
    items = []
    def add(self, item):
        self.items.append(item)

a = CartBad(); b = CartBad()
a.add("apple")
print(b.items)                        # b never added anything...

# FIX: build per-instance state inside __init__.
class CartGood:
    def __init__(self):
        self.items = []
    def add(self, item):
        self.items.append(item)

a = CartGood(); b = CartGood()
a.add("apple")
print(b.items)
['apple']
[]

There it is in the output: b reports ['apple'] despite adding nothing, because a and b were appending to a single shared list. Moving self.items = [] into __init__ gives each cart its own list, and b is correctly empty.

Methods, classmethods, and staticmethods

class Order:
    tax_rate = 0.08

    def __init__(self, subtotal):
        self.subtotal = subtotal

    def total(self):                       # instance method — needs self
        return self.subtotal * (1 + self.tax_rate)

    @classmethod
    def from_cents(cls, cents):            # classmethod — an alternate constructor
        return cls(cents / 100)

    @staticmethod
    def is_business_day(d):                # staticmethod — no self, no cls
        return d.weekday() < 5

A regular method receives the instance as self — that is most methods. A @classmethod receives the class as cls, which is exactly what you want for an alternate constructor (Order.from_cents(...), just as datetime.fromisoformat(...) does). A @staticmethod receives neither; it is just a function parked in the class’s namespace because it belongs there conceptually. When in doubt, write a regular method; reach for classmethod for alternate constructors; and treat staticmethod with suspicion, since most candidates would be just as happy as module-level functions.

@property — computed attributes that read like data

A property is a method you access without parentheses. You reach for it when a value should look like plain data to the caller — cart.total — even though it is computed under the hood:

class ShoppingCart:
    def __init__(self):
        self.items = []   # list of (name, unit_price, quantity)
        self.tax_rate = 0.08

    def add(self, name, unit_price, quantity=1):
        self.items.append((name, unit_price, quantity))

    @property
    def subtotal(self):
        return sum(price * qty for _, price, qty in self.items)

    @property
    def total(self):
        return round(self.subtotal * (1 + self.tax_rate), 2)

    def __repr__(self):
        return f"ShoppingCart(items={len(self.items)}, total=${self.total})"

cart = ShoppingCart()
cart.add("Pen",        2.50, quantity=3)
cart.add("Notebook",   8.00, quantity=2)
cart.add("Headphones", 49.99)

print("subtotal:", cart.subtotal)       # accessed like data — no ()
print("total:   ", cart.total)
print("cart:    ", cart)
subtotal: 73.49
total:    79.37
cart:     ShoppingCart(items=3, total=$79.37)

cart.total reads as a value, not a computation, and that is the entire point. The guideline: if a value is cheap and free of side effects to compute, a property makes the API pleasant; if it is expensive (an API call) or changes state, an honest method like cart.fetch_total() tells the caller the truth.

Setters — for validating writes

If a value only needs reading, just expose the attribute directly — do not write Java-style trivial getters and setters, which is not idiomatic Python. But when a write needs validation or must recompute related state, the property setter syntax is exactly right:

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius             # leading underscore = "internal"

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

t = Temperature(20)
print(t.celsius, "C =", t.fahrenheit, "F")

t.celsius = 100                 # goes through the setter — validation runs
print(t.celsius, "C =", t.fahrenheit, "F")

try:
    t.celsius = -300            # invalid — below absolute zero
except ValueError as e:
    print("error:", e)
20 C = 68.0 F
100 C = 212.0 F
error: below absolute zero

Assigning t.celsius = 100 looks like plain attribute assignment, yet it ran through the setter — which is why t.celsius = -300 raised. The convention _celsius (a single leading underscore) signals “internal, don’t touch from outside”; Python does not enforce it, but linters and reviewers will.

When NOT to write a class

A common early instinct is to make a class for everything, and in Python you frequently should not. A bag of fields with no behaviour wants a @dataclass; a bag of fields that needs validation wants Pydantic; a single function with a configuration knob wants to be a function with that parameter; and a “manager” full of static methods wants to be a plain module. Reach for a class when you genuinely have non-trivial state plus the methods that change or report on it — the ShoppingCart above is a fair example, and so is a database-connection wrapper, an LLM client, or a running simulation.

In one breath

  • __init__ is the constructor; self is the instance, bound automatically because methods live on the class.
  • Class attributes are shared by all instances — never use a mutable one as per-instance state; build it in __init__.
  • @classmethod (gets cls) is for alternate constructors; @staticmethod rarely beats a module function.
  • @property exposes a computed value as if it were data; add a setter only to validate or recompute on write.
  • Prefer a dataclass, Pydantic, a function, or a module unless you truly have state and behaviour.

Practice

Quick check

0/3
Q1What does `self` refer to inside a method?
Q2Why is `class Cart: items = []` a bug if each cart should have its own list?
Q3When does @property make the API nicer than a regular method?

What’s next

A single class is useful; classes that share behaviour are where object-orientation begins. Next: inheritance vs composition — how to reuse behaviour, and why “has-a” usually beats “is-a”.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
What is the difference between `__new__` and `__init__` in Python, and when would you override `__new__`?

`__new__` allocates and returns the new object; `__init__` receives that object and populates its attributes. You rarely touch `__new__` — its main legitimate uses are subclassing immutable types like `int` or `str`, and implementing the Singleton pattern.

What is the difference between an instance method, a class method, and a static method in Python?

Instance methods receive the instance as the first argument (`self`) and can read and modify instance state. Class methods receive the class as the first argument (`cls`) via `@classmethod` and are used for alternative constructors or class-level operations. Static methods receive no implicit argument and are plain functions namespaced inside a class.

What is the difference between class attributes and instance attributes in Python, and why are mutable class attributes dangerous?

Class attributes are defined on the class object and shared by all instances; instance attributes are defined on the individual instance and shadow any class attribute of the same name. A mutable class attribute (such as a list or dict) is shared across all instances, so mutating it via one instance mutates it for every other instance — a common and silent bug.

How does the `@property` decorator work in Python, and when should you prefer it over a plain attribute?

`@property` turns a method into a descriptor that Python calls automatically on attribute access, letting you add validation or computation behind a dot-access interface without changing callers. Use it when a value is derived, needs guarding, or must be lazily computed — not as a default for every attribute.

What does `__slots__` do in Python, and when should you use it?

`__slots__` replaces the per-instance `__dict__` with a fixed-size C array of slot descriptors, cutting memory usage per instance by 40–60% and speeding up attribute access. Use it for classes that create many small, fixed-attribute instances — but be aware it prevents dynamic attribute assignment and complicates multiple inheritance.

Related lessons

Explore further

Skip to content