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
selfis 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., andfunctools.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=Truesaves memory;frozen=Truemakes instances immutable;order=Truegenerates comparisons.- Use
default_factoryfor 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:
attrslibrary: 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**kwargsand callsuper().__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,AssertionErrorfor 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_propertyfor expensive computed attributes.- Avoid creating tons of tiny objects in tight loops; consider tuples or arrays.
- Use
lru_cachefor 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
- Bad:
- 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=Trueare 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
Official Python docs
- The Python Data Model: https://docs.python.org/3/reference/datamodel.html
- Dataclasses: https://docs.python.org/3/library/dataclasses.html
- abc (Abstract Base Classes): https://docs.python.org/3/library/abc.html
- typing and typing_extensions: https://docs.python.org/3/library/typing.html
- functools (cached_property, total_ordering, singledispatchmethod): https://docs.python.org/3/library/functools.html
Books and deep dives
- Fluent Python, 2nd Ed. (Luciano Ramalho) — exceptional coverage of the data model and protocols
- Effective Python, 2nd Ed. (Brett Slatkin) — pragmatic best practices
- Python in a Nutshell, 3rd Ed. (Martelli et al.) — comprehensive reference
Libraries and tools
- attrs: https://www.attrs.org/
- Pydantic: https://docs.pydantic.dev/
- Hypothesis (property-based testing): https://hypothesis.readthedocs.io/
- pytest: https://docs.pytest.org/
- mypy (static type checker): https://mypy-lang.org/
Talks and articles
- “The Python Data Model” (various PyCon talks by Luciano Ramalho)
- “Stop Writing Classes” (Jack Diederich) — a useful counterpoint; know when OOP is not needed
- Raymond Hettinger’s talks on Pythonic patterns and APIs
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.