Object-Oriented Programming (OOP) in Python is a superpower when you learn to use the language’s data model and protocols to your advantage. This tutorial is a comprehensive, end-to-end guide—from the very basics of classes and objects to advanced features like descriptors, protocols, metaclasses, and performance optimizations. The goal: to make you more capable than 99% of your peers by the end.

What makes Python’s OOP special isn’t just syntax—it’s the “data model” that lets your objects integrate naturally with the language (iteration, context managers, arithmetic, indexing, etc.). We’ll cover essentials, best practices, pitfalls, and real-world patterns, with concrete code examples throughout.

Note: The examples target Python 3.11+ features (e.g., typing.Self, dataclass slots). When needed, alternatives for earlier versions are mentioned.


Table of Contents

  • Introduction
  • OOP in Python at a Glance
  • Core Building Blocks
    • Classes, Instances, Attributes
    • Methods, self, and init
    • Class vs Instance Attributes
    • Encapsulation with Properties
  • Special (Dunder) Methods That Matter
  • Python Data Model Protocols
    • Iteration and Context Managers
    • Container and Numeric Protocols
    • Callables and Formatting
  • Properties, Descriptors, getattr/getattribute, and slots
  • Dataclasses, attrs, and Pydantic
  • Inheritance, Mixins, super(), and MRO
  • Abstract Base Classes vs Protocols (Structural Typing)
  • Design Principles and Patterns That Actually Work in Python
  • Value Objects, Operator Overloading, and Immutability
  • Validation, Invariants, and Error Handling
  • Performance and Memory Tips
  • Concurrency Safety and Immutability
  • Packaging, API Design, and Extensibility
  • Testing OOP Code
  • Serialization and Versioning
  • Advanced Topics: Metaclasses, init_subclass, class_getitem
  • Common Pitfalls and How to Avoid Them
  • Putting It All Together: A Mini Plugin-Based Task Runner
  • Conclusion
  • Best Resources

OOP in Python at a Glance

  • Everything is an object (including functions, classes, and modules).
  • Classes define behavior (methods) and state (attributes).
  • Python favors duck typing: “If it quacks like a duck, it’s a duck.”
  • The Python Data Model (dunder methods) lets objects integrate with language syntax and built-ins.

Think in protocols, not hierarchies. In Python, interfaces are about behaviors your object supports, not its lineage.


Core Building Blocks

Classes, Instances, Attributes

class User:
    def __init__(self, username: str, email: str):
        self.username = username
        self.email = email

alice = User("alice", "alice@example.com")
print(alice.username)  # "alice"
  • Instance attributes live in obj.__dict__ by default.
  • Methods are just functions stored on the class; when accessed via an instance, Python binds self.

Methods, self, and init

class Counter:
    def __init__(self, start: int = 0):
        self.value = start

    def increment(self, step: int = 1) -> None:
        self.value += step

    def current(self) -> int:
        return self.value
  • self is a convention; Python passes the instance automatically as the first argument to instance methods.

Class vs Instance Attributes

class Config:
    # Class attribute (shared across instances)
    TIMEOUT = 30

    def __init__(self, name: str):
        self.name = name  # Instance attribute

a = Config("A")
b = Config("B")
Config.TIMEOUT = 60  # affects all unless shadowed

Be careful when you mutate a class attribute that is a container (e.g., list or dict): all instances see the same object.

Encapsulation with Properties

Python uses conventions for “privacy” (_internal, __mangled), but properties offer real encapsulation.

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

    @property
    def balance(self) -> float:
        return self._balance

    @balance.setter
    def balance(self, value: float) -> None:
        if value < 0:
            raise ValueError("Balance cannot be negative.")
        self._balance = value

Special (Dunder) Methods That Matter

Implement these to make your objects “first-class”:

  • __repr__ (debugging) and __str__ (user-friendly)
  • Equality/ordering: __eq__, __lt__, etc., and functools.total_ordering
  • Hashing for dict/set keys: __hash__ (must be consistent with equality)
  • Truthiness: __bool__ or __len__
  • Copying and construction: __new__ (rare), __init__
  • Context managers: __enter__, __exit__
  • Iteration: __iter__, __next__
  • Callables: __call__
from functools import total_ordering

@total_ordering
class Version:
    def __init__(self, major: int, minor: int):
        self.major = major
        self.minor = minor

    def __repr__(self) -> str:
        return f"Version({self.major}, {self.minor})"

    def __eq__(self, other) -> bool:
        if not isinstance(other, Version):
            return NotImplemented
        return (self.major, self.minor) == (other.major, other.minor)

    def __lt__(self, other) -> bool:
        if not isinstance(other, Version):
            return NotImplemented
        return (self.major, self.minor) < (other.major, other.minor)

    def __hash__(self) -> int:
        return hash((self.major, self.minor))

Return NotImplemented for unsupported comparisons to let Python try the reflected operation or raise cleanly.


Python Data Model Protocols

Iteration and Context Managers

class Countdown:
    def __init__(self, start: int):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current < 0:
            raise StopIteration
        val = self.current
        self.current -= 1
        return val

class Resource:
    def __enter__(self):
        print("Acquiring")
        return self

    def __exit__(self, exc_type, exc, tb):
        print("Releasing")
        return False  # propagate exceptions

Container and Numeric Protocols

class Bag:
    def __init__(self):
        self._items = {}

    def __len__(self):
        return sum(self._items.values())

    def __contains__(self, item):
        return item in self._items

    def __getitem__(self, item):
        return self._items.get(item, 0)

    def add(self, item, qty=1):
        self._items[item] = self._items.get(item, 0) + qty

Numeric overloading example for a vector is shown later.

Callables and Formatting

class Greeter:
    def __init__(self, greeting: str = "Hello"):
        self.greeting = greeting

    def __call__(self, name: str) -> str:
        return f"{self.greeting}, {name}!"

greet = Greeter("Hi")
print(greet("Alice"))  # "Hi, Alice!"

Properties, Descriptors, getattr/getattribute, and slots

Properties are built on descriptors. You can define your own to centralize logic.

class NonEmptyStr:
    def __set_name__(self, owner, name):
        self.private_name = "_" + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        v = str(value)
        if not v.strip():
            raise ValueError("Must be a non-empty string.")
        setattr(obj, self.private_name, v)

class Person:
    name = NonEmptyStr()

    def __init__(self, name: str):
        self.name = name
  • __getattr__(self, name) is called when normal attribute lookup fails.
  • __getattribute__(self, name) is called for every lookup; be extremely careful to avoid infinite recursion.
class Lazy:
    def __init__(self):
        self._loaded = False

    def __getattr__(self, name):
        if not self._loaded:
            self.data = {"x": 42}
            self._loaded = True
        if name in self.__dict__:
            return self.__dict__[name]
        raise AttributeError(name)

slots for Memory and Speed

class Point:
    __slots__ = ("x", "y")
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y
  • Prevents per-instance __dict__, saves memory, can speed up attribute access.
  • Caveats: no new attributes unless you add __dict__ to slots; plays differently with multiple inheritance.

Dataclasses, attrs, and Pydantic

Dataclasses reduce boilerplate and encode intent.

from dataclasses import dataclass, field

@dataclass(slots=True, frozen=True, order=True)
class Product:
    sku: str
    price: float
    tags: list[str] = field(default_factory=list)
  • slots=True saves memory; frozen=True makes instances immutable; order=True generates comparisons.
  • Use default_factory for mutable defaults.
  • Use __post_init__ for validation.
from dataclasses import dataclass

@dataclass
class PositiveQuantity:
    value: int

    def __post_init__(self):
        if self.value <= 0:
            raise ValueError("Quantity must be positive.")

Alternatives:

  • attrs library: feature-rich, mature, faster in some cases.
  • Pydantic: validation, parsing, and serialization for data models (great for APIs).

For domain models (rich behavior), dataclasses + properties/descriptors are excellent. For validation-heavy data exchange, consider Pydantic.


Inheritance, Mixins, super(), and MRO

Prefer composition over inheritance. When you do inherit, keep hierarchies shallow and cooperative.

class Logged:
    def log(self, msg: str) -> None:
        print(f"[LOG] {msg}")

class Repository(Logged):
    def save(self, obj) -> None:
        self.log(f"Saving {obj!r}")

Multiple Inheritance and super()

Understand the Method Resolution Order (MRO) and always use super() in cooperative classes.

class Base:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.base = True

class MixinA(Base):
    def __init__(self, a: int, **kwargs):
        self.a = a
        super().__init__(**kwargs)

class MixinB(Base):
    def __init__(self, b: int, **kwargs):
        self.b = b
        super().__init__(**kwargs)

class Concrete(MixinA, MixinB):
    def __init__(self, a: int, b: int):
        super().__init__(a=a, b=b)

Cooperative multiple inheritance requires every __init__ to accept **kwargs and call super().__init__.


Abstract Base Classes vs Protocols (Structural Typing)

  • ABCs (from abc) define required methods; inheritance-based.
  • Protocols (from typing) define structural requirements; duck-typed, no inheritance needed.
from abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def put(self, key: str, value: bytes) -> None: ...
from typing import Protocol, runtime_checkable

@runtime_checkable
class SupportsClose(Protocol):
    def close(self) -> None: ...

Generics and typing.Self:

from typing import TypeVar, Generic, Self

T = TypeVar("T")

class Builder(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []

    def add(self, item: T) -> Self:
        self._items.append(item)
        return self

    def build(self) -> list[T]:
        return list(self._items)

Design Principles and Patterns That Actually Work in Python

  • Single Responsibility: keep classes focused.
  • Open/Closed: extend via composition, callbacks, or plugins, not endless subclassing.
  • Liskov: respect expectations (don’t widen preconditions or narrow postconditions).
  • Interface Segregation: use small Protocols instead of monolithic interfaces.
  • Dependency Inversion: depend on abstractions (Protocols), inject dependencies.

Patterns:

  • Strategy: pass a callable or an object implementing a Protocol.
  • Adapter: wrap an object to match a Protocol.
  • Observer: callbacks or event emitters.
  • Factory: functions that construct objects; avoid god-factories.
  • Template Method: base class provides algorithm skeleton; hooks override specifics.
from typing import Protocol, Callable

class Discount(Protocol):
    def __call__(self, price: float) -> float: ...

def percent_off(pct: float) -> Discount:
    def apply(price: float) -> float:
        return price * (1 - pct/100)
    return apply

class Cart:
    def __init__(self, prices: list[float], discount: Discount | None = None):
        self.prices = prices
        self.discount = discount

    def total(self) -> float:
        subtotal = sum(self.prices)
        return self.discount(subtotal) if self.discount else subtotal

Value Objects, Operator Overloading, and Immutability

Value objects should be immutable, comparable, and hashable.

from __future__ import annotations
from dataclasses import dataclass
from math import hypot

@dataclass(frozen=True, slots=True)
class Vector2:
    x: float
    y: float

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

    def __mul__(self, scalar: float) -> Vector2:
        return Vector2(self.x * scalar, self.y * scalar)

    def magnitude(self) -> float:
        return hypot(self.x, self.y)

    def __repr__(self) -> str:
        return f"Vector2(x={self.x}, y={self.y})"

Validation, Invariants, and Error Handling

  • Validate early (in __init__/__post_init__).
  • Maintain invariants across methods.
  • Raise precise exceptions (ValueError, TypeError, AssertionError for internal assumptions in tests, not production).
class Percentage:
    def __init__(self, value: float):
        if not (0.0 <= value <= 100.0):
            raise ValueError("Percentage must be between 0 and 100.")
        self._value = value

    @property
    def value(self) -> float:
        return self._value

Performance and Memory Tips

  • Prefer __slots__ for many instances.
  • Use dataclass(slots=True) for conciseness and speed.
  • @functools.cached_property for expensive computed attributes.
  • Avoid creating tons of tiny objects in tight loops; consider tuples or arrays.
  • Use lru_cache for pure function computations, not stateful methods.
from functools import cached_property

class Report:
    def __init__(self, data: list[int]):
        self.data = data

    @cached_property
    def histogram(self) -> dict[int, int]:
        counts: dict[int, int] = {}
        for x in self.data:
            counts[x] = counts.get(x, 0) + 1
        return counts

Concurrency Safety and Immutability

  • The GIL doesn’t make your code thread-safe. It only protects object-level bytecodes, not your invariants.
  • Favor immutability for shared data.
  • Use locks for shared mutable state.
import threading

class SafeCounter:
    def __init__(self):
        self._value = 0
        self._lock = threading.Lock()

    def inc(self) -> None:
        with self._lock:
            self._value += 1

    def value(self) -> int:
        with self._lock:
            return self._value

Packaging, API Design, and Extensibility

  • Organize by domain, not layers. Keep small modules with clear responsibilities.
  • Expose a clean public API via __all__.
  • Use entry points (Python packaging) for plugin discovery.
  • Document with docstrings and type hints; generate docs with Sphinx or mkdocs.
# mylib/__init__.py
from .models import Product, Cart
__all__ = ["Product", "Cart"]
__version__ = "1.0.0"

Testing OOP Code

  • Aim for behavior-driven tests. Test public APIs, not internals.
  • Use dependency injection to swap collaborators with fakes.
  • Mock external I/O with unittest.mock.
import pytest
from unittest.mock import Mock

def test_cart_with_discount():
    discount = Mock(return_value=90.0)
    cart = Cart([50, 50], discount=discount)
    assert cart.total() == 90.0
    discount.assert_called_once_with(100.0)

Serialization and Versioning

  • Prefer explicit JSON encoders/decoders; avoid pickle for untrusted data.
  • For dataclasses: asdict() for JSON, but customize for complex types.
  • Use __getstate__/__setstate__ to control pickling if necessary.
from dataclasses import asdict
import json

p = Product("SKU1", 19.99, ["sale"])
json_str = json.dumps(asdict(p))

For long-lived storage, include a version field in your schema to migrate objects safely.


Advanced Topics: Metaclasses, init_subclass, class_getitem

Use sparingly; prefer simpler hooks first.

init_subclass

class Registered:
    registry: dict[str, type] = {}

    def __init_subclass__(cls, key: str | None = None, **kwargs):
        super().__init_subclass__(**kwargs)
        if key:
            Registered.registry[key] = cls

class Foo(Registered, key="foo"):
    pass

assert Registered.registry["foo"] is Foo

class_getitem for Generic-Like Behavior

class Box:
    def __class_getitem__(cls, item):
        # allow Box[int] syntax
        return (cls, item)

assert Box[int] == (Box, int)

Minimal Metaclass Example

class RegistryMeta(type):
    def __new__(mcls, name, bases, ns, **kw):
        cls = super().__new__(mcls, name, bases, ns)
        if not hasattr(mcls, "registry"):
            mcls.registry = {}
        if name != "Base":
            mcls.registry[name.lower()] = cls
        return cls

class Base(metaclass=RegistryMeta): ...
class EmailHandler(Base): ...
class SmsHandler(Base): ...

# RegistryMeta.registry -> {"emailhandler": EmailHandler, "smshandler": SmsHandler}

Common Pitfalls and How to Avoid Them

  • Mutable default arguments:
    • Bad: def f(items=[])
    • Good: def f(items=None): items = [] if items is None else items
  • Forgetting super() in multiple inheritance chains.
  • Mutating class-level containers unintentionally.
  • Defining __eq__ without consistent __hash__ (mutable objects shouldn’t be hashable).
  • Overusing inheritance when a Protocol or composition suffices.
  • Heavy __getattribute__ logic causing recursion or performance issues.
  • Relying on __del__ for cleanup; use context managers and weakrefs instead.
  • Pickling classes defined inside functions (not supported by default).

Putting It All Together: A Mini Plugin-Based Task Runner

Goals:

  • Users define tasks as classes.
  • Tasks are discovered via registration.
  • Strategies for execution (serial/parallel) are pluggable.
  • Clean protocols, dataclasses, and minimal magic.
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, runtime_checkable, ClassVar
import time
import concurrent.futures as futures

# 1) Protocols for extensibility
class Task(Protocol):
    name: str
    def run(self) -> str: ...

class Executor(Protocol):
    def execute(self, tasks: list[Task]) -> list[str]: ...

# 2) Registration via simple decorator
class TaskRegistry:
    _tasks: ClassVar[dict[str, type[Task]]] = {}

    @classmethod
    def register(cls, key: str):
        def decorator(task_cls: type[Task]):
            cls._tasks[key] = task_cls
            return task_cls
        return decorator

    @classmethod
    def create(cls, key: str, **kwargs) -> Task:
        task_cls = cls._tasks[key]
        return task_cls(**kwargs)  # type: ignore[call-arg]

# 3) A couple of tasks
@dataclass(slots=True)
class SleepTask:
    name: str
    seconds: float

    def run(self) -> str:
        time.sleep(self.seconds)
        return f"{self.name}: slept {self.seconds}s"

TaskRegistry.register("sleep")(SleepTask)

@dataclass(slots=True)
class EchoTask:
    name: str
    message: str

    def run(self) -> str:
        return f"{self.name}: {self.message}"

TaskRegistry.register("echo")(EchoTask)

# 4) Executors implementing the protocol
class SerialExecutor:
    def execute(self, tasks: list[Task]) -> list[str]:
        return [t.run() for t in tasks]

class ThreadPoolExecutor:
    def __init__(self, workers: int = 4):
        self.workers = workers

    def execute(self, tasks: list[Task]) -> list[str]:
        with futures.ThreadPoolExecutor(max_workers=self.workers) as ex:
            return list(ex.map(lambda t: t.run(), tasks))

# 5) Putting it together
def main() -> None:
    tasks: list[Task] = [
        TaskRegistry.create("echo", name="t1", message="hello"),
        TaskRegistry.create("sleep", name="t2", seconds=0.2),
        TaskRegistry.create("echo", name="t3", message="world"),
    ]

    execs: list[Executor] = [SerialExecutor(), ThreadPoolExecutor(workers=4)]
    for ex in execs:
        results = ex.execute(tasks)
        print(ex.__class__.__name__, "->", results)

if __name__ == "__main__":
    main()

Why this design works:

  • Protocols keep dependencies light and testable.
  • Registration via decorator is explicit and simple.
  • Dataclasses with slots=True are memory-friendly.
  • Executors are strategies; swapping is trivial.
  • Minimal magic, maximum clarity; extension is easy.

Conclusion

Mastering OOP in Python is about embracing the language’s data model and designing for behaviors, not hierarchies. Start with clean classes and properties, then build fluency with dunder methods and protocols. Reach for dataclasses to encode intent, descriptors when you need powerful attribute control, and ABCs/Protocols for extensibility. Keep hierarchies shallow, prefer composition, and design for testing and change. With the patterns and techniques in this guide, you’ll write Pythonic OOP that feels native to the language—and scales from scripts to systems.


Best Resources

Pro tip: Read the data model docs end-to-end once a year. You’ll notice new opportunities to make your classes feel native to Python as your codebase evolves.