WEBBYFOX-OS PATH /blog/type-hints-django-generic-models/ DOC 19 MIN READ NODE LDN-01
FEED ACTIVE 13:42 BST
./post · type-hints-django-generic-models.md
READ
// ARTICLE · MAY 22, 2026

Type Hints in Django: From Optional to Generic Django Models

Eight years after PEP 484 landed, Django typing has finally crossed the line from "technically possible" to genuinely pleasant. With Django 5.x, Python 3.13+, the latest django-stubs, and PEP 695's clean generic syntax, you can write type-safe ORM code that actually feels Pythonic. This is the journey from your first Optional[int] annotation to writing a fully generic Django Model, plus every pattern in between — typed QuerySets, closed TypedDicts, the Self type, generic CRUD services, type-safe JSONFields, and a complete typed app you can copy.

DjangoPythonType HintsMypy
· ~19 min read · Rizwan Mansuri
$ cat ./type-hints-django-generic-models.md
READ

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 read article.title at runtime you get a str. A naive type checker sees the assignment and concludes the attribute is CharField, not str.
  • Lazy, queryset-quacking managers. Article.objects looks like a Manager but behaves like a QuerySet. 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 because ForeignKey's contribute-to-class wires up a RelatedManager attribute on the parent model — but the User class definition contains no articles attribute 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 / EmailField with null=False (default) hold an empty str, not None, when no value is set. Type as str. Do not add | None.
  • Same fields with null=True can hold either a string or None. Type as str | 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 importing typing.List, typing.Dict, typing.Tuple, typing.Set — these aliases are removed in Python 3.15.
  • PEP 604 (Python 3.10) — union syntax with |. Use int | str instead of Union[int, str] and X | None instead of Optional[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 | None is fine if Author is defined above; otherwise write "Author" | None.
  • Django's from __future__ import annotations is no longer recommended. It defers all annotation evaluation to strings, which breaks the runtime introspection django-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 default None doesn't magically become X | None.
  • No untyped def calls. Calling a function that has no annotations is itself an error — so an unannotated dependency poisons every caller.
  • No Any returns. Functions that return Any get 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:

  1. Install the toolchain. Run mypy with strict = false. Note the count.
  2. Add disallow_untyped_defs = true only. Annotate everything that fails. Commit.
  3. Add no_implicit_optional = true. Fix. Commit.
  4. Tighten one flag per week until strict = true is 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 using TypedDict as 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 TypedDict is a plain dict at 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 of Model." 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 either int or str but never any other type." Rarely needed in Django code.
  • PEP 696 (Python 3.13) adds defaults. class CrudService[T: Model = Article]: says T defaults to Article if 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 generic Field.
  • PEP 800 disjoint_base — declare mixin bases that must never be combined. Useful for enforcing "either a SoftDeleteMixin or a HardDeleteMixin, not both." Already accepted; landing in typing_extensions now, 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-stubs as a separate package.

16. Production checklist

  1. ✅ Replace every Optional[X] with X | None and every typing.List/Dict/Tuple with the builtin.
  2. ✅ Add django-stubs[compatible-mypy] and point the plugin at your settings module.
  3. ✅ Type custom managers as Manager["Model"] and custom querysets as QuerySet["Model"], with method bridges on the manager.
  4. ✅ Declare objects: MyManager = MyManager() with the annotation, not just an assignment.
  5. ✅ Switch to TypedDict(closed=True) for every API payload, settings section, and JSONField schema.
  6. ✅ Adopt PEP 695 syntax (def f[T](…), class C[T]) for any new generic code.
  7. ✅ Use Self for chainable methods on QuerySets and mixins.
  8. ✅ Use Protocol for "anything that has shape X" parameters.
  9. ✅ Validate every JSONField with either a closed TypedDict or a Pydantic-backed field — never leave it as raw dict.
  10. ✅ Gate PRs with mypy . --strict in CI. No new Any introduced. No new # type: ignore without a comment explaining why.
  11. ✅ Audit annually for cast() usage — refactor toward zero.
  12. ✅ Keep strict_settings = true in django-stubs config 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.

$ ls ./related/
RELATED
$ cd ../ · · Rizwan Mansuri ↗ RSS feed · ↑ top