After my last post "How to install a LSP server that supports Django" was shared, I got feedback that some fellows tried to install the LSP, but didn't get the autocomplete working as shown in my screenshot. So I continue with this article.

People often miss the detail that the core power of an LSP server comes from the static type checker (or the compiler in case of compiled languages). When you write window. then hit the Tab key, the LSP server starts to suggest the attributes of the window object. But it can only know which attributes the window object has, if the type of the window variable is inferred successfully and correctly. The later job is done by static type checker, or concretely, Mypy.
So, for a Django project, how do you set up Mypy so type inference works? Basically, it's just like adding Mypy for type checking, but with more honesty. I use the word "honesty" because some projects added Mypy but the team members try to get the green "passed" status by making Mypy skip the type checking, ignoring the errors instead of really fixing them.
You will need to add both django-stubs and django-stubs-ext as dependencies. If your project has pyproject.toml file, django-stubs-ext will go in the main dependency list and django-stubs just needs to be in the same dependency group as mypy, in my case, that's the dev group.
dependencies = [
"django >= 6.1",
"django-stubs-ext >= 6.1",
...
]
[dependency-groups]
dev = [
"mypy >= 2.3.1",
"django-stubs >= 6.1",
"djangorestframework-stubs >= 3.18.0",
...
]
(In 2026, if you tell an AI assistant to scaffold a Python project, it will define "dev" as a group in [project.optional-dependencies] instead of [dependency-groups]. That is wrong)
Then enable the django-stubs plugin for Mypy:
[tool.mypy]
python_version = "3.14"
plugins = [
"mypy_django_plugin.main",
"mypy_drf_plugin.main",
]
warn_redundant_casts = true
warn_unused_ignores = true
Some Python packages do not provide type info yet; we can tell Mypy to ignore them:
[[tool.mypy.overrides]]
module = [
"django_filters.*",
"anymail.*",
]
ignore_missing_imports = true
but keep an eye on this list. If a package is updated with type info added, remove it from this "ignore" list.
When writing Python code, we may forget to add type annotations, which weakens Mypy. We can let Ruff remind us by adding ANN rules to the Ruff config.
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "BLE001", "UP", "ANN201", "ANN205"]
ignore = ["UP040", "UP046", "UP047", "UP049"]
If you are using Python older than 3.14, you should get used to the from __future__ import annotations and add this to Ruff lint config:
[tool.ruff.lint]
...
# Enable `from __future__ import annotations` imports
future-annotations = true
Using this future feature of type annotation will save us from the headache of circular imports when you just need the type annotation but have to import classes at runtime.
One more step is to add this line to the settings.py file.
import django_stubs_ext
django_stubs_ext.monkeypatch()
Note that adding Mypy to the project does not automatically make type inference work. It's only half the job. The other half is that you have to add type annotations to functions and object methods. This part is pretty difficult and though I tried so hard to make my projects pass the Mypy check, I can't confidently say that I can resolve all the cases. If you plan to let AI assistants do this task, be aware that most of the time, AI assistants will try to suppress errors by type: ignore, or use Any type instead of doing a real fix, and that doesn't help Mypy or pylsp at all.
If your project depends on pydantic, you will need to enable more Mypy rules and add more type annotations. For example, the disallow_any_generics = true rule will force you to define admin like this:
class TeamAdmin(admin.ModelAdmin[Team]):
...
If you find it difficult to define some types, sometimes you can read the source code of django-stubs and copy its implementation. For example, my project needs a custom ArrayField and I define it as:
from typing import TypeVar
from django.contrib.postgres.fields import ArrayField
from django.forms.widgets import Widget
# Ref: https://github.com/typeddjango/django-stubs/blob/master/django-stubs/contrib/postgres/fields/array.pyi
# __set__ value type
_ST = TypeVar('_ST')
# __get__ return type
_GT = TypeVar('_GT')
class _TypedMultipleChoiceField(forms.TypedMultipleChoiceField):
def __init__(self, *args, **kwargs) -> None:
kwargs.pop('base_field', None)
kwargs.pop('max_length', None)
# We will overwrite label to prevent it from being rendered
kwargs['label'] = ''
super().__init__(*args, **kwargs)
class ChoiceArrayField(ArrayField[_ST, _GT]):
"""
A field that allows us to store an array of choices.
Uses Django 4.2's postgres ArrayField
and a `TypedMultipleChoiceField` for its form field.
Usage:
choices = ChoiceArrayField(
models.CharField(max_length=..., choices=(...,)), blank=[...], default=[...]
)
"""
def formfield(
self,
form_class: type[forms.Field] | None = None,
choices_form_class: type[forms.ChoiceField] | None = None,
required: bool = False,
widget: Widget | type[Widget] | None = None,
**kwargs,
) -> forms.Field | None:
if not widget:
widget = FilteredSelectMultiple(self.verbose_name, is_stacked=False)
form_class = form_class or _TypedMultipleChoiceField
choices_form_class = choices_form_class or forms.MultipleChoiceField
return super().formfield(
form_class=form_class,
choices_form_class=choices_form_class,
required=required,
widget=widget,
choices=self.base_field.choices,
coerce=self.base_field.to_python,
**kwargs,
)
Here, the Field[_ST, _GT] pattern is just copied from django-stubs source.