# Set up type checking for a Python project with ty

A type checker reads your code without running it and reports the places where the types do not line up. This tutorial adds [ty](https://pydevtools.com/handbook/reference/ty.md) to a [uv](https://pydevtools.com/handbook/reference/uv.md) project, plants three bugs that a test suite would miss, reads what ty says about each one, and tunes which rules ty enforces.

{{< callout type="info" >}}
The handbook recommends [Pyrefly](https://pydevtools.com/handbook/reference/pyrefly.md) as the default type checker for new projects, because ty is still in beta. Choose ty when you want one toolchain across linting, packaging, and type checking, or when you value its gradual guarantee that annotating working code never introduces new errors. [ty vs Pyrefly](https://pydevtools.com/handbook/explanation/ty-vs-pyrefly.md) covers the tradeoff.
{{< /callout >}}

## Prerequisites

[Install uv on your system.](https://pydevtools.com/handbook/how-to/how-to-install-uv.md) You do not need Python installed separately; uv fetches an interpreter for you.

## Create a project to check

```console
$ uv init --no-package ty-demo
Initialized project `ty-demo` at `/path/to/ty-demo`
$ cd ty-demo
```

If you see `error: command not found: uv`, finish the [installation guide](https://pydevtools.com/handbook/how-to/how-to-install-uv.md) and reopen your shell.

`--no-package` asks for a flat layout with `main.py` at the project root; a plain `uv init` scaffolds a `src/` package instead. Either works with ty, and the flat layout keeps the file paths in the output short.

## Write code that hides three bugs

Replace the contents of `main.py` with a small user lookup:

```python {filename="main.py"}
def find_user(users: list[dict[str, str]], name: str) -> dict[str, str] | None:
    for user in users:
        if user["name"] == name:
            return user
    return None


def greet(name: str) -> str:
    return f"Hello, {name}!"


def count_label(users: list[dict[str, str]]) -> str:
    return len(users)


people = [{"name": "Ada", "email": "ada@example.com"}]

found = find_user(people, "Ada")
print(greet(found["name"]))
print(greet(len(people)))
print(count_label(people))
```

Every function here carries type annotations, and that is what gives ty something to check. The annotation `dict[str, str] | None` on `find_user` is a promise: callers might get a dictionary back, or they might get `None`.

Run it:

```console
$ uv run main.py
Hello, Ada!
Hello, 1!
1
```

The program prints three lines with no traceback and exits 0. A test suite that asserts on this output would pass.

The file still holds three defects. `find_user` can return `None`, and the next line subscripts the result without checking. `count_label` declares that it returns a `str` and returns an `int`. `greet` declares a `str` parameter and gets handed an `int`.

None of the three fire today, because Ada happens to be in the list and an f-string formats an `int` as happily as a `str`. They fire on the day someone looks up a name that is missing, or calls `.upper()` on what `count_label` returned.

## Add ty as a development dependency

```console
$ uv add --dev ty
Using CPython 3.14.6
Creating virtual environment at: .venv
Resolved 2 packages in 13ms
Installed 1 package in 5ms
 + ty==0.0.65
```

Your Python and ty versions may differ. The new `.venv/` directory is the project's virtual environment, created because this is the first dependency. uv records ty under `[dependency-groups]` in [pyproject.toml](https://pydevtools.com/handbook/reference/pyproject.toml.md) as a minimum version and pins the resolved version in `uv.lock`, so a collaborator who runs `uv sync` gets the same checker you have.

To evaluate ty on a codebase without committing to it, run `uvx ty check` instead. That checks the project without adding anything to your dependencies.

## Run your first check

```console
$ uv run ty check
error[invalid-return-type]: Return type does not match returned value
  --> main.py:12:49
   |
12 | def count_label(users: list[dict[str, str]]) -> str:
   |                                                 --- Expected `str` because of return type
13 |     return len(users)
   |            ^^^^^^^^^^ expected `str`, found `int`
   |

error[not-subscriptable]: Cannot subscript object of type `None` with no `__getitem__` method
  --> main.py:19:13
   |
19 | print(greet(found["name"]))
   |             ^^^^^^^^^^^^^
   |

error[invalid-argument-type]: Argument to function `greet` is incorrect
  --> main.py:20:13
   |
20 | print(greet(len(people)))
   |             ^^^^^^^^^^^ Expected `str`, found `int`
   |
info: Function defined here
 --> main.py:8:5
  |
8 | def greet(name: str) -> str:
  |     ^^^^^ --------- Parameter declared here
  |

Found 3 diagnostics
```

ty found three defects in a program that exited 0 a moment ago, by reading annotations alone.

## Read one diagnostic closely

Take the last one apart, because every ty diagnostic follows the same shape:

* `error` is the severity. Rules can also report at `warn` level, which you will configure later in this tutorial.
* `[invalid-argument-type]` is the rule name. This is the string you use to look the rule up or to suppress it.
* `--> main.py:20:13` is the file, line, and column.
* The excerpt with carets marks the exact expression at fault, and the trailing note names both types.
* The `info:` block points at the *other* end of the mismatch: the `greet` signature that declared the expectation.

Two locations per diagnostic is the part worth internalizing. A type error is a disagreement between a declaration and a use, and ty shows you both sides so you can decide which one is wrong.

## Honor the return type you declared

Start at the top of the output. `count_label` promises a `str` and hands back whatever `len()` returns, which is an `int`. The declaration is the useful half here, so change the body to match:

```python {filename="main.py"}
def count_label(users: list[dict[str, str]]) -> str:
    return str(len(users))
```

```console
$ uv run ty check
error[not-subscriptable]: Cannot subscript object of type `None` with no `__getitem__` method
  --> main.py:19:13
   |
19 | print(greet(found["name"]))
   |             ^^^^^^^^^^^^^

error[invalid-argument-type]: Argument to function `greet` is incorrect
  --> main.py:20:13
   |
20 | print(greet(len(people)))
   |             ^^^^^^^^^^^ Expected `str`, found `int`
info: Function defined here
 --> main.py:8:5
  |
8 | def greet(name: str) -> str:
  |     ^^^^^ --------- Parameter declared here

Found 2 diagnostics
```

## Rule out None

The next error says `found` might be `None`, and `None` cannot be subscripted. `find_user` returns `None` when no match exists, and nothing between the call and the subscript rules that out.

Handle the missing case explicitly:

```python {filename="main.py"}
found = find_user(people, "Ada")
if found is None:
    raise SystemExit("No user named Ada")

print(greet(found["name"]))
```

```console
$ uv run ty check
error[invalid-argument-type]: Argument to function `greet` is incorrect
  --> main.py:23:13
   |
23 | print(greet(len(people)))
   |             ^^^^^^^^^^^ Expected `str`, found `int`
info: Function defined here
 --> main.py:8:5
  |
8 | def greet(name: str) -> str:
  |     ^^^^^ --------- Parameter declared here

Found 1 diagnostic
```

The subscript on the following line is untouched, yet its error is gone. The `if found is None` guard proved to ty that `None` is impossible past that point, so ty narrowed the type of `found` from `dict[str, str] | None` to `dict[str, str]` for the rest of the block. This is type narrowing, and it is why a guard clears errors on lines you never edited.

## Pass the type the function asks for

`len(people)` is an `int`, and `greet` declares `name: str`. You already have a function that turns the count into a string, so use it:

```python {filename="main.py"}
print(greet(count_label(people)))
```

```console
$ uv run ty check
All checks passed!
```

Run the program to confirm the output is unchanged:

```console
$ uv run main.py
Hello, Ada!
Hello, 1!
1
```

Same three lines, and now the annotations are telling the truth about the code.

## Look up a rule you do not recognize

`ty explain rule` prints the definition of any rule name you see in the output:

```console
$ uv run ty explain rule unresolved-attribute
# unresolved-attribute

Default level: error | Stable (since 0.0.1-alpha.1)

## What it does

Checks for unresolved attributes.

## Why is this bad?

Accessing an unbound attribute will raise an `AttributeError` at runtime.
An unresolved attribute is not guaranteed to exist from the type alone,
so this could also indicate that the object is not of the type that the user expects.

... (a short annotated code example follows)
```

The `Default level` line tells you what you would be changing if you configured this rule, and the stability marker tells you how settled the rule is. Run `ty explain rule --help` to see how to dump every rule at once, or browse the [rules reference](https://docs.astral.sh/ty/reference/rules/).

## Silence a diagnostic ty gets wrong

Static analysis cannot follow every dynamic trick, so sometimes ty flags code that is correct. Create `settings.py` with an attribute assigned through `setattr`:

```python {filename="settings.py"}
class Settings:
    pass


settings = Settings()
setattr(settings, "debug", True)

print(settings.debug)
```

```console
$ uv run ty check
error[unresolved-attribute]: Object of type `Settings` has no attribute `debug`
 --> settings.py:8:7
  |
8 | print(settings.debug)
  |       ^^^^^^^^^^^^^^

Found 1 diagnostic
```

ty is right that `Settings` declares no `debug` attribute and wrong that this breaks at runtime. Suppress this one line with a comment that names the rule:

```python {filename="settings.py"}
print(settings.debug)  # ty: ignore[unresolved-attribute]
```

```console
$ uv run ty check
All checks passed!
```

A bare `# ty: ignore` with no rule name also works, but it hides every diagnostic on that line, including future ones, so always name the rule you mean. Either form can also sit on its own line directly above the code, which helps when the offending line is already long.

Suppressions rot, so ty tracks them. Declare the attribute properly and the suppression has nothing left to hide:

```python {filename="settings.py"}
class Settings:
    debug: bool = False


settings = Settings()
settings.debug = True

print(settings.debug)  # ty: ignore[unresolved-attribute]
```

```console
$ uv run ty check
warning[unused-ignore-comment]: Unused `ty: ignore` directive
 --> settings.py:8:24
  |
8 | print(settings.debug)  # ty: ignore[unresolved-attribute]
  |                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
help: Remove the unused suppression comment
  |
7 |
  - print(settings.debug)  # ty: ignore[unresolved-attribute]
8 + print(settings.debug)
  |
```

A suppression that outlives its error becomes a diagnostic of its own, so the comments you leave behind do not quietly accumulate.

## Change a rule's level in pyproject.toml

Per-line comments suit one-off exceptions. When a whole rule is more noise than signal for your project, set its level in `pyproject.toml`. Every rule accepts `error`, `warn`, or `ignore`.

Put `settings.py` back to the `setattr` version and drop the suppression comment, so there is a live diagnostic to work with. Then add:

```toml {filename="pyproject.toml"}
[tool.ty.rules]
unresolved-attribute = "warn"
```

```console
$ uv run ty check
warning[unresolved-attribute]: Object of type `Settings` has no attribute `debug`
 --> settings.py:8:7
  |
8 | print(settings.debug)
  |       ^^^^^^^^^^^^^^

Found 1 diagnostic
```

The severity changed from `error` to `warning`, and the diagnostic still prints.

{{< callout type="warning" >}}
Downgrading a rule to `warn` does not make the check pass. `ty check` exits with status 1 whenever it finds any diagnostic, warnings included, so a pre-commit hook or CI step still fails. Pass `--exit-zero-on-warning` when you want warnings to be advisory and only errors to fail the build.
{{< /callout >}}

Setting the rule to `ignore` removes the diagnostic entirely:

```toml {filename="pyproject.toml"}
[tool.ty.rules]
unresolved-attribute = "ignore"
```

```console
$ uv run ty check
All checks passed!
```

## Relax a rule for one file

Turning off `unresolved-attribute` everywhere costs you the check in the code that needs it most. Scope the exception to the file that earned it instead.

Delete the `[tool.ty.rules]` table you just added and replace it with an overrides entry:

```toml {filename="pyproject.toml"}
[[tool.ty.overrides]]
include = ["settings.py"]

[tool.ty.overrides.rules]
unresolved-attribute = "ignore"
```

Removing the global table matters. A project-wide `unresolved-attribute = "ignore"` silences the rule everywhere, and leaving it in place next to the override means `main.py` keeps the rule turned off no matter what the override says.

```console
$ uv run ty check
All checks passed!
```

`main.py` still gets the full rule set; only `settings.py` is exempt. `include` accepts glob patterns, which makes this the right tool for relaxing rules across a directory such as generated code or a test suite. The [configuration reference](https://docs.astral.sh/ty/reference/configuration/) lists every setting these tables accept.

## Keep the check running

You now have the loop: write annotated code, run `uv run ty check`, then fix what is real and configure what is not. Two things make it stick.

Run ty in your editor rather than only in the terminal. ty ships a language server that reports the same diagnostics as you type, and [How to try the ty type checker](https://pydevtools.com/handbook/how-to/how-to-try-the-ty-type-checker.md) covers the setup for VS Code, Zed, Neovim, and PyCharm.

Run ty in continuous integration so a mismatch cannot reach the default branch. [How to use ty in CI](https://pydevtools.com/handbook/how-to/how-to-use-ty-in-ci.md) has the GitHub Actions job.

## Learn More

- [ty: A Complete Guide](https://pydevtools.com/handbook/explanation/ty-complete-guide.md) goes deeper on ty's type system, the gradual guarantee, and its behavior on unannotated code
- [How do mypy, pyright, and ty compare?](https://pydevtools.com/handbook/explanation/how-do-mypy-pyright-and-ty-compare.md) sets ty against the alternatives on speed, conformance, and maturity
- [How to gradually adopt type checking in an existing Python project](https://pydevtools.com/handbook/how-to/how-to-gradually-adopt-type-checking-in-an-existing-python-project.md) handles the case this tutorial skipped: a large codebase that reports hundreds of diagnostics on day one
- [Set up Ruff for formatting and checking your code](https://pydevtools.com/handbook/tutorial/set-up-ruff-for-formatting-and-checking-your-code.md) adds the linting and formatting half of the same toolchain
- [ty documentation](https://docs.astral.sh/ty/) is the official reference
- [ty playground](https://play.ty.dev) checks snippets in the browser, which is handy for sharing a reproduction
