1. Why typing Django used to feel like Stockholm Syndrome
Django predates Python's type system by more than a decade, and the framework is built on patterns that the early type system simply could not express. Three things have historically made it painful:
- Descriptor-driven fields.
title = models.CharField(...)assigns a CharField instance at class-definition time, but when you readarticle.titleat runtime you get astr. A naive type checker sees the assignment and concludes the attribute isCharField, notstr. - Lazy, queryset-quacking managers.
Article.objectslooks like aManagerbut behaves like aQuerySet. Chain it:Article.objects.filter().exclude().order_by()— every step returns a slightly different runtime object than what the class declaration suggests. - Reverse relations conjured by metaclass magic.
user.articles.all()works at runtime becauseForeignKey's contribute-to-class wires up aRelatedManagerattribute on the parent model — but theUserclass definition contains noarticlesattribute for a checker to see.
The fix for all three is the same: a plugin. mypy_django_plugin (and the parallel pyright stubs in django-stubs) inspects field declarations, manager chains, and FK reverse relations at type-check time and synthesises the right types. Without the plugin, you are fighting the framework. With it, ~95% of the ORM type-checks cleanly out of the box.
2. The Optional trap — null=True is not what you think it is
The single most common Django typing bug is conflating nullable with optional. Consider:
class Article(models.Model):
title: str = models.CharField(max_length=200)
subtitle: str | None = models.CharField(max_length=200, null=True, blank=True)
body: str = models.TextField(default="")
word_count: int | None = models.IntegerField(null=True)
published_at: datetime | None = models.DateTimeField(null=True)
slug: str = models.SlugField(unique=True)
Three rules cover almost every case:
CharField/TextField/SlugField/EmailFieldwithnull=False(default) hold an emptystr, notNone, when no value is set. Type asstr. Do not add| None.- Same fields with
null=Truecan hold either a string orNone. Type asstr | None. - Every other field (
IntegerField,FloatField,DateTimeField,BooleanField,JSONField, FKs, etc.) follows the obvious rule:null=True⇒ add| None;null=False⇒ don't.
The django-stubs plugin enforces this automatically — it inspects null= at type-check time. If your hand-written annotation disagrees with the plugin's inferred type, mypy will tell you. Lean on it.
3. Goodbye typing.Optional, hello X | None
Two PEPs killed off a generation of imports:
- PEP 585 (Python 3.9) — collections are subscriptable directly. Use
list[int],dict[str, int],tuple[int, ...],set[str]. Stop importingtyping.List,typing.Dict,typing.Tuple,typing.Set— these aliases are removed in Python 3.15. - PEP 604 (Python 3.10) — union syntax with
|. Useint | strinstead ofUnion[int, str]andX | Noneinstead ofOptional[X].
# Before (Python 3.8 and earlier)
from typing import Optional, List, Dict, Union
def fetch_articles(
author_id: Optional[int] = None,
tags: Optional[List[str]] = None,
) -> Dict[str, Union[int, str]]:
...
# After (Python 3.10+ — what you should be writing in 2026)
def fetch_articles(
author_id: int | None = None,
tags: list[str] | None = None,
) -> dict[str, int | str]:
...
Two practical notes on the migration:
- Forward references still need string quotes when you reference a class defined later in the same file:
Author | Noneis fine ifAuthoris defined above; otherwise write"Author" | None. - Django's
from __future__ import annotationsis no longer recommended. It defers all annotation evaluation to strings, which breaks the runtime introspectiondjango-stubs's plugin and Django itself rely on (Django reads field annotations to wire descriptors). On Python 3.14+, lazy annotation evaluation (PEP 649) handles forward references natively, so the import is no longer needed. Strip it; quote individual forward refs as"Article"when required.
A grep to surface what needs replacing:
grep -rnE "from typing import.*(Optional|List|Dict|Tuple|Set|Union|FrozenSet)" \
--include="*.py" .
# Bulk replace Optional[X] -> X | None with ruff
ruff check --fix --select UP007,UP045 .
4. The 2026 toolchain — mypy, django-stubs, and pyright
A minimal but production-grade setup:
# pyproject.toml
[project.optional-dependencies]
typing = [
"django-stubs[compatible-mypy]>=5.1",
"djangorestframework-stubs>=3.15",
"celery-types>=0.22",
"types-redis",
"types-requests",
]
[tool.mypy]
python_version = "3.13"
plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"]
strict = true
show_error_codes = true
warn_unused_ignores = true
disallow_untyped_decorators = true
disallow_any_generics = true
implicit_reexport = false
[[tool.mypy.overrides]]
module = "*.migrations.*"
ignore_errors = true
[tool.django-stubs]
django_settings_module = "config.settings"
strict_settings = true
What strict = true turns on, in plain English:
- Every function must be annotated. No more silently-untyped helpers.
- No implicit
Optional. A parameter with defaultNonedoesn't magically becomeX | None. - No untyped
defcalls. Calling a function that has no annotations is itself an error — so an unannotated dependency poisons every caller. - No
Anyreturns. Functions that returnAnyget flagged unless explicitly opted in.
If you are starting from zero, do not turn strict on day one — your build will scream at you for hours. The pragmatic path:
- Install the toolchain. Run mypy with
strict = false. Note the count. - Add
disallow_untyped_defs = trueonly. Annotate everything that fails. Commit. - Add
no_implicit_optional = true. Fix. Commit. - Tighten one flag per week until
strict = trueis achievable. CI gate it.
What about pyright / basedpyright? They are dramatically faster than mypy on large codebases and ship with VS Code's Pylance. The catch: pyright doesn't load mypy plugins, so it falls back to whatever stubs django-stubs ships statically. For Django code, the practical answer in 2026 is to run both — mypy in CI for full plugin coverage, pyright in the editor for instant feedback. The annotations you write satisfy both.
5. Typing the ORM — Managers, QuerySets, and reverse relations
The biggest payoff from django-stubs is end-to-end ORM typing. Once configured, this just works:
from django.db.models import QuerySet
def published_articles() -> QuerySet[Article]:
return Article.objects.filter(status="published").order_by("-published_at")
def latest_for_author(author_id: int, limit: int = 10) -> list[Article]:
return list(published_articles().filter(author_id=author_id)[:limit])
Custom managers — the one pattern everyone gets wrong
Many tutorials still recommend ArticleManager = ArticleQuerySet.as_manager(). It's tidy at runtime — and a typing dead end. as_manager() returns a dynamic class that no static checker can see through. Write the pair explicitly:
from typing import Self
class ArticleQuerySet(models.QuerySet["Article"]):
def published(self) -> Self:
return self.filter(status="published")
def for_author(self, author: User) -> Self:
return self.filter(author=author)
def trending(self, days: int = 7) -> Self:
cutoff = timezone.now() - timedelta(days=days)
return self.filter(published_at__gte=cutoff).order_by("-view_count")
class ArticleManager(models.Manager["Article"]):
def get_queryset(self) -> ArticleQuerySet:
return ArticleQuerySet(self.model, using=self._db)
# Bridge the chain methods so Article.objects.published() type-checks
def published(self) -> ArticleQuerySet: return self.get_queryset().published()
def for_author(self, author: User) -> ArticleQuerySet: return self.get_queryset().for_author(author)
def trending(self, days: int = 7) -> ArticleQuerySet: return self.get_queryset().trending(days)
class Article(models.Model):
title: str = models.CharField(max_length=200)
status: str = models.CharField(max_length=20, default="draft")
objects: ArticleManager = ArticleManager()
Yes, it's more typing (the keyboard kind). The payoff: every call site of Article.objects.published().for_author(user).trending() is statically checked, autocompletes properly, and refactors cleanly.
Reverse relations with proper hinting
The plugin synthesises reverse-relation attributes on the parent model — but only if you give it a hint. The canonical pattern uses a TYPE_CHECKING block:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from django.db.models.manager import RelatedManager
from articles.models import Article, Comment
class User(AbstractUser):
# …regular fields here…
if TYPE_CHECKING:
articles: RelatedManager[Article]
comments: RelatedManager[Comment]
The plugin will fill these in for you in most cases, but explicit declarations make IDE autocompletion work even without the plugin loaded, and they document the relation graph in the model file.
FK forward access — author vs author_id
A ForeignKey creates two runtime attributes on the instance: the related object (lazy-loaded, may trigger a query) and the integer/UUID FK column. Both should be in the type picture:
class Article(models.Model):
author: User = models.ForeignKey(User, on_delete=models.CASCADE,
related_name="articles")
author_id: int # synthesised by the plugin from the FK above
editor: User | None = models.ForeignKey(User, null=True, blank=True,
related_name="edited_articles",
on_delete=models.SET_NULL)
editor_id: int | None
6. TypedDict — the unsung typing hero
TypedDict shines anywhere you handle structured JSON-ish payloads: view responses, form data, API requests, settings sections, JSONField shapes, prompt templates. Three features have made it production-grade between 2023 and 2026:
NotRequired/Required(PEP 655, Python 3.11) — mark individual keys as optional within an otherwise-required dict.ReadOnly(PEP 705, Python 3.13) — declare keys that callers may inspect but must not mutate.closed=True(PEP 728, Python 3.15) — reject extra keys at type-check time. This was the long-missing piece for usingTypedDictas a real schema.
from typing import TypedDict, NotRequired, ReadOnly
class ArticleResponse(TypedDict, closed=True):
id: ReadOnly[int]
title: str
body: str
author_id: int
tags: list[str]
published_at: str | None
word_count: NotRequired[int]
def article_to_dict(article: Article) -> ArticleResponse:
return {
"id": article.pk,
"title": article.title,
"body": article.body,
"author_id": article.author_id,
"tags": list(article.tags.values_list("slug", flat=True)),
"published_at": article.published_at.isoformat() if article.published_at else None,
# word_count is NotRequired — omit safely
}
Two non-obvious benefits over dataclass:
- Zero runtime overhead. A
TypedDictis a plaindictat runtime. No instance construction, no validators, no__init__. JSON dumps and template rendering see a normal mapping. - Drop-in JSON.
json.loads()returns a value you can cast directly to your TypedDict — the schema you assert is the schema your API contract documents.
TypedDict for Django settings sections
One of the highest-leverage uses I've found in real projects — typed accessors for settings blocks:
# config/settings_types.py
from typing import TypedDict, NotRequired
class AISettings(TypedDict, closed=True):
ANTHROPIC_API_KEY: str
DEFAULT_MODEL: str
DAILY_TOKEN_BUDGET: int
PROMPT_CACHE_TTL_SEC: NotRequired[int]
class CacheSettings(TypedDict, closed=True):
BACKEND: str
LOCATION: str
KEY_PREFIX: str
TIMEOUT_SEC: int
SOCKET_TIMEOUT: NotRequired[float]
# config/settings.py
AI: AISettings = { "ANTHROPIC_API_KEY": env("ANTHROPIC_API_KEY"),
"DEFAULT_MODEL": "claude-sonnet-4-6",
"DAILY_TOKEN_BUDGET": 5_000_000 }
CACHE: CacheSettings = { "BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": env("REDIS_URL"),
"KEY_PREFIX": "myapp",
"TIMEOUT_SEC": 300 }
Now settings.AI["DAILY_TOKEN_BUDGET"] type-checks, autocompletes, and refuses typos. Pair with django-stubs' strict_settings = true and you catch missing keys at boot.
7. Self — the right way to type fluent interfaces
Before PEP 673 (Python 3.11), typing a chainable method that returns the same class was an annoying dance with TypeVar. Now:
from typing import Self
class FilterableMixin:
def for_user(self, user: User) -> Self:
return self.filter(author=user) # type: ignore[attr-defined]
def active(self) -> Self:
return self.filter(is_active=True) # type: ignore[attr-defined]
class ArticleQuerySet(FilterableMixin, models.QuerySet["Article"]):
def published(self) -> Self:
return self.filter(status="published")
The Self annotation does the right thing through inheritance: when a subclass calls for_user(), the return type is the subclass, not FilterableMixin. That means ArticleQuerySet().for_user(u).published() type-checks cleanly because the first call returns ArticleQuerySet, which has .published().
8. PEP 695 — Python's cleaner generic syntax
Python 3.12 introduced inline generic syntax — finally, generics that read like Java/TypeScript rather than a homework assignment in functional programming. The old way:
from typing import TypeVar, Generic
T = TypeVar("T")
M = TypeVar("M", bound="Model")
def first(xs: list[T]) -> T | None:
return xs[0] if xs else None
class Repository(Generic[M]):
def __init__(self, model_cls: type[M]) -> None:
self.model_cls = model_cls
The PEP 695 way (no imports required for the type parameter):
def first[T](xs: list[T]) -> T | None:
return xs[0] if xs else None
class Repository[M: Model]:
def __init__(self, model_cls: type[M]) -> None:
self.model_cls = model_cls
def all(self) -> QuerySet[M]:
return self.model_cls.objects.all()
def by_id(self, pk: int) -> M | None:
return self.model_cls.objects.filter(pk=pk).first()
# Usage — fully inferred
articles = Repository(Article) # Repository[Article]
maybe = articles.by_id(42) # Article | None
Three things to know about the new syntax:
- The bound goes after the colon.
[M: Model]means "M is some subclass ofModel." Use this freely for ORM helpers — it's what unlocks the generic patterns later in this post. - Constraints use a tuple.
[T: (int, str)]means "T is eitherintorstrbut never any other type." Rarely needed in Django code. - PEP 696 (Python 3.13) adds defaults.
class CrudService[T: Model = Article]:says T defaults toArticleif the caller doesn't specialise. Useful for libraries.
9. Generic class-based views with proper inference
Django CBVs have always been "generic" in spirit. With django-stubs they're now generic in the type-system sense, too:
from django.views.generic import ListView, DetailView, UpdateView
class ArticleListView(ListView[Article]):
model = Article
template_name = "articles/list.html"
context_object_name = "articles"
paginate_by = 20
def get_queryset(self) -> QuerySet[Article]:
return Article.objects.published().for_author(self.request.user)
class ArticleDetailView(DetailView[Article]):
model = Article
template_name = "articles/detail.html"
slug_field = "slug"
class ArticleUpdateView(UpdateView[Article]):
model = Article
fields = ["title", "body", "status"]
template_name = "articles/edit.html"
With the type parameter declared, self.object, self.get_object(), the context-data dict, and the form_class slot are all inferred as the right model type. Override get_context_data and your IDE will know what context["article"] is.
10. Custom generic Django models — the centrepiece
This is the pattern the post title is teasing: writing a Model subclass that takes a type parameter and surfaces typed access to it across the ORM. Three real-world recipes follow.
Recipe A — a generic abstract base for "wrapper" models
Some apps need a polymorphic wrapper — a single Django model that contains a reference to one of many target models. Audit logs, queued jobs, reaction/like rows, attachments. Without typing, you write GenericForeignKey and lose all type safety. With PEP 695 generics:
from typing import get_args
from django.db import models
class TargetedRecord[T: models.Model](models.Model):
"""Abstract base for rows that wrap a single target model instance."""
target_id: int = models.PositiveBigIntegerField(db_index=True)
created_at: datetime = models.DateTimeField(auto_now_add=True)
class Meta:
abstract = True
@classmethod
def target_model(cls) -> type[T]:
"""Return the type T was parameterised with."""
# cls.__orig_bases__ contains the parameterised generic base.
for base in cls.__orig_bases__: # type: ignore[attr-defined]
args = get_args(base)
if args:
return args[0]
raise TypeError(f"{cls.__name__} did not specialise its type parameter")
@property
def target(self) -> T:
return self.target_model().objects.get(pk=self.target_id)
# Concrete child — specialises T to Article
class ArticleAuditLog(TargetedRecord[Article]):
action: str = models.CharField(max_length=20)
actor_id: int = models.PositiveBigIntegerField()
class Meta:
db_table = "article_audit_log"
At a call site, log.target is statically known to be an Article — no cast(), no # type: ignore. That's the win.
Recipe B — a generic CRUD service
The classic use case. A reusable service that performs the standard four operations on any Django model, with the result types inferred:
from django.db import models, transaction
class CrudService[T: models.Model]:
def __init__(self, model_cls: type[T]) -> None:
self.model_cls = model_cls
def create(self, **fields: object) -> T:
return self.model_cls.objects.create(**fields)
def get(self, pk: int) -> T:
return self.model_cls.objects.get(pk=pk)
def get_or_none(self, pk: int) -> T | None:
return self.model_cls.objects.filter(pk=pk).first()
@transaction.atomic
def update(self, pk: int, **fields: object) -> T:
obj = self.model_cls.objects.select_for_update().get(pk=pk)
for k, v in fields.items():
setattr(obj, k, v)
obj.save(update_fields=list(fields))
return obj
def delete(self, pk: int) -> int:
deleted, _ = self.model_cls.objects.filter(pk=pk).delete()
return deleted
# Type inference — no annotations needed at call sites
articles = CrudService(Article)
new_article: Article = articles.create(title="Type Hints", body="…")
maybe: Article | None = articles.get_or_none(42)
deleted_count: int = articles.delete(7)
Recipe C — a generic queryset mixin
Reuse query logic across models with the same shape — soft-deletable, timestamped, owned-by-user:
class TimestampedQuerySet[M: models.Model](models.QuerySet[M]):
def created_after(self, dt: datetime) -> Self:
return self.filter(created_at__gt=dt)
def created_before(self, dt: datetime) -> Self:
return self.filter(created_at__lt=dt)
def stale(self, days: int = 30) -> Self:
cutoff = timezone.now() - timedelta(days=days)
return self.filter(updated_at__lt=cutoff)
class SoftDeleteQuerySet[M: models.Model](models.QuerySet[M]):
def alive(self) -> Self: return self.filter(deleted_at__isnull=True)
def deleted(self) -> Self: return self.filter(deleted_at__isnull=False)
# Compose for a specific model
class ArticleQuerySet(SoftDeleteQuerySet["Article"], TimestampedQuerySet["Article"]):
def published(self) -> Self:
return self.alive().filter(status="published")
11. Type-safe JSONField — two production patterns
JSONField is the eternal Pandora's box of Django typing: it accepts anything, returns anything, and silently rots when your schema evolves. Two patterns make it tractable.
Pattern A — TypedDict schema, plain JSONField
Fastest to adopt, zero runtime cost. The trick: declare the field type as your TypedDict rather than as dict.
from typing import TypedDict, NotRequired
class ArticleMetadata(TypedDict, closed=True):
seo_title: str
seo_description: str
canonical_url: NotRequired[str]
open_graph: NotRequired[dict[str, str]]
indexed_at: NotRequired[str]
class Article(models.Model):
metadata: ArticleMetadata = models.JSONField(default=dict)
# Usage — fully typed
article = Article.objects.get(slug="my-article")
seo: str = article.metadata["seo_title"] # checked
extra = article.metadata["unknown"] # error: extra key not in closed TypedDict
The runtime payload is still a normal dict, so article.metadata.get("seo_title") works and JSON serialisation is unaffected. The static checker enforces the schema; the runtime trusts it. Validate at the boundaries (incoming requests, deserialised payloads) if you don't fully trust your data sources.
Pattern B — Pydantic-backed JSONField
Use this when you actually want runtime validation in addition to static typing — the moment data crosses an untrusted boundary, before it lands in the DB, the model itself rejects bad shapes.
import json
from typing import Any
from pydantic import BaseModel, Field, HttpUrl
from django.db import models
class ArticleMetadata(BaseModel):
# Every field has a default so ArticleMetadata() yields a valid instance —
# which lets us pass the class itself as the JSONField factory below.
seo_title: str = ""
seo_description: str = ""
canonical_url: HttpUrl | None = None
open_graph: dict[str, str] = Field(default_factory=dict)
class PydanticJSONField[M: BaseModel](models.JSONField):
def __init__(self, model_class: type[M], **kwargs: Any) -> None:
self.model_class = model_class
super().__init__(**kwargs)
def from_db_value(self, value, expression, connection) -> M | None:
if value is None:
return None
if isinstance(value, str):
return self.model_class.model_validate_json(value)
return self.model_class.model_validate(value)
def to_python(self, value) -> M | None:
if value is None or isinstance(value, self.model_class):
return value
return self.from_db_value(value, None, None)
def get_prep_value(self, value) -> str | None:
if value is None:
return None
if isinstance(value, self.model_class):
return value.model_dump_json()
return json.dumps(value)
# Usage — `default=ArticleMetadata` calls the class as a factory on insert,
# producing a fully-defaulted instance that round-trips cleanly through the field.
class Article(models.Model):
metadata: ArticleMetadata = PydanticJSONField(ArticleMetadata, default=ArticleMetadata)
Now article.metadata.seo_title autocompletes, and an attempt to save with metadata={"seo_title": 42} raises ValidationError at write time, not three weeks later in a downstream report.
12. Protocol — duck typing meets the type system
Django code is full of "anything that looks like X" patterns: an object with a save() method, a manager-ish thing, a request-shaped object. Structural subtyping (Protocol) describes these without forcing inheritance.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Saveable(Protocol):
def save(self, *, update_fields: list[str] | None = None) -> None: ...
def bulk_touch(items: list[Saveable]) -> None:
for it in items:
it.save(update_fields=["updated_at"])
# Works with any model — and isinstance(obj, Saveable) works too
bulk_touch([Article(...), Comment(...), User(...)])
In Django code I reach for Protocol in three places: describing the shape of a request object in a helper that runs both under DRF and plain Django views, describing the "thing that has a pk and a save()" interface in generic write paths, and describing the third-party-vendor SDK shape in pluggable backends.
13. A complete typed Django app, end-to-end
Putting it all together — a minimal but production-shaped articles app with strict typing throughout:
# articles/models.py
from datetime import datetime, timedelta
from typing import Self, TypedDict, NotRequired
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
User = get_user_model()
class ArticleMetadata(TypedDict, closed=True):
seo_title: str
seo_description: str
reading_time: NotRequired[int]
class ArticleQuerySet(models.QuerySet["Article"]):
def published(self) -> Self:
return self.filter(status="published",
published_at__lte=timezone.now())
def for_author(self, author_id: int) -> Self:
return self.filter(author_id=author_id)
def recent(self, days: int = 7) -> Self:
cutoff = timezone.now() - timedelta(days=days)
return self.filter(published_at__gte=cutoff)
class ArticleManager(models.Manager["Article"]):
def get_queryset(self) -> ArticleQuerySet:
return ArticleQuerySet(self.model, using=self._db)
def published(self) -> ArticleQuerySet: return self.get_queryset().published()
def for_author(self, author_id: int) -> ArticleQuerySet: return self.get_queryset().for_author(author_id)
def recent(self, days: int = 7) -> ArticleQuerySet: return self.get_queryset().recent(days)
class Article(models.Model):
STATUS_CHOICES: list[tuple[str, str]] = [("draft", "Draft"),
("published", "Published"),
("archived", "Archived")]
title: str = models.CharField(max_length=200)
slug: str = models.SlugField(unique=True)
body: str = models.TextField(default="")
status: str = models.CharField(max_length=20,
choices=STATUS_CHOICES,
default="draft")
author: User = models.ForeignKey(User, on_delete=models.CASCADE,
related_name="articles")
author_id: int # synthesised by django-stubs
published_at: datetime | None = models.DateTimeField(null=True, blank=True)
metadata: ArticleMetadata = models.JSONField(default=dict)
created_at: datetime = models.DateTimeField(auto_now_add=True)
updated_at: datetime = models.DateTimeField(auto_now=True)
objects: ArticleManager = ArticleManager()
class Meta:
ordering = ["-published_at", "-created_at"]
indexes = [models.Index(fields=["status", "published_at"])]
def __str__(self) -> str:
return self.title
# articles/services.py
from django.db import transaction
from .models import Article, ArticleMetadata
class ArticleService:
@transaction.atomic
def publish(self, *, article_id: int, actor_id: int) -> Article:
article = Article.objects.select_for_update().get(pk=article_id)
if article.status == "published":
return article
article.status = "published"
article.published_at = timezone.now()
article.save(update_fields=["status", "published_at"])
return article
def upsert_metadata(self, *, article_id: int,
patch: ArticleMetadata) -> ArticleMetadata:
article = Article.objects.get(pk=article_id)
merged: ArticleMetadata = {**article.metadata, **patch} # type: ignore[typeddict-item]
article.metadata = merged
article.save(update_fields=["metadata"])
return merged
# articles/views.py
from django.http import JsonResponse, HttpRequest, HttpResponse
from django.views.generic import DetailView, ListView
from typing import TypedDict, NotRequired
from .models import Article
class ArticlePayload(TypedDict, closed=True):
id: int
title: str
slug: str
body: str
author_id: int
published_at: str | None
metadata: dict[str, object]
def _serialize(article: Article) -> ArticlePayload:
return {
"id": article.pk,
"title": article.title,
"slug": article.slug,
"body": article.body,
"author_id": article.author_id,
"published_at": article.published_at.isoformat() if article.published_at else None,
"metadata": dict(article.metadata),
}
def article_detail_api(request: HttpRequest, slug: str) -> HttpResponse:
article = Article.objects.published().get(slug=slug)
return JsonResponse(_serialize(article))
class ArticleListView(ListView[Article]):
model = Article
template_name = "articles/list.html"
context_object_name = "articles"
paginate_by = 20
def get_queryset(self) -> "QuerySet[Article]":
return Article.objects.published().recent(30)
class ArticleDetailView(DetailView[Article]):
model = Article
template_name = "articles/detail.html"
slug_field = "slug"
Run mypy articles/ --strict against this. It passes clean — no Any, no cast(), no # type: ignore except for the deliberate dict-merge widening in the service.
14. Common pitfalls and how to fix them
"Cannot determine type of …" from django-stubs
Usually means a custom manager wasn't declared as a class attribute properly, or you forgot to chain through it. Check that you wrote objects: ArticleManager = ArticleManager() (with the annotation), not just objects = ArticleManager().
Migrations don't type-check (and shouldn't)
Auto-generated migrations are snapshots, not living code. They reference models that may no longer exist, contain literal strings for fields, and import in ways the checker doesn't appreciate. The mypy config above excludes them — keep it that way. If a custom migration has real logic, factor that into a separate helper module and type-check the helper.
Any creeping in from third-party code
Most Django ecosystem libraries now ship stubs (DRF, Celery, channels, django-extensions, django-filter). For those that don't, write a minimal .pyi in your repo's stubs/ directory and point mypy at it with MYPYPATH=stubs. Even a one-line stub silences the Any cascade.
Type narrowing with assert_type
A debugging aid added in Python 3.11 — fails the type-check if the inferred type doesn't match. Sprinkle in places where you want a contract to hold:
from typing import assert_type
articles = Article.objects.published()
assert_type(articles, ArticleQuerySet) # passes — chains preserved
assert_type(articles.first(), Article | None) # passes — .first() returns optional
The cast() question
cast(X, value) tells the type-checker "trust me, this is an X" with zero runtime check. It is sometimes the right answer (you've just done an isinstance check the checker can't follow, e.g. across a serialisation boundary). It is more often a smell — you're papering over a real shape problem.
A useful rule: a single cast() per file is fine; three is a smell; ten means the abstraction needs rethinking.
15. What's coming next
The frontier in 2026:
- PEP 747 TypeForm (Python 3.15) — annotate "type-form values" themselves. Will eventually let
Model._meta.get_field("title")return a properly-typed field descriptor rather than a genericField. - PEP 800 disjoint_base — declare mixin bases that must never be combined. Useful for enforcing "either a
SoftDeleteMixinor aHardDeleteMixin, not both." Already accepted; landing intyping_extensionsnow, stdlib in 3.15. - PEP 696 TypeVar defaults (Python 3.13) — letting library authors write
class CrudService[T: Model = Article]so callers can omit the parameter for the common case. - Django itself shipping with type hints. The Django Foundation has gradually been merging types into core (forms, signals, parts of the ORM) since the 4.x line. Realistically, full first-party typing arrives somewhere in the 6.x cycle, finally retiring
django-stubsas a separate package.
16. Production checklist
- ✅ Replace every
Optional[X]withX | Noneand everytyping.List/Dict/Tuplewith the builtin. - ✅ Add
django-stubs[compatible-mypy]and point the plugin at your settings module. - ✅ Type custom managers as
Manager["Model"]and custom querysets asQuerySet["Model"], with method bridges on the manager. - ✅ Declare
objects: MyManager = MyManager()with the annotation, not just an assignment. - ✅ Switch to
TypedDict(closed=True)for every API payload, settings section, and JSONField schema. - ✅ Adopt PEP 695 syntax (
def f[T](…),class C[T]) for any new generic code. - ✅ Use
Selffor chainable methods on QuerySets and mixins. - ✅ Use
Protocolfor "anything that has shape X" parameters. - ✅ Validate every
JSONFieldwith either a closedTypedDictor a Pydantic-backed field — never leave it as rawdict. - ✅ Gate PRs with
mypy . --strictin CI. No newAnyintroduced. No new# type: ignorewithout a comment explaining why. - ✅ Audit annually for
cast()usage — refactor toward zero. - ✅ Keep
strict_settings = trueindjango-stubsconfig so missing settings keys fail at boot, not in production.
Summary
Django typing in 2026 is the rare ergonomic win where the late-arriving solution turns out to be the right one. Optional was a hint; X | None is the spec. Manager was a hope; Manager["Model"] is the contract. JSONField was a trap; closed TypedDicts are the schema. And the PEP 695 generic syntax has finally made writing reusable, type-safe Django abstractions feel as natural as writing a TypeScript class.
If you adopt only three patterns from this post, make them: switch every Optional to | None, type your custom managers explicitly, and lock down JSONField with a closed TypedDict. The first costs an afternoon, the second buys back a class of refactor pain, and the third routinely catches schema-drift bugs that would otherwise surface as customer support tickets.
The rest of the journey — generic CRUD services, generic abstract models, Pydantic-backed fields, the Self type — is the long tail. Adopt those as the use cases come up. They will.