How to configure mypy and django-stubs in a uv project
Run mypy on a Django project without django-stubs and the first error is Need type annotation for "name" on every model field. Mypy cannot infer that models.CharField(max_length=200) resolves to str or that Question.objects.filter() returns a QuerySet[Question]. The django-stubs package and its bundled mypy plugin fill those gaps.
This guide installs both with uv and configures everything from a single pyproject.toml, with an override pattern for adopting type checking gradually on a legacy Django codebase.
This guide assumes a working uv-managed Django project. Follow Set up a Django project with uv first if there isn’t one yet.
Install django-stubs and mypy
django-stubs ships its own mypy version pin via the compatible-mypy extra. Install both as dev dependencies in one command:
uv add --dev "django-stubs[compatible-mypy]"uv resolves a compatible set and writes them to the dev group in pyproject.toml:
$ uv add --dev "django-stubs[compatible-mypy]"
Resolved 13 packages in 114ms
Installed 8 packages in 67ms
+ django-stubs==6.0.3
+ django-stubs-ext==6.0.3
+ mypy==1.20.2
+ mypy-extensions==1.1.0
+ typing-extensions==4.15.0
...
The exact versions vary. This guide was verified with django-stubs 6.0.3 and mypy 1.20.2.
The compatible-mypy extra is not optional. django-stubs 6.x pins a mypy range (currently >=1.13,<1.21); install django-stubs without the extra and a newer or older mypy will produce plugin errors at startup.
django-stubs-ext arrived alongside as a transitive dependency. It contains the runtime monkey-patching django-stubs cannot do from stub files alone, such as making generic Django classes like QuerySet[Question] subscriptable at runtime.
Register the Django mypy plugin
Add two blocks to pyproject.toml. The first turns on the plugin; the second tells the plugin where the project’s settings module lives:
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
[tool.django-stubs]
django_settings_module = "mysite_config.settings"Replace mysite_config.settings with the dotted path to the settings module the project uses; that is whatever manage.py sets as DJANGO_SETTINGS_MODULE. Without [tool.django-stubs] the plugin falls back to the DJANGO_SETTINGS_MODULE environment variable, which is brittle in CI; setting it in pyproject.toml keeps the configuration in one place.
pyproject.toml is the only file needed. mypy.ini and setup.cfg work too, but splitting Django plugin configuration across two files is a recipe for one falling out of sync.
Annotate ALLOWED_HOSTS in settings.py
django-admin startproject writes ALLOWED_HOSTS = [] into settings.py. With the plugin enabled, mypy infers that as list[<nothing>] and reports:
$ uv run mypy mysite_config/
mysite_config/settings.py:28: error: Need type annotation for "ALLOWED_HOSTS" (hint: "ALLOWED_HOSTS: list[<type>] = ...") [var-annotated]
Found 1 error in 1 file (checked 1 source file)
Edit settings.py and annotate the empty list:
ALLOWED_HOSTS: list[str] = []ALLOWED_HOSTS is the only bare collection a Django 6.0 startproject leaves behind. Custom project templates may add others; the same : list[T] = [] annotation pattern applies.
Run mypy and confirm Django-aware types work
uv run mypy polls/A clean run prints only the line count:
$ uv run mypy polls/
Success: no issues found in 6 source files
To confirm the plugin is doing its job, plant a deliberate type error that only the plugin can catch:
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from polls.models import Question
def index(request: HttpRequest) -> HttpResponse:
q: Question = Question.objects.filter(pk=1) # bug: .filter() returns QuerySet
return render(request, "polls/index.html", {"q": q})Run mypy:
$ uv run mypy polls/views.py
polls/views.py:8: error: Incompatible types in assignment (expression has type "QuerySet[Question, Question]", variable has type "Question") [assignment]
Found 1 error in 1 file (checked 1 source file)
The error message names QuerySet[Question, Question] because django-stubs knows that Manager.filter() returns a queryset of the model, not a single instance. Without the plugin, Question.objects resolves to Any and the bug ships unnoticed. The same Django-aware typing covers Manager.get()/QuerySet.filter() return values and reverse foreign-key accessors like question.choice_set.
Adopt type checking gradually
A Django project that has been running in production for two years rarely passes a strict mypy run on the first attempt. The practical path is to leave most of the codebase under loose checking, and apply stricter rules to the modules that already have annotations or are being actively rewritten.
Start with a baseline that just enables the plugin:
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
[tool.django-stubs]
django_settings_module = "mysite_config.settings"Then add per-module overrides for the parts of the codebase that should hold a higher standard. Mypy’s [[tool.mypy.overrides]] arrays let each section target a glob of modules and apply different flags:
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
# Strict checking on the modules that are ready for it.
[[tool.mypy.overrides]]
module = ["polls.views", "polls.models", "polls.serializers"]
disallow_untyped_defs = true
warn_return_any = true
# Silence errors in legacy modules that haven't been annotated yet.
[[tool.mypy.overrides]]
module = ["polls.legacy_helpers", "polls.old_management"]
ignore_errors = true
[tool.django-stubs]
django_settings_module = "mysite_config.settings"ignore_errors = true skips the listed modules entirely while still letting them be imported by checked code. As legacy modules get annotated, move them out of the ignore list and into the strict list.
Some larger Django codebases invert the pattern once most code is typed. Set strict = true at the top level and list only the holdouts:
[tool.mypy]
strict = true
plugins = ["mypy_django_plugin.main"]
[[tool.mypy.overrides]]
module = ["polls.legacy_helpers"]
ignore_errors = trueFor the deeper rationale and an end-to-end walkthrough of the codemod and per-file # type: ignore patterns, see How to gradually adopt type checking in an existing Python project.
Handle migrations and tests
Migrations are auto-generated and rarely worth type-checking. Tests often use assert and runtime patching that strict mypy flags. Carve them out with overrides:
[[tool.mypy.overrides]]
module = ["*.migrations.*", "*.tests.*"]
ignore_errors = trueThe patterns match every migrations/ and tests/ (or tests.py) module under every app. Drop *.tests.* if the project’s tests are already well-typed and you want them held to the same bar as the rest of the code.
Install stubs for third-party Django packages
Django itself is covered by django-stubs, but third-party Django packages like Django REST Framework and django-filter ship without type information. Mypy reports:
error: Cannot find implementation or library stub for module named "rest_framework" [import-not-found]
Two paths forward:
-
Install community stubs. Several popular packages have separate stub distributions on PyPI. Examples:
djangorestframework-stubs[compatible-mypy]for DRF,django-filter-stubsfor django-filter. Add them as dev dependencies the same way asdjango-stubs:uv add --dev "djangorestframework-stubs[compatible-mypy]" -
Silence missing imports per package. When no stubs exist, tell mypy to treat the import as
Any:[[tool.mypy.overrides]] module = ["some_untyped_django_app.*"] ignore_missing_imports = true
Always check PyPI for a *-stubs package before suppressing. Real types beat Any even when the stubs are partial.
Run mypy in CI
Once mypy passes locally, add it to CI so type errors block merges. The same configuration in pyproject.toml applies; only the invocation changes:
uv run mypy .The command runs alongside uv run pytest and uv run ruff check . in the same CI job. See Setting up GitHub Actions with uv for a workflow that wires those checks into every push.