Skip to content

ty and pyrefly find different bugs

April 27, 2026·Tim Hopper

I ran ty 0.0.32 and pyrefly 0.62.0 against Textualize/rich (~38k lines, 100 Python files, with a [tool.mypy] config but no ty or pyrefly config) expecting one to win outright. Instead ty raised 49 errors and pyrefly raised 20. That 2.5x gap is misleading: pyrefly silently honors rich’s existing # type: ignore[mypy-code] comments while ty does not, and once you account for that the tools agree on roughly half the issues. The other half splits into specialties each tool finds and the other misses.

The frame “which Rust-based type checker is better” treats ty and pyrefly as competing replacements for mypy or pyright. On rich they behaved more like complementary linters with overlapping core checks, disjoint specialties, and meaningfully different ignore-comment policies.

Reproduce the run

Both tools install via uv tool install. The setup:

git clone --depth 1 https://github.com/Textualize/rich.git
cd rich
git rev-parse --short HEAD  # 46cebbb at time of writing
uv venv .venv && uv pip install -e .
ty check --python .venv rich
pyrefly check --python-interpreter-path .venv/bin/python rich

Headline numbers from that run:

ty pyrefly
Errors raised 49 20
Errors suppressed by existing comments 0 21
Wall-clock cold check 0.18s 0.32s

ty was about twice as fast as pyrefly on rich. Both finish under half a second. For practical CI choices, treat them as equivalently fast.

ty does not honor mypy-style ignore codes

Of the 36 locations where ty raised an error and pyrefly stayed silent, 20 had an existing # type: ignore[mypy-code] comment on the line. pyrefly honored those comments by default. ty did not, because the comments use mypy’s rule-code namespace and ty’s own rule names are different.

A concrete example. Rich uses # type: ignore[union-attr] to silence mypy’s complaint that sys.__stdin__ is Optional:

_STDIN_FILENO = sys.__stdin__.fileno()  # type: ignore[union-attr]
_STDOUT_FILENO = sys.__stdout__.fileno()  # type: ignore[union-attr]
_STDERR_FILENO = sys.__stderr__.fileno()  # type: ignore[union-attr]

pyrefly is silent. ty raises an error on every line:

error[unresolved-attribute]: Attribute `fileno` is not defined on `None` in union `TextIOWrapper[_WrappedBuffer] | None`
  --> rich/console.py:80:21
   |
80 |     _STDIN_FILENO = sys.__stdin__.fileno()  # type: ignore[union-attr]
   |                     ^^^^^^^^^^^^^^^^^^^^
   |

ty’s rule is unresolved-attribute. Mypy’s rule is union-attr. ty does not recognize union-attr as one of its codes, so the suppression does not apply. The same pattern repeats for # type: ignore[name-defined], # type: ignore[attr-defined], and other mypy codes scattered through rich.

This is documented behavior. Pyrefly’s pyrefly check --help describes the relevant flag:

--enabled-ignores: Respect ignore directives from only these tools.
                   Defaults to type,pyrefly.
- type:    Enables `# type: ignore`
- pyrefly: Enables `# pyrefly: ignore`
- pyright: Enables `# pyright: ignore`

ty requires # ty: ignore[code] or # type: ignore[ty:code] with its own rule names. The FastAPI migration ran into exactly this situation across five projects.

If you want to verify the gap empirically on rich, drop pyrefly’s mypy-ignore handling:

pyrefly check --python-interpreter-path .venv/bin/python --enabled-ignores=pyrefly rich
# 38 errors (2 warnings not shown)

Without honoring # type: ignore, pyrefly raises 38 errors instead of 20. The 18-error swing is almost all of pyrefly’s “missing” findings.

Map the shared findings

After accounting for ignores, ty and pyrefly identify the same issue at roughly half the locations. Most of the agreed-on findings are unresolved imports for rich’s optional Jupyter and attrs extras (IPython.display, ipywidgets, attr), plus a few real type errors:

ty:      [invalid-argument-type] Argument to bound method `list.append` is incorrect
                                 --> rich/console.py:2004
pyrefly: [bad-argument-type]     Argument `ConsoleRenderable` is not assignable to
                                 parameter `object` with type `Styled`
                                 --> rich/console.py:2004

Same line, same bug, different formatting. If a tool catches one of these, the other usually does too. Where they diverge is in their specialties.

Where ty pulls ahead

After excluding the ignore-comment cases, ty raised about 16 errors pyrefly did not. Two categories dominated:

Multi-line Liskov-violation panels. Rich’s _NullFile overrides IO[str] methods with Buffer-typed parameters. ty walks the protocol mismatch all the way down:

error[invalid-method-override]: Invalid override of method `writelines`
    --> rich/_null_file.py:39:9
     |
  39 |     def writelines(self, __lines: Iterable[str]) -> None:
     |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Definition is incompatible with `IO.writelines`
     |
    ::: stdlib/typing.pyi:1957:9
     |
1957 |     def writelines(self, lines: Iterable[AnyStr], /) -> None: ...
     |         ---------------------------------------------------- `IO.writelines` defined here
     |
info: parameter `lines` has an incompatible type: `Iterable[Buffer]` is not assignable to `Iterable[str]`
info: └── protocol `Iterable[Buffer]` is not assignable to protocol `Iterable[str]`
info:     └── incompatible return types: `Iterator[Buffer]` is not assignable to `Iterator[str]`
info: This violates the Liskov Substitution Principle

pyrefly flagged this less verbosely, when it flagged it at all. ty’s panel points at both signatures and walks the protocol chain.

Union-attribute analysis on standard streams. ty notices sys.__stdin__ is typed TextIOWrapper[_WrappedBuffer] | None, so calling .fileno() on it is unsafe. pyrefly typically narrows or accepts these accesses without comment.

Where pyrefly pulls ahead

pyrefly raised 10 errors at locations where ty was silent. Two categories that don’t appear in ty’s output at all:

ctypes _CField strictness. Pyrefly distinguishes between a _CField[c_short, int, c_short | int] (the descriptor) and the int you get when you read it on an instance. Rich’s Windows console adapter triggers this:

ERROR Argument `_CField[c_short, int, c_short | int]` is not assignable to parameter `row` with type `int` in function `WindowsCoordinates.__new__` [bad-argument-type]
   --> rich/_win32_console.py:384:39
    |
384 |         return WindowsCoordinates(row=coord.Y, col=coord.X)
    |                                       ^^^^^^^

Whether this is a real bug or pyrefly being pedantic is a judgment call. ty does not raise it.

Dynamic-class attribute access. Pygments’s Lexer exposes aliases and name through a metaclass-style mechanism that pyrefly does not pick up. Where rich introspects them, pyrefly raises:

ERROR Object of class `Lexer` has no attribute `aliases` [missing-attribute]
   --> rich/syntax.py:417:16
    |
417 |             if lexer.aliases:
    |                ^^^^^^^^^^^^^

ty trusts the dynamic attribute and stays silent. Either tool’s behavior is defensible; they disagree.

Suppressed-error counts in the summary line. Pyrefly tells you how many findings were silenced by ignore comments (20 errors (21 suppressed, 2 warnings not shown) on the rich run). ty does not surface this number, so a codebase with heavy ignore use looks cleaner under ty than it is.

ty reads like rustc; pyrefly reads like grep

ty prints multi-line panels with --> file:line headers, source snippets, and info: explanation lines underneath each error. The format reads like a compiler error from rustc and works well when reading one finding at a time. The same format becomes a wall of text when scrolling 49 findings on a project the size of rich.

pyrefly prints a single ERROR <message> [category] line followed by a smaller source panel. Easier to skim a long list, harder to understand any one finding without context. Both tools group diagnostics by category in summary form on request.

Pick ty if you only run one; run both when you can

If you are starting a project from scratch with no existing ignore comments, start with ty in local checks and CI. It reads pyproject.toml, leaves vendored code alone, and the verbose error format trades skimmability for clarity on individual findings. Use # ty: ignore[code] for any suppressions, since ty will not honor mypy-style codes by default.

If you are migrating a project that already runs mypy or pyright, expect ty to surface every previously-suppressed error. The FastAPI migration used dual-comment workarounds (# type: ignore[assignment] # ty: ignore[unused-ignore-comment]) to manage this during the transition. pyrefly hides this friction by honoring # type: ignore[code] regardless of which tool’s namespace the code came from. Whether that is the right behavior depends on whether you treat each prior suppression as load-bearing or as cruft.

If you can run both, use ty for local checks and CI; add pyrefly as a full-tree CI job on pushes or a nightly run and triage the disjoint findings. ty’s Liskov panels and union-attribute analysis catch real protocol bugs pyrefly misses; pyrefly’s ctypes and dynamic-class-attribute analysis catch real type drift ty misses. Both tools are fast enough that running both in CI on changed paths is cheap.

Neither is a strict superset of the other, and neither is a strict superset of mypy or pyright. The “best Python type checker” question does not have a winner yet. ty and pyrefly are picking somewhat different fights, and on a real codebase that shows up as two different sets of bugs and two different policies on suppression syntax.

To try ty yourself, see how to try the ty type checker. For the broader survey across mypy, pyright, ty, pyrefly, basedpyright, and zuban, see how do mypy, pyright, and ty compare?.

Last updated on

Please submit corrections and feedback...