# 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):
passLinters and Type Checkers
Red squiggly lines
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 pyrightThe --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 pyrightVS 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:
pyrightIn 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!
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_casefor functions,PascalCasefor 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.tomlconfiguration
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:
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!
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 = 25The 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 NoneThe 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.
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 * 2After the if x is None check, Pyright “narrows” the type of x from int | None to just int in the else branch.
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.
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") # OKMode: read
set_mode("delete") # Pyright error: not a valid literalfrom typing import Final
# Final variables shouldn't be reassigned
MAX_CONNECTIONS: Final = 100MAX_CONNECTIONS = 200 # Pyright warns about thisType 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 yetThe traditional solution is string annotations:
class Node:
def __init__(self, value: int):
self.value = value
self.next: "Node | None" = None # OK with quotesPEP 563 proposed making all annotations strings by default with:
from __future__ import annotationsThis 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.
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 flexibilityBut 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!
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.
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.