Object-Oriented Python Best Practices
Python is not Java - lean toward simple objects, functions, and protocols rather than deep inheritance hierarchies. These rules keep OO code Pythonic and testable.
How to Use This List
- Apply in design reviews for new domain models.
- Use when refactoring god-classes or mixin soup.
- Pair with mypy protocols and Pydantic at system edges.
- Revisit after incidents caused by shared mutable class state.
A - Modeling
- Use
@dataclassfor data records; add methods only when behavior is intrinsic. Not every dict needs a class. - Prefer composition (
has-a) over inheritance (is-a) beyond one level. Inject collaborators in__init__. - Use
Enum/StrEnuminstead of string constants. Exhaustivematchand typo prevention. - Model money, IDs, and quantities as small value types. Not bare int/str across layers.
- Keep classes focused - one reason to change. Split read models from write services when needed.
B - Inheritance and APIs
- Limit mixins to one concern (logging, serialization). Document MRO when using multiple.
- Call
super().__init__in cooperative multiple inheritance chains. Broken chains skip initialization. - Prefer
Protocolor ABC only when multiple implementations exist. YAGNI for single impl. - Use
@classmethodfactories for alternate constructors. Not overloaded__init__patterns. - Public API small; prefix internals with
_. Properties expose stable attributes over raw fields.
C - Correctness
- Align
__eq__and__hash__for value objects used in sets/dicts. Or usefrozen=Truedataclass. - Never mutate frozen or supposedly immutable objects. Tuple fields inside dataclass still mutable.
- Validate invariants in
__post_init__or setters. Fail fast at construction. - Return
NotImplementedfrom__eq__for unknown types. Let other operand try reverse comparison. - Avoid mutable class attributes as shared defaults. Set per-instance in
__init__.
D - Metaprogramming Discipline
- Default to
__init_subclass__for registration hooks. Not custom metaclasses. - Reach for descriptors/properties only when validation or lazy compute repeats. Simple
__init__otherwise. - Use
__slots__only with measured memory pressure. Accept inheritance constraints. - Document plugin registries and clear them in tests. Prevent cross-test pollution.
- Skip operator overloading unless domain is naturally numeric/vector-like. Surprising
+hurts readers.
E - Testing and Boundaries
- Inject dependencies (clock, storage, notifier) in constructors. Not hard-coded globals.
- Fake implementations for ABC/Protocol ports in unit tests. No network in domain tests.
- Use Pydantic 2 at HTTP boundaries; plain dataclasses inside. Separation of transport and domain.
- Prefer functions for stateless transforms between rich objects. Methods when behavior belongs to data.
- Keep Django/FastAPI views thin - domain logic in testable services.
FAQs
When is inheritance OK?
Framework extension points, small mixins, true subtype polymorphism with few levels.
dataclass vs Pydantic model?
dataclass in domain core; Pydantic where JSON/schema validation required.
Should every file have classes?
No - modules of functions are Pythonic. Classes when state + behavior bundle naturally.
Protocol or ABC for ports?
Protocol for structural third-party types; ABC when you control inheritance and want instantiation guards.
How many methods per class?
No fixed max - if class scrolls endlessly, split by responsibility or extract helpers.
private with double underscore?
__name mangling rare in app code - single _ convention sufficient.
inherit from built-ins?
class MyDict(dict) sparingly - often wrap or compose instead for clearer APIs.
ORM models as domain?
Keep anemic ORM rows separate from rich domain when complexity grows - anti-corruption layer.
testing dataclasses?
Construct with minimal fields; use replace() for frozen variants in tests.
biggest OO smell in Python?
Deep inheritance replacing simple functions - refactor to composition and protocols.
Related
- OOP Basics - intro examples
- Inheritance & MRO - super and mixins
- Protocols & Structural Typing - interfaces
- dataclasses - record pattern
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.