Skip to content
1stepGrowLearnCompareGrow

Python Classes and Objects: Object-Oriented Programming, Part 1

Python classes and objects explained with tested code: __init__, self, dunder methods, properties and dataclasses, plus the shared-list bug to learn first.

Althaf Ashraf

AI Systems Engineer, Tata Consultancy Services

9 min readUpdated
Share
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:

Python
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:

Python
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.
  • self is the instance. In fact, acct.deposit(500) is really BankAccount.deposit(acct, 500), because Python passes the instance as the first argument for you. That is the entire mechanism.
  • Attributes assigned on self belong 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.

Python
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__:

Python
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:

Python
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().

Python
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)
Python
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:

Python
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
Python
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.

Python
# 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:

Python
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 self in a method definition. def deposit(amount) fails when called with TypeError: 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 self in __init__.
  • Bypassing your own validation. Assign through the property in __init__, as Temperature does.
  • Returning a value from __init__. It must return None, otherwise Python raises TypeError.
  • 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.

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.

Agentic AIRetrieval-Augmented GenerationLangChainDecision modelling

Related reading

Free weekly newsletter

Get the shortlist before everyone else

Every Sunday we send one email with the week’s sharpest course comparison, a career roadmap worth stealing, and the tech news and hiring signals we’re watching.

  • No fluff, ever
  • Unsubscribe anytime
  • 5-minute read

We never share your address. One click to leave.