Python – Class

July 28, 20265 min readUpdated 8/20/2026

A class bundles data with the operations on it. This post covers writing one — __init__, what self really is, and the three dunder methods worth adding by hand.

The smallest useful class

class Account:
    def __init__(self, number, balance):
        self.number = number
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount

checking = Account("1001-0001", 1250.00)
checking.deposit(100)

print(checking.number)      # Output: 1001-0001
print(checking.balance)     # Output: 1350.0

class, a CapitalisedName, a colon, and indented methods. Calling Account(...) creates an instance and runs __init__ on it.

There is no field declaration section. Attributes come into existence when you assign them, and __init__ is where you do that — every attribute the object will ever have should be set there, even if only to None, so a reader can see the shape in one place.

__init__ is not a constructor

The object already exists by the time __init__ runs. Its job is to initialise, which is why it returns nothing:

class Account:
    def __init__(self, balance=0.0):
        print(f"initialising with {balance}")
        self.balance = balance

a = Account(100)
print(a.balance)

# Output: initialising with 100
# Output: 100

Parameters work exactly as in any function — defaults, keywords, *args. The same mutable-default rule applies too: def __init__(self, items=[]) shares one list between every instance you create, which is the object-oriented version of the bug in Functions.

self

self is the instance, passed as the first argument to every method. Python passes it for you when you call through the instance:

class Account:
    def __init__(self, balance):
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount

a = Account(100)

a.deposit(50)                # the normal way
Account.deposit(a, 50)       # exactly what the line above does

print(a.balance)             # Output: 200

Both lines do the same thing. a.deposit(50) is looked up on the class and called with a as the first argument — that is the entire mechanism, and it is why self must be written out in every method definition.

It is a convention, not a keyword: you could call it anything and everyone would hate you. Forgetting it gives TypeError: deposit() takes 1 positional argument but 2 were given, which is worth recognising because the number in the message is always one less than you expect.

Instance attributes versus class attributes

class Account:
    bank_name = "Love Some Coding Bank"     # shared by every instance
    count = 0

    def __init__(self, balance):
        self.balance = balance              # one per instance
        Account.count += 1

a = Account(100)
b = Account(200)

print(a.bank_name)      # Output: Love Some Coding Bank
print(Account.count)    # Output: 2
print(a.balance, b.balance)   # Output: 100 200

Anything assigned in the class body is shared; anything assigned to self belongs to the instance. Use class attributes for constants and genuinely shared state, and be careful — a mutable class attribute is shared, so appending to it from one instance changes it for all of them.

__repr__, the one to always write

Without it, printing an object tells you nothing useful:

class Bare:
    def __init__(self, balance):
        self.balance = balance

class Account:
    def __init__(self, number, balance):
        self.number = number
        self.balance = balance

    def __repr__(self):
        return f"Account({self.number!r}, {self.balance})"

print(repr(Bare(100))[:14])          # Output: <__main__.Bare
print(repr(Account("1001", 1250)))   # Output: Account('1001', 1250)
print([Account("1001", 1250)])       # Output: [Account('1001', 1250)]

The last line is the payoff. print() on a list shows each item's __repr__, so without one a list of ten accounts is ten memory addresses. Every debugging session you ever have on this class is improved by those two lines.

The convention is that __repr__ should look like the code that would recreate the object. !r in the f-string applies repr() to the value, which is what puts the quotes around the string.

__str__ and __eq__

class Account:
    def __init__(self, number, balance):
        self.number = number
        self.balance = balance

    def __repr__(self):
        return f"Account({self.number!r}, {self.balance})"

    def __str__(self):
        return f"Account {self.number} — ${self.balance:,.2f}"

    def __eq__(self, other):
        if not isinstance(other, Account):
            return NotImplemented
        return self.number == other.number

print(str(Account("1001", 1250)))              # Output: Account 1001 — $1,250.00
print(Account("1001", 1250) == Account("1001", 999))   # Output: True

__str__ is for people and __repr__ for developers; print() uses __str__ and falls back to __repr__. Write __repr__ always and __str__ only when the object has a natural display form.

Without __eq__, two objects are equal only if they are the same object, so two accounts loaded from the same row would compare unequal. Returning NotImplemented for a foreign type lets Python try the other operand's comparison rather than declaring them unequal.

Methods that do not need an instance

Two decorators cover the cases where a method belongs to the class rather than to one object:

class Account:
    FEE = 2.50

    def __init__(self, number, balance):
        self.number = number
        self.balance = balance

    @staticmethod
    def is_valid_number(number):
        return len(number) == 9 and number[4] == "-"

    @classmethod
    def from_row(cls, row):
        number, balance = row.split(",")
        return cls(number, float(balance))

print(Account.is_valid_number("1001-0001"))     # Output: True
print(Account.from_row("1001-0001,1250.00").balance)   # Output: 1250.0

A @staticmethod takes no self — it is a plain function that lives in the class because that is where you would look for it. A @classmethod receives the class as cls, which is what lets it build and return an instance.

from_row is the pattern to remember: an alternative constructor. Python has only one __init__ per class, so when you need a second way to build an object — from a CSV row, from JSON, from a database record — a classmethod named from_something is the idiom. Using cls rather than Account means it keeps working in a subclass.

Privacy is a convention

class Account:
    def __init__(self, balance):
        self._balance = balance      # "internal, please do not touch"

a = Account(100)
print(a._balance)      # Output: 100

A leading underscore means "this is an implementation detail". Nothing enforces it — the last line works fine. Python's position is that the convention is enough and a determined caller will get in anyway, so the language does not pretend otherwise.

Writing this much boilerplate for a class that mostly holds data is exactly what dataclasses exist to remove. First, OOP covers inheritance and what to do instead of it.