Skip to content

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 pyrefly

uv 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 init

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

news/models.py
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:

news/checks.py
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 QuerySet

Running 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 id and pk
  • ForeignKey and ManyToManyField attributes, plus foreign-key _id shadow fields
  • Model.objects as Manager[Model], and .get()/.filter() return types
  • Choices, IntegerChoices, and TextChoices enums and their get_<field>_display() methods

Not yet resolved:

  • Custom manager and QuerySet methods. See the next section.
  • Reverse relation accessors. reporter.article_set raises Object of class Reporter has no attribute article_set [missing-attribute]. A custom related_name="articles" has the same gap and is not a workaround; reporter.articles raises the same error.
  • QuerySet transforms that reshape the row. .values() returns QuerySet[Article, dict[str, Any]] with untyped keys, .annotate() returns QuerySet[Article, Article] without the added field, and .aggregate() returns dict[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 = false

Every 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 check

It 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.

Learn More

Last updated on