Formatters, Linters, Type Checkers, and That Thing Called LSP

2026/02/15

BUILDdevex🍞

Python has this thing where six tools run on your code before it even executes. Formatter, linter, type checker, language server — they all read your source, they all complain about different things, and if you’re honest, you’re not totally sure where one ends and the other begins.

Four tools, four layers. Each catches things the previous one can’t.

Formatter — make it pretty

A formatter rewrites your code to follow consistent style. It doesn’t change behavior. At all. It just moves your parentheses around.

# before
x=1
def foo( a,b):
    return a +b

# after — same logic, consistent style
x = 1
def foo(a, b):
    return a + b

Spaces around =, commas, line lengths. The formatter doesn’t know what your code does — it knows what it looks like.

Tools: Black, autopep8, ruff format.

Linter — this looks suspicious

A linter reads your code and flags things that are probably mistakes. Still doesn’t run it. Pattern-matches on syntax.

import os          # ⚠️ imported but never used
unused = 42        # ⚠️ assigned but never read
if x == True:      # ⚠️ just write 'if x:'

The key word is suspicious. The linter can’t prove these are bugs. Maybe you’ll use os later. But statistically, an unused import is a mistake.

Linters are file-local. One file at a time. They don’t know or care what other files in your project do.

Tools: Flake8, pylint, ruff check.

Type checker — this will crash

Here’s where it gets interesting.

A type checker doesn’t pattern-match on syntax — it traces values through your code. It builds a model of what every variable is, what every function returns, and checks whether the pieces fit.

def add(a: int, b: int) -> int:
    return a + b

add("hello", 3)   # ❌ str + int will crash at runtime

The linter wouldn’t catch this. Syntactically, it’s a fine function call. But the type checker knows "hello" is a str, a expects int, and str + int is going to blow up.

It goes deeper:

def get_user(id: int) -> User | None:
    ...

user = get_user(5)
print(user.name)    # ❌ user might be None

The type checker is reasoning about what could happen. That get_user might return None. Access .name on None, you crash. This is the class of bugs that shows up at 2 AM in production when a specific database query returns no results.

Here’s the catch: type checking is cross-file. Change a type in models.py, and the type checker needs to recheck every file that imports from it. Fundamentally more expensive than linting — and harder to run incrementally.

Tools: mypy, pyright, ty.

LSP — show me in the editor

LSP isn’t a tool. It’s a communication protocol — a pipe between your editor and the actual tools.

Without LSP: you run ruff check file.py in your terminal, read the output, find the line, fix it.

With LSP: red squiggles as you type. Hover over a function, see its signature. Keyboard shortcut, auto-fix the import.

Same analysis, different delivery mechanism. Any editor that speaks LSP connects to any tool that speaks LSP. That’s the whole point.

Tools: ruff server (built into the ruff binary), pyright, pylsp.

The stack, in order

These four layers form a stack. Each sees deeper into your code than the last:

Reads code Changes code Understands types In-editor
Formatter ✓ (rewrites) via LSP
Linter some auto-fix shallow via LSP
Type checker ✗ (reports) deep via LSP
LSP ✓ (pipe)

And they get progressively more expensive — which is why you run them in this order in CI:

format: ~1s    lint: ~3s    type check: ~30s    tests: ~minutes

Fail fast on the cheap stuff.

Incremental CI — where the split shows

In CI, you want to check only what changed. Whether that’s easy or hard depends entirely on one thing: is the tool file-local or cross-file?

Formatter and linter — trivially incremental. They analyze each file in isolation. Changed foo.py? Check foo.py. Done.

CHANGED=$(git diff --name-only origin/main -- '*.py')
ruff format --check $CHANGED
ruff check $CHANGED

Type checker — not so easy.

# types.py (you changed this)
class User:
    age: int        # was str, changed to int

# views.py (you didn't touch this)
def display(user: User):
    print("Age: " + user.age)  # ❌ str + int — NOW broken

Your change in types.py broke views.py — but views.py isn’t in the diff. Check only changed files and you miss it completely.

File-local tools are embarrassingly parallelizable and trivially incremental. Cross-file tools need a dependency graph — “if types.py changed, recheck everything that imports from it.” That’s a harder problem, and it’s why type checking is the expensive step in any CI pipeline.

The Astral stack

Astral is doing to code quality tools what uv did to packaging. Same playbook: take fragmented Python tooling, rewrite in Rust, ship as one binary.

Tool Replaces Layer
ruff check Flake8, pylint Linter
ruff format Black Formatter
ruff server ruff-lsp LSP
ty mypy, pyright Type checker

Ruff collapsed the linter and formatter. ty is doing it for type checking. Whether you use Astral’s tools specifically doesn’t matter. The mental model does — four layers, four depths of analysis. Know what each catches, know why some are cheap and others aren’t, and the tool choice becomes obvious.