Classes
class syntax, __init__, methods, properties — the building blocks of object-oriented Python, without the over-engineering.
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.
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;selfis 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(getscls) is for alternate constructors;@staticmethodrarely beats a module function.@propertyexposes 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
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”.
Practice this in an interview
All questions`__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.
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.
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.
`@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.
`__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.