Skip to content

ty vs Pyrefly: which Python type checker should you pick?

ty (Astral) and Pyrefly (Meta) are Python type checkers with built-in language servers and pyproject.toml configuration. Compare their diagnostics on representative project code, especially unannotated functions and framework-generated methods.

For the wider field that includes mypy, pyright, Basedpyright, and Zuban, see How do Python type checkers compare?.

For a measured comparison on Textualize’s Rich codebase, see ty and pyrefly find different bugs, which shows where their diagnostics diverge and how existing ignore comments affect the count.

Pick Pyrefly by default

Use Pyrefly for most Python projects today. Pick ty when its inference policy and language server fit the project, including projects that use Astral’s uv and Ruff. Both tools catch errors in unannotated code; their policies differ.

Important

Every Pyrefly example below assumes preset = "default" in your config. Pyrefly’s out-of-the-box preset for an unconfigured project is basic, which silences most type errors. Run pyrefly init first. See Watch out for Pyrefly’s basic preset for the full list.

Compare the adoption tools

Pyrefly combines a stable release policy with configuration migration, annotation generation, and built-in framework support. ty offers its own language server, rule overrides, and configurable return inference. Neither a release number nor a conformance score proves that a checker covers a project’s dynamic APIs.

The typing conformance suite measures agreement with the typing specification. Compare pinned checker and suite revisions with a named metric before using its results to rank tools. Test the typing constructs and framework APIs the project relies on as well.

Pyrefly translates mypy configs with one command

If you’re migrating from mypy, Pyrefly offers a CLI surface ty does not yet match:

  • pyrefly init reads mypy.ini or [tool.mypy] in pyproject.toml and writes a translated config. When it detects a mypy config, it emits the legacy preset. That preset also limits unannotated-body checking and return inference, so inspect the generated settings before treating it as equivalent to the old mypy policy.
  • pyrefly infer writes type annotations directly into source files. See how to add type annotations with pyrefly infer.
  • pyrefly suppress bulk-adds or removes ignore comments.
  • permissive-ignores = true honors leftover # mypy: ignore-errors comments.

ty’s CLI is narrower: ty check, ty server, ty explain. The mypy-to-ty migration is a manual translation. Review existing suppressions as well: ty does not interpret mypy error codes as ty rule names.

Check framework behavior separately

Pyrefly’s Django support uses installed django-stubs to model fields, relationships, and other ORM constructs without a mypy plugin. It covers a subset of Django, so verify the particular manager, query, and relationship patterns the project uses.

ty’s plugin documentation describes built-in Pydantic support and no mypy plugin interface. A project depending on django-stubs plus mypy’s plugin should compare actual diagnostics before migrating. Type-check a Django project with Pyrefly covers that setup.

How ty and Pyrefly check Pydantic

With Pydantic installed and Pyrefly configured with preset = "default", both tools accept the lax constructor call and reject the whole-model strict coercion shown here. Per-field strictness differs, so native Pydantic support does not imply identical checking.

In lax mode (Pydantic’s default), both accept coercible types and both reject None:

from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int = 0

User(name="ada", age="40")   # both: ok (str coerces to int at runtime)
User(name="ada", age=None)   # both: error (None is not coercible)

Adding model_config = ConfigDict(strict=True) switches both tools to exact-type checking, and both flag the coercion:

from pydantic import BaseModel, ConfigDict

class User(BaseModel):
    model_config = ConfigDict(strict=True)
    name: str
    age: int = 0

User(name="ada", age="40")   # both: error under strict mode

Both also honor extra="forbid" and reject unknown keyword arguments.

The tools diverge on one case: a field-level Strict() marker. ty reads it; Pyrefly’s default preset does not.

from typing import Annotated
from pydantic import BaseModel, Strict

class Record(BaseModel):
    id: Annotated[int, Strict()]

Record(id="1")   # ty: error   Pyrefly: no error

If you use per-field Strict() to keep some fields exact while the rest stay lax, ty catches violations Pyrefly currently misses. This win is narrow: a bool passed to a strict int field stays silent in both tools because bool is a static subtype of int, and neither replaces Pydantic’s runtime validation. For whole-model strictness via ConfigDict(strict=True), the two agree.

One caveat applies to both: in lax mode they accept any str for an int field, including "forty", which Pydantic rejects at runtime. Static checking models the lax/strict distinction, not whether a specific string is actually coercible.

Diverge on code with no annotations

Both tools infer types from unannotated code. They differ in how strongly they constrain inferred containers and whether they infer function return types by default.

The same snippet produces opposite verdicts (both tools at their normal checking level, Pyrefly with preset = "default"):

x = []
x.append(1)
x.append("foo")
  • ty: All checks passed! (allows the element type to include both int and str)
  • Pyrefly: bad-argument-type (infers list[int] from the first append, rejects the second)

The divergence holds for a non-empty literal too. Given my_list = [1, 2, 3], ty allows a later my_list.append("foo") while Pyrefly flags it. ty stays permissive on inferred container types; Pyrefly enforces them.

An explicit x: list[int] = [] makes both reject the string append. This does not make their behavior identical on all annotated code. ty also offers [tool.ty.analysis] return-type-inference = "always" to infer ordinary unannotated function returns; its default is "never". See how the checkers handle untyped code for runnable examples.

Watch out for Pyrefly’s basic preset

The most common Pyrefly mistake is running it without pyrefly init and assuming the green output means clean code. Without an applicable native config or a checker config it can migrate, Pyrefly applies the basic preset, which only flags syntax errors, missing imports, and unknown names. Wrong-argument types, missing returns, and most of what users expect a type checker to catch are silenced.

The naming is confusing: there is also a preset literally named default, but it only applies once a config file exists and no preset = line is set. Without a native configuration, Pyrefly first attempts to migrate an existing checker config in memory; otherwise it uses basic. The Pyrefly configuration docs state the rule directly: “Otherwise Pyrefly uses the same basic preset the IDE uses.”

The six presets define different checking policies:

  • off silences every error kind. For projects opting in to specific checks manually.

  • basic is the unconfigured fallback. It also skips fully unannotated bodies and disables return inference.

  • legacy is a migration starting point, including reduced unannotated-body checking and return inference. Emitted when migrating mypy configuration.

  • default is the standard Pyrefly experience. Use this for new projects.

  • strict enables additional diagnostics and missing-parameter annotation checks. Consult the preset definitions rather than assuming equivalence to another checker’s strict mode.

  • all enables every error kind, including unannotated-return.

Run pyrefly init and choose default or strict. Or set preset = "default" in pyrefly.toml (or [tool.pyrefly] in pyproject.toml) by hand. The full preset reference is in the Pyrefly Reference page.

ty has no equivalent preset system. Diagnostics are configured by setting individual rules under [tool.ty.rules].

Check plugin-dependent behavior before migrating

Neither tool has a mypy-style plugin system that user code can extend. That leaves a few surfaces unchecked under both:

  • SQLAlchemy 2.0 constructor arguments. Both tools read Mapped[T] field types correctly, so u.id reveals as int. But the declarative Base.__init__ uses **kwargs, and neither tool checks User(id="not-an-int") against the field annotations.
  • Celery task signatures. The mypy plugin for Celery validated .delay() and .apply_async() calls against the task’s declared parameters. No equivalent in ty or Pyrefly.
  • Framework-generated methods. Support varies by decorator and library pattern. Pyrefly has native attrs support; verify the specific decorator and generated method rather than treating all attrs classes alike.
  • Arbitrary user plugins. If your codebase ran on a custom mypy plugin (a builder pattern, a metaclass-heavy API), there’s nowhere to plug equivalent logic into ty or Pyrefly today.

Keep mypy in CI until the replacement covers the framework behavior the project relies on. Native support and plugin support are different compatibility surfaces.

Last updated on