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.

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

Decision guide:
  • 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 bar when 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 compatible read(self, key: str) -> bytes | None signature — without requiring the class to inherit from anything.