# 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:
[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-versionsetting, but you usually shouldn’t set it. If yourpyproject.tomlalready declaresproject.requires-python, Ruff infers the target from that — which is the right place for it, since every other tool reads it too. Settingtarget-versionexplicitly 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]asint | 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:
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: 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 = 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 write the value itself.
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. 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.12The 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 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, switched on per file with:
from __future__ import annotationsThat 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.
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.
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!
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 itStudent(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.
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.
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.xline and described by its authors as beta, with a stable release expected - Worth watching precisely because of who is building it
- Documentation
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.
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.