ty
ty is a Python type checker and language server written in Rust by Astral, the team behind uv and Ruff. It does the same job as mypy or Pyright: read your annotations, read typeshed and the stubs in your virtual environment, and tell you where the types do not line up. The difference is the implementation. It is a native binary with no Python startup cost, it reuses Ruff's parser, and it recomputes only the parts of the program a change actually affected, which is what makes the editor side feel instant on a large repository. The same binary serves both roles: ty check on the command line and ty server as an LSP with completions, hover, go-to-definition, auto-import and inlay hints. The type system it implements is deliberately built for codebases that are only partly annotated, with support for redeclarations, intersection types and narrowing that follows reachability. It is also, by the maintainers' own version policy, beta software on 0.0.x version numbers with no stable API.
ty is already the fastest way to get type feedback in an editor, and the CLI is fast enough to change how often you run it. Treat it as a second opinion pinned to an exact version until it leaves 0.0.x, and keep mypy or Pyright as the gate if your project relies on plugins.
Use it if
- Type checking is the slow step in CI or your editor stalls on a repository large enough that mypy's incremental cache stopped helping
- You already standardise on uv and Ruff and want the same distribution story: uvx ty check with nothing installed, a standalone installer that needs no Python at all, and one version pinned in the lockfile
- You want an open-source language server with completions and go-to-definition that is not tied to a specific editor, which Pyright's Pylance bundle is
- You are adding types to a partly annotated codebase and want a checker whose defaults tolerate that, with per-directory overrides for tests and generated code
- You need CI output in a native format: --output-format github, gitlab or junit are built in rather than bolted on with a wrapper
- You need stability. The project's own version policy says 0.0.x with no stable API and that breaking changes, including changes to diagnostics, may happen between any two versions. Five releases shipped between 29 July and 6 August 2026. Pin the exact version in CI or your build will break on a Tuesday.
- Your codebase depends on a mypy plugin. django-stubs, the SQLAlchemy plugin and similar tools have no ty equivalent because there is no plugin API, so ty simply cannot understand the types those plugins synthesise.
- The tracker tells you the coverage story: 845 open issues plus 6 open PRs, and the type system support matrix lives in an open GitHub tracking issue rather than in the documentation. Expect to hit unimplemented corners on anything unusual.
- You have thousands of existing # type: ignore[code] comments tuned to mypy. ty only honours codes prefixed with ty:, so mypy codes are ignored and a bare type: ignore silences everything on the line, which is not the migration most teams want.
- You target Python 3.7 through 3.9. Those versions can be selected but the bundled stubs do not fully describe them, so you get false positives and false negatives the docs warn about up front.
- You want to fix the checker yourself. Development happens in the astral-sh/ruff repository, not this one, so the issue tracker and the source live in different places.
Setup reality
uvx ty check runs it with nothing installed, which is the fastest way to try it, but for a team you want uv add --dev ty so everyone gets the same build; there is also a standalone shell installer that needs no Python on the machine. The setting that actually decides whether ty is useful is environment discovery: it looks at VIRTUAL_ENV, then a .venv in the project root, then an activated Conda environment, then any python3 on PATH. In a CI job that installs dependencies without activating the venv it will happily fall back to the system interpreter and drown you in unresolved-import errors, so either activate the environment or pass --python .venv. Configuration goes in [tool.ty] inside pyproject.toml or in a ty.toml, and if both exist the ty.toml wins and the pyproject table is ignored completely rather than merged. A pyproject.toml with no tool.ty table is skipped and discovery walks further up the tree, which is a real surprise in a monorepo. Finally, the target Python version defaults to the lower bound of your requires-python, so a lax requires-python = ">=3.9" makes ty check your code as 3.9 and flag syntax that your actual runtime supports; set python-version explicitly if those two ever disagree.
Patterns
Try it on a project in one commandrun-without-installing
uvx ty check
# or check specific paths
uvx ty check src scripts/backfill.pyPaths passed as positional arguments are checked even when an exclude pattern or a gitignore entry would normally skip them, unless you also pass --force-exclude. Run this from inside the project so it finds the pyproject.toml and the virtual environment.
Pin ty as a dev dependencypin-in-project
uv add --dev ty
uv run ty check
# bump it deliberately
uv lock --upgrade-package tyPinning matters more here than for most tools: on 0.0.x, a new release can change which diagnostics fire, so an unpinned CI job can fail on a commit that touched nothing. uv run also sets VIRTUAL_ENV, which is how ty finds your installed packages.
Set rule severity in the project configconfigure-rule-levels
# pyproject.toml
[tool.ty.rules]
possibly-missing-import = "error"
possibly-unresolved-reference = "warn"
redundant-cast = "ignore"
[tool.ty.terminal]
output-format = "concise"If a ty.toml exists next to this file, the whole [tool.ty] table is ignored rather than merged, which is the most common reason a config change appears to do nothing. A pyproject.toml with no [tool.ty] table at all is skipped and discovery continues up the directory tree.
Silence one diagnostic without disabling the rulesuppress-inline
a = 10 + "test" # ty: ignore[unsupported-operator]
sum_three("one", 5) # ty: ignore[missing-argument, invalid-argument-type]
# combine with another checker in one comment
sum_three("one", 5, 2) # type: ignore[arg-type, ty:invalid-argument-type]In a plain type: ignore comment, ty only reads codes prefixed with ty: and ignores everything else, so a mypy code sitting there suppresses nothing on the ty side while a bare type: ignore suppresses every rule on that line. A file-level suppression is the same comment placed on its own line before any code.
Choose what gets checkedselect-files
# pyproject.toml
[tool.ty.src]
include = ["src", "tests"]
exclude = ["src/generated", "!**/build/"]
respect-ignore-files = truePatterns are anchored to the project root, so exclude = ["src"] does not match tests/src; use **/src for that, at a cost in discovery speed. A leading ! re-includes something the built-in default exclusions dropped, which is how you get build/ checked again.
Different strictness for tests and generated coderelax-rules-per-directory
# pyproject.toml
[[tool.ty.overrides]]
include = ["tests/**", "**/test_*.py"]
[tool.ty.overrides.rules]
possibly-unresolved-reference = "warn"
[[tool.ty.overrides]]
include = ["generated/**"]
exclude = ["generated/important.py"]
[tool.ty.overrides.rules]
unresolved-attribute = "ignore"Overrides are an array of tables, so the double brackets are load-bearing and a single-bracket [tool.ty.overrides] is a different, wrong shape. When several overrides match the same file, later entries win, and any override beats the global [tool.ty.rules] table.
Stop a dependency without stubs from flooding the outputhandle-untyped-dependencies
# pyproject.toml
[tool.ty.analysis]
allowed-unresolved-imports = ["legacy_vendor.**", "!legacy_vendor.core"]
replace-imports-with-any = ["some_untyped_sdk.**"]The two settings do different things: allowed-unresolved-imports only silences the unresolved-import diagnostic, while replace-imports-with-any makes the module resolve to Any so downstream usage stops erroring too. Reach for the second one only when you accept losing all type information behind that import.
Check against the interpreter you actually deploy onpin-python-version
# pyproject.toml
[tool.ty.environment]
python-version = "3.12"
python-platform = "linux"
python = "./.venv"Without python-version, ty takes the lower bound of project.requires-python, so a permissive ">=3.9" makes it reject match statements in a service that only ever runs on 3.12. python-platform decides what sys.platform narrows to, which changes whether platform-specific stdlib attributes exist.
Change severity for one runoverride-rules-on-cli
ty check \
--error possibly-missing-attribute \
--warn unused-ignore-comment \
--ignore redundant-cast
# ratchet: everything is an error
ty check --error allLater flags override earlier ones, and command-line settings always beat both config files. --error all is the honest way to see the true size of the backlog before you decide which rules to turn down in config.
Wire it into GitHub Actionsci-exit-codes
- run: uv sync --dev
- run: uv run ty check --output-format github --error-on-warning
# during migration, report but do not block
- run: uv run ty check --output-format concise --exit-zero--error-on-warning and --exit-zero are mutually exclusive and ty rejects the combination rather than picking one. The github format emits workflow annotations so failures land inline on the diff; junit and gitlab are there for other runners.
Run continuously or as a language serverwatch-and-serve
ty check --watch # recheck affected files on save
ty server # LSP over stdio, for your editor configWatch mode uses the same incremental engine as the language server, so after the first run it only recomputes what the edit reached, which is much faster than looping ty check. ty server takes no options; everything is configured through the editor client and the project config file.
Look up what a rule actually meansexplain-a-diagnostic
ty explain rule invalid-argument-type
ty explain rule unresolved-importWorth doing before you add a suppression comment, because several ty rule names have no mypy counterpart and the fix is often a real bug rather than a checker disagreement. The full list is also generated into the rules reference on the docs site.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| mypy | PyPI | You need plugins, a decade of settled behaviour, or the strict mode your team already writes code against |
| pyright | PyPI | You want a mature checker with strong inference today and are happy running it through Node |
| pyrefly | PyPI | You want the other fast Rust checker and would rather bet on Meta's implementation than Astral's |