On this page
Python classes and objects are how you bundle data with the functions that work on that data. A class is the blueprint; an object, or instance, is one thing built from it with its own state. Use a class when data and behaviour keep travelling together; otherwise a plain function is simpler.
Classes also bring bugs that plain functions do not. Define a list in the class body and every instance shares it, so one portfolio's stock appears in another's. This part is for Python programmers who are comfortable with functions and want the object-oriented features they will actually use: __init__ and self, class versus instance attributes, dunder methods, properties, and when a dataclass or a function beats a class. Part 2 covers inheritance and composition.
How do you define Python classes and objects?
Write class Name: with an __init__ method that sets attributes on self, add methods, then call the class like a function to create an object:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self.balance += amount
return self.balance
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
return self.balance
Using it:
acct = BankAccount("Ananya", 1000)
print(acct.deposit(500)) # 1500
print(acct.withdraw(200)) # 1300
print(acct.owner) # Ananya
Three things are happening.
__init__runs when you create an instance. It is not a constructor in the C++ sense, because the object already exists. According to the data model reference, it is called after the instance has been created, so it is an initialiser that sets up state.selfis the instance. In fact,acct.deposit(500)is reallyBankAccount.deposit(acct, 500), because Python passes the instance as the first argument for you. That is the entire mechanism.- Attributes assigned on
selfbelong to that instance. As a result, two accounts have independent balances.
Why are mutable class attributes a trap?
Because a list or dict defined in the class body is a single object shared by every instance, so a change through one instance shows up in all of them. This is the bug worth learning before you hit it.
class Portfolio:
holdings = [] # class attribute - shared by ALL instances
def add(self, stock):
self.holdings.append(stock)
a = Portfolio()
b = Portfolio()
a.add("INFY")
print(b.holdings) # ['INFY'] <- b sees a's stock
holdings was defined in the class body, so there is exactly one list shared by every instance. Consequently, appending through one instance is visible from all of them. The Python tutorial on class and instance variables uses almost the same example with a list of dog tricks.
The fix is to create the mutable object per instance, in __init__:
class Portfolio:
def __init__(self):
self.holdings = [] # a new list for each instance
def add(self, stock):
self.holdings.append(stock)
Class attributes are still genuinely useful for constants shared across instances:
class Circle:
PI = 3.14159 # fine - immutable, genuinely shared
def __init__(self, radius):
self.radius = radius
def area(self):
return Circle.PI * self.radius ** 2
print(Circle(2).area()) # 12.56636
In real code you would use math.pi, but the principle holds. The rule: immutable shared values as class attributes, mutable state on self.
What are dunder methods?
Dunder methods are special methods named with double underscores, such as __repr__, __eq__ and __add__. They let your objects work with built-in syntax such as +, == and print().
class Money:
def __init__(self, amount, currency="INR"):
self.amount = amount
self.currency = currency
def __repr__(self):
return f"Money({self.amount}, {self.currency!r})"
def __str__(self):
return f"{self.currency} {self.amount:,.2f}"
def __add__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError("Currency mismatch")
return Money(self.amount + other.amount, self.currency)
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return (self.amount, self.currency) == (other.amount, other.currency)
a = Money(1500)
b = Money(2500)
print(a + b) # INR 4,000.00
print(repr(a)) # Money(1500, 'INR')
print(a == Money(1500)) # True
print(a == 1500) # False
print([a, b]) # [Money(1500, 'INR'), Money(2500, 'INR')]
a + 5 # TypeError: unsupported operand type(s) for +: 'Money' and 'int'
The isinstance checks matter. Without them, a == 1500 crashes with an AttributeError because an integer has no amount. Returning NotImplemented tells Python to try the other operand and, failing that, to fall back to a sensible default or a clear TypeError.
__repr__ is for developers and should ideally be unambiguous; __str__ is for users. Define __repr__ at minimum, since it is what shows up in debuggers, logs and lists, and the default <__main__.Money object at 0x...> tells you nothing.
One side effect to know: a class that defines __eq__ without __hash__ becomes unhashable, so {a} raises TypeError. That is deliberate. If you need Money in sets or as dictionary keys, define __hash__ from the same fields and treat the object as immutable.
How do properties work?
Use a property when you want an attribute that is computed, or validated on assignment, without changing the calling code:
class Temperature:
def __init__(self, celsius=0):
self.celsius = celsius # goes through the setter
@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(25)
print(t.fahrenheit) # 77.0 - looks like an attribute, runs a method
t.celsius = 30 # validated
t.celsius = -300 # ValueError: Below absolute zero
Temperature(-500) # ValueError: Below absolute zero
t.fahrenheit = 100 # AttributeError - read-only property
Notice that __init__ assigns self.celsius, not self._celsius. Writing to the private name directly would skip the setter, so Temperature(-500) would quietly succeed.
The leading underscore on _celsius is a convention meaning "internal, do not touch from outside". Python does not enforce it. A double underscore, such as __celsius, triggers name mangling to _Temperature__celsius, which avoids clashes in subclasses but is not real privacy either.
When should you not write a class?
Plenty of code is worse as a class. If your class has an __init__ and exactly one method, it wanted to be a function.
# over-engineered
class DataCleaner:
def __init__(self, df):
self.df = df
def clean(self):
return self.df.dropna().drop_duplicates()
# better
def clean(df):
return df.dropna().drop_duplicates()
For plain data containers, a dataclass removes the boilerplate:
from dataclasses import dataclass, field
@dataclass
class Point:
x: float
y: float
p = Point(1.0, 2.0)
print(p) # Point(x=1.0, y=2.0)
print(p == Point(1.0, 2.0)) # True - __repr__ and __eq__ for free
@dataclass
class Basket:
items: list = field(default_factory=list) # not items: list = []
The dataclasses documentation explains why: a bare [] default raises ValueError, which protects you from the shared-list trap above.
Quick reference: class building blocks
| Feature | What it is for | Example |
|---|---|---|
__init__ |
Set up instance state | self.balance = balance |
| Instance attribute | Data owned by one object | self.holdings = [] |
| Class attribute | Constant shared by all instances | PI = 3.14159 |
__repr__ / __str__ |
Developer and user text | Money(1500, 'INR') / INR 1,500.00 |
__eq__, __add__ |
Make == and + work |
Return NotImplemented for other types |
@property |
Computed or validated attribute | t.fahrenheit |
@dataclass |
Data container without boilerplate | Point(x=1.0, y=2.0) |
Common mistakes with classes
- Forgetting
selfin a method definition.def deposit(amount)fails when called withTypeError: BankAccount.deposit() takes 1 positional argument but 2 were given, because the instance is passed as well. - Mutable class attributes. Put lists and dictionaries on
selfin__init__. - Bypassing your own validation. Assign through the property in
__init__, asTemperaturedoes. - Returning a value from
__init__. It must returnNone, otherwise Python raisesTypeError. - Classes with one method. Write a function instead.
What comes after classes and objects?
Part 2 of this series covers inheritance, composition, class and static methods, and abstract base classes, plus why "prefer composition over inheritance" is advice worth taking seriously.
The Sunday Growth Brief
One email a week: the best new comparisons, a fresh roadmap and the tech news worth your attention.
No spam. Unsubscribe in one click.
Related reading
Inheritance and composition in Python (OOP Part 2) continues the series. For the fundamentals underneath, see Python's building blocks. Custom exception classes are a common first use of classes, covered in exception handling in Python.
Frequently asked questions
When should I write a class instead of a function?
Write a class when you have state that several functions need to share and change over time. If you find yourself passing the same three arguments into every function in a module, those arguments probably want to be a class. If your code is a transformation with no persistent state, keep it a function.
What is self and why do I have to write it?
self is the instance the method was called on. Python passes it automatically when you call obj.method(), and the explicit self in the definition makes that binding visible. The Python tutorial calls the name nothing more than a convention, but every tool and reader expects it, so never rename it.
What is the difference between a class attribute and an instance attribute?
A class attribute is defined in the class body and shared by every instance. An instance attribute is assigned on self, usually in __init__, and belongs to that object alone. Immutable shared constants work well as class attributes, but mutable ones such as lists cause surprising shared-state bugs.
Do data scientists need OOP?
You need to read it, because pandas, scikit-learn and most libraries you use are built with classes. You will write classes occasionally, such as a custom transformer or a small data container. Most analysis code, however, is clearer as a set of functions, so do not force a class where none is needed.
Written by
Althaf Ashraf
AI Systems Engineer, Tata Consultancy Services
AI systems engineer working on agentic decision systems and retrieval architectures at TCS, with a focus on getting AI into real workflows rather than demos.

