Linters and Type Checkers

Red squiggly lines

Author

Karsten Naert

Published

October 20, 2026

Code Quality in Modern Python

Writing Python code that works is one thing. Writing Python code that others (including future you) can understand, maintain, and trust is another. This is where code quality tools come in.

The golden rule:

Ensure your code style is consistent within a project, even with multiple authors.

Modern Python development relies on two categories of tools:

  • Linters and formatters help maintain consistent style and catch common mistakes
  • Type checkers catch type-related bugs before runtime

The main players we’ll focus on:

  • Ruff: A blazingly fast linter and formatter written in Rust
  • Pyright: A static type checker with excellent VS Code integration

Getting Started: Tool Setup

Let’s get the tools installed and configured first, so you can follow along with the examples.

Installing with uv

If you’re using uv to manage your project, adding these tools is straightforward:

uv add --dev ruff pyright

The --dev flag indicates these are development dependencies - you need them while writing code, but not when running your application.

For existing projects, you can also install them globally:

uv tool install ruff
uv tool install pyright

VS Code Integration

Ruff Extension

Install the official Ruff extension from the VS Code marketplace. Search for “Ruff” by Astral Software.

Once installed, Ruff will automatically:

  • Show linting errors as you type
  • Format your code on save (if configured)
  • Provide quick fixes for common issues

Pylance (Pyright)

If you installed the Python extension pack from Microsoft, you have Pylance, which uses Pyright under the hood. Make sure it’s enabled in your settings:

{
    "python.analysis.typeCheckingMode": "basic"
}

The type checking modes are:

  • "off": No type checking
  • "basic": Reasonable balance
  • "strict": Maximum strictness

Intuitively speaking the difference between basic and strict is that basic means: when in doubt, consider it correct; whereas strict means: when in doubt, consider it incorrect.

Inlay Hints

Want to see what types Pyright infers? Enable inlay hints:

{
    "python.analysis.typeCheckingMode": "basic",
    "python.analysis.inlayHints.functionReturnTypes": true,
    "python.analysis.inlayHints.variableTypes": true
}

This shows type information inline in your editor - very helpful when learning!

Basic Configuration

You can also create or modify your pyproject.toml to configure the tools:

[project]
requires-python = ">=3.13"     # Ruff reads this; no need to repeat yourself

[tool.ruff]
line-length = 88               # the Black-compatible default

[tool.ruff.lint]
select = [
    "E",   # pycodestyle errors
    "W",   # pycodestyle warnings
    "F",   # pyflakes
    "I",   # isort
    "N",   # pep8-naming
]

[tool.pyright]
typeCheckingMode = "basic"
pythonVersion = "3.13"

Two things worth noticing:

  • Ruff has a target-version setting, but you usually shouldn’t set it. If your pyproject.toml already declares project.requires-python, Ruff infers the target from that — which is the right place for it, since every other tool reads it too. Setting target-version explicitly overrides it, and then you have two sources of truth to keep in sync.
  • Both these settings matter more than they look. They tell the tools which syntax is allowed: with a target of 3.9, Ruff will not suggest rewriting Optional[int] as int | None, because that spelling didn’t exist yet.

For pyright, these settings will override what’s in your settings.json.

Running the Tools

Command Line

Check for linting issues:

ruff check .

Format your code:

ruff format .

Run type checking:

pyright

In VS Code

With the extensions installed, you’ll see:

  • Red/yellow squiggly lines for errors/warnings
  • Type information in tooltips
  • Quick fixes (💡 icon)

Consider installing the extension Error Lens by Alexander to see an inline description of the error or warning.

Now try creating a simple Python file with an obvious error and watch the tools catch it!

Exercise

Set up a new project and verify your tools are working:

  1. Create a new file test_tools.py
  2. Write some intentionally “bad” code:
def greet(name):
    x=1+2  # Missing spaces around operator
    return "Hello "+name  # Type checker will flag this without annotations
  1. Observe:
    • Ruff should flag the spacing issue
    • Pyright should mention missing type annotations
    • Try running ruff format test_tools.py and see it fix the spacing

Linting and Formatting Basics

PEP 8: The Python Style Guide

PEP 8 is the official Python style guide. It covers:

  • Indentation (4 spaces, not tabs)
  • Maximum line length (79 characters, though 88-100 is now common)
  • Naming conventions (snake_case for functions, PascalCase for classes)
  • Whitespace around operators
  • And much more…

PEP 8 is more of a guideline than strict rules. The key insight:

A style guide is about consistency. Consistency within a project is most important.

Most modern Python standard libraries follow PEP 8, though older ones (like logging or datetime) may deviate for historical reasons.

Useful resource: pep8.org provides a quick reference.

Ruff: The Modern All-in-One Tool

Ruff has taken the Python community by storm. Why?

  • Speed: Written in Rust, it’s 10-100x faster than alternatives
  • All-in-one: Replaces multiple tools (pycodestyle, pyflakes, isort, and more)
  • Formatter included: Also replaces Black for code formatting
  • Easy to configure: Single pyproject.toml configuration

Ruff checks for:

  • Style violations (PEP 8)
  • Common bugs (undefined variables, unused imports)
  • Code complexity
  • Import sorting
  • And 700+ other rules

Example violations Ruff catches:

# Unused import
import sys

# Undefined variable
print(undefined_var)

# Line too long
def very_long_function_name_that_takes_many_parameters(param1, param2, param3, param4, param5, param6, param7, param8):
    pass

The Formatter

Ruff’s formatter is compatible with Black’s output. The philosophy:

By using an auto-formatter, you agree to cede control over formatting details. In return, you get consistency and freedom from formatting debates.

Run ruff format . and your code will be consistently formatted. No more arguing about where to put commas!

Exercise

Practice with Ruff:

  1. Create a file with several style issues:
import sys
import os,json


def badFunction( x,y ):
    z=x+y
    return z

unused_var = 10
  1. Run ruff check and observe the errors
  2. Run ruff format and see the automatic fixes
  3. Run ruff check again - some issues remain (like unused imports)
  4. Use ruff check --fix to auto-fix what’s possible

Type Annotations: Quick Start

Python is dynamically typed: a variable is just a name, and the same name can refer to an int now and a str a moment later. The value it refers to always has a well defined type — Python simply doesn’t fix that type in advance, and doesn’t check it until the code actually runs. Type annotations let you write down what you intend, so that static analysis tools can check it before running your code.

Your First Type Hints

The basic syntax:

def greet(name: str) -> str:
    return "Hello " + name

# Variable annotation
count: int = 0

# You can annotate without assigning
age: int
age = 25

The annotation name: str tells type checkers that name should be a string. The -> str indicates the return type.

Let’s see what happens with a type error:

def greet(name: str) -> str:
    return "Hello " + name

greet(42)  # Pyright will flag this!

Pyright would report: Argument of type "Literal[42]" cannot be assigned to parameter "name" of type "str"

Essential Types

Modern Python (3.10+) uses clean syntax for common types:

# Basic types
name: str = "Alice"
age: int = 30
height: float = 1.75
is_student: bool = True

# Collections (no imports needed in Python 3.10+!)
numbers: list[int] = [1, 2, 3]
scores: dict[str, float] = {"math": 95.5, "physics": 88.0}
coordinates: tuple[int, int] = (10, 20)

# Union types with |
def process(value: int | float) -> str:
    return str(value)

# Optional values (can be None)
def find_user(user_id: int) -> str | None:
    if user_id > 0:
        return "User found"
    return None

The type annotation for None is a weird one. Logically you would expect NoneType (the type of None) but it turns out this is an exception, and for None you write the value itself.

Common Mistake

Don’t write:

def greet(name: str = None):  # Wrong!
    ...

This is a type error - None is not a string! Instead:

def greet(name: str | None = None):  # Correct
    if name is None:
        name = "stranger"
    return f"Hello {name}"

Type Narrowing

Type checkers are smart. They understand control flow:

def process_value(x: int | None) -> int:
    if x is None:
        return 0
    # Pyright knows x must be int here
    return x * 2

After the if x is None check, Pyright “narrows” the type of x from int | None to just int in the else branch.

Exercise

Practice basic type annotations:

  1. Add type hints to this function:
def calculate_average(numbers):
    if not numbers:
        return None
    return sum(numbers) / len(numbers)
  1. What should the return type be? Remember, it can return None!

  2. Create a function find_max(numbers: list[int]) -> int | None that returns the maximum value or None for empty lists.

  3. Test your annotations by passing wrong types and seeing Pyright complain.

Understanding Types in Python

Now that you’ve seen type annotations in action, let’s understand how Python’s type system works under the hood.

Specification vs Implementation

There’s an important distinction in programming languages:

  • Specification: The abstract definition of the language (syntax, semantics, behavior)
  • Implementation: Actual software that executes code following the specification

For Python:

  • The official documentation describes the specification
  • CPython is the reference implementation (written in C)
  • Alternative implementations exist: PyPy (faster for some workloads), IronPython (.NET), Jython and GraalPy (JVM)

CPython is the “reference implementation” - the standard everyone follows. Alternative implementations often lag behind in version support and may have limited support for C extension modules.

This matters for type checking because type checkers analyze the specification, not a specific implementation.

Strong vs Weak Typing

Python is strongly typed: every value has a specific type.

x = 5
type(x)
int
y = "hello"
type(y)
str

You can’t accidentally treat a string as a number:

"hello" + 5  # Type error at runtime
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[6], line 1
----> 1 "hello" + 5  # Type error at runtime

TypeError: can only concatenate str (not "int") to str

Compare this to weakly-typed languages (like JavaScript or PHP) where types are more fluid and automatic conversions happen.

Even types have types:

type(int)
type
type(type)
type

The type of type is type itself - mind-bending but consistent!

Dynamic vs Static Typing

This is where Python gets interesting.

Dynamic typing means types are used at runtime - while your program is running:

def add(a, b):
    return a + b

# Works fine - Python checks types when + is executed
add(5, 10)
15
# Also works - strings can be added too!
add("Hello ", "world")
'Hello world'
# Fails at runtime
add("Hello", 5)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[11], line 2
      1 # Fails at runtime
----> 2 add("Hello", 5)

Cell In[9], line 2, in add(a, b)
      1 def add(a, b):
----> 2     return a + b

TypeError: can only concatenate str (not "int") to str

In statically typed languages (C, Java, Rust), types are checked at compile time - before the program runs. The compiler prevents you from even building code with type errors.

Python’s type annotations add optional static checking through external tools. The annotations don’t affect runtime behavior:

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

# Python happily runs this, even though it violates the annotations!
add("Hello ", "world")
'Hello world'

Type checkers like Pyright analyze your code statically (before running) and warn about violations, but Python itself ignores them at runtime.

Trade-offs

Dynamic Typing Pros:

  • Flexibility and rapid development
  • Easy to write and modify code quickly
  • Great for prototyping and scripting

Dynamic Typing Cons:

  • Type errors only discovered at runtime
  • Less self-documenting code
  • Harder to maintain large codebases

Static Typing Pros:

  • Catch errors before running code
  • Better IDE support (autocomplete, refactoring)
  • Self-documenting code
  • Easier to maintain and refactor

Static Typing Cons:

  • More verbose (must write type annotations)
  • Can slow initial development
  • Learning curve for complex types

Python with type checkers tries to get the best of both worlds: develop quickly with dynamic types, but add static checking where it helps.

Duck Typing

If it walks like a duck and quacks like a duck, it’s a duck.

When Python is running, it cares about what an object can do, not what it is. In other words, it cares about what attributes (fields and methods) and object has, not what class it inherits from:

def greet_person(person):
    print(f"Hello {person.name}")

class PersonWithAge:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

class PersonWithAlive:
    def __init__(self, name: str, alive: bool):
        self.name = name
        self.alive = alive

donald = PersonWithAge("Donald", 79)
joe = PersonWithAlive("Joe", False)

# Both work because both have a .name attribute
greet_person(donald)
greet_person(joe)
Hello Donald
Hello Joe

This is duck typing - the function doesn’t care what type the objects are, only that they have a .name attribute.

But you can hopefully see it’s challenging to provide a good type annotation for the function greet_person. You would like to say: anything that has an attribute name is valid; but how do you say that? The answer is given futher down, in the section on protocols.

Exercise

Think about the trade-offs:

  1. When might you prefer dynamic typing?
  2. When might static type checking be crucial?
  3. Can you think of a project where mixing both makes sense?

Try this: Take a small script you’ve written and add type annotations. Does it reveal any bugs you didn’t know about?

Advanced Type Features

Modern Python has a rich type system. Let’s explore the features you’ll encounter in real projects.

Modern Type Syntax (Python 3.10+)

Python 3.10 introduced cleaner syntax that doesn’t require imports from the typing module:

# Old way (before Python 3.10) - still works
from typing import Union, List, Dict, Optional

def old_style(x: Union[int, str]) -> List[int]:
    return [1, 2, 3]

# New way (Python 3.10+) - preferred!
def new_style(x: int | str) -> list[int]:
    return [1, 2, 3]

Key improvements:

Old (< 3.10) New (3.10+)
Union[int, str] int | str
Optional[int] int | None
List[int] list[int]
Dict[str, int] dict[str, int]
Tuple[int, str] tuple[int, str]

Always use the modern syntax in new code - it’s more readable and Pythonic.

Python 3.12+ Features

Python 3.12 introduced type parameter syntax (PEP 695):

# Generic function with type parameters
def identity[T](x: T) -> T:
    return x

# Generic class
class Box[T]:
    def __init__(self, value: T):
        self.value = value
    
    def get(self) -> T:
        return self.value

# Usage
box_int = Box(42)
box_str = Box("hello")

This is cleaner than the old TypeVar approach. The [T] syntax declares a type parameter that can represent any type.

Useful typing Module Features

Some features still need imports from typing:

from typing import Literal, Final

# Literal types - only specific values allowed
def set_mode(mode: Literal["read", "write", "append"]) -> None:
    print(f"Mode: {mode}")

set_mode("read")  # OK
Mode: read
set_mode("delete")  # Pyright error: not a valid literal
from typing import Final

# Final variables shouldn't be reassigned
MAX_CONNECTIONS: Final = 100
MAX_CONNECTIONS = 200  # Pyright warns about this

Type aliases help with complex types. Since Python 3.12 there is a dedicated type statement for them, from the same PEP 695 as the [T] syntax above:

type Vector = list[float]
type Matrix = list[Vector]

def add_vectors(v1: Vector, v2: Vector) -> Vector:
    return [a + b for a, b in zip(v1, v2, strict=True)]

You will still meet the older spellings. Both of these mean the same thing, and both are now discouraged in new code:

from typing import TypeAlias

Vector2 = list[float]                    # a plain assignment; works, but ambiguous
Vector3: TypeAlias = list[float]         # explicit, but soft-deprecated since 3.12

The problem with the plain assignment is that a type checker cannot tell whether you meant “this is a type alias” or “this is a variable that happens to hold a class”. The type statement says it unambiguously, and it is lazily evaluated, so an alias may refer to a class defined further down the file.

Forward References and the from __future__ Situation

Sometimes you need to reference a class within its own definition:

class Node:
    def __init__(self, value: int):
        self.value = value
        self.next: Node | None = None  # Error! Node not defined yet

The traditional solution is string annotations:

class Node:
    def __init__(self, value: int):
        self.value = value
        self.next: "Node | None" = None  # OK with quotes

PEP 563 proposed making all annotations strings by default, switched on per file with:

from __future__ import annotations

That solved the forward-reference problem, and also meant you stopped paying the runtime cost of evaluating annotations you never looked at. It was supposed to become the default in Python 3.10, then 3.11, then 3.12… and the situation stayed confusing for years.

In the end it was cancelled, because turning every annotation into a string breaks anything that wants to read annotations at runtime — and, as we will see below, that turns out to be a lot of code. The alternative that won is PEP 649, amended by PEP 749: since Python 3.14, annotations are evaluated lazily. They are not evaluated when the function or class is defined, but only when something actually asks for them — at which point you get the real objects, not strings.

So on Python 3.14 and later, the Node example above simply works without quotes and without any __future__ import.

Is from __future__ import annotations deprecated?

Not yet, and this is worth being precise about, because you will see it claimed both ways. The documentation lists its “mandatory in” version as Never, with a footnote saying it “was previously scheduled to become mandatory in Python 3.10, but the change was delayed and ultimately canceled. This feature will eventually be deprecated and removed.”

So: it still works, it is not deprecated today, and it is on its way out. In new code targeting 3.14+ you don’t need it. In older code, you will keep meeting it — now you know what it was for.

Overloads

Sometimes a function’s return type depends on its input type in ways that simple unions can’t express:

def process(x: int | None) -> int | None:
    if x is None:
        return None
    return x * 2

def needs_int(value: int) -> int:
    return value + 10

# This should be safe, but type checker isn't sure
result = process(5)
needs_int(result)  # Pyright warns: result might be None!

Overloads solve this by declaring multiple signatures:

from typing import overload

@overload
def process(x: int) -> int: ...

@overload
def process(x: None) -> None: ...

def process(x: int | None) -> int | None:
    if x is None:
        return None
    return x * 2

# Now the type checker knows: int -> int, None -> None
result = process(5)  # type: int
needs_int(result)  # OK!
20

The @overload decorated functions are just signatures - they have no body (just ...). The actual implementation comes last, without the decorator.

Warning

Type checkers can’t verify your overloads are correct - they trust what you declare. Make sure your implementation actually matches your overload signatures!

Protocols: Structural Subtyping

Remember duck typing? Protocols bring duck typing to the static type world.

Without protocols, this causes issues:

def greet_person(person) -> None:
    print(f"Hello {person.name}")

class PersonWithAge:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

# Type checker complains: what type is person?

We could force inheritance:

class BasePerson:
    def __init__(self, name: str):
        self.name = name

def greet_person(person: BasePerson) -> None:
    print(f"Hello {person.name}")

class PersonWithAge(BasePerson):
    def __init__(self, name: str, age: int):
        super().__init__(name)
        self.age = age

# Now it works, but we lost duck typing flexibility

But now only BasePerson subclasses work. We’ve lost the flexibility of duck typing.

Protocols solve this with structural subtyping:

from typing import Protocol

class HasName(Protocol):
    name: str

def greet_person(person: HasName) -> None:
    print(f"Hello {person.name}")

# No inheritance needed!
class PersonWithAge:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

class PersonWithAlive:
    def __init__(self, name: str, alive: bool):
        self.name = name
        self.alive = alive

# Both work! They match the protocol structurally
greet_person(PersonWithAge("Alice", 30))
greet_person(PersonWithAlive("Bob", True))
Hello Alice
Hello Bob

A Protocol defines what attributes/methods an object must have. Any class that has those members (regardless of inheritance) satisfies the protocol.

This is static duck typing - you get the flexibility of duck typing with the safety of static checking!

Exercise

Practice with advanced features:

  1. Overloads: Create a parse function that:

    • Takes str and returns int | None (parsing might fail)
    • Takes list[str] and returns list[int] (skipping invalid entries)
    • Use overloads to make the return type precise
  2. Protocols: Create a Drawable protocol with a draw() -> None method. Make classes Circle and Square that satisfy it without inheriting from anything.

  3. Type Parameters: Create a generic Pair[T] class that holds two values of the same type.

Annotations at runtime

Everything so far has treated annotations as notes for a tool that never runs your code. That is the original purpose, and Python itself still ignores them:

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

add("Hello ", "world")     # Python does not care
'Hello world'

But annotations are not thrown away. They are stored on the object, and any library can read them:

add.__annotations__
{'a': int, 'b': int, 'return': int}

A whole category of modern Python libraries is built on exactly that. Instead of asking you to describe your data twice — once for the type checker and once for the library — they read the annotations you already wrote and do something with them.

You have already seen one: dataclasses, from OOP I. The @dataclass decorator reads the annotations in the class body to work out what the fields are.

Pydantic goes further and enforces them at runtime, converting where it can and raising a detailed error where it cannot:

from pydantic import BaseModel

class Student(BaseModel):
    name: str
    year: int

Student(name="Alice", year="2")     # note the string; pydantic converts it
Student(name='Alice', year=2)
Student(name="Alice", year="not a number")
---------------------------------------------------------------------------
ValidationError                           Traceback (most recent call last)
Cell In[28], line 1
----> 1 Student(name="Alice", year="not a number")

File /var/www/pydev2/.venv/lib/python3.13/site-packages/pydantic/main.py:253, in BaseModel.__init__(self, **data)
    251 # `__tracebackhide__` tells pytest and some other tools to omit this function from tracebacks
    252 __tracebackhide__ = True
--> 253 validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
    254 if self is not validated_self:
    255     warnings.warn(
    256         'A custom validator is returning a value other than `self`.\n'
    257         "Returning anything other than `self` from a top level model validator isn't supported when validating via `__init__`.\n"
    258         'See the `model_validator` docs (https://docs.pydantic.dev/latest/concepts/validators/#model-validators) for more details.',
    259         stacklevel=2,
    260     )

ValidationError: 1 validation error for Student
year
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='not a number', input_type=str]
    For further information visit https://errors.pydantic.dev/2.11/v/int_parsing

FastAPI, which we meet in the REST API module, is built on top of pydantic and takes the idea to its conclusion. From a signature like

@app.get("/students/{student_id}")
def read_student(student_id: int, full: bool = False) -> Student:
    ...

it works out that student_id comes from the URL and must be converted to an int, that full is an optional query parameter, what the response looks like, what error to return when the input is wrong, and it generates the API documentation. All of that from annotations.

Note

This is also why PEP 563 (turning every annotation into a string) was ultimately abandoned in favour of PEP 649: the ecosystem had come to depend on annotations still being real objects at runtime.

Stub Files

What if a library has no type annotations?

Stub files (.pyi) provide type information separately:

# mylib.py - actual code, no types
def process(data):
    return data.upper()

# mylib.pyi - stub file with types
def process(data: str) -> str: ...

Major projects have stub packages: - pandas-stubs for pandas - types-requests for requests - types-beautifulsoup4 for Beautiful Soup

Install with: pip install pandas-stubs types-requests

Most type checkers automatically find and use these stubs when available. The stubs are maintained separately from the library itself.

Tip

Many popular libraries now include type information directly (called “py.typed” libraries). Always check if a library has built-in types before installing stubs!

Tool Ecosystem

Let’s survey the landscape of Python quality tools.

mypy vs pyright

Both are popular type checkers with different strengths:

mypy

  • The original Python type checker
  • Written in Python
  • Created by Jukka Lehtosalo; developed further at Dropbox, where Guido van Rossum was among the contributors
  • Very configurable
  • Extensive plugin system
  • Documentation

pyright

  • Written in TypeScript, runs on Node.js
  • Much faster than mypy
  • Excellent VS Code integration (via Pylance)
  • Developed by Microsoft
  • Stricter by default
  • Documentation

The field has become crowded very quickly, and both newcomers are written in Rust with speed as the headline feature:

pyrefly

  • From Meta, where it is the successor to their older Pyre
  • Reached a stable 1.0 in May 2026
  • Used on large codebases including Instagram, PyTorch and JAX
  • pyrefly.org

ty

  • From Astral, the company behind Ruff and uv
  • Still on a 0.x line and described by its authors as beta, with a stable release expected
  • Worth watching precisely because of who is building it
  • Documentation
Warning

This particular corner of the ecosystem is moving fast, so check the current state before you commit a project to one of them. For a project today, mypy and pyright remain the safe choices; pyrefly is the most credible of the new ones.

See here and here for comparisons.

Historical Tools: pycodestyle and flake8

Before Ruff became dominant, you’d typically use a variety of other tools.

pycodestyle (formerly pep8)

  • Checks code against PEP 8 style guide
  • Just style checking, no bug detection
  • Still used in some projects
  • Documentation

pyflakes

  • Checks for logical errors (undefined variables, unused imports)
  • No style checking
  • Fast and lightweight

flake8

  • Combines pycodestyle + pyflakes
  • Plugin system for additional checks
  • Very popular before Ruff

black

  • Code formatter
  • Opinionated and uncompromising

isort

  • Import statement organizer

Ruff now handles all of these in one fast tool. You might still encounter these in older projects, but for new work, Ruff is the recommended choice.

Why Ruff won:

  • 10-100x faster (Rust vs Python)
  • Combines linting + formatting (replaces flake8 + black + isort)
  • Modern codebase, active development
  • Simple configuration

Legacy projects still use these tools, but new projects should use Ruff.

Exercise

Explore the ecosystem:

  1. Look at the mypy playground - paste some code and see what mypy catches
  2. Compare mypy vs pyright on the same code
  3. Find a library you use and check if it has type stubs or built-in types
  4. Browse typeshed to see stub files for the standard library