Home Tutorials OOP

OOP

Python Classes and OOP Basics: Attributes, Methods, and __init__

Pyford Notes July 6, 2026 7 min read
Key points
  • __init__ runs automatically when a class is called to create a new instance; it sets up that instance's starting state.
  • self is the instance itself, passed automatically as the first argument to every instance method.
  • Instance attributes belong to one object; class attributes are shared by every instance unless overridden.
  • Dunder methods like __repr__ and __eq__ let your objects work with built-in functions and operators.

Defining a class

A class is a blueprint for creating objects. Define one with the class keyword and a body of attributes and methods:

class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages

b = Book("Dune", "Frank Herbert", 412)
print(b.title)   # Dune

Calling Book(...) does two things behind the scenes: Python creates a new, empty object, then calls __init__ on it with the arguments you supplied. The object itself is what self refers to inside the method.

__init__ and self

__init__ is not a constructor in the strict sense — the object already exists by the time __init__ runs — it is an initialiser that sets up the object's starting attributes. self must be the first parameter of every instance method, though Python supplies it automatically at call time; you never pass it explicitly:

class Book:
    def __init__(self, title, pages):
        self.title = title
        self.pages = pages

    def summary(self):
        return f"{self.title} ({self.pages} pages)"

b = Book("Dune", 412)
print(b.summary())   # Dune (412 pages)

Inside summary, self.title and self.pages refer back to the specific object the method was called on — b in this case.

Instance attributes vs class attributes

Attributes set on self inside __init__ belong to that one instance. Attributes defined directly in the class body, outside any method, are shared by all instances until an instance sets its own attribute of the same name:

class Book:
    library_name = "City Library"   # class attribute, shared

    def __init__(self, title):
        self.title = title           # instance attribute, per-object

a = Book("Dune")
b = Book("Emma")
print(a.library_name, b.library_name)   # City Library City Library

a.library_name = "Uptown Branch"        # creates an instance attribute on a
print(a.library_name, b.library_name)   # Uptown Branch City Library

Reassigning a.library_name does not change the class attribute — it creates a new instance attribute on a that shadows it. b is unaffected. This distinction matters most with mutable class attributes like lists: appending to a shared list class attribute affects every instance, which is rarely what you want.

Writing methods

A method is just a function defined inside a class body. Beyond reading and setting attributes, methods can call other methods on self, and can return new values or modify state:

class Book:
    def __init__(self, title, pages, pages_read=0):
        self.title = title
        self.pages = pages
        self.pages_read = pages_read

    def read(self, pages):
        self.pages_read = min(self.pages, self.pages_read + pages)

    def percent_complete(self):
        return round(100 * self.pages_read / self.pages, 1)

b = Book("Dune", 412)
b.read(103)
print(b.percent_complete())   # 25.0

Common dunder methods

Methods with double underscores on both sides ("dunder" methods) hook your class into Python's built-in behaviour. __repr__ controls how an object prints in a REPL or inside a container; __eq__ controls what == does between two instances:

class Book:
    def __init__(self, title, pages):
        self.title = title
        self.pages = pages

    def __repr__(self):
        return f"Book({self.title!r}, {self.pages})"

    def __eq__(self, other):
        if not isinstance(other, Book):
            return NotImplemented
        return self.title == other.title and self.pages == other.pages

print(Book("Dune", 412))                     # Book('Dune', 412)
print(Book("Dune", 412) == Book("Dune", 412)) # True

Without a custom __eq__, two separate instances compare unequal even with identical attributes, because the default comparison checks object identity, not content. Returning NotImplemented — rather than False — for a mismatched type lets Python fall back to the other object's comparison logic instead of asserting inequality outright.

Properties: computed attributes

A @property lets a method be accessed with plain attribute syntax — no parentheses — which is useful for a value that is computed from other attributes rather than stored directly:

class Book:
    def __init__(self, title, pages_read, pages):
        self.title = title
        self.pages_read = pages_read
        self.pages = pages

    @property
    def percent_complete(self):
        return round(100 * self.pages_read / self.pages, 1)

b = Book("Dune", 103, 412)
print(b.percent_complete)   # 25.0, no parentheses needed

percent_complete is never stored — it is recalculated from pages_read and pages every time it is read, so it can never silently go stale the way a manually updated attribute could. A matching @name.setter method can be added if you also want assignment through the same attribute name to run custom validation logic, rather than setting the underlying value directly.

classmethod and staticmethod

A regular method always receives the instance as its first argument. @classmethod instead receives the class itself, which is useful for alternative constructors; @staticmethod receives neither, for a function that logically belongs with the class but needs no access to instance or class state:

class Book:
    def __init__(self, title, pages):
        self.title = title
        self.pages = pages

    @classmethod
    def from_csv_row(cls, row):
        title, pages = row.split(",")
        return cls(title, int(pages))

    @staticmethod
    def is_valid_title(title):
        return bool(title.strip())

b = Book.from_csv_row("Dune,412")
print(b.title, b.pages)               # Dune 412
print(Book.is_valid_title("   "))     # False

from_csv_row is called on the class itself, not an existing instance, and cls(title, int(pages)) creates a new one — this pattern gives a class multiple named ways to construct an object, alongside the single __init__ signature.