Python Abstract Base Classes and Protocols Guide
Python offers two mechanisms for defining interfaces: Abstract Base Classes (ABC) from the abc module enforce nominal subtyping — a class must explicitly inherit from the ABC to satisfy the contract. Protocols (PEP 544, Python 3.8+) enforce structural subtyping — any class that implements the required methods satisfies the Protocol, without explicit inheritance. Choosing between them determines how tightly your code couples implementations to their interfaces.
Table of Contents
Abstract Base Classes with abc
Inherit from ABC (or set metaclass=ABCMeta) to make a class abstract. Any class with unimplemented abstract methods cannot be instantiated — Python raises TypeError at construction time, giving you a clear error message instead of a silent AttributeError at call time.
from abc import ABC, abstractmethod
class Storage(ABC):
"""Abstract interface for pluggable storage backends."""
@abstractmethod
def read(self, key: str) -> bytes | None:
"""Return stored bytes for key, or None if not found."""
@abstractmethod
def write(self, key: str, value: bytes) -> None:
"""Persist value under key."""
@abstractmethod
def delete(self, key: str) -> bool:
"""Delete key. Return True if it existed."""
def exists(self, key: str) -> bool:
"""Non-abstract convenience method with default implementation."""
return self.read(key) is not None
# Concrete implementation — must implement all abstract methods
class MemoryStorage(Storage):
def __init__(self):
self._store: dict[str, bytes] = {}
def read(self, key: str) -> bytes | None:
return self._store.get(key)
def write(self, key: str, value: bytes) -> None:
self._store[key] = value
def delete(self, key: str) -> bool:
if key in self._store:
del self._store[key]
return True
return False
# Attempting to instantiate the ABC itself raises TypeError
try:
s = Storage()
except TypeError as e:
print(e) # Can't instantiate abstract class Storage with abstract methods ...
mem = MemoryStorage()
mem.write("config", b'{"debug": true}')
print(mem.read("config")) # b'{"debug": true}'
print(mem.exists("config")) # True
Abstract Methods and Properties
Combine @abstractmethod with @property, @classmethod, or @staticmethod to enforce abstract properties and class-level methods in subclasses. The order of decorators matters: @abstractmethod must be the innermost decorator.
from abc import ABC, abstractmethod
class DataSource(ABC):
@property
@abstractmethod
def name(self) -> str:
"""Human-readable source name."""
@classmethod
@abstractmethod
def from_config(cls, config: dict) -> "DataSource":
"""Factory method — construct from config dict."""
@abstractmethod
async def fetch(self, query: str) -> list[dict]:
"""Async fetch — works with abstract async methods too."""
@property
def is_async(self) -> bool:
"""Concrete property — shared by all subclasses."""
import asyncio
import inspect
return inspect.iscoroutinefunction(self.fetch)
class PostgresSource(DataSource):
def __init__(self, dsn: str, schema: str = "public"):
self._dsn = dsn
self._schema = schema
@property
def name(self) -> str:
return f"postgres:{self._schema}"
@classmethod
def from_config(cls, config: dict) -> "PostgresSource":
return cls(dsn=config["dsn"], schema=config.get("schema", "public"))
async def fetch(self, query: str) -> list[dict]:
# Real impl would use asyncpg
return [{"row": 1}]
src = PostgresSource.from_config({"dsn": "postgresql://localhost/db", "schema": "analytics"})
print(src.name) # postgres:analytics
print(src.is_async) # True
ABC Design Patterns
ABCs are ideal for the Template Method pattern — define the skeleton algorithm in the base class and let subclasses fill in the abstract steps. This enforces a consistent execution flow while allowing customisation at specific points.
from abc import ABC, abstractmethod
from typing import Any
class ETLPipeline(ABC):
"""Template Method pattern: fixed ETL flow, abstract steps."""
def run(self) -> None:
"""Template method — defines the algorithm skeleton."""
data = self.extract()
data = self.transform(data)
self.load(data)
self.notify(len(data))
@abstractmethod
def extract(self) -> list[dict]:
"""Pull raw data from the source."""
@abstractmethod
def transform(self, data: list[dict]) -> list[dict]:
"""Clean and reshape data."""
@abstractmethod
def load(self, data: list[dict]) -> None:
"""Write data to destination."""
def notify(self, count: int) -> None:
"""Optional hook — override to send alerts."""
print(f"Pipeline complete: {count} records loaded")
class CRMtoDWH(ETLPipeline):
def extract(self) -> list[dict]:
print("Extracting from CRM API...")
return [{"id": 1, "name": "Acme Corp", "value": 50000}]
def transform(self, data: list[dict]) -> list[dict]:
return [{**row, "value_k": row["value"] / 1000} for row in data]
def load(self, data: list[dict]) -> None:
print(f"Loading {len(data)} rows into data warehouse...")
def notify(self, count: int) -> None:
print(f"Slack alert: {count} rows synced from CRM")
CRMtoDWH().run()
Protocols: Structural Typing
A Protocol defines a structural interface — any class that has the required attributes and methods satisfies the Protocol, regardless of inheritance. This is Python's version of Go interfaces and TypeScript's structural types. It is the preferred approach for library code and decoupled architectures because implementations don't need to import the Protocol at all.
from typing import Protocol
class Readable(Protocol):
def read(self, key: str) -> bytes | None: ...
class Writable(Protocol):
def write(self, key: str, value: bytes) -> None: ...
class ReadWritable(Readable, Writable, Protocol):
"""Compose protocols with multiple inheritance."""
# Any class with read() and write() satisfies ReadWritable
class S3Storage:
"""Does NOT inherit from any Protocol — still satisfies ReadWritable."""
def read(self, key: str) -> bytes | None:
print(f"S3 GET {key}")
return b"data"
def write(self, key: str, value: bytes) -> None:
print(f"S3 PUT {key} ({len(value)} bytes)")
class FileStorage:
def read(self, key: str) -> bytes | None:
try:
return open(key, "rb").read()
except FileNotFoundError:
return None
def write(self, key: str, value: bytes) -> None:
open(key, "wb").write(value)
# Type-checked functions accept any implementation
def cache_value(store: ReadWritable, key: str, value: bytes) -> None:
store.write(key, value)
assert store.read(key) == value
cache_value(S3Storage(), "config/app.json", b'{"env":"prod"}')
cache_value(FileStorage(), "/tmp/test.bin", b"\x00\x01\x02")
Runtime Checkable Protocols
Add @runtime_checkable to allow isinstance() checks against a Protocol at runtime. Only the presence of methods is checked — not their signatures — so use it for duck-typing checks in framework code.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
@runtime_checkable
class AsyncCloseable(Protocol):
async def aclose(self) -> None: ...
class DatabaseConnection:
def close(self) -> None:
print("DB connection closed")
class HttpSession:
async def aclose(self) -> None:
print("HTTP session closed")
conn = DatabaseConnection()
session = HttpSession()
print(isinstance(conn, Closeable)) # True
print(isinstance(session, AsyncCloseable)) # True
print(isinstance(42, Closeable)) # False
async def cleanup_resources(resources: list):
for resource in resources:
if isinstance(resource, AsyncCloseable):
await resource.aclose()
elif isinstance(resource, Closeable):
resource.close()
else:
print(f"Warning: {resource!r} has no close method")
ABC vs Protocol: When to Use Each
- Use ABC when you want explicit opt-in (implementations must inherit and declare their intent), when you have shared base implementation (template methods), or when you need to enforce an initialisation contract via
__init__. - Use Protocol when you want structural duck-typing (implementations don't need to know about the Protocol), when writing library code that consumers implement independently, or when retrofitting existing classes without modifying them.
- Use both: define a Protocol for static type checking and an ABC as an optional convenience base class with shared logic.
collections.abc Built-ins
The standard library's collections.abc module provides ABCs for all common container types. Inherit from them to get free mixin methods — implement just the abstract methods and the rest come for free.
from collections.abc import MutableMapping
class CaseInsensitiveDict(MutableMapping):
"""dict-like class where keys are case-insensitive."""
def __init__(self, *args, **kwargs):
self._store: dict[str, tuple[str, any]] = {}
self.update(*args, **kwargs)
def __setitem__(self, key: str, value):
self._store[key.lower()] = (key, value)
def __getitem__(self, key: str):
return self._store[key.lower()][1]
def __delitem__(self, key: str):
del self._store[key.lower()]
def __iter__(self):
return (original_key for original_key, _ in self._store.values())
def __len__(self):
return len(self._store)
# Implementing 5 abstract methods gives us all MutableMapping mixin methods free:
# update(), get(), pop(), setdefault(), keys(), values(), items(), __contains__
d = CaseInsensitiveDict(Content_Type="application/json", Accept="*/*")
print(d["content-type"]) # application/json
print(d["ACCEPT"]) # */*
d.update({"X-API-Key": "secret"})
print(list(d.keys()))
Frequently Asked Questions
- Can a class satisfy both an ABC and a Protocol?
- Yes. A class can inherit from an ABC (satisfying nominal typing) and simultaneously satisfy one or more Protocols (structural typing). The two mechanisms are completely orthogonal and work well together.
- What happens if I forget to implement an abstract method?
- Python raises
TypeError: Can't instantiate abstract class Foo with abstract method barwhen you try to create an instance. The error is caught at construction time, not at call time, which makes ABCs much safer than convention-based duck typing. - How do Protocols work with mypy?
- mypy performs structural compatibility checks at type-check time. If a function parameter is typed as
Readable, mypy verifies that any value passed has a compatibleread(self, key: str) -> bytes | Nonesignature — without requiring the class to inherit from anything.