# ty and pyrefly find different bugs


> [!NOTE]
> **Update 2026-04-29**: An earlier version of this post used a multi-line Liskov-violation panel from `rich/_null_file.py` as an example of ty catching a real protocol bug. That diagnostic turned out to be a [ty false positive](https://github.com/astral-sh/ty/issues/2237): ty reports the base parameter type as `Buffer` when checking overrides on subclasses of `IO[str]`, even though the parameterization should resolve `AnyStr` to `str`. mypy and pyright are both silent on the same code. The "Where ty pulls ahead" section has been rewritten with a tuple-arity finding instead, and the conclusion's reference to "Liskov panels" has been dropped. Thanks to a reader for the catch.

> [!NOTE]
> **Update 2026-04-30**: The wall-clock numbers below are tied to ty 0.0.32 and pyrefly 0.62.0 against the specific rich commit listed in the reproduce section. Both tools release frequently and optimize aggressively, so newer versions may shift these numbers. The post's bottom line is unchanged: the two tools are equivalently fast for practical CI choices.

I ran [ty](https://pydevtools.com/handbook/reference/ty.md) 0.0.32 and [pyrefly](https://pydevtools.com/handbook/reference/pyrefly.md) 0.62.0 against [Textualize/rich](https://github.com/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](https://pydevtools.com/handbook/reference/mypy.md) or [pyright](https://pydevtools.com/handbook/reference/pyright.md). 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:

```bash
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`:

```python
_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:

```text
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:

```text
--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](https://pydevtools.com/blog/migrating-from-mypy-to-ty-lessons-from-fastapi.md) ran into exactly this situation across five projects.

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

```bash
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:

```text
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 12 errors pyrefly did not. Two categories stood out:

Tuple-arity narrowing. Rich's `Padding.unpack` accepts a `PaddingDimensions` union (an `int`, a 1-tuple, a 2-tuple, or a 4-tuple of ints) and dispatches on `len(pad)`:

```python
if len(pad) == 2:
    pad_top, pad_right = pad
if len(pad) == 4:
    top, right, bottom, left = pad
```

The `len() == 2` check doesn't narrow `pad` to the 2-tuple variant in either tool's type system. ty notices this and reports unpacking errors against the other variants:

```text
error[invalid-assignment]: Not enough values to unpack
  --> rich/padding.py:69:13
   |
69 |             pad_top, pad_right = pad
   |             ^^^^^^^^^^^^^^^^^^   --- Got 1
   |             |
   |             Expected 2

error[invalid-assignment]: Too many values to unpack
  --> rich/padding.py:69:13
   |
69 |             pad_top, pad_right = pad
   |             ^^^^^^^^^^^^^^^^^^   --- Got 4
   |             |
   |             Expected 2
```

pyrefly is silent on the same lines. Whether this is a real bug or ty being strict about runtime-only narrowing is a judgment call (rich's runtime check is correct), but it is a class of finding ty surfaces and pyrefly doesn't.

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:

```text
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:

```text
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](https://pydevtools.com/blog/migrating-from-mypy-to-ty-lessons-from-fastapi.md) 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 still necessary 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 tuple-arity narrowing and union-attribute analysis catch type drift 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. Expect false positives from both as they mature: rich's `_null_file.py` triggers a [ty bug](https://github.com/astral-sh/ty/issues/2237) on overrides of `IO[str]` methods that mypy and pyright don't flag.

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.

This post does not cover [Zuban](https://pydevtools.com/handbook/reference/zuban.md), the other young commercial type checker in this space. The [type-checker comparison page](https://pydevtools.com/handbook/explanation/how-do-mypy-pyright-and-ty-compare.md) covers it alongside mypy, pyright, ty, pyrefly, and basedpyright.

To try ty yourself, see [how to try the ty type checker](https://pydevtools.com/handbook/how-to/how-to-try-the-ty-type-checker.md).
