How mypy, ty, and Pyrefly handle untyped code
Run three Python type checkers over the same unannotated function and you get three different answers. One stays silent, one reports an error, and the third does either depending on whether a config file exists. None of them is wrong. They start from different assumptions about what to do with code you never annotated.
That single decision, what to do with untyped code, explains most of the friction that teams hit when they switch checkers. A codebase that passes mypy cleanly can light up with errors the first time you run ty or Pyrefly, without a single annotation having changed.
Two models for untyped code
Type checkers split into two camps on the question of unannotated code.
Annotation-gated checkers treat annotations as the signal to check. Code you haven’t annotated keeps its dynamic behavior and is largely left alone. mypy is the canonical example: by default it does not check the body of a function that has no annotations on its parameters or return. This is gradual typing working as designed. You add annotations where you want static guarantees and the rest of the code runs untouched.
Inference-based checkers infer types for everything, annotated or not, and check it all. ty and Pyrefly take this path, as do pyright and most of the newer checkers. They read an unannotated function, work out what its locals and return value must be, and report errors in code that mypy would skip.
How mypy handles untyped code
mypy is the annotation-gated checker. It reads the signature of every function but checks the body only when that signature carries annotations. A function with no annotations on its parameters or return is skipped, which is the single most surprising thing about mypy for people who expect it to catch obvious bugs everywhere.
The default skips unannotated function bodies
The literal type error below goes unreported because the function carries no annotations:
$ cat body.py
def f():
return 1 + "x" # literal error inside a fully unannotated body
$ mypy body.py
Success: no issues found in 1 source file
Turn on --check-untyped-defs and mypy analyzes the body:
$ mypy --check-untyped-defs body.py
body.py:2: error: Unsupported operand types for + ("int" and "str") [operator]
Found 1 error in 1 file (checked 1 source file)
Checking a body is not the same as requiring annotations
Two flags are easy to confuse. --check-untyped-defs analyzes unannotated bodies without demanding annotations. --disallow-untyped-defs does the reverse: it requires every function to be annotated and reports the missing annotation rather than the bug inside it.
$ mypy --disallow-untyped-defs body.py
body.py:1: error: Function is missing a return type annotation [no-untyped-def]
Found 1 error in 1 file (checked 1 source file)
--strict turns on both, so the same unannotated function reports the missing annotation and the operator error together:
$ mypy --strict body.py
body.py:1: error: Function is missing a return type annotation [no-untyped-def]
body.py:2: error: Unsupported operand types for + ("int" and "str") [operator]
Found 2 errors in 1 file (checked 1 source file)
How to configure mypy strict mode lists the full set of flags --strict enables.
An untyped call returns Any
The return value of an unannotated function is Any to its callers. mypy then checks nothing done with that value, which is how untyped code spreads Any into annotated code:
$ cat ret.py
def get_id():
return 1
r = get_id()
reveal_type(r)
r.upper() # int has no .upper(), but r is Any
$ mypy ret.py
ret.py:4: note: Revealed type is "Any"
Success: no issues found in 1 source file
r is Any, so r.upper() passes even though get_id() returns an int. To flag the call itself, use --disallow-untyped-calls:
$ mypy --disallow-untyped-calls ret.py
ret.py:3: error: Call to untyped function "get_id" in typed context [no-untyped-call]
Third-party imports without stubs become Any
Import a package that ships no type information (no inline annotations and no py.typed marker) and mypy cannot see its types. By default it reports the gap and treats every name from that module as Any:
$ cat imp.py
import cowsay
reveal_type(cowsay.cow)
$ mypy imp.py
imp.py:1: error: Skipping analyzing "cowsay": module is installed, but missing library stubs or py.typed marker [import-untyped]
imp.py:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
imp.py:2: note: Revealed type is "Any"
Found 1 error in 1 file (checked 1 source file)
--ignore-missing-imports (or a per-module ignore_missing_imports override) silences the error and keeps the Any type. --follow-untyped-imports instead reads the module’s source and type-checks against it, so genuine errors that the Any fallback hid, such as touching an attribute that the module never defines, start surfacing.
Restrict Any with the disallow-any flags
Once annotations are in place, a family of flags controls where Any is still tolerated. The simplest is --warn-return-any, which catches an annotated function returning an Any even though the rest of the file looks typed:
$ cat warn.py
from typing import Any
def grab() -> Any: # explicitly typed Any
return 1
def caller() -> int:
return grab() # int return, but value is Any
$ mypy warn.py
Success: no issues found in 1 source file
$ mypy --warn-return-any warn.py
warn.py:7: error: Returning Any from function declared to return "int" [no-any-return]
Found 1 error in 1 file (checked 1 source file)
The --disallow-any-* siblings reject Any in other positions: --disallow-any-explicit forbids the literal Any annotation, --disallow-any-generics forbids unparameterized generics like list instead of list[int], and --disallow-any-expr flags any expression whose inferred type is Any. Together they are mypy’s levers for stopping Any from spreading after the untyped-def flags have done their work.
How ty handles untyped code
ty is inference-based like Pyrefly, so it checks unannotated function bodies by default. One principle governs the rest of its behavior: the gradual guarantee, which holds that adding or removing annotations on working code should never introduce new errors. ty honors it by inferring Unknown, an implicit form of Any, wherever a concrete type is missing, and then declining to complain about what happens to it.
ty checks bodies but never asks for annotations
The unannotated body below is analyzed, and the literal error inside it is caught:
$ cat body.py
def f():
return 1 + "x" # literal error in a fully unannotated body
$ ty check body.py
error[unsupported-operator]: Unsupported `+` operation
--> body.py:2:12
Found 1 diagnostic
ty never reports a function for lacking annotations, though. A fully unannotated function with no internal error passes cleanly:
$ cat noann.py
def add(a, b):
return a + b
$ ty check noann.py
All checks passed!
There is no --disallow-untyped-defs equivalent. To require annotations, ty’s documentation points to Ruff’s flake8-annotations (ANN) rules, which enforce their presence as a lint separate from type checking.
Unannotated symbols are Unknown
When ty cannot read a type from an annotation, it infers Unknown. The return of an unannotated function and an unannotated parameter are both Unknown:
$ cat reveals.py
from typing import reveal_type
def get_id():
return 1
r = get_id()
reveal_type(r)
r.upper() # int has no .upper()
def greet(name: str, count):
reveal_type(count)
$ ty check reveals.py
info[revealed-type]: Revealed type
6 | reveal_type(r)
| ^ `Unknown`
info[revealed-type]: Revealed type
10 | reveal_type(count)
| ^^^^^ `Unknown`
Found 2 diagnostics
The consequence shows on line 7, where r.upper() is silent. Because get_id() is unannotated, ty types its return as Unknown rather than inferring int, so it raises no objection to calling a string method on the result. This is the gradual guarantee at work: ty will not flag code that has no annotations to check against, which is the exact spot where Pyrefly (inferring int) reports an error. ty’s Unknown here plays the same role as mypy’s Any.
Untyped imports are analyzed, not waved through
mypy treats a stubless package as Any and stays silent until you pass --follow-untyped-imports. ty reads the module’s source by default. Importing the same stubless cowsay and touching an attribute that it does not define is caught out of the box:
$ cat imp.py
from typing import reveal_type
import cowsay
reveal_type(cowsay.cow)
$ ty check imp.py
error[unresolved-attribute]: Module `cowsay` has no member `cow`
--> imp.py:3:13
info[revealed-type]: Revealed type
3 | reveal_type(cowsay.cow)
| ^^^^^^^^^^ `Unknown`
Found 2 diagnostics
ty issues no “missing stubs” warning. It analyzes whatever source it can resolve and falls back to Unknown only where it genuinely cannot infer a type.
Nothing here is configurable
ty exposes no setting that changes how it treats untyped code. There is no check_untyped_defs to flip and no preset to choose. The [tool.ty.rules] table adjusts the severity of individual diagnostics, turning a rule into a warning or silencing it, but it cannot make ty skip unannotated bodies or demand annotations. The gradual guarantee is the design, not a default you opt into.
How Pyrefly handles untyped code
Pyrefly is an inference-based checker: by default it analyzes unannotated function bodies and reports errors that mypy would skip. It is also the most configurable of the three on this axis, with three separate settings that control how far inference reaches. The catch is that with no config file at all, Pyrefly skips unannotated bodies, the opposite of what its inference-based reputation suggests.
The no-config default skips unannotated bodies
Run pyrefly check in a project with no pyrefly.toml and Pyrefly applies its basic preset, which sets check-unannotated-defs = false. In that mode it skips unannotated bodies exactly like mypy:
$ cat body.py
def f():
return 1 + "x" # literal error inside a fully unannotated body
$ pyrefly check body.py
INFO 0 errors
No `pyrefly.toml` found — using preset `basic`.
Run `pyrefly init` to continue setting up Pyrefly.
The moment a config file exists, the option default (check-unannotated-defs = true) takes over and the same file reports the error:
$ echo > pyrefly.toml # an empty config is enough
$ pyrefly check body.py
ERROR `+` is not supported between `Literal[1]` and `Literal['x']` [unsupported-operation]
--> body.py:2:12
Running pyrefly init is what flips the project off the basic preset and onto full inference, which is why teams see Pyrefly “suddenly find errors” right after initializing. The basic and legacy presets keep check-unannotated-defs = false; the default and strict presets set it to true.
Three settings control how far inference reaches
With a config in place, three options govern Pyrefly’s treatment of untyped code:
| Option | Default | What it controls |
|---|---|---|
check-unannotated-defs |
true |
Whether the bodies of fully unannotated functions are analyzed at all |
infer-return-types |
"checked" |
Whether an analyzed function’s return propagates a real type or Any ("checked", "annotated", "never") |
infer-with-first-use |
true |
Whether an empty container like [] takes its element type from later use |
The effect is visible with reveal_type. Under the full-inference defaults, Pyrefly infers a concrete type for an unannotated return and a populated container, so misuse of either is caught:
$ cat reveals.py
from typing import reveal_type
def get_id():
return 1
r = get_id()
reveal_type(r)
r.upper() # int has no .upper()
x = []
x.append(1)
reveal_type(x)
$ pyrefly check reveals.py # check-unannotated-defs=true, infer-return-types="checked", infer-with-first-use=true
INFO revealed type: Literal[1] [reveal-type]
ERROR Object of class `int` has no attribute `upper` [missing-attribute]
--> reveals.py:7:1
INFO revealed type: list[int] [reveal-type]
INFO 1 error
The return of get_id() is inferred as Literal[1], so r is an int and r.upper() is rejected. The empty list x takes its element type from the later x.append(1), becoming list[int].
Set infer-return-types = "never" and infer-with-first-use = false and Pyrefly stops propagating those inferred types. The same code reveals Unknown, and the r.upper() misuse goes silent:
$ pyrefly check reveals.py # infer-return-types="never", infer-with-first-use=false
INFO revealed type: Unknown [reveal-type]
INFO revealed type: list[Unknown] [reveal-type]
INFO 0 errors
Unknown here is the same implicit-Any placeholder ty uses: operations on it are not checked.
Unannotated parameters stay Unknown
No setting makes Pyrefly infer a parameter type from the function body. Wherever Pyrefly analyzes a function, an unannotated parameter is Unknown:
$ cat partial.py
from typing import reveal_type
def greet(name: str, count): # name annotated, count not
reveal_type(count)
$ pyrefly check partial.py
INFO revealed type: Unknown [reveal-type]
This matters for partially annotated functions: annotating the return or some parameters does not retroactively type the ones you left bare. To catch misuse of count, annotate it.
Make Pyrefly match mypy
To reproduce mypy’s default behavior (skip unannotated bodies, treat unannotated returns as Any):
# pyrefly.toml
check-unannotated-defs = false
infer-return-types = "never"To match mypy --check-untyped-defs (analyze the bodies, but still treat unannotated returns as Any):
# pyrefly.toml
check-unannotated-defs = true
infer-return-types = "never"pyrefly init writes a starting config for you and, when it finds a mypy configuration to migrate, emits the legacy preset so a previously mypy-clean codebase does not flood you with new errors on the first run. The older untyped-def-behavior option is deprecated; its "check-and-infer-return-type" value maps to check-unannotated-defs = true with infer-return-types = "checked".
When inference itself disagrees
The split so far is about whether a checker looks at untyped code. The checkers also disagree on what they infer once they do look, and the clearest case is the return of an unannotated function:
def get_id():
return 1
r = get_id()
reveal_type(r)
r.upper() # int has no .upper()Pyrefly infers the concrete return type and reports the misuse. mypy and ty both stay silent: mypy types the return Any, ty types it Unknown, and neither objects to r.upper():
| Checker | Revealed type of r |
r.upper() |
|---|---|---|
| mypy | Any |
silent |
| ty | Unknown |
silent |
| Pyrefly | Literal[1] |
error: int has no attribute upper |
The practical effect: the same annotation-free bug is caught by Pyrefly and missed by mypy and ty. Switching checkers can surface or hide bugs on identical, annotation-free code. Moving between them is a rule-tuning exercise, not a drop-in swap.
Map each behavior to its setting
The same untyped-code question gets a different answer and a different knob in each checker:
| Behavior | mypy | ty | Pyrefly |
|---|---|---|---|
| Check unannotated bodies? | Off by default; --check-untyped-defs (or --strict) |
Always on, not configurable | On once a config file exists (check-unannotated-defs); off under the no-config basic preset |
| Require annotations on defs? | --disallow-untyped-defs (or --strict) |
No equivalent; use Ruff flake8-annotations |
No equivalent; use Ruff flake8-annotations |
| Return type of an unannotated call | Any; --disallow-untyped-calls flags the call site |
Unknown |
Inferred via infer-return-types ("never" falls back to Unknown) |
| Untyped third-party import (no stubs) | Any plus an [import-untyped] warning; --follow-untyped-imports to analyze the source |
Analyzes the source; Unknown where it cannot infer |
Analyzes the source; Unknown where it cannot infer |
Empty-container element (x = []) |
list[int] from later use |
list[int] from later use |
list[int] via infer-with-first-use (off gives list[Unknown]) |
Two patterns fall out of the table. mypy is the only checker that skips unannotated bodies by default and the only one that treats a stubless import as opaque Any rather than reading its source. ty is the only checker with no knobs at all, because the gradual guarantee fixes its behavior by design.