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 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 129ms
Installed 8 packages in 244ms
+ asgiref==3.12.1
+ django==6.1.1
+ django-stubs==6.1.0
+ django-stubs-ext==6.1.0
+ pyrefly==1.3.0
+ sqlparse==0.6.0
+ types-pyyaml==6.0.12.20260906
+ typing-extensions==4.16.0
Django support is built in and needs no extra plugin.
Generate a Pyrefly config
Run pyrefly init to write a [tool.pyrefly] table into pyproject.toml:
uv run pyrefly initWithout a Pyrefly config or a successfully migrated mypy/pyright 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 two models 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 (excerpt; source locations and code frames omitted):
$ 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 resolves model fields and relationships declared in the same module. Custom managers and QuerySet transforms remain loosely typed because Django wires them up dynamically at runtime.
Resolved:
- Model field types, including auto-generated
idandpk - ForeignKey and ManyToManyField attributes, plus foreign-key
_idshadow fields - Same-file reverse ForeignKey, OneToOneField, and ManyToManyField relationships, including default accessors and custom
related_namevalues 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.
- 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 can expose these typing gaps. Three 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 diagnostic. The standard # type: ignore[pyrefly:<error-kind>] form leaves unrelated diagnostics on the same line visible:
published = Article.objects.published() # type: ignore[pyrefly:missing-attribute]Pyrefly also accepts # pyrefly: ignore[<error-kind>]. A bare # type: ignore or # pyrefly: ignore silences every error on the line. If the checked file has exactly one diagnostic and it is suppressed, the summary is:
$ uv run pyrefly check news/checks.py
INFO 0 errors (1 suppressed)
Disable a rule project-wide. When 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; read the error-kind reference 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 model fields and same-file relationships.
mypy with the django-stubs plugin introspects Django at runtime, so it types custom managers, 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 centered on statically declared model structure gets Pyrefly’s plugin-free path. A project built on custom managers 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.