Python’s traditional approach to polymorphism is inheritance — a function that accepts an Animal can receive any subclass. But inheritance requires upfront design: you must know the hierarchy before writing the code. Protocols (introduced in Python 3.8) bring structural subtyping — a type satisfies a Protocol if it has the right methods and attributes, regardless of its inheritance chain. This is “duck typing with documentation”: the Protocol says “I need something that quacks like a duck” without requiring the duck to inherit from Duck. Protocols make dependency injection in FastAPI testable without creating elaborate class hierarchies.
Defining and Using Protocols
from typing import Protocol, runtime_checkable
# ── Define a Protocol — describe what methods/attributes a type must have ─────
class Serialisable(Protocol):
"""Any object with a to_dict() method satisfies this Protocol."""
def to_dict(self) -> dict: ...
class Closeable(Protocol):
"""Any object with a close() method satisfies this Protocol."""
def close(self) -> None: ...
# ── Concrete classes — no inheritance required ────────────────────────────────
class Post:
def __init__(self, id: int, title: str):
self.id = id
self.title = title
def to_dict(self) -> dict:
return {"id": self.id, "title": self.title}
class User:
def __init__(self, id: int, name: str):
self.id = id
self.name = name
def to_dict(self) -> dict:
return {"id": self.id, "name": self.name}
# ── Function accepts anything that satisfies the Protocol ─────────────────────
def export_to_json(obj: Serialisable) -> str:
import json
return json.dumps(obj.to_dict())
# Both work — neither inherits from Serialisable
print(export_to_json(Post(1, "Hello"))) # {"id": 1, "title": "Hello"}
print(export_to_json(User(1, "Alice"))) # {"id": 1, "name": "Alice"}
# mypy checks that both Post and User have to_dict() -> dict ✓
implements keyword or explicit inheritance required. The Protocol is checked by mypy (statically) or with isinstance() (at runtime, if the Protocol is decorated with @runtime_checkable). This is Python’s built-in duck typing made explicit and checkable.@runtime_checkable on a Protocol enables isinstance() checks, but they are incomplete — they only check that the required methods exist, not that they have the right signatures or that attribute types match. isinstance(obj, MyProtocol) returning True does not guarantee the object will work correctly — it just means the method names are present. Full correctness is only guaranteed by mypy’s static analysis.Protocols for FastAPI Dependency Injection
from typing import Protocol
from fastapi import FastAPI, Depends
app = FastAPI()
# ── Define Protocol for the repository interface ───────────────────────────────
class PostRepository(Protocol):
"""Interface for post data access — any object with these methods qualifies."""
def get_by_id(self, post_id: int) -> dict | None: ...
def list_published(self, page: int, size: int) -> list[dict]: ...
def create(self, data: dict) -> dict: ...
def update(self, post_id: int, data: dict) -> dict | None: ...
def delete(self, post_id: int) -> bool: ...
# ── Production implementation ──────────────────────────────────────────────────
class SQLAlchemyPostRepository:
def __init__(self, db):
self.db = db
def get_by_id(self, post_id: int) -> dict | None:
post = self.db.query(Post).filter(Post.id == post_id).first()
return post.__dict__ if post else None
def list_published(self, page: int, size: int) -> list[dict]:
posts = (self.db.query(Post)
.filter(Post.published == True)
.offset((page - 1) * size)
.limit(size).all())
return [p.__dict__ for p in posts]
def create(self, data: dict) -> dict:
post = Post(**data)
self.db.add(post)
self.db.commit()
return post.__dict__
def update(self, post_id: int, data: dict) -> dict | None:
post = self.db.query(Post).get(post_id)
if not post:
return None
for k, v in data.items():
setattr(post, k, v)
self.db.commit()
return post.__dict__
def delete(self, post_id: int) -> bool:
post = self.db.query(Post).get(post_id)
if not post:
return False
self.db.delete(post)
self.db.commit()
return True
# ── Test implementation (in-memory) ───────────────────────────────────────────
class InMemoryPostRepository:
def __init__(self):
self._store: dict[int, dict] = {}
self._next_id = 1
def get_by_id(self, post_id: int) -> dict | None:
return self._store.get(post_id)
def list_published(self, page: int, size: int) -> list[dict]:
pub = [p for p in self._store.values() if p.get("published")]
start = (page - 1) * size
return pub[start:start + size]
def create(self, data: dict) -> dict:
post = {**data, "id": self._next_id}
self._store[self._next_id] = post
self._next_id += 1
return post
def update(self, post_id: int, data: dict) -> dict | None:
if post_id not in self._store:
return None
self._store[post_id].update(data)
return self._store[post_id]
def delete(self, post_id: int) -> bool:
return self._store.pop(post_id, None) is not None
# ── Route handler accepts the Protocol type ───────────────────────────────────
def get_repo(db = Depends(get_db)) -> PostRepository:
return SQLAlchemyPostRepository(db)
@app.get("/posts/{post_id}")
def get_post(post_id: int, repo: PostRepository = Depends(get_repo)):
post = repo.get_by_id(post_id)
if post is None:
raise HTTPException(404)
return post
# ── Test — inject in-memory repo with no database needed ──────────────────────
def test_get_post():
repo = InMemoryPostRepository()
repo.create({"title": "Test", "published": True})
post = repo.get_by_id(1)
assert post["title"] == "Test" # ✓ no database, no FastAPI app needed
runtime_checkable Protocols
from typing import Protocol, runtime_checkable
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> None: ...
class Circle:
def draw(self): print("O")
class Square:
def draw(self): print("□")
class Triangle:
pass # no draw() method
# isinstance() checks for method presence
print(isinstance(Circle(), Drawable)) # True
print(isinstance(Square(), Drawable)) # True
print(isinstance(Triangle(), Drawable)) # False
# Filter a list to only Drawable objects
shapes = [Circle(), Square(), Triangle(), Circle()]
drawables = [s for s in shapes if isinstance(s, Drawable)]
for d in drawables:
d.draw() # O, □, O
Common Mistakes
Mistake 1 — Using abstract base classes where a Protocol is cleaner
❌ Wrong — requires inheritance from abstract class:
from abc import ABC, abstractmethod
class Repository(ABC):
@abstractmethod
def get(self, id: int): ...
class MyRepo(Repository): # MUST inherit — couples implementation to ABC
def get(self, id: int): ...
✅ Better — Protocol: no inheritance needed:
class Repository(Protocol):
def get(self, id: int) -> dict | None: ...
class MyRepo: # satisfies Protocol without inheriting ✓
def get(self, id: int) -> dict | None: ...
Mistake 2 — Expecting runtime_checkable to check method signatures
❌ Wrong — isinstance only checks method names, not types:
@runtime_checkable
class Repo(Protocol):
def get(self, id: int) -> dict | None: ...
class Wrong:
def get(self): # wrong signature — but isinstance still returns True!
return "broken"
isinstance(Wrong(), Repo) # True — method name exists, signature not checked!
✅ Correct — use mypy for full signature verification.
Mistake 3 — Defining Protocols too narrowly
❌ Wrong — Protocol requires too many methods, limits useful implementations:
class EmailSender(Protocol):
def send(self, to, subject, body): ...
def send_html(self, to, subject, html_body): ...
def send_attachment(self, to, subject, body, file_path): ...
# Testing requires implementing ALL of these
✅ Correct — define narrow, focused Protocols:
class EmailSender(Protocol):
def send(self, to: str, subject: str, body: str) -> None: ...
# ✓ test double only needs to implement send()
Quick Reference
| Concept | Code |
|---|---|
| Define Protocol | class MyProto(Protocol): def method(self): ... |
| Runtime checkable | @runtime_checkable class MyProto(Protocol): |
| isinstance check | isinstance(obj, MyProto) (needs @runtime_checkable) |
| Accept Protocol type | def f(repo: PostRepository): ... |
| Satisfy without inheriting | Just implement the required methods |
| FastAPI DI with Protocol | Return concrete type from Depends(), type-hint with Protocol |