Следуя принципу: “Работает - не трогай” и привыкнув к mypy еще очень много лет назад, я в какой-то момент собрал свой собственный конфиг линтеров и форматтеров и таскал его, то выкидывая, то добавляя что-то новое: там были flake8, isort, black, yapf, pylint, pyflakes… Но потом пришел Ruff, и всех их не стало. И только mypy пережил их всех. Надолго ли?
Посмотрев недавно на время прогона mypy в Gitlab CI, я решил обратить внимание на ty и Pyrefly как на возможную замену mypy: хотелось сократить время проверки и при этом не потерять в её качестве. Для сравнения взял два своих бэкенда, на которых mypy уже используется. Проверил время запуска с нуля и повторной проверки, а затем разобрал расхождения в диагностике.
Наши кандидаты, которые участвовали в сравнении
Основной столп проверки типов в Python - mypy. mypy проверяет Python-код по аннотациям и настройкам проекта. В моих бэкендах он уже встроен в процесс разработки, поэтому служит точкой отсчёта. Обычный CLI сохраняет результаты анализа на диске и использует их при следующих запусках; отдельно есть демон dmypy, который держит состояние в памяти. Для библиотек с динамическим поведением mypy поддерживает плагины; в проекте A подключён плагин Pydantic.
ty — тайпчекер и language server на Rust от Astral, разработчиков Ruff и uv. Его можно запускать из командной строки или использовать в редакторе. Для повторной работы внутри живого процесса предусмотрен инкрементальный анализ: например, ty check --watch перепроверяет затронутый код при изменениях. В этом эксперименте я измерял отдельные запуски ty check, поэтому результаты не описывают скорость watch-режима или редактора, но он тоже умеет работать фоном.
Pyrefly — другой тайпчекер и language server на Rust, развиваемый Meta. Он умеет импортировать существующие настройки mypy и содержит встроенную поддержку динамических библиотек, в том числе Pydantic. Это делает его интересным кандидатом для таких бэкендов, но импорт конфигурации не означает совпадения всех правил. Аналогично у него проверять буду отдельные CLI-запуски Pyrefly check; возможности language server в замеры не входят.
На чем сравнивал?
Взял два существующих асинхронных бэкенда. Оба - веб-приложения. Одно - поменьше, другое - побольше. В каждом помимо веб-фреймворков еще использовались FastStream и APScheduler.
Оба уже используют mypy, поэтому, естественно, сравнение будет не на 100% честным, а в том числе и в контексте возможного переезда и изменения проекта под новый тайпчекер - аннотации, настройки и подавления ошибок подстраивались под mypy годами разработки, и желательно не тратить на переезд слишком много времени.
Бэкенд A |
Бэкенд B |
|
|---|---|---|
Файлов в проверке |
852 |
2161 |
Python в окружении |
3.14.5 |
3.13.16 |
Веб-фреймворк |
FastAPI 0.135.1 |
Litestar 2.20.0 |
SQLAlchemy |
2.0.48 |
2.0.46 |
Pydantic |
2.12.5 |
2.12.5 |
Эксперимент был проведен на mypy 2.4.0, ty 0.0.87 и Pyrefly 1.3.2. В проектах до этого стоял mypy 1.19.1. Его результаты в сравнении не участвуют: я запустил новую версию с существующими настройками, поэтому в одном из проектов появились новые ошибки, которых не было раньше.
Работал на отдельных копиях проектов с их установленными зависимостями. Всем анализаторам передал одинаковые списки файлов приложения, включая миграции, без тестов (тесты исторически не проверялись на типы и не следовали в этом смысле ограничениям production-кода).
Сначала пришлось разобраться с настройками
Первый проход с минимально необходимой настройкой окружения дал такие результаты:
Инструмент |
Бэкенд A |
Бэкенд B |
|---|---|---|
mypy |
0 ошибок |
2 ошибки |
ty |
14 ошибок, 19 предупреждений |
116 ошибок, 27 предупреждений |
Pyrefly |
45 ошибок |
180 ошибок |
У Pyrefly к этим ошибкам добавлялись 38 и 105 предупреждений, которые по умолчанию видны только в summary. Инструменты уже на этом этапе показывают разный состав сообщений.
Pyrefly также сообщил, что импортировал настройки mypy и выбрал preset legacy. Так работает его автоматическая миграция конфигурации. В этом режиме он ещё не сообщал об опечатке в имени параметра, которую разберу ниже; замечание появилось после перехода на default.
Для второго прохода явно задал платформу и уточнил правила: у mypy включил сообщения о неиспользуемых подавлениях и лишних cast, у Pyrefly выбрал default, проверку тел неаннотированных функций и отсутствующих аннотаций результата. Сами ignore в коде сохранил.
На том же коде mypy теперь выдал 24 сообщения для A и 17 для B, Pyrefly — 49 и 193 ошибки. Результат ty не изменился. Дальше примеры проверены с этими настройками.
Отдельно проверил две ошибки импорта lxml.etree у ty в B. Библиотека работала, но анализатору не хватало описания её типов. Добавление types-lxml 2026.2.16 убрало ошибки импорта и открыло три замечания к аннотациям HTML-элементов. Всего сообщений стало 144 вместо 143: теперь ty мог проверить больше кода. Этот контрольный запуск не входит в замеры скорости ниже.
Одинакового режима проверки у этих инструментов нет: различаются правила, вывод типов и поддержка библиотек. Чтобы разобраться в результатах, дальше для каждого выбранного сообщения я проверял конкретный вызов или выражение.
Очепятки
В одном из бэкендов mypy не нашёл ошибок, а ty и Pyrefly указали на несовместимый метод клиента внешнего API. Если очень упростить, то код выглядит так:
from abc import ABC, abstractmethod class Catalog(ABC): @abstractmethod async def fetch(self, item_id: str) -> str: raise NotImplementedError class RemoteCatalog(Catalog): async def fetch(self, itme_id: str) -> str: return itme_id async def lookup(client: Catalog) -> str: return await client.fetch(item_id="42")
В реализации опечатка: itme_id вместо item_id. При позиционном вызове всё работает. Но функция lookup пользуется интерфейсом и передаёт именованный аргумент. С RemoteCatalog она получает TypeError.
Чтобы воспроизвести ошибку, достаточно вызвать lookup:
import asyncio asyncio.run(lookup(RemoteCatalog()))
mypy пропустил этот пример. ty сообщил о несовместимом переопределении, Pyrefly с preset default — о несовпадающем имени параметра. После переименования itme_id в item_id вызов работает и все три проверки проходят.
В исходном сервисе найденный вызов был позиционным, поэтому эта опечатка ему не мешала. Ошибка проявляется, когда вызывающий код пользуется именем аргумента из базового интерфейса.
Запись в __all__, за которой ничего нет
В бэкенде A Pyrefly нашёл имя в __all__ которого не было в модуле. Сокращённый пример:
class Record: pass __all__ = ["Record", "OldRecord"]
mypy и ty не сообщили о проблеме. Pyrefly указал, что OldRecord не определён.
from module import падает с AttributeError: согласно правилам импорта, перечисленные в __all__ имена должны существовать. После удаления OldRecord импорт работает и все три проверки проходят. Данная проблема проявляется при определённом способе использования модуля, но т. к. у меня в проектах нигде не использовался import *, то мы спокойно жили с этим.
Pydantic: что не так с нулём для Decimal
Два предыдущих примера показывали реальные ошибки, которые пропустил mypy, а теперь разберем обратную ситуацию - ошибку, которой на самом деле нет. Довольно обычное использование Pydantic для валидации входных данных от пользователя API:
from decimal import Decimal from typing import Annotated from pydantic import BaseModel, Field class Request(BaseModel): amount: Annotated[Decimal, Field(gt=0)]
Внезапно Pyrefly требует, чтобы значение ограничения gt было совместимо с типом поля Decimal, и отвергает обычный целый ноль. Причем это сработало только на проверке типов при объявлении поля, а не сообщение о невозможности сравнить Decimal с int (в коде были примеры, где сравнивались Decimal с целыми числами и Pyrefly их переваривает спокойно). mypy с Pydantic-плагином и ty этот код принимают.
При этом в Pydantic 2.12.5 положительный Decimal принимается, ноль и отрицательное значение отклоняются, и вообще сравнение Decimal с обычным нулём поддерживается в стандартной библиотеке, и это никогда не было проблемой.
Запись Field(gt=Decimal("0")) убирает сообщение Pyrefly. В проверке до и после валидация ведёт себя одинаково, поэтому вроде как не смертельная проблема, но все равно ходить по проекту и исправлять это не особо приятно.
Откуда у элемента списка появился None?
Следующее расхождение встретилось уже без фреймворка или специфичной библиотеки. В бэкенде B нужно выбрать запись с максимальным приоритетом, а для пустого списка вернуть None:
from collections.abc import Sequence from dataclasses import dataclass @dataclass class Record: score: int def choose(records: Sequence[Record]) -> Record | None: return max(records, key=lambda record: record.score, default=None)
ty и Pyrefly предупреждают: у None нет атрибута score. Но в функции явно указано, что в records нет None. Он указан как результат для пустой последовательности, и функция key к нему не применяется.
И вы не поверите, но в этом примере пустой список возвращает None, непустой - нужную запись. Это соответствует контракту max в официальной документации Python.
С явной аннотацией функции ключа проблема в ty и Pyrefly исчезает:
def choose(records: Sequence[Record]) -> Record | None: def score(record: Record) -> int: return record.score return max(records, key=score, default=None)
mypy принимает оба варианта; ty и Pyrefly перестают сообщать об ошибке с аннотированной функцией ключа. Вот тут уже прям ред флаг для меня - надо вместо красивых и лаконичных анонимных функций, которые легко читаются, использовать более сложные конструкции и размазывать код.
SQLAlchemy: анализатор не увидел унаследованную колонку
Больше всего повторяющихся ошибок Pyrefly было в SQLAlchemy-запросах: 145 сообщений о неизвестных полях обновления. Они относятся к колонкам из mixin с declared_attr. Вот сокращённый пример на SQLAlchemy 2.0.48. Типичная история с общими атрибутами у нескольких моделей:
from datetime import datetime from sqlalchemy import DateTime, update from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column from sqlalchemy.sql.dml import Update class Base(DeclarativeBase): pass class TimestampMixin: @declared_attr def modified_at(cls) -> Mapped[datetime]: return mapped_column(DateTime()) class Record(Base, TimestampMixin): __tablename__ = "records" id: Mapped[int] = mapped_column(primary_key=True) def statement(when: datetime) -> Update: return update(Record).values(modified_at=when)
При update(Record).values(modified_at=when) Pyrefly сообщает о неизвестном поле modified_at. mypy и ty не возражают.
Для проверенной версии Pyrefly помогает передать сам mapped attribute:
def statement(when: datetime) -> Update: return update(Record).values({Record.modified_at: when})
Оба способа обновления проходят runtime-проверку. Второй позволяет обойти ограничение Pyrefly на этом объявлении колонки.
При этом Pyrefly нашёл в SQLAlchemy-вызовах и реальные ошибки аннотаций, о которых mypy и ty в наших прогонах не сообщили. В бэкенде A при проверке update(...).values(...) он указал на передачу Decimal в поле с типом Mapped[float]. Ещё в четырёх местах передавались значения, допускающие None, хотя в аннотациях полей его не было. Тут прям плюсик в копилку Pyrefly.
Из этих сообщений ещё не следует, что неправильно составлен запрос: несовместимыми могут оказаться как переданное значение, так и аннотация. Я проверил объявления колонок. Первая использовала Numeric с типом Decimal, остальные были объявлены с nullable=True. Значения для них допустимы, а исправления нужны в типах: Mapped[Decimal] и соответствующие Mapped[T | None]. Здесь проверка Pyrefly помогла найти неточные аннотации, несмотря на ложные срабатывания с унаследованными колонками выше.
«Не нашёл» или «мы попросили не сообщать»
В каждом репозитории есть код, в котором часть проверок типов уже была выключена. Например, обработчик ошибки базы данных обращался к цепочке причин исключения:
def constraint_name(exc: Exception) -> str: return exc.__cause__.__cause__.constraint_name # type: ignore[union-attr]
mypy принимает строку. Pyrefly тоже не выдаёт ошибку, но сообщает о подавленной диагностике. ty выводит замечания с кодом unresolved-attribute.
В нашем обработчике расчёт сделан на конкретную цепочку трансляции ошибок asyncpg через SQLAlchemy: после двух переходов по __cause__ ожидается исключение драйвера с constraint_name. Из аннотации Exception этот контракт не следует. Даже наличие двух причин само по себе не гарантирует нужный атрибут.
При переезде можно сохранить подавление, если этот контракт подтверждён для обрабатываемых ошибок; для ty тогда понадобится адаптировать комментарий. Другой вариант — явно обработать незнакомую форму исключения. Для минимального примера я проверил такой вариант:
def constraint_name(exc: Exception) -> str: cause = exc.__cause__ original = cause.__cause__ if cause is not None else None name = getattr(original, "constraint_name", None) return name if isinstance(name, str) else "unknown"
Он проходит все три анализатора. "unknown" здесь служит результатом для примера; в сервисе нужно выбрать, как сохранить и обработать исходную ошибку, если имя ограничения извлечь не удалось. Падение действующего обработчика этим примером не доказано.
Насколько сообщение помогает исправить код?
При замене тайпчекера мне важно и то, сколько времени уходит на разбор его вывода. Посмотрим на ту же цепочку исключений без ignore: здесь все три инструмента видят проблему, поэтому можно сравнить объяснения.
Ниже реальные сообщения из CLI, без итоговых счётчиков. У mypy я включил --pretty --show-error-context --show-column-numbers, у ty выбрал формат full, у Pyrefly — full-text. Выбираем самые “многословные” режимы, чтобы человек/LLM мог понять, что не так тут.
mypy:
unsuppressed.py: note: In function "constraint_name": unsuppressed.py:2:12: error: Item "None" of "BaseException | None" has no attribute "__cause__" [union-attr] return exc.__cause__.__cause__.constraint_name ^~~~~~~~~~~~~~~~~~~~~~~ unsuppressed.py:2:12: error: Item "BaseException" of "BaseException | Any | None" has no attribute "constraint_name" [union-attr] return exc.__cause__.__cause__.constraint_name ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ unsuppressed.py:2:12: error: Item "None" of "BaseException | Any | None" has no attribute "constraint_name" [union-attr] return exc.__cause__.__cause__.constraint_name ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
mypy называет функцию, показывает строку и отдельно перечисляет проблемные варианты union. Из сообщения понятно, что нужно проверить: наличие причины исключения и наличие constraint_name у следующего исключения. Но читать три почти одинаковых фрагмента подряд тяжело; появившийся в промежуточном типе Any тоже требует внимания. Это три диагностики одной цепочки обращений.
ty:
error[unresolved-attribute]: Attribute `__cause__` is not defined on `None` in union `BaseException | None` --> unsuppressed.py:2:12 | 2 | return exc.__cause__.__cause__.constraint_name | ^^^^^^^^^^^^^^^^^^^^^^^ error[unresolved-attribute]: Object of type `BaseException | None` has no attribute `constraint_name` --> unsuppressed.py:2:12 | 2 | return exc.__cause__.__cause__.constraint_name | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ty оставляет два сообщения: о доступе к __cause__ через возможный None и об атрибуте constraint_name. В этом примере мне проще читать такой вывод: оба обращения видны, варианты union не повторяются отдельными ошибками.
Pyrefly:
ERROR Object of class `NoneType` has no attribute `__cause__` [missing-attribute] --> unsuppressed.py:2:12 | 2 | return exc.__cause__.__cause__.constraint_name | ^^^^^^^^^^^^^^^^^^^^^^^
Pyrefly кратко указывает на первый небезопасный доступ. Для первого шага этого достаточно: нужно обработать отсутствие причины. Но по этому выводу ещё не видно следующей проблемы с constraint_name. После исправления проверку придётся запустить снова; здесь я не исследовал, какие сообщения появятся после каждой промежуточной правки.
Ни один из этих выводов не даёт готового решения для обработчика. Одной проверки на None недостаточно: базовый класс исключения не обещает атрибут constraint_name, а функция должна вернуть str.
В случае с именем параметра из раздела «Очепятки» путь до исправления короче. ty показывает определения дочернего и базового методов, а затем объясняет причину:
info: the parameter named `itme_id` does not match `item_id` (and can be used as a keyword parameter) info: This violates the Liskov Substitution Principle
Pyrefly обходится одной конкретной подсказкой:
Got parameter name `itme_id`, expected `item_id`
Оба сообщения позволяют перейти к переименованию, предварительно проверив вызовы с именованными аргументами. У ty больше контекста о нарушенном контракте, у Pyrefly короче путь к самой опечатке. mypy этот случай пропустил, поэтому сравнивать формулировку у него здесь нечего.
А вот в примере с max оба инструмента точно подсвечивают record.score, но не объясняют, почему вывели None для аргумента функции ключа. Только чтение контракта max и проверка поведения показали, что защитная ветка не нужна. Поэтому при выборе замены я бы смотрел, помогает ли сообщение найти причину и проверить исправление. Аккуратное оформление само по себе не делает диагностику верной.
Что получилось на выбранных примерах?
Собрал итог в одну табличку: + - инструмент корректно проверил код, который в проекте был (или нашел ошибку, или не сообщил о проблеме, когда ее нет), - - инструмент или проигнорировал ошибку, или, наоборот, нашел проблему там, где ее на самом деле нет.

Отдельная полезная находка на исходном бэкенде A — несовместимость значений в update(...).values(...) с аннотациями Mapped: один случай с Decimal и четыре с optional-типами. О них сообщил только Pyrefly; проверка объявлений колонок показала, что исправлять нужно аннотации. Я эти примеры сюда отдельно не выводил. Код из них покрыт тестами, и если бы не работало приведение типов, то мы бы их отловили при тестировании. Пометил звездочкой, потому что больших потерь от этих ошибок не происходит, но потенциально они возможны.
Все варианты после исправлений проходят все три анализатора. То есть можно в теории писать код, который удовлетворит всех троих, и если бы не ложноположительные моменты в ty и Pyrefly, то я бы, наверное, так и сделал.
Скорость: проверка с нуля и повторный запуск
Для замены mypy меня интересуют два сценария: проверка проекта без готового кэша, например в новом окружении CI, и повторная локальная проверка. В обоих случаях запускал новый процесс CLI на том же наборе файлов, с настройками второго прохода. На каждую пару «проект / инструмент» сделал пять запусков в каждом режиме, всего 60 измерений.
У mypy перед каждым запуском с нуля был новый пустой каталог кэша. Для повторной серии сначала выполнил прогрев, затем пять раз использовал сохранённый кэш. Файлы между запусками не менялись.
Для ty и Pyrefly в использованных CLI-командах нет переиспользования сохранённого кэша анализа между процессами. Поэтому их второй столбец — повторный запуск, а не аналог mypy с дисковым кэшем. Инкрементальную работу живого процесса, dmypy, watch-режим и language server я не измерял.
Инструмент |
С нуля |
Повторная проверка |
|---|---|---|
mypy |
Пустой каталог кэша |
Дисковый кэш после прогрева |
ty |
Новый процесс |
Новый процесс, без сохранённого кэша анализа |
Pyrefly |
Новый процесс |
Новый процесс, без сохранённого кэша анализа |
Кэш файловой системы ОС не очищал. Замеры сделаны на обычной рабочей машине с фоновыми сервисами. «С нуля» здесь означает отсутствие результатов предыдущего анализа, а не полностью холодную ОС.
Ниже медианы пяти прогонов в секундах.

При проверке с нуля ty оказался быстрее mypy примерно в 29 раз на A и в 16 раз на B; Pyrefly — примерно в 17 и 12 раз. Для CI, который каждый раз начинает без кэша mypy, это заметный выигрыш на этих проектах.
С готовым кэшем картина меняется. В A mypy укладывается в 0,396 с, ty — в 0,287 с, Pyrefly — в 0,353 с: разница уже меньше 0,11 с. В B mypy быстрее обоих: 0,449 с против 0,661 с у ty и 0,931 с у Pyrefly.
Стал бы я сейчас заменять mypy?
По скорости у обоих кандидатов есть аргумент для перехода: проверка с нуля существенно короче. Для повторной проверки с сохранённым кэшем преимущество уже зависит от проекта; в B mypy оказался быстрее, но если мы представим сценарий обычной работы, то это незаметно. В целом это и подтверждается моим опытом - пока я в очередной раз не открыл разбивку этапа lint CI, я и не обращал внимания, что mypy так-то достаточно медленный по сравнению с тем же Ruff (он, наверное, устанавливается дольше, чем работает).
Условие «не менее качественный» этот эксперимент полноценно не доказывает. ty и Pyrefly нашли нарушения, о которых mypy не сообщил, но также потребовали разбирать ограничения вывода типов и библиотечной поддержки. Оба проекта до сравнения проверялись только mypy, но более устаревшими версиями, поэтому часть ошибок, которые умеет находить обновленная версия, могла быть исправлена раньше. По оставшимся сообщениям нельзя установить, какой инструмент пропускает меньше ошибок вообще.
В целом я бы в качестве профилактики раз в месяц прогонял оба проекта и через ty, и через Pyrefly и руками или с помощью нейронки разбирал все случаи несовпадений, чтобы исправлять ошибки, пропущенные mypy, но это та работа, которую пока нельзя полностью автоматизировать и переложить на CI. А вопрос со скоростью mypy в CI я решил добавлением кэша на папку .mypy_cache.
После проведенного эксперимента я остаюсь с мыслью, что искать замену mypy пока рано. После истории с Ruff и uv у меня были какие-то надежды на компанию Astral и их ty, но пока он все еще не готов к продакшену + меня удивило, что они не сделали совместимость ignore-комментариев с mypy, как раньше сделали фичу для быстрого переезда с Poetry на uv. Но, может быть, это в планах. Эта возможность очень нужна для вероятного переезда в будущем: все-таки отредактировать один файлик pyproject.toml руками сильно проще, чем искать по всему проекту эти комментарии с причинами игнора и разбирать, как себя поведет новый инструмент в каждом отдельном случае. Будем посмотреть за развитием инструментов дальше, а пока посидим на mypy.
Расскажите, чем у себя на проектах пользуетесь вы? С какими проблемами с типизацией сталкивались? Будет очень интересно и полезно почитать ваши истории.
stackshortlist
Интересное сравнение, особенно то, что вы разбирали расхождения в диагностике, а не только время прогона. Для проектов с плагинами mypy, например для Django или pydantic, переход пока выглядит главным риском, ведь ty и Pyrefly их не поддерживают и часть ошибок просто перестанет ловиться. Не пробовали запускать новый чекер параллельно с mypy в CI какое-то время, чтобы накопить статистику расхождений перед заменой?