Linters, formatters and type checkers
Tools that read source code without running it: linters flag likely bugs, formatters reprint the layout, and type checkers check that values have the types the code claims.
4 technologies on this page ↓How it works
All three start the same way: they parse each file into a syntax tree, without running anything. A linter walks that tree and applies rules, each a small check such as “this import is never used” or “this comparison is always true”; many rules come with an automatic fix. Ruff has over 900 rules for Python, Clippy over 800 lints for Rust, and ESLint hundreds for JavaScript. A formatter ignores how the code was laid out and prints the tree again in one canonical style, so spacing, quotes and line breaks stop being a matter of taste; Black even checks that the reformatted code still parses to an equivalent tree. A type checker works out the type of every expression, from annotations where they exist and by inference where they don’t, and reports where a value can’t have the type the code needs, such as an int passed where a str is expected. In Python and with TypeScript’s tsc, this happens entirely before the program runs, and Python ignores annotations at runtime. The three overlap little: a linter sees an unused import that a type checker skips, and a type checker sees a wrong argument type that a linter misses. Teams usually run all three on save in the editor (through a language server), again before commit, and in CI with a check-only flag so the build fails on problems but never rewrites code.
A manuscript before printing. The typesetter (formatter) fixes spacing and margins without touching the words. The copy editor (linter) works through a checklist of common mistakes and corrects most of them. The fact-checker (type checker) checks every claim against the references and only reports what doesn’t match.
Reach for it when
- Any codebase with more than one author: a formatter ends style debates in review because there is nothing left to decide
- In CI, with check-only flags (
ruff format --check,ruff check, a type checker’s exit code) so a pull request can’t merge with findings - Dynamically typed code (Python, JavaScript through TypeScript) where a wrong type would otherwise only show up at runtime
Think twice when
- Switching on every rule in an old codebase at once; the thousands of findings get ignored. Start small, or record existing errors as a baseline (
basedpyright --writebaseline,pyrefly suppress) - Treating a clean type check as proof the program works: Python doesn’t enforce annotations at runtime, and untyped libraries or
Anyhide errors - Alternating two formatters on the same files: Ruff, for one, says its formatter isn’t meant to be used interchangeably with Black
A real example
A small Python service has a 17-line module with three issues and a bug. ruff check reports three findings in milliseconds (unsorted imports, an unused import os, an old-style Optional[str]), and ruff check --fix repairs all three. ruff format changes one line, orders=[...] to orders = [...]. Neither notices that report() assigns the int returned by total() to a variable declared str; the type checker does, at line 16, column 18. After the fix, CI runs uv sync --locked, ruff check, ruff format --check and basedpyright in sequence, and all four exit with code 0.
Kinds of linters, formatters and type checkers
Specific products, and what each does differently.
Linters →
Rule engines that flag likely bugs, unused code and risky or outdated patterns, and fix many of them automatically.
Formatters →
Tools that reprint code in one canonical layout, so style never comes up in review.
Type checkers →
Tools that infer and check the type of every value without running the program.
Three tools, three questions#
They read the same syntax tree and answer different questions, which is why projects run all three.
| Kind | Question it answers | Changes your code? | Examples |
|---|---|---|---|
| Linter | Is this code probably wrong, risky or outdated? | Only fixes you ask for (--fix) | Ruff, ESLint, Clippy, go vet |
| Formatter | Is it laid out the canonical way? | Yes, that is its job; --check only reports | gofmt, Prettier, Black, ruff format |
| Type checker | Can every value have the type the code needs? | No, it only reports | TypeScript’s tsc, mypy, Pyright, basedpyright, Pyrefly, ty |
One Python project, step 1: uv creates it and adds the tools#
uv (0.13.0 here) creates the project, records Pydantic as a dependency and Ruff plus basedpyright as development dependencies in [dependency-groups], and pins all of them in uv.lock. Everyone who runs uv sync gets the same tool versions, so the editor, a teammate’s laptop and CI report the same findings. Output trimmed where marked.
uv init shop && cd shop # pyproject.toml, .python-version, src/shop/__init__.py
uv add pydantic
# Using CPython 3.12.13
# Creating virtual environment at: .venv
# Resolved 6 packages in 12ms
# + pydantic==2.14.0 (plus its 4 dependencies and the project itself)
uv add --dev ruff basedpyright
# Resolved 9 packages in 6ms
# + basedpyright==1.40.2
# + nodejs-wheel-binaries==24.19.0 (basedpyright bundles Node.js, so no npm is needed)
# + ruff==0.17.0Step 2: one pyproject.toml configures all three#
uv init wrote the first four tables and uv add filled in the dependencies; the [tool.*] tables at the end are added by hand. Ruff reads [tool.ruff]; basedpyright reads [tool.basedpyright] and finds the .venv uv created without being told. Its own default mode, recommended, turns on every rule, so this project starts from Pyright’s standard set.
[project]
name = "shop"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.14.0",
]
[project.scripts]
shop = "shop:main"
[build-system]
requires = ["uv_build>=0.13.0,<0.14.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"basedpyright>=1.40.2",
"ruff>=0.17.0",
]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"] # pycodestyle, Pyflakes, isort, bugbear, pyupgrade
[tool.basedpyright]
typeCheckingMode = "standard"Step 3: a module with three lint findings, one layout slip and one bug#
src/shop/orders.py. The bug is in report(): total() returns an int, but the result is assigned to a variable declared str. Python would run this without complaint.
import os
from typing import Optional
from pydantic import BaseModel
class Order(BaseModel):
id: int
total_cents: int
note: Optional[str] = None
def total(orders: list[Order]) -> int:
return sum(o.total_cents for o in orders)
def report() -> str:
orders=[Order(id=1, total_cents=1299), Order(id=2, total_cents=450)]
label: str = total(orders)
return labelStep 4: lint, fix, format, type-check#
Real output from Ruff 0.17.0 and basedpyright 1.40.2 (based on Pyright 1.1.414); long paths shortened to …/shop. Each tool exits with code 1 when it finds something, which is what fails a CI job.
uv run ruff check --output-format concise
# src/shop/orders.py:1:1: I001 [*] Import block is un-sorted or un-formatted
# src/shop/orders.py:1:8: F401 [*] `os` imported but unused
# src/shop/orders.py:9:11: UP045 [*] Use `X | None` for type annotations
# Found 3 errors.
# [*] 3 fixable with `--fix`.
uv run ruff check --fix # sorts imports, drops os and Optional, writes str | None
# Found 3 errors (3 fixed, 0 remaining).
uv run ruff format # one change: orders=[...] becomes orders = [...]
# 1 file reformatted, 2 files left unchanged
uv run basedpyright # the bug Ruff can’t see
# …/shop/src/shop/orders.py
# …/shop/src/shop/orders.py:16:18 - error: Type "int" is not assignable to declared type "str"
# "int" is not assignable to "str" (reportAssignmentType)
# 1 error, 0 warnings, 0 notesStep 5: fix the bug, then run what CI runs#
With report() changed to return f"{total(orders) / 100:.2f} EUR", the whole gate passes. --locked makes uv sync fail if uv.lock is stale instead of updating it.
uv sync --locked && uv run ruff check && uv run ruff format --check && uv run basedpyright
# Resolved 9 packages in 5ms
# Checked 9 packages in 1ms
# All checks passed!
# 3 files already formatted
# 0 errors, 0 warnings, 0 notes
uv run python -c "from shop.orders import report; print(report())"
# 17.49 EURSwapping the type checker: Pyright and Pyrefly#
The checkers are interchangeable at this level, with two traps worth knowing. A checker started outside the project environment can’t see its dependencies. And Pyrefly 1.3, finding no [tool.pyrefly] table, chose its low-noise basic preset, which skips assignment checks; asking for the default preset (or adding the table) finds the bug. Run on the file from step 4, before the fix.
uvx pyright # runs in uvx’s own environment, not the project’s .venv
# …/shop/src/shop/orders.py:1:6 - error: Import "pydantic" could not be resolved (reportMissingImports)
uv run --with pyright pyright # same checker inside the project environment
# …/shop/src/shop/orders.py:16:18 - error: Type "int" is not assignable to declared type "str"
uvx pyrefly check
# INFO 0 errors
# No `pyrefly.toml` found — using preset `basic`.
uvx pyrefly check --preset default
# ERROR `int` is not assignable to `str` [bad-assignment]
# --> src/shop/orders.py:16:18
# INFO 1 errorIn code · python
import os # linter: F401, `os` imported but unused
# formatter: reprints the next line as `def total(prices: list[int]) -> int:`
def total(prices:list[int])->int:
return sum(prices)
# type checker: "int" is not assignable to "str"
label: str = total([1299, 450])
# linter: E711, comparison to `None` should be `is None`
if label == None:
print("empty")
print(label) # runs anyway and prints 1749: Python doesn't check annotationsOften confused with
Where it came from1978–2025
In 1978 Stephen C. Johnson at Bell Labs was writing a yacc grammar for C and dealing with portability problems from porting Unix to a 32-bit machine. The checker Johnson wrote, lint, shipped outside Bell Labs with Unix V7 in 1979 and gave its name to a whole category of tools, even though the original did static analysis rather than style checks.
Formatters took longer to win. Go’s 2013 blog post on gofmt could already report that about 70% of Go packages in the wild were formatted with it. JavaScript followed with Prettier in 2017 and Python with Black, both selling the same deal: give up control over layout and never discuss it again.
Optional types for dynamic languages grew alongside. Jukka Lehtosalo started mypy in 2012, and PEP 484 (2014) turned its annotation style into standard Python syntax while leaving checking to outside tools, which is why Python now has several competing checkers. Since 2022 a wave of tools rewritten in Rust has made all three kinds much faster: Ruff for linting and formatting, then Pyrefly and ty for types.
Why it’s built this way
- Reprint everything instead of flagging style
- A formatter that rebuilds the layout from the syntax tree leaves nothing to argue about, and frees linters to focus on rules that catch real bugs.
- Few options, on purpose
- Prettier’s maintainers found that every option reopened the style debate as a debate about options; Black asks users to cede control in return for speed and determinism.
- Optional types, checked by a separate tool
- PEP 484 kept annotations out of the runtime so existing code kept working and checking stayed voluntary, letting projects add types gradually.
- Rewrite the tools in a compiled language
- Ruff’s author argued Python tooling could be much faster, as esbuild and swc had shown for JavaScript: linting CPython, Ruff’s first release was about 150 times faster than Flake8 on macOS (about 25 times with Flake8 hacked to use multiprocessing).
Timeline
- 1978
Stephen C. Johnson writes
lintfor C at Bell Labs - 2012
Jukka Lehtosalo starts mypy
- 2013
About 70% of Go packages already use gofmt
- 2014
PEP 484 proposes standard type hints for Python 3.5
- 2017
Prettier 1.0 for JavaScript
- 2022
Ruff, a Python linter written in Rust
- 2025
Rust-based Python type checkers: Pyrefly (alpha, May) and ty (beta, December)
- Wikipedia: Lint (software)
- Go blog: go fmt your code (Jan 2013)
- Prettier blog: Prettier 1.0 (Apr 2017)
- Prettier docs: Option philosophy
- Black docs: The uncompromising code formatter
- mypy: About (started 2012)
- PEP 484: Type hints (created Sep 2014)
- Charlie Marsh: Python tooling could be much, much faster (Aug 2022)
- Pyrefly blog: Introducing Pyrefly (May 2025)
- Astral blog: ty (Dec 2025)
Sources20
- Ruff FAQ: How does Ruff compare to ty, Mypy, Pyright or Pyre?
- Ruff README
- Ruff docs: The Ruff formatter (Black compatibility, interchangeability)
- Ruff docs: Configuring Ruff
- Ruff v0.17.0 release notes (Oct 2026)
- Clippy documentation: Introduction
- ESLint docs: Core concepts
- Black docs: The uncompromising code formatter
- Prettier docs: Prettier vs. linters
- PEP 484: Type hints
- TypeScript handbook: The basics
- uv docs: Managing dependencies (dependency groups)
- uv docs: Locking and syncing (--locked)
- uv 0.13.0 release notes (Oct 2026)
- basedpyright docs: Better defaults (recommended mode, .venv detection)
- basedpyright docs: Baseline
- basedpyright docs: PyPI package (bundled Node.js)
- Pyright docs: Configuration (typeCheckingMode)
- Pyrefly docs: Configuration (presets, automatic configuration)
- Pyrefly README (pyrefly suppress)