Linters and Type Checkers

Red squiggly lines

Author

Karsten Naert

Published

November 15, 2025

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:

[tool.ruff]
line-length = 88  # Black-compatible
target-version = "py310"

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

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

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: every variable holds a well defined time during program execution, and variables can hold any type. Type annotations let you add optional type information that static analysis tools can check 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 can use the value instead.

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:

from typing import TypeAlias

# Define a type alias
Vector: TypeAlias = list[float]
Matrix: TypeAlias = list[Vector]

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

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 with:

from __future__ import annotations

This was supposed to become standard in Python 3.10, then 3.11, then 3.12… but it was postponed indefinitely. The situation was highly confusing for a long time. The competing PEP 649 proposes a different approach where all annotations are “lazily evaluated”. Since Python 3.14 the “deferred evaluation of annotations” finally made it into the language via PEP 749. It means the from __future__ import annotations is now deprecated.

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.

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
  • Developed by Guido van Rossum and team
  • 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

Two other type checkers are Pyre and Pyrefly, both by Meta. Finally, there is also a new kid on the block, it’s called Ty and it’s from Astral Software, the company behind Ruff and uv. We will watch its career with great interest. See here and here for some information.

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