How to Type-Check a Django Project with Pyrefly
Pyrefly, the handbook’s recommended type checker, reads Django models without a plugin. Install django-stubs, and Pyrefly follows those type stubs directly: no mypy_django_plugin equivalent to register, no settings module to point at, no runtime introspection.
This guide installs both with uv, runs Pyrefly against a Django app, shows which ORM features resolve and which do not at Pyrefly 1.1.1, and covers the exclusions and suppressions that make a first run on an existing codebase manageable. It assumes a working uv-managed Django project; follow Set up a Django project with uv first if there isn’t one.
Install Pyrefly and django-stubs
Add the stubs and the checker as dev dependencies in one command:
uv add --dev django-stubs pyreflyuv resolves a compatible set and writes them to the dev group in pyproject.toml:
$ uv add --dev django-stubs pyrefly
Resolved 10 packages in 192ms
Installed 5 packages in 26ms
+ django-stubs==6.0.7
+ django-stubs-ext==6.0.7
+ pyrefly==1.1.1
+ typing-extensions==4.16.0
...
Django support is built in as of Pyrefly 0.42.0 and needs no extra. This guide was verified with pyrefly 1.1.1, django-stubs 6.0.7, and Django 6.0.7.
Generate a Pyrefly config
Run pyrefly init to write a [tool.pyrefly] table into pyproject.toml:
uv run pyrefly initWithout a config, Pyrefly falls back to the low-noise basic preset, which silences the assignment and attribute checks that catch Django type bugs. pyrefly init writes a config that turns on the default preset so those checks run. See the Pyrefly configuration docs for the available presets.
Nothing Django-specific goes in the config. There is no plugin line and no settings module to declare, unlike mypy, which needs both.
Run Pyrefly and confirm Django-aware types
These checks run against a small app with one model and one foreign key:
from django.db import models
class Reporter(models.Model):
full_name = models.CharField(max_length=100)
class Article(models.Model):
class Status(models.TextChoices):
DRAFT = "DR", "Draft"
PUBLISHED = "PB", "Published"
headline = models.CharField(max_length=200)
status = models.CharField(max_length=2, choices=Status.choices, default=Status.DRAFT)
reporter = models.ForeignKey(Reporter, on_delete=models.CASCADE)Point Pyrefly at the app. A model module with correct annotations reports no errors:
$ uv run pyrefly check news/models.py
INFO 0 errors
To see the Django-aware typing, add a probe with reveal_type:
from typing import reveal_type
from news.models import Article
def probe() -> None:
a = Article.objects.get(pk=1)
reveal_type(a.headline) # str
reveal_type(a.reporter) # Reporter
reveal_type(a.reporter_id) # int
reveal_type(a.get_status_display()) # str
reveal_type(Article.objects) # Manager[Article]
wrong: Article = Article.objects.filter(pk=1) # bug: .filter() returns a QuerySetRunning the check prints each revealed type and flags the bug (file-location prefixes trimmed):
$ uv run pyrefly check news/checks.py
INFO revealed type: str [reveal-type]
INFO revealed type: Reporter [reveal-type]
INFO revealed type: int [reveal-type]
INFO revealed type: str [reveal-type]
INFO revealed type: Manager[Article] [reveal-type]
ERROR `QuerySet[Article, Article]` is not assignable to `Article` [bad-assignment]
INFO 1 error
Pyrefly resolved the CharField as str, the ForeignKey as the related Reporter, the reporter_id shadow field as int, and get_status_display() as str. It caught the assignment of a QuerySet to a variable annotated as a single Article, a bug that ships silently when objects resolves to Any.
What does Pyrefly resolve, and what does it skip?
Pyrefly covers a subset of Django’s ORM. The parts driven by the model definition resolve; the parts Django wires up dynamically at runtime do not.
Resolved:
- Model field types, including auto-generated
idandpk - ForeignKey and ManyToManyField attributes, plus foreign-key
_idshadow fields Model.objectsasManager[Model], and.get()/.filter()return typesChoices,IntegerChoices, andTextChoicesenums and theirget_<field>_display()methods
Not yet resolved:
- Custom manager and QuerySet methods. See the next section.
- Reverse relation accessors.
reporter.article_setraisesObject of class Reporter has no attribute article_set [missing-attribute]. A customrelated_name="articles"has the same gap and is not a workaround;reporter.articlesraises the same error. - QuerySet transforms that reshape the row.
.values()returnsQuerySet[Article, dict[str, Any]]with untyped keys,.annotate()returnsQuerySet[Article, Article]without the added field, and.aggregate()returnsdict[str, Any].
Class-based generic views are recognized as classes, but per-instance attributes such as self.object and get_queryset() results stay loosely typed, so a view-heavy codebase should not expect the same precision it gets on model fields.
Custom managers degrade to the base manager
The most common blindside is a custom manager or QuerySet. Define objects = ArticleQuerySet.as_manager() and Pyrefly types the assignment against Django’s base attribute:
ERROR `Manager[Article]` is not assignable to attribute `objects` with type `Manager[Self@Model]` [bad-assignment]
Article.objects then reveals as the base Manager[Article], and a custom method call does not resolve:
$ uv run pyrefly check news/checks.py
INFO revealed type: Manager[Article] [reveal-type] # Article.objects
ERROR Object of class `Manager` has no attribute `published` [missing-attribute]
Manager.from_queryset(ArticleQuerySet) behaves the same way: the result types as Manager[Article] and the custom methods are lost. Silence these with the suppressions in the next section until Pyrefly’s Django coverage grows.
Turn Pyrefly on without drowning in errors
A first run on an existing Django project surfaces the gaps above across every app. Three config-level controls keep the signal usable.
Exclude generated code. Migrations are the loudest source of errors and rarely worth checking. Add project-excludes to [tool.pyrefly]:
[tool.pyrefly]
project-excludes = [
"**/migrations/**",
]Suppress a single line. A # pyrefly: ignore[<error-kind>] comment silences one error where an unsupported accessor is unavoidable:
articles = reporter.articles # pyrefly: ignore[missing-attribute]A bare # pyrefly: ignore silences every error on the line. A suppressed run reports the count so nothing hides silently:
$ uv run pyrefly check news/checks.py
INFO 0 errors (1 suppressed)
Disable a rule project-wide. When reverse relations or custom managers produce too many missing-attribute errors to annotate line by line, turn the kind off in [tool.pyrefly.errors]:
[tool.pyrefly.errors]
missing-attribute = falseEvery error kind is toggled by name this way; run uv run pyrefly check --help or read the error-suppression docs for the full list.
Run Pyrefly in CI and your editor
Once Pyrefly passes locally, check the whole project in one command so type errors block merges:
uv run pyrefly checkIt 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.
For live feedback while editing, Pyrefly ships a language server. Install the Pyrefly VS Code extension, or point any LSP-capable editor at Pyrefly’s language server. The Pyrefly reference covers editor setup.
Choose between Pyrefly and mypy’s django-stubs plugin
Pick based on how much of Django’s dynamic surface a codebase leans on. Both read the same django-stubs package, but resolve Django differently.
Pyrefly follows the stubs statically. It needs no plugin, no settings module, and no ALLOWED_HOSTS annotation on a fresh startproject, and it runs 10-50x faster than mypy on large codebases (see the Pyrefly reference for the benchmark). It covers the model-driven subset above.
mypy with the django-stubs plugin introspects Django at runtime, so it types custom managers, reverse relations like question.choice_set, settings access, and .values()/.annotate() results that Pyrefly leaves loose. The cost is mypy-only checking, a plugin and settings module to configure, and slower runs.
A project that stays close to model fields, foreign keys, and choices gets the faster, config-light path from Pyrefly. A project built on custom managers, reverse relations, and heavy QuerySet transforms gets more coverage from the mypy plugin today.
Django REST Framework is out of scope for both django-stubs and this page. DRF ships no type information; a typed DRF project adds djangorestframework-stubs on top.