Understanding Python Classes: A Comprehensive Guide

1. Introduction

Python is a multi-paradigm language, but at its heart it is built around the idea that almost everything is an object. Lists, strings, integers, functions — they are all objects. Object-Oriented Programming (OOP) is the design philosophy that structures code around objects: bundles of data (attributes) and behaviour (methods) that model real-world or conceptual entities.

A class is the blueprint for creating objects. Just as a house blueprint defines what every house built from it will look like and how it will behave, a Python class defines the attributes and methods that every instance of that class will possess.

This blog takes you from defining your first class all the way through advanced topics like inheritance, dunder methods, class methods, static methods, properties, dataclasses, and abstract base classes — with working code examples at every step.

2. What Is a Class?

A class is a user-defined data type. It packages related data and the functions that operate on that data into one cohesive unit. When you create an object from a class, you get an instance — a concrete realisation of the blueprint with its own independent state.

Key vocabulary: class = blueprint, instance/object = a thing built from that blueprint, attribute = data stored on an object, method = a function defined inside a class.

Defining a Minimal Class

class Dog:
"""A simple class representing a dog."""
pass # 'pass' means: nothing here yet
# Create two independent instances
fido = Dog()
buddy = Dog()
print(type(fido)) # <class '__main__.Dog'>
print(fido is buddy) # False — different objects in memory

3. The __init__ Method & Instance Attributes

The __init__ method is the initialiser (commonly called the constructor). Python calls it automatically when a new instance is created. It is where you set up every attribute the object needs from birth.

The first parameter of every method in a class is self — a reference to the instance being created or operated on. Python passes it automatically; you never supply it yourself.

class Dog:
"""Represents a dog with a name, breed, and age."""
def __init__(self, name: str, breed: str, age: int):
# Instance attributes — unique to each object
self.name = name
self.breed = breed
self.age = age
def bark(self):
return f"{self.name} says: Woof!"
def describe(self):
return f"{self.name} is a {self.age}-year-old {self.breed}."
# Instantiate
fido = Dog(name="Fido", breed="Labrador", age=3)
rex = Dog(name="Rex", breed="German Shepherd", age=5)
print(fido.bark()) # Fido says: Woof!
print(rex.describe()) # Rex is a 5-year-old German Shepherd.

Best practice: always annotate parameter types in __init__ for clarity, even though Python does not enforce them at runtime.

4. Class Attributes vs Instance Attributes

Instance attributes are defined on self inside __init__ and are unique to each object. Class attributes are defined directly in the class body and are shared by all instances. Modifying a class attribute through the class affects every instance; setting it on a specific instance creates a new instance-level shadow copy.

class BankAccount:
# Class attribute — shared by all accounts
interest_rate = 0.035 # 3.5%
total_accounts = 0
def __init__(self, owner: str, balance: float = 0.0):
# Instance attributes
self.owner = owner
self.balance = balance
BankAccount.total_accounts += 1 # modify via class, not self
def apply_interest(self):
self.balance += self.balance * BankAccount.interest_rate
acc1 = BankAccount("Alice", 1000)
acc2 = BankAccount("Bob", 2000)
print(BankAccount.total_accounts) # 2
acc1.apply_interest()
print(acc1.balance) # 1035.0
# Change the class attribute — affects all instances
BankAccount.interest_rate = 0.04
acc2.apply_interest()
print(acc2.balance) # 2080.0

5. Types of Methods

Python classes support three kinds of methods, each serving a distinct role:

Method TypeFirst ParameterDecoratorTypical Use
Instance methodself(none)Read/modify instance state
Class methodcls@classmethodFactories; access/modify class state
Static method(none)@staticmethodUtility logic; no access to class or instance

All Three in One Class

class Temperature:
scale = "Celsius" # class attribute
def __init__(self, value: float):
self.value = value
# ── Instance method ──────────────────────────────────────
def to_fahrenheit(self) -> float:
return self.value * 9/5 + 32
# ── Class method — alternative constructor (factory) ──────
@classmethod
def from_fahrenheit(cls, f: float) -> "Temperature":
return cls((f - 32) * 5/9)
# ── Static method — pure utility, no self or cls ──────────
@staticmethod
def is_freezing(celsius: float) -> bool:
return celsius <= 0
def __repr__(self):
return f"Temperature({self.value:.2f} {Temperature.scale})"
t1 = Temperature(100)
print(t1.to_fahrenheit()) # 212.0
t2 = Temperature.from_fahrenheit(32) # factory classmethod
print(t2) # Temperature(0.00 Celsius)
print(Temperature.is_freezing(-5)) # True

6. Inheritance

Inheritance lets a child class (subclass) reuse and extend the attributes and methods of a parent class (superclass). This promotes the DRY principle — Don’t Repeat Yourself — and models natural is-a relationships: a Dog is an Animal, a Car is a Vehicle.

Single Inheritance

class Animal:
"""Base class for all animals."""
def __init__(self, name: str, species: str):
self.name = name
self.species = species
def speak(self) -> str:
return f"{self.name} makes a sound."
def __str__(self):
return f"{self.name} ({self.species})"
class Dog(Animal): # Dog inherits from Animal
def __init__(self, name: str, breed: str):
super().__init__(name, species="Canis lupus familiaris")
self.breed = breed
# Override speak() from the parent
def speak(self) -> str:
return f"{self.name} says: Woof!"
def fetch(self, item: str) -> str:
return f"{self.name} fetches the {item}!"
class Cat(Animal):
def __init__(self, name: str, indoor: bool = True):
super().__init__(name, species="Felis catus")
self.indoor = indoor
def speak(self) -> str:
return f"{self.name} says: Meow!"
dog = Dog("Fido", "Labrador")
cat = Cat("Whiskers")
print(dog.speak()) # Fido says: Woof!
print(cat.speak()) # Whiskers says: Meow!
print(dog.fetch("ball")) # Fido fetches the ball!
print(str(dog)) # Fido (Canis lupus familiaris)
# isinstance() checks inheritance chain
print(isinstance(dog, Animal)) # True
print(isinstance(dog, Cat)) # False

Multiple Inheritance & MRO

Python supports inheriting from more than one parent. The Method Resolution Order (MRO) determines which parent’s method is called first when there is ambiguity. Python uses the C3 linearisation algorithm.

class Flyable:
def move(self):
return "Flying through the air"
class Swimmable:
def move(self):
return "Swimming through water"
class Duck(Flyable, Swimmable): # Flyable listed first
def quack(self):
return "Quack!"
donald = Duck()
print(donald.move()) # Flying through the air (Flyable wins — listed first)
print(Duck.__mro__) # MRO tuple shows resolution order

7. Encapsulation & Access Control

Encapsulation means bundling data and the methods that operate on it, and restricting direct access to internal state. Python uses naming conventions rather than strict access modifiers:

  • public_attr — accessible anywhere (default).
  • _protected_attr — convention: internal to class and subclasses, do not access from outside.
  • __private_attr — name-mangled to _ClassName__attr; harder (not impossible) to access externally.
class Person:
def __init__(self, name: str, age: int):
self.name = name # public
self._id = id(self) # protected (convention)
self.__age = age # private (name-mangled)
def get_age(self) -> int:
return self.__age
def have_birthday(self):
self.__age += 1
p = Person("Alice", 30)
print(p.name) # Alice
print(p.get_age()) # 30
# print(p.__age) # AttributeError

8. Properties — Controlled Attribute Access

The @property decorator lets you expose a method as if it were a plain attribute. This gives you clean dot-notation access while still running validation or computed logic behind the scenes. You can also define a setter and deleter.

class Circle:
def __init__(self, radius: float):
self._radius = radius # store privately
@property
def radius(self) -> float:
"""Getter — read as circle.radius"""
return self._radius
@radius.setter
def radius(self, value: float):
"""Setter — enforces positive radius"""
if value < 0:
raise ValueError("Radius cannot be negative.")
self._radius = value
@property
def area(self) -> float:
"""Computed property — no setter needed"""
import math
return math.pi * self._radius ** 2
@property
def circumference(self) -> float:
import math
return 2 * math.pi * self._radius
c = Circle(5)
print(c.radius) # 5
print(f"{c.area:.2f}") # 78.54
c.radius = 10 # calls setter
print(f"{c.area:.2f}") # 314.16
# c.radius = -1 # raises ValueError

9. Dunder (Magic) Methods

Dunder methods (double underscore on both sides) are how Python hooks your class into built-in operations: printing, comparison, arithmetic, iteration, and more. Implementing them makes your objects feel like first-class Python citizens.

MethodTriggered ByPurpose
__init__ClassName(…)Initialise new instance
__repr__repr(obj)Unambiguous developer string
__str__str(obj) / print(obj)Human-readable string
__len__len(obj)Return length
__eq__obj1 == obj2Equality comparison
__lt__obj1 < obj2Less-than comparison
__add__obj1 + obj2Addition operator
__getitem__obj[key]Index / key access
__iter__for x in objMake object iterable
__enter__/__exit__with obj as xContext manager protocol

A Rich Example — Vector2D

import math
class Vector2D:
"""2D vector with full operator support."""
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __repr__(self) -> str:
return f"Vector2D({self.x}, {self.y})"
def __str__(self) -> str:
return f"({self.x}, {self.y})"
def __add__(self, other: "Vector2D") -> "Vector2D":
return Vector2D(self.x + other.x, self.y + other.y)
def __sub__(self, other: "Vector2D") -> "Vector2D":
return Vector2D(self.x - other.x, self.y - other.y)
def __mul__(self, scalar: float) -> "Vector2D":
return Vector2D(self.x * scalar, self.y * scalar)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Vector2D):
return NotImplemented
return self.x == other.x and self.y == other.y
def __abs__(self) -> float: # magnitude
return math.sqrt(self.x**2 + self.y**2)
def __len__(self) -> int: # dimension
return 2
v1 = Vector2D(3, 4)
v2 = Vector2D(1, 2)
print(v1 + v2) # (4, 6)
print(v1 - v2) # (2, 2)
print(v1 * 3) # (9, 12)
print(abs(v1)) # 5.0 — magnitude
print(v1 == Vector2D(3, 4)) # True

10. Abstract Base Classes (ABCs)

An Abstract Base Class defines an interface — a set of methods that every subclass must implement. If a subclass fails to implement an abstract method, Python raises a TypeError when you try to instantiate it. Use abc.ABC and the @abstractmethod decorator.

from abc import ABC, abstractmethod
class Shape(ABC):
"""Abstract base class — cannot be instantiated directly."""
@abstractmethod
def area(self) -> float:
...
@abstractmethod
def perimeter(self) -> float:
...
def describe(self) -> str: # concrete method — inherited as-is
return (f"{self.__class__.__name__}: "
f"area={self.area():.2f}, perimeter={self.perimeter():.2f}")
class Rectangle(Shape):
def __init__(self, w: float, h: float):
self.w, self.h = w, h
def area(self) -> float: return self.w * self.h
def perimeter(self) -> float: return 2 * (self.w + self.h)
class Circle(Shape):
import math as _math
def __init__(self, r: float):
self.r = r
def area(self) -> float: import math; return math.pi * self.r**2
def perimeter(self) -> float: import math; return 2 * math.pi * self.r
shapes = [Rectangle(4, 6), Circle(5)]
for s in shapes:
print(s.describe())
# Shape() # TypeError: Can't instantiate abstract class

11. Dataclasses — Less Boilerplate

The @dataclass decorator (Python 3.7+) auto-generates __init__, __repr__, and __eq__ from your field annotations. For data-holding classes, this eliminates significant boilerplate while keeping the code readable.

from dataclasses import dataclass, field
from typing import List
@dataclass
class Student:
name: str
age: int
grades: List[float] = field(default_factory=list)
def average(self) -> float:
return sum(self.grades) / len(self.grades) if self.grades else 0.0
def add_grade(self, g: float) -> None:
self.grades.append(g)
s = Student(name="Priya", age=20)
s.add_grade(88.5)
s.add_grade(92.0)
s.add_grade(79.5)
print(s) # Student(name='Priya', age=20, grades=[88.5, 92.0, 79.5])
print(s.average()) # 86.67
# frozen=True makes the dataclass immutable (hashable)
@dataclass(frozen=True)
class Point:
x: float
y: float
p = Point(1.0, 2.0)
# p.x = 5 # FrozenInstanceError

Use @dataclass when the class primarily holds data. Use a regular class when behaviour dominates or you need fine-grained control over __init__.

12. The Four Pillars of OOP in Python

PillarWhat It MeansPython Feature
EncapsulationBundle data + behaviour; hide internal details_protected, __private, @property
AbstractionExpose only what is necessary; hide complexityAbstract Base Classes (ABC), interfaces
InheritanceReuse and extend parent class behaviourclass Child(Parent), super()
PolymorphismDifferent classes, same interface, different implMethod overriding, duck typing

13. Best Practices

  1. Name classes with CapWords (PascalCase): BankAccount, not bank_account.
  2. Keep classes focused — one class, one responsibility (Single Responsibility Principle).
  3. Use @property instead of explicit getter/setter methods for attribute access control.
  4. Always call super().__init__() in child class initialisers to ensure proper initialisation.
  5. Implement __repr__ on every non-trivial class — it makes debugging vastly easier.
  6. Prefer composition over inheritance when the relationship is has-a rather than is-a.
  7. Use @dataclass for simple data containers instead of writing boilerplate __init__ and __repr__.
  8. Define abstract interfaces with ABC so subclasses cannot forget to implement required methods.
  9. Avoid deep inheritance hierarchies — more than 2–3 levels becomes hard to reason about.
  10. Write docstrings for every class and public method; use type hints for all parameters.

14. Quick Reference Cheat Sheet

ConceptSyntax / Example
Define a classclass Dog:
Constructordef __init__(self, name): self.name = name
Instance methoddef bark(self): return ‘Woof!’
Class method@classmethod  def create(cls, …): return cls(…)
Static method@staticmethod  def utility(x): return x * 2
Inheritanceclass Puppy(Dog): …
Call parent methodsuper().__init__(name)
Property (getter)@property  def age(self): return self._age
Property (setter)@age.setter  def age(self, v): self._age = v
Dunder stringdef __repr__(self): return f’Dog({self.name})’
Abstract method@abstractmethod  def speak(self): …
Dataclass@dataclass  class Point:  x: float; y: float
Check typeisinstance(obj, Dog)
Check subclassissubclass(Puppy, Dog)

15. Conclusion

Python classes are the cornerstone of scalable, maintainable code. Starting from a simple class with __init__, you can progressively layer on inheritance, polymorphism, encapsulation via properties, operator overloading through dunder methods, enforced interfaces with ABCs, and reduced boilerplate with dataclasses.

The real power of OOP comes not from any single feature but from how these concepts work together to model complex domains in code that reads naturally and grows gracefully. Keep your classes small and focused, lean on composition where appropriate, and write tests — and your Python OOP code will stand the test of time.

Happy Coding!


Discover more from DataSangyan

Subscribe to get the latest posts sent to your email.

Leave a Reply