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

- **Author:** Althaf Ashraf — AI Systems Engineer, Tata Consultancy Services (https://www.1stepgrow.com/authors/althaf-ashraf/)
- **Published:** Jul 29, 2026 · **Updated:** Sep 17, 2026
- **Topic:** Python & Programming · **Format:** Guide · **Read time:** 9 min
- **Canonical URL:** https://www.1stepgrow.com/articles/python-classes-objects/

## Key takeaways

- A class bundles data with the functions that operate on it, so reach for one when those two keep travelling together.
- self is just the instance, passed automatically when you call a method; the name is a convention, not a keyword.
- Mutable class attributes are shared across all instances, which is the classic OOP bug in Python.
- Comparison and arithmetic dunder methods should return NotImplemented for types they do not handle.
- Not everything needs a class, and a plain function or a dataclass is often the better answer.

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](https://www.1stepgrow.com/articles/python-inheritance-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](https://docs.python.org/3/reference/datamodel.html#object.__init__), 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](https://docs.python.org/3/tutorial/classes.html#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](https://docs.python.org/3/library/dataclasses.html#mutable-default-values) 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](https://www.1stepgrow.com/articles/python-inheritance-composition) covers inheritance, composition, class and static methods, and abstract base classes, plus why "prefer composition over inheritance" is advice worth taking seriously.



## Related reading

[Inheritance and composition in Python (OOP Part 2)](https://www.1stepgrow.com/articles/python-inheritance-composition) continues the series. For the fundamentals underneath, see [Python's building blocks](https://www.1stepgrow.com/articles/python-basics). Custom exception classes are a common first use of classes, covered in [exception handling in Python](https://www.1stepgrow.com/articles/python-exception-handling).

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

---
_Source: 1stepGrow (https://www.1stepgrow.com/articles/python-classes-objects/). Cite with the title, "1stepGrow" and a link._
