# How to configure mypy and django-stubs in a uv project


Run [mypy](https://pydevtools.com/handbook/reference/mypy.md) 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](https://pydevtools.com/handbook/reference/uv.md) and configures everything from a single [`pyproject.toml`](https://pydevtools.com/handbook/reference/pyproject.toml.md), 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](https://pydevtools.com/handbook/tutorial/set-up-a-django-project-with-uv.md) 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:

```bash
uv add --dev "django-stubs[compatible-mypy]"
```

uv resolves a compatible set and writes them to the `dev` group in `pyproject.toml`:

```console
$ 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:

```toml
[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:

```console
$ 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:

```python {filename="mysite_config/settings.py"}
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

```bash
uv run mypy polls/
```

A clean run prints only the line count:

```console
$ 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:

```python {filename="polls/views.py"}
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:

```console
$ 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:

```toml
[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:

```toml
[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:

```toml
[tool.mypy]
strict = true
plugins = ["mypy_django_plugin.main"]

[[tool.mypy.overrides]]
module = ["polls.legacy_helpers"]
ignore_errors = true
```

For 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](https://pydevtools.com/handbook/how-to/how-to-gradually-adopt-type-checking-in-an-existing-python-project.md).

## 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:

```toml
[[tool.mypy.overrides]]
module = ["*.migrations.*", "*.tests.*"]
ignore_errors = true
```

The 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:

```console
error: Cannot find implementation or library stub for module named "rest_framework"  [import-not-found]
```

Two paths forward:

1. **Install community stubs.** Several popular packages have separate stub distributions on PyPI. Examples: `djangorestframework-stubs[compatible-mypy]` for DRF, `django-filter-stubs` for django-filter. Add them as dev dependencies the same way as `django-stubs`:

   ```bash
   uv add --dev "djangorestframework-stubs[compatible-mypy]"
   ```

2. **Silence missing imports per package.** When no stubs exist, tell mypy to treat the import as `Any`:

   ```toml
   [[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:

```bash
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](https://pydevtools.com/handbook/tutorial/setting-up-github-actions-with-uv.md) for a workflow that wires those checks into every push.

## Learn More

- [mypy reference](https://pydevtools.com/handbook/reference/mypy.md)
- [How to configure mypy strict mode](https://pydevtools.com/handbook/how-to/how-to-configure-mypy-strict-mode.md)
- [How to gradually adopt type checking in an existing Python project](https://pydevtools.com/handbook/how-to/how-to-gradually-adopt-type-checking-in-an-existing-python-project.md)
- [How to migrate from mypy to ty](https://pydevtools.com/handbook/how-to/how-to-migrate-from-mypy-to-ty.md)
- [How to configure Ruff for Django](https://pydevtools.com/handbook/how-to/how-to-configure-ruff-for-django.md)
- [Set up a Django project with uv](https://pydevtools.com/handbook/tutorial/set-up-a-django-project-with-uv.md)
- [django-stubs on GitHub](https://github.com/typeddjango/django-stubs)
- [mypy plugin reference for django-stubs](https://github.com/typeddjango/django-stubs#configuration)
