Pythonic Patterns Intermediate¶
When you'd use this
Python-specific patterns that differ from traditional GoF — protocols, mixins, descriptors, context managers as patterns.
Use Python's own idioms (duck typing, context managers, generators) instead of porting verbose patterns from other languages.
Why Python patterns differ from GoF¶
A core question explored in Pythonic Patterns: Why Python patterns differ from GoF.
The Gang of Four patterns were designed for C++ and Java — languages with: - No first-class functions - No duck typing - No multiple inheritance - No decorators or context managers
Python has all of these, making many classic patterns unnecessary or much simpler.
Strategy → just use functions¶
In Python a function is a first-class object, so pass the function directly instead of building a Strategy class hierarchy.
# Java-style strategy (unnecessary in Python)
class SortStrategy(ABC):
@abstractmethod
def sort(self, data): ...
class BubbleSort(SortStrategy): ...
class QuickSort(SortStrategy): ...
# Pythonic — functions ARE strategies
def process(data, sort_fn=sorted):
return sort_fn(data)
process(data, sort_fn=lambda x: sorted(x, reverse=True))
process(data, sort_fn=heapq.nsmallest)
Singleton → module-level instance¶
A module is already a singleton — define the object at module level instead of a Singleton class.
# Don't use metaclass singletons. Just use a module.
# database.py
class _Database:
def __init__(self):
self.connection = None
def connect(self, url):
self.connection = create_connection(url)
db = _Database() # THE instance
# Everyone imports the same object:
# from database import db
# db.connect("postgres://...")
Mixin pattern — reusable behavior via multiple inheritance¶
Compose small behavior-only classes into a class — use to share cross-cutting methods without a deep hierarchy.
import json
from datetime import datetime
class TimestampMixin:
"""Adds created_at / updated_at tracking."""
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
original_init = cls.__init__
def new_init(self, *args, **kw):
original_init(self, *args, **kw)
self.created_at = datetime.utcnow()
self.updated_at = datetime.utcnow()
cls.__init__ = new_init
def touch(self):
self.updated_at = datetime.utcnow()
class SerializableMixin:
"""Adds to_dict() and to_json() methods."""
def to_dict(self) -> dict:
return {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
def to_json(self) -> str:
return json.dumps(self.to_dict(), default=str)
class ValidatableMixin:
"""Adds validate() that checks _validators class attribute."""
def validate(self) -> list[str]:
errors = []
for field, validator in getattr(self.__class__, "_validators", {}).items():
value = getattr(self, field, None)
error = validator(value)
if error:
errors.append(f"{field}: {error}")
return errors
# Compose behaviors
class User(TimestampMixin, SerializableMixin, ValidatableMixin):
_validators = {
"name": lambda v: "required" if not v else None,
"email": lambda v: "invalid" if v and "@" not in v else None,
}
def __init__(self, name: str, email: str):
self.name = name
self.email = email
u = User("Alice", "alice@example.com")
print(u.to_json()) # {"name": "Alice", "email": "...", "created_at": "..."}
print(u.validate()) # []
print(u.created_at) # 2026-08-23 ...
bad_user = User("", "invalid")
print(bad_user.validate()) # ['name: required', 'email: invalid']
Registry pattern — auto-register subclasses¶
Have subclasses register themselves (via __init_subclass__) so a factory can find them by name — use for plugins and dispatch.
class Serializer:
"""Base class that auto-registers all serializers by format name."""
_registry: dict[str, type] = {}
def __init_subclass__(cls, format_name: str = "", **kwargs):
super().__init_subclass__(**kwargs)
if format_name:
cls._registry[format_name] = cls
@classmethod
def get(cls, format_name: str) -> "Serializer":
klass = cls._registry.get(format_name)
if not klass:
raise ValueError(f"Unknown format: {format_name}. Available: {list(cls._registry)}")
return klass()
class JSONSerializer(Serializer, format_name="json"):
def serialize(self, data): return json.dumps(data)
def deserialize(self, raw): return json.loads(raw)
class CSVSerializer(Serializer, format_name="csv"):
def serialize(self, data): return "\n".join(",".join(map(str, row)) for row in data)
def deserialize(self, raw): return [line.split(",") for line in raw.splitlines()]
class YAMLSerializer(Serializer, format_name="yaml"):
def serialize(self, data): import yaml; return yaml.dump(data)
def deserialize(self, raw): import yaml; return yaml.safe_load(raw)
# Usage — no if/elif chain!
serializer = Serializer.get("json")
output = serializer.serialize({"name": "Alice"})
print(output) # '{"name": "Alice"}'
print(Serializer._registry) # {'json': JSONSerializer, 'csv': CSVSerializer, 'yaml': YAMLSerializer}
Context manager as resource pattern¶
Model acquire/release as a with-block — the Pythonic way to guarantee cleanup for any resource.
from contextlib import contextmanager
@contextmanager
def database_transaction(connection):
"""Pattern: resource acquisition + guaranteed cleanup."""
cursor = connection.cursor()
try:
yield cursor
connection.commit()
except Exception:
connection.rollback()
raise
finally:
cursor.close()
@contextmanager
def temporary_setting(obj, attr, value):
"""Pattern: temporary modification + restore."""
original = getattr(obj, attr)
setattr(obj, attr, value)
try:
yield
finally:
setattr(obj, attr, original)
Descriptor as validator pattern¶
Reuse validation logic across attributes with a descriptor — the mechanism behind typed/validated fields.
class Validated:
"""Reusable field validator — use as class attribute."""
def __init__(self, validator, error_msg="Invalid value"):
self.validator = validator
self.error_msg = error_msg
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
if obj is None: return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not self.validator(value):
raise ValueError(f"{self.name}: {self.error_msg} (got {value!r})")
obj.__dict__[self.name] = value
# Reusable validators
def positive(v): return isinstance(v, (int, float)) and v > 0
def non_empty_str(v): return isinstance(v, str) and len(v.strip()) > 0
def valid_email(v): return isinstance(v, str) and "@" in v and "." in v
class Product:
name = Validated(non_empty_str, "must be non-empty string")
price = Validated(positive, "must be positive number")
email = Validated(valid_email, "must be valid email")
def __init__(self, name, price, email):
self.name = name
self.price = price
self.email = email
p = Product("Widget", 9.99, "a@b.com") # OK
try:
Product("", 9.99, "a@b.com")
except ValueError as e:
print(e) # name: must be non-empty string (got '')
Practice Exercises¶
- Rewrite 3 GoF patterns using Python idioms (functions, protocols, decorators).
- Build a mixin library — TimestampMixin, AuditMixin, SoftDeleteMixin, CacheMixin.
- Implement a plugin registry using
__init_subclass__and demonstrate dynamic loading. - Use descriptors to build a validated model class (like a mini-Pydantic).
- Compare: write the same feature using GoF Strategy (class hierarchy) vs Pythonic (first-class functions).
💬 Discussion
Have a question about this topic? Found an error? Share your thoughts below.