Python 3.12+ · Updated June 2026

Python Classes
Object-Oriented Programming, Done Right

Master Python classes – from definition and `__init__` to methods, `@property`, inheritance, polymorphism, dunder methods, and dataclasses. Over 50 runnable examples, each explained in depth: why it's used, how it operates, how it affects your program, how it shapes the outcome, and why it matters.

classes_demo.py
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def greet(self):
        return f"Hello, I'm {self.name}"

p = Person("Glenn", 30)
print(p.greet())
print(f"Age: {p.age}")
$ python classes_demo.py
Hello, I'm Glenn
Age: 30
Pyodide loading…

📖 What Is a Python Class?

A class is a blueprint for creating objects — it defines a set of attributes (data) and methods (behaviour) that all objects of that class will have. Classes are the foundation of Object-Oriented Programming (OOP), the paradigm that dominates modern software development. A class encapsulates data and the operations that act on that data, bundling them into a single, coherent unit.

Why Every Programmer Must Learn This Curriculum

Classes are the fundamental unit of object-oriented programming in Python. They are the primary mechanism for modelling real-world entities, organising code, and building large, maintainable systems. Every framework you'll use — Django, Flask, FastAPI, SQLAlchemy, pytest — is built on classes. If functions are the verbs of your program, classes are the nouns — the things your program describes and manipulates.

This curriculum is sequenced from first principles to advanced patterns. You'll start with how classes are defined and how objects are instantiated, move through instance methods and attributes, master the four pillars of OOP (encapsulation, abstraction, inheritance, and polymorphism), understand the Python data model through dunder methods, explore dataclasses for reducing boilerplate, and finish with real-world design patterns.

By the end, you'll understand not just the syntax but the why: why classes are the right tool for modelling complex systems, why encapsulation protects your data, why inheritance enables code reuse, why polymorphism enables flexibility, and why dunder methods make your objects feel native to Python.

50+ runnable examples 20+ OOP concepts Beginner to Advanced 3–3.5 hours to complete

🎯 What You Will Learn

  • How classes are defined and objects are instantiated
  • The __init__ method — the constructor
  • Instance attributes and methods — the state and behaviour of objects
  • Class attributes and class methods — state shared across all instances
  • The @property decorator — computed attributes and controlled access
  • @classmethod and @staticmethod — alternative constructors and utilities
  • Encapsulation — using _ and __ to control access
  • Inheritance — creating hierarchies and reusing code
  • Method overriding and the super() function
  • Multiple inheritance and the Method Resolution Order (MRO)
  • Dunder methods — __str__, __repr__, __eq__, __add__, and more
  • Dataclasses — reducing boilerplate for data containers
  • Three real-world mini projects that combine everything above

🌍 The Significance of Classes in the Programming World

Classes are how programs model the real world. A User class models a user, a Product class models a product, a Order class models an order — these are the building blocks of every business application, every web service, every game, and every simulation.

  • Modelling real-world entities — users, products, orders, employees, accounts
  • Data and behaviour together — each object knows its own data and how to act on it
  • Code reuse through inheritance — share behaviour between related classes
  • Flexibility through polymorphism — treat different objects uniformly
  • Encapsulation for safety — hide internal details, expose clean interfaces
  • Framework design — every major Python framework is built on classes

Mastering classes means you'll write code that is more organised, more maintainable, and more expressive — the foundation of professional object-oriented programming.

🤔 Why Programmers Should Master Python Classes

Because classes are how you model the real world in code

🧩 Classes Bundle Data and Behaviour

Without classes, you have separate data structures and functions that operate on them — a loose coupling that becomes hard to manage as your program grows. Classes bundle state (attributes) and behaviour (methods) together, creating self-contained units that are easier to understand, maintain, and test.

  • Encapsulation — keep related data and behaviour together
  • Abstraction — hide complexity behind a clean interface
  • Reuse — inheritance lets you build on existing classes
  • Flexibility — polymorphism lets you treat different objects uniformly
  • Real-world modelling — classes map naturally to real-world entities
💡 If you find yourself passing the same data structures into the same functions repeatedly, that's a signal you might want to bundle them into a class. The data and the operations on it belong together.

🚀 Classes Are the Foundation of Professional Python

Professional Python code is organised into classes, modules, and packages. Classes are the primary mechanism for separation of concerns and encapsulation — each class has a single responsibility and hides its internal details. This makes code easier to understand, easier to test, and easier to change.

🔤 Class Basics – Definition, __init__, and Instances

The foundation everything else in this tutorial builds on

📝 Defining Classes and Creating Instances

The class Keyword and the __init__ Constructor

A class is defined with the class keyword. The __init__ method is the constructor — it runs when a new instance is created. self refers to the instance being created.

# Example 1: defining a simple class
class Dog:
    def __init__(self, name, breed):
        self.name = name
        self.breed = breed

    def bark(self):
        return f"{self.name} says Woof!"

# Example 2: creating instances
buddy = Dog("Buddy", "Golden Retriever")
max_dog = Dog("Max", "German Shepherd")

print(buddy.bark())
print(max_dog.bark())
print(f"Buddy is a {buddy.breed}")
🔍 Why this code is used: Classes are the blueprint for creating objects. __init__ initialises each new instance with its own state.

⚙️ How it operates: class Dog defines a new class. __init__ is called automatically when Dog() is called. self is the instance being created — it's passed automatically by Python. self.name and self.breed are instance attributes, unique to each dog.

📊 How it affects the program: Example 2 shows that buddy and max_dog are independent objects — each has its own name and breed.

🎯 How it stimulates the outcome: The .bark() method uses self.name to personalise the output — each dog says its own name.

📌 Significance: The self parameter is the key to object-oriented programming in Python — it's how methods access the specific instance they're called on.
📤 Attributes and Methods

Instance Attributes and Instance Methods

class Car:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year
        self.odometer = 0

    def drive(self, miles):
        if miles < 0:
            raise ValueError("Cannot drive negative miles")
        self.odometer += miles
        return f"Driven {miles} miles. Total: {self.odometer}"

    def description(self):
        return f"{self.year} {self.make} {self.model}"

# Example 3: using the class
my_car = Car("Toyota", "Camry", 2022)
print(my_car.description())
print(my_car.drive(50))
print(my_car.drive(30))
🔍 Why this code is used: Attributes store state; methods define behaviour. Together they model an object's data and operations.

⚙️ How it operates: self.odometer = 0 sets a default value for all new cars. self.drive() modifies the instance's state. Each call to drive() affects the same odometer attribute of the specific car instance.

📊 How it affects the program: Example 3 shows that my_car maintains its own state — the odometer increases with each drive call.

🎯 How it stimulates the outcome: The validation in drive() prevents negative miles — this is encapsulation in action: the object protects its own state.

📌 Significance: Attributes and methods are the twin pillars of object state and behaviour — together they define what an object is and what it can do.
📦 Class Attributes

Attributes Shared Across All Instances

class Student:
    school = "Python University"

    def __init__(self, name, grade):
        self.name = name
        self.grade = grade

# Example 4: accessing class attributes
alice = Student("Alice", 95)
bob = Student("Bob", 88)

print(alice.school)
print(bob.school)
print(Student.school)

# Example 5: modifying a class attribute affects all instances
Student.school = "Coding Academy"
print(alice.school)
print(bob.school)
🔍 Why this code is used: Class attributes store data that is shared by all instances — like a school name that all students share.

⚙️ How it operates: Class attributes are defined at the class level, outside any method. They are accessed via the class (Student.school) or via an instance (alice.school). Modifying a class attribute changes it for all instances.

📊 How it affects the program: Example 5 shows that changing Student.school affects both alice and bob — they share the same class attribute.

🎯 How it stimulates the outcome: Class attributes are ideal for constants, configuration, and data that is truly shared — like a school name, a default setting, or a counter for all instances.

📌 Significance: Class attributes are a key tool for managing shared state across all instances of a class.

📋 Instance Methods – Behaviour of Objects

Methods are functions defined inside a class that operate on instances

📋 Instance Methods

Methods That Operate on Instances

Instance methods are the most common type of method. They always receive self as the first argument, giving them access to the instance's attributes and other methods.

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance
        self._transactions = []

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Amount must be positive")
        self.balance += amount
        self._transactions.append(f"+{amount}")
        return f"Deposited {amount}. Balance: {self.balance}"

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount
        self._transactions.append(f"-{amount}")
        return f"Withdrew {amount}. Balance: {self.balance}"

    def statement(self):
        return f"{self.owner}: ${self.balance:.2f} ({len(self._transactions)} txns)"

# Example 6: using the bank account
acc = BankAccount("Glenn", 1000)
print(acc.deposit(500))
print(acc.withdraw(200))
print(acc.statement())
🔍 Why this code is used: Instance methods define the behaviour of objects — what they can do.

⚙️ How it operates: Each method receives self automatically. Methods can read and modify instance attributes (self.balance, self._transactions). Methods can call other methods on the same instance.

📊 How it affects the program: Example 6 shows that the account's state is maintained across multiple method calls — balance changes with each deposit/withdraw.

🎯 How it stimulates the outcome: The _transactions attribute (with a leading underscore) is a convention for "protected" data — it's part of the object's internal state, not intended for external use.

📌 Significance: Instance methods are the primary way objects expose behaviour — they're the interface through which the outside world interacts with the object.
🔒 Encapsulation with Private Attributes

Using _ and __ for Access Control

Python has no true "private" access control, but conventions and name mangling provide protection. A leading underscore (_name) signals "protected — use with care". Double underscores (__name) trigger name mangling, making the attribute harder to accidentally access.

class User:
    def __init__(self, username, password):
        self.username = username
        self.__password = password
        self._session_id = None

    def authenticate(self, password):
        return password == self.__password

# Example 7: accessing attributes
u = User("glenn", "secret123")
print(u.username)
print(u.authenticate("secret123"))

# Example 8: name mangling
print(u._User__password)
🔍 Why this code is used: Encapsulation protects internal state from accidental modification — a core principle of OOP.

⚙️ How it operates: A single underscore is a convention — it means "protected". Double underscores trigger name mangling — the attribute is renamed to _ClassName__attribute, making it harder to accidentally access but still possible if you know the mangled name.

📊 How it affects the program: Example 7 shows that __password is not directly accessible — it's hidden behind the authenticate() method.

🎯 How it stimulates the outcome: Name mangling is not security — it's a mechanism to avoid accidental name collisions in inheritance. It signals "this is internal, don't touch it."

📌 Significance: Encapsulation is a fundamental principle of OOP — it protects an object's internal state and enforces a clean interface.

🔒 The @property Decorator – Computed Attributes and Controlled Access

Make methods look like attributes

🔒 @property

Getters, Setters, and Computed Attributes

The @property decorator lets you define methods that can be accessed like attributes. This is useful for computed attributes (e.g., area from width and height) and for controlling access to private attributes (validation in setters).

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        if value <= 0:
            raise ValueError("Width must be positive")
        self._width = value

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        if value <= 0:
            raise ValueError("Height must be positive")
        self._height = value

    @property
    def area(self):
        return self._width * self._height

# Example 9: using the Rectangle class
r = Rectangle(5, 3)
print(r.width)
print(r.area)
r.width = 10
print(r.area)

# Example 10: validation in setter
try:
    r.width = -5
except ValueError as e:
    print(f"Error: {e}")
🔍 Why this code is used: @property lets you add logic to attribute access without changing the interface — you can start with a simple attribute and later add validation or computation without breaking callers.

⚙️ How it operates: @property defines a getter — accessing r.width calls the width method. @width.setter defines a setter — assigning r.width = 10 calls the setter with 10. The area property is read-only (no setter) — it's computed from width and height.

📊 How it affects the program: Example 9 shows that area updates automatically when width changes — it's always correct because it's computed on demand.

🎯 How it stimulates the outcome: Example 10 demonstrates validation in the setter — assigning an invalid value raises an error, protecting the object's state.

📌 Significance: @property is a key tool for encapsulation — it lets you control access to attributes while maintaining a simple, attribute-like interface.

👥 @classmethod and @staticmethod – Class-Level Methods

Methods that operate on the class itself, not on instances

👥 @classmethod

Alternative Constructors and Class-Level Operations

A @classmethod receives the class (not the instance) as its first argument. It's commonly used for alternative constructors — methods that create instances in different ways.

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day

    @classmethod
    def from_string(cls, date_str):
        year, month, day = map(int, date_str.split("-"))
        return cls(year, month, day)

    @classmethod
    def today(cls):
        from datetime import date
        today = date.today()
        return cls(today.year, today.month, today.day)

    def __repr__(self):
        return f"Date({self.year}, {self.month}, {self.day})"

# Example 11: using class methods
d1 = Date.from_string("2026-06-01")
print(d1)
🔍 Why this code is used: Class methods are the primary tool for alternative constructors — they let you create instances from different input formats.

⚙️ How it operates: @classmethod receives cls (the class) as the first argument. cls() creates a new instance. The method can be called on the class (Date.from_string()) or on an instance (though this is less common).

📊 How it affects the program: Example 11 shows that from_string() parses a string and returns a Date instance — a clean, readable way to create objects from different data sources.

🎯 How it stimulates the outcome: Class methods are also used for singleton patterns, factory methods, and any operation that logically belongs to the class rather than individual instances.

📌 Significance: Class methods are a powerful tool for flexible object creation and class-level operations — they're used extensively in libraries and frameworks.
📦 @staticmethod

Utility Functions Inside a Class

A @staticmethod receives no implicit first argument — it's a regular function that happens to live inside a class for organisational purposes.

class MathUtils:
    @staticmethod
    def is_even(n):
        return n % 2 == 0

    @staticmethod
    def is_prime(n):
        if n < 2:
            return False
        for i in range(2, int(n ** 0.5) + 1):
            if n % i == 0:
                return False
        return True

# Example 13: using static methods
print(MathUtils.is_even(4))
print(MathUtils.is_prime(7))
print(MathUtils.is_prime(10))
🔍 Why this code is used: Static methods are used for utility functions that are logically related to the class but don't need access to instance or class data.

⚙️ How it operates: @staticmethod doesn't receive self or cls. It's essentially a regular function namespaced inside the class. It's called on the class (MathUtils.is_even(4)) and doesn't have access to instance attributes.

📊 How it affects the program: Example 13 shows that static methods are used like regular functions, but they're grouped inside the class for better organisation.

🎯 How it stimulates the outcome: Use @staticmethod when a function is related to the class but doesn't need access to instance or class state — for helpers, utilities, and pure functions.

📌 Significance: Static methods are a tool for organising related functions — they keep your code clean and logically grouped.
🔀 @classmethod vs @staticmethod

When to Use Which

class Demo:
    class_var = "shared"

    @classmethod
    def class_method(cls):
        return f"Class method: {cls.class_var}"

    @staticmethod
    def static_method():
        return "Static method: no access to class or instance"

# Example 14: class method vs static method
print(Demo.class_method())
print(Demo.static_method())
🔍 Why this code is used: Understanding the difference between class methods and static methods helps you choose the right tool for each situation.

⚙️ How it operates: Class methods receive cls and can access class attributes. Static methods receive no implicit argument. Class methods can be overridden in subclasses; static methods cannot (they're just functions).

📊 How it affects the program: Use @classmethod when you need to access or modify class state, or when you need an alternative constructor. Use @staticmethod for utility functions that don't need class or instance data.

🎯 How it stimulates the outcome: The choice between @classmethod and @staticmethod signals intent to readers — class methods are about the class; static methods are about the function.

📌 Significance: Both are important tools for organising code, and choosing the right one makes your code clearer and more maintainable.

🌿 Inheritance – Building on Existing Classes

One of the four pillars of OOP — code reuse and hierarchies

🌿 Basic Inheritance

Subclasses and the super() Function

Inheritance lets you create a new class that extends or modifies an existing class. The subclass inherits all attributes and methods of the parent class, and can override them or add new ones.

class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "Some generic sound"

class Dog(Animal):
    def speak(self):
        return f"{self.name} says Woof!"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says Meow!"

# Example 15: using inheritance
d = Dog("Buddy")
c = Cat("Whiskers")
print(d.speak())
print(c.speak())
🔍 Why this code is used: Inheritance lets you model "is-a" relationships — a Dog is an Animal, a Cat is an Animal.

⚙️ How it operates: class Dog(Animal) means Dog inherits from Animal. Dog gets Animal's __init__ and speak methods, but overrides speak with its own implementation. The subclass can also add new methods and attributes.

📊 How it affects the program: Example 15 shows that each subclass has its own implementation of speak() — this is polymorphism in action.

🎯 How it stimulates the outcome: Inheritance promotes code reuse — common behaviour is defined once in the parent class, and subclasses only specify what's different.

📌 Significance: Inheritance is one of the cornerstones of OOP — it enables hierarchical organisation and code reuse.
🔧 Using super()

Calling the Parent Class

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def description(self):
        return f"{self.name} earns ${self.salary}"

class Manager(Employee):
    def __init__(self, name, salary, department):
        super().__init__(name, salary)
        self.department = department

    def description(self):
        parent_desc = super().description()
        return f"{parent_desc} and manages {self.department}"

# Example 16: using super()
m = Manager("Alice", 75000, "Engineering")
print(m.description())
🔍 Why this code is used: super() lets you call the parent class's methods — essential for extending behaviour without duplicating code.

⚙️ How it operates: super().__init__(name, salary) calls the parent class's constructor, ensuring the parent's attributes are initialised. super().description() calls the parent's method, then the subclass adds its own content.

📊 How it affects the program: Example 16 shows that Manager reuses Employee's logic and extends it — the parent's description is included in the child's description.

🎯 How it stimulates the outcome: super() is essential in inheritance hierarchies — it ensures that all levels of the hierarchy are properly initialised and that method overriding is clean and maintainable.

📌 Significance: super() is the correct way to call parent class methods in Python — it's more robust than hardcoding the parent class name.
🔀 Multiple Inheritance and MRO

Inheriting from Multiple Classes

class Flyer:
    def fly(self):
        return "Flying high!"

class Swimmer:
    def swim(self):
        return "Swimming deep!"

class Duck(Flyer, Swimmer):
    def quack(self):
        return "Quack!"

# Example 17: multiple inheritance
d = Duck()
print(d.fly())
print(d.swim())
print(d.quack())

# Example 18: Method Resolution Order (MRO)
print(Duck.__mro__)
🔍 Why this code is used: Multiple inheritance lets a class inherit from multiple parent classes — useful for mixing in behaviour from several sources.

⚙️ How it operates: A class can inherit from multiple parent classes by listing them in parentheses. The Method Resolution Order (MRO) determines which method is called when there are conflicts — Python uses the C3 linearisation algorithm.

📊 How it affects the program: Example 17 shows that Duck inherits methods from both Flyer and Swimmer — it has both fly and swim behaviour.

🎯 How it stimulates the outcome: __mro__ (Example 18) shows the order in which Python searches for methods — it's a tuple of classes from most specific to most general.

📌 Significance: Multiple inheritance is a powerful tool, but it requires care to avoid the "diamond problem" — Python's MRO handles it cleanly, but understanding MRO is essential when using multiple inheritance.

✨ Dunder Methods – The Python Data Model

Magic methods that make your objects feel native to Python

✨ __str__ and __repr__

String Representations of Objects

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

    def __str__(self):
        return f"{self.title} by {self.author}"

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

# Example 19: __str__ and __repr__
book = Book("1984", "George Orwell", 328)
print(book)
print(repr(book))
🔍 Why this code is used: __str__ and __repr__ control how your objects are displayed as strings — essential for debugging and user-facing output.

⚙️ How it operates: __str__ is called by print() and str() — it should be user-friendly. __repr__ is called by repr() and is used for debugging — it should be unambiguous and ideally reproducible.

📊 How it affects the program: Example 19 shows that print(book) uses the user-friendly __str__, while repr(book) shows a more detailed representation.

🎯 How it stimulates the outcome: A good __repr__ should return a string that, when evaluated, recreates the object — this is the Python convention.

📌 Significance: Implementing these methods makes your objects behave like native Python objects — they're more usable and debuggable.
🔢 __eq__, __lt__, and Rich Comparison

Comparing Objects

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __eq__(self, other):
        if not isinstance(other, Person):
            return False
        return self.name == other.name and self.age == other.age

    def __lt__(self, other):
        if not isinstance(other, Person):
            return NotImplemented
        return self.age < other.age

    def __repr__(self):
        return f"Person('{self.name}', {self.age})"

# Example 20: comparison methods
p1 = Person("Alice", 30)
p2 = Person("Alice", 30)
p3 = Person("Bob", 25)

print(p1 == p2)
print(p1 == p3)
print(p3 < p1)
🔍 Why this code is used: Comparison methods let you define how your objects are compared — essential for sorting, testing, and logical operations.

⚙️ How it operates: __eq__ defines equality (==). __lt__ defines less-than (<). Python can automatically derive other comparisons (!=, >=, etc.) from these if you use functools.total_ordering.

📊 How it affects the program: Example 20 shows that two Person objects with the same name and age are considered equal — and they can be compared by age.

🎯 How it stimulates the outcome: Implementing these methods makes your objects usable with Python's comparison operators and sorting functions (sorted(), max(), min()).

📌 Significance: Rich comparison methods are what make Python objects behave like values — they're essential for mathematical and logical operations.
➕ __add__, __len__, and Other Dunders

Arithmetic and Sequence-Like Behaviour

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented
        return Vector(self.x + other.x, self.y + other.y)

    def __mul__(self, scalar):
        if not isinstance(scalar, (int, float)):
            return NotImplemented
        return Vector(self.x * scalar, self.y * scalar)

    def __len__(self):
        return 2

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

# Example 21: arithmetic and sequence dunders
v1 = Vector(3, 4)
v2 = Vector(1, 2)
v3 = v1 + v2
print(v3)
print(v1 * 2)
print(len(v1))
🔍 Why this code is used: Dunder methods for arithmetic and sequence behaviour let your objects support operators like +, *, and len().

⚙️ How it operates: __add__ defines addition (+). __mul__ defines multiplication (*). __len__ defines the length (len()). Each method should return NotImplemented for unsupported types to allow Python to try the reverse operation.

📊 How it affects the program: Example 21 shows that Vector objects support +, *, and len() — they feel like native Python types.

🎯 How it stimulates the outcome: Implementing these methods makes your objects usable in mathematical expressions and with built-in functions — a hallmark of well-designed Python classes.

📌 Significance: The Python data model is what makes the language feel consistent — dunder methods are the hooks that make your objects behave like native types.

📦 Dataclasses – Reducing Boilerplate

Python 3.7+ feature that auto-generates common methods

📦 Dataclass Basics

@dataclass — Auto-generate __init__, __repr__, __eq__, and more

Dataclasses are a modern alternative to writing boilerplate code for data containers. They automatically generate __init__, __repr__, __eq__, and other methods based on the declared fields.

from dataclasses import dataclass, field

@dataclass
class Product:
    name: str
    price: float
    quantity: int = 0
    tags: list[str] = field(default_factory=list)

    @property
    def total_value(self) -> float:
        return self.price * self.quantity

# Example 22: using a dataclass
p = Product("Laptop", 999.99, 5)
print(p)
print(p.total_value)

# Example 23: dataclass with default values
p2 = Product("Mouse", 25.50)
print(p2)

# Example 24: dataclass equality
p3 = Product("Laptop", 999.99, 5)
print(p == p3)
🔍 Why this code is used: Dataclasses reduce boilerplate for data-centric classes — they're the modern, recommended way to write simple classes that store data.

⚙️ How it operates: @dataclass automatically generates __init__, __repr__, __eq__, and __hash__ (if frozen=True) based on the annotated fields. Fields with defaults must come after fields without defaults. field(default_factory=list) provides mutable default values safely.

📊 How it affects the program: Example 22 shows that the dataclass provides a clean __repr__ and __init__ automatically. Example 24 shows that equality is also auto-generated.

🎯 How it stimulates the outcome: Dataclasses are ideal for data containers — they save you from writing repetitive boilerplate code while still allowing you to add custom methods and properties.

📌 Significance: Dataclasses are one of the most popular features added to Python in recent years — they're the go-to tool for data classes in modern Python code.
❄️ Frozen Dataclasses

Immutable Dataclasses with frozen=True

@dataclass(frozen=True)
class Point:
    x: int
    y: int

# Example 25: frozen dataclass — immutable
p = Point(3, 4)
print(p)

try:
    p.x = 5
except FrozenInstanceError as e:
    print(f"Error: {e}")
🔍 Why this code is used: frozen=True makes dataclass instances immutable — useful for hashable, read-only data.

⚙️ How it operates: With frozen=True, the dataclass becomes immutable — attempting to assign to a field raises FrozenInstanceError. Frozen dataclasses are hashable and can be used as dictionary keys.

📊 How it affects the program: Example 25 shows that a frozen dataclass cannot be modified — it's like a tuple with named fields.

🎯 How it stimulates the outcome: Use frozen dataclasses for values that should never change — coordinates, configuration, and other immutable data.

📌 Significance: Frozen dataclasses combine the readability of named fields with the immutability of tuples — the best of both worlds.

💼 Real-World Mini Projects

Three self-contained programs that combine everything above

🏷️ Project 1 – Library Management System

Books, Members, and Borrowing with Classes

Demonstrates class design, encapsulation, and the relationship between classes — a classic OOP project.

class Book:
    def __init__(self, title, author, isbn):
        self.title = title
        self.author = author
        self.isbn = isbn
        self.available = True

    def borrow(self):
        if not self.available:
            raise ValueError("Book already borrowed")
        self.available = False

    def return_book(self):
        if self.available:
            raise ValueError("Book was not borrowed")
        self.available = True

    def __repr__(self):
        status = "Available" if self.available else "Borrowed"
        return f"'{self.title}' by {self.author} [{status}]"

class Member:
    def __init__(self, name, member_id):
        self.name = name
        self.member_id = member_id
        self.borrowed_books = []

    def borrow_book(self, book):
        if book in self.borrowed_books:
            raise ValueError("Already borrowed this book")
        book.borrow()
        self.borrowed_books.append(book)

    def return_book(self, book):
        if book not in self.borrowed_books:
            raise ValueError("Did not borrow this book")
        book.return_book()
        self.borrowed_books.remove(book)

    def __repr__(self):
        return f"Member({self.name}, {self.member_id})"

# Example 26: using the library system
book1 = Book("1984", "George Orwell", "123-456")
book2 = Book("Brave New World", "Aldous Huxley", "789-012")
alice = Member("Alice", 101)

print(book1)
alice.borrow_book(book1)
print(book1)
alice.return_book(book1)
print(book1)
🔍 Why this code is used: This project models a real-world system with two interacting classes — demonstrating encapsulation, state management, and relationships between objects.

⚙️ How it operates: Book manages its own availability. Member manages a list of borrowed books. The borrow_book and return_book methods coordinate between the two classes.

📊 How it affects the program: Example 26 shows the complete flow — borrowing a book changes its state, and returning it restores availability.

🎯 How it stimulates the outcome: This pattern scales directly to real library systems, inventory management, and any system that tracks resource allocation.

📌 Significance: This project demonstrates how classes model real-world entities and their interactions — the foundation of object-oriented design.
📊 Project 2 – Employee Payroll with Inheritance

Different Employee Types with Shared and Unique Behaviour

class Employee:
    def __init__(self, name, employee_id):
        self.name = name
        self.employee_id = employee_id

    def calculate_pay(self):
        raise NotImplementedError("Subclass must implement")

    def __repr__(self):
        return f"{self.__class__.__name__}({self.name}, {self.employee_id})"

class SalariedEmployee(Employee):
    def __init__(self, name, employee_id, annual_salary):
        super().__init__(name, employee_id)
        self.annual_salary = annual_salary

    def calculate_pay(self):
        return self.annual_salary / 12

class HourlyEmployee(Employee):
    def __init__(self, name, employee_id, hourly_rate, hours_worked):
        super().__init__(name, employee_id)
        self.hourly_rate = hourly_rate
        self.hours_worked = hours_worked

    def calculate_pay(self):
        return self.hourly_rate * self.hours_worked

# Example 27: polymorphism in action
employees = [
    SalariedEmployee("Alice", 101, 60000),
    HourlyEmployee("Bob", 102, 25, 160),
]

for emp in employees:
    print(f"{emp.name}: ${emp.calculate_pay():.2f}")
🔍 Why this code is used: This project demonstrates inheritance and polymorphism — different employee types share common code but implement their own pay calculation.

⚙️ How it operates: Employee is an abstract base class with a calculate_pay method that subclasses must implement. SalariedEmployee and HourlyEmployee each implement their own pay logic. The loop treats all employees uniformly — polymorphism.

📊 How it affects the program: Example 27 shows that the loop works for both salaried and hourly employees — each object knows how to calculate its own pay.

🎯 How it stimulates the outcome: This pattern is the foundation of many real-world systems — payroll, e-commerce pricing, and any system with multiple types that share a common interface.

📌 Significance: Inheritance and polymorphism are the cornerstones of object-oriented design — they enable flexible, extensible systems.
🎯 Project 3 – Shopping Cart with Dataclasses

Clean Data Models with Dataclasses

from dataclasses import dataclass, field
from typing import List

@dataclass
class Item:
    name: str
    price: float
    quantity: int = 1

    @property
    def subtotal(self) -> float:
        return self.price * self.quantity

@dataclass
class ShoppingCart:
    items: List[Item] = field(default_factory=list)

    def add_item(self, item: Item):
        for existing in self.items:
            if existing.name == item.name:
                existing.quantity += item.quantity
                return
        self.items.append(item)

    def remove_item(self, name: str):
        self.items = [i for i in self.items if i.name != name]

    @property
    def total(self) -> float:
        return sum(i.subtotal for i in self.items)

# Example 28: using the shopping cart
cart = ShoppingCart()
cart.add_item(Item("Laptop", 999.99))
cart.add_item(Item("Mouse", 25.50, 2))
cart.add_item(Item("Laptop", 999.99))

print(cart.items)
print(f"Total: ${cart.total:.2f}")
🔍 Why this code is used: Dataclasses make data models clean and concise — this project demonstrates a real-world e-commerce cart.

⚙️ How it operates: Item is a dataclass with a subtotal property. ShoppingCart is a dataclass containing a list of items with methods to add/remove items and compute the total. Adding an existing item increments quantity instead of duplicating.

📊 How it affects the program: Example 28 shows that adding a duplicate item increments the quantity — the cart automatically merges identical items.

🎯 How it stimulates the outcome: This pattern scales directly to real e-commerce systems — the same design principles apply to orders, invoices, and inventory.

📌 Significance: Dataclasses are the modern way to write data containers — they reduce boilerplate and make code more maintainable.

❓ Frequently Asked Questions

Common questions about Python classes, answered

What is the difference between a class and an object?

+

A class is a blueprint or template for creating objects. An object is an instance of a class — it has its own state (attributes) and behaviour (methods). You create an object by calling the class: my_obj = MyClass().

What is self in Python classes?

+

self refers to the current instance of the class. It's the first parameter of every instance method. Python passes it automatically when you call a method on an instance. It's a convention, not a keyword — you could name it this, but self is the standard.

What is the difference between __str__ and __repr__?

+

__str__ is for user-friendly output, used by print() and str(). __repr__ is for developers and debugging, used by repr() and in the REPL. __repr__ should ideally return a string that could recreate the object.

What is inheritance and why use it?

+

Inheritance lets you create a new class (child) that extends an existing class (parent). The child inherits all attributes and methods from the parent, and can override or add to them. It's used for code reuse, organising code hierarchically, and modelling "is-a" relationships.

What is a dataclass and when should I use one?

+

A dataclass (Python 3.7+) is a class that automatically generates __init__, __repr__, __eq__, and other methods. Use dataclasses for data containers — classes that primarily store data rather than complex behaviour. They reduce boilerplate significantly.

What is the difference between @classmethod and @staticmethod?

+

@classmethod receives the class (cls) as the first argument — it can access and modify class state. @staticmethod receives no special first argument — it's a regular function inside a class. Use @classmethod for alternative constructors and class-level operations; use @staticmethod for utility functions.

What is the @property decorator used for?

+

@property lets you define methods that can be accessed like attributes. It's used for computed attributes (like area from width and height) and for controlling attribute access with validation. It's a key tool for encapsulation.

What is the Method Resolution Order (MRO) in Python?

+

MRO is the order in which Python searches for methods in a class hierarchy, especially important with multiple inheritance. Python uses the C3 linearisation algorithm. You can view it with ClassName.__mro__.

What is the difference between _ and __ for attribute names?

+

A single leading underscore (_name) is a convention meaning "protected — don't use this externally". Double underscores (__name) trigger name mangling — the name is changed to _ClassName__name to avoid accidental name collisions in subclasses. Neither is truly private; they're conventions and protection mechanisms.

When should I use a class instead of a function?

+

Use a class when you need to maintain state across multiple operations, when you have related data and behaviour that belong together, or when you need to model a real-world entity with properties and methods. Use a function for pure operations that don't need to maintain state.

✏️ Practice Exercises & Quizzes

Test your understanding with hands-on problems and multiple-choice questions

🧪 Exercise 1 – Define a Class with __init__ and Methods

Task: Define a Circle class with attributes radius and methods area() and circumference().

Example: Circle(5).area() → 78.54

class Circle:
    # Your code here
    pass

# Test
print(Circle(5).area())
import math

class Circle:
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return math.pi * self.radius ** 2

    def circumference(self):
        return 2 * math.pi * self.radius

# Explanation:
# 1. __init__ initialises the radius attribute
# 2. area() computes πr² using math.pi
# 3. circumference() computes 2πr
🧪 Exercise 2 – Use @property for a Computed Attribute

Task: Add a diameter property to the Circle class from Exercise 1.

Example: Circle(5).diameter → 10

class Circle:
    def __init__(self, radius):
        self.radius = radius

    # Add a @property for diameter here
    pass
class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def diameter(self):
        return self.radius * 2

# Explanation:
# 1. @property makes diameter accessible like an attribute
# 2. It's computed from radius — no separate storage needed
# 3. Access it as circle.diameter (no parentheses)
🧪 Exercise 3 – Implement Inheritance

Task: Create a Vehicle class and a Car subclass that inherits from it.

Vehicle should have make, model attributes and a start() method. Car should add a doors attribute and override start() to include a custom message.

class Vehicle:
    # Your code here
    pass

class Car(Vehicle):
    # Your code here
    pass

# Test
print(Car("Toyota", "Camry", 4).start())
class Vehicle:
    def __init__(self, make, model):
        self.make = make
        self.model = model

    def start(self):
        return "Vehicle starting..."

class Car(Vehicle):
    def __init__(self, make, model, doors):
        super().__init__(make, model)
        self.doors = doors

    def start(self):
        return f"Car starting... {self.make} {self.model} with {self.doors} doors"

# Explanation:
# 1. Vehicle defines common attributes and a start method
# 2. Car inherits from Vehicle and adds doors
# 3. Car overrides start() and uses super() to call the parent's __init__
🧪 Exercise 4 – Implement Dunder Methods

Task: Add __eq__ and __lt__ to the Circle class so circles can be compared by radius.

class Circle:
    def __init__(self, radius):
        self.radius = radius

    # Add __eq__ and __lt__ here
    pass

# Test
print(Circle(5) == Circle(5))
print(Circle(3) < Circle(5))
class Circle:
    def __init__(self, radius):
        self.radius = radius

    def __eq__(self, other):
        if not isinstance(other, Circle):
            return False
        return self.radius == other.radius

    def __lt__(self, other):
        if not isinstance(other, Circle):
            return NotImplemented
        return self.radius < other.radius

# Explanation:
# 1. __eq__ checks if two circles have the same radius
# 2. __lt__ compares radii for less-than
# 3. Both handle type checking for safety
🧪 Exercise 5 – Convert a Class to a Dataclass

Task: Convert this class to a dataclass:

class Person:
    def __init__(self, name, age, city="Unknown"):
        self.name = name
        self.age = age
        self.city = city

    def __repr__(self):
        return f"Person('{self.name}', {self.age}, '{self.city}')"

# Write the dataclass version here
from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int
    city: str = "Unknown"

# Explanation:
# 1. @dataclass auto-generates __init__ and __repr__
# 2. Fields are defined with type annotations
# 3. Default values work the same as in regular classes
# 4. The dataclass version is much shorter and cleaner
📝 Quick Quiz

Multiple-Choice Questions

1. What is the name of the constructor method in a Python class?

A) __construct__
B) __init__
C) __new__
D) __call__

2. What does self represent in a method?

A) The class itself
B) The current instance
C) The parent class
D) A global variable

3. Which decorator is used to make a method accessible like an attribute?

A) @classmethod
B) @property
C) @staticmethod
D) @abstractmethod

4. What is inheritance in OOP?

A) A class extending another
B) A class containing another
C) A function calling another
D) A variable pointing to another

5. What does @classmethod receive as its first argument?

A) The instance (self)
B) The class (cls)
C) Nothing
D) The parent class

6. Which dunder method is called by print(obj)?

A) __str__
B) __repr__
C) __print__
D) __display__

7. What is the purpose of super()?

A) Call the parent class
B) Create a new instance
C) Delete an instance
D) Import a module

8. Which dataclass parameter makes instances immutable?

A) frozen=True
B) immutable=True
C) readonly=True
D) const=True

🏁 Congratulations – You've Mastered Python Classes!

You've now covered the fundamental building block of object-oriented programming in Python — the class — in genuine depth, from basic definition to advanced patterns.

✅ What You've Accomplished

  • Understood class definition, instantiation, and the __init__ constructor
  • Mastered instance attributes and methods — the state and behaviour of objects
  • Used @property for computed attributes and controlled access
  • Learned @classmethod for alternative constructors and @staticmethod for utilities
  • Explored encapsulation with _ and __ naming conventions
  • Understood inheritance — code reuse, super(), and method overriding
  • Worked with multiple inheritance and the Method Resolution Order (MRO)
  • Implemented dunder methods — __str__, __repr__, __eq__, __lt__, __add__, and more
  • Used dataclasses to reduce boilerplate code
  • Built three real-world mini projects: a library system, employee payroll, and a shopping cart
  • Worked through 50+ runnable examples, five exercises, and eight quiz questions

🌍 Why This Matters Beyond This Page

Everything you learned here reappears constantly across Python programming. Every framework — Django, Flask, FastAPI, SQLAlchemy, pytest — is built on classes. Every time you model a user, a product, an order, or any real-world entity, you reach for a class. The patterns you've practiced — inheritance, polymorphism, encapsulation, dunder methods — are used in production code every day. Classes are not a beginner topic you leave behind; they are the foundation of every object-oriented program you'll ever write.

📚 Next Steps

Now that classes feel natural, consider extending into:

  • Abstract Base Classes (ABCs) — defining interfaces and enforcing contracts
  • Metaclasses — classes that create classes
  • Descriptors — controlling attribute access at a deeper level
  • Context Managers — __enter__ and __exit__ for resource management
  • Iterators — __iter__ and __next__ for custom iteration
  • Design Patterns — Singleton, Factory, Observer, and more

💌 A Final Word

Classes are where Python's philosophy of "readable, explicit code" meets the power of object-oriented design. user = User("glenn", "glenn@email.com") tells a reader exactly what's happening in a way that a dictionary or list never can. The gap between "I can write class MyClass:" and "I understand inheritance, polymorphism, dunder methods, and dataclasses" is the same gap that separates a beginner from a confident intermediate developer. You've just closed a large part of that gap for one of Python's most fundamental concepts. Keep experimenting, keep breaking things intentionally to see why they break, and keep building.

Now go build something object-oriented and elegant! 🏗️

Poetic Bytes · 2026 · Made with ❤️ by Glenn Junsay Pansensoy

"A class is a promise: every object will have the same shape and the same behaviour."