# The Complete Python Code Quality Stack in 2026: Ruff + mypy

# The Complete Python Code Quality Stack in 2026: Ruff + mypy

If you're starting a professional Python project today, you only need two tools to cover linting, formatting, and type checking: **Ruff** and **mypy**. Together they replace an entire ecosystem — flake8, dozens of flake8 plugins, isort, Black, pyupgrade, and pylint — while being faster and easier to configure.

This guide covers the complete setup: Ruff as both linter and formatter, mypy for type checking, how they complement each other, and every rule worth enabling with concrete examples.

## The big picture: what does each tool do?

Before Python had Ruff, a professional project needed a pile of tools:

| Concern | Old tools | Modern replacement |
| --- | --- | --- |
| Linting | flake8 + 20 plugins, pylint | `ruff check` |
| Import sorting | isort | `ruff check` (rule `I`) |
| Code formatting | Black | `ruff format` |
| Syntax modernization | pyupgrade | `ruff check` (rule `UP`) |
| Type checking | mypy | mypy (still the best) |

Ruff handles linting **and** formatting in a single binary. mypy handles type checking. There is some overlap — but they complement each other rather than compete.

### Why not just Ruff?

Ruff's linter can catch some type-related issues (like `UP` for modern type syntax or `FBT` for boolean traps), but it does **static analysis on a per-file basis**. It doesn't understand your program's types across modules.

mypy performs **cross-file type inference**. It knows that if `get_user()` returns `User | None`, you must handle the `None` case before accessing `.name`. Ruff can't do this.

**You need both.**

* * *

## Part 1: Ruff as formatter

`ruff format` is a drop-in replacement for Black. It produces nearly identical output and is significantly faster.

```toml
[tool.ruff]
target-version = "py313"  # your project's minimum Python version
line-length = 88           # same default as Black
```

That's it for the formatter configuration. Run it with:

```bash
ruff format .              # format all files
ruff format --check .      # check without modifying (useful in CI)
```

### What the formatter does

*   Enforces consistent indentation, quotes, trailing commas, and line breaks
    
*   Wraps long lines at the configured `line-length`
    
*   Adds trailing commas to multi-line structures (so git diffs are cleaner)
    
*   Normalizes string quotes to double quotes by default
    

### Formatter vs linter: don't overlap

Because Ruff handles both formatting and linting, some lint rules overlap with the formatter. This is why Ruff only enables `E4`, `E7`, `E9` by default — the remaining `E` rules (like `E1` for indentation) are already handled by the formatter. If you enable the full `E` prefix alongside `ruff format`, they won't conflict — Ruff is designed to handle this — but the formatter will fix most stylistic issues before the linter even sees them.

Similarly, the `Q` (quotes), `W` (whitespace), and `COM` (trailing commas) lint rules overlap with the formatter. `Q` and `W` are fine to enable as a safety net, but `COM` should be skipped since the formatter handles trailing commas.

* * *

## Part 2: Ruff as linter — `select` vs `extend-select`

Before diving into the rules, let's clarify a common source of confusion.

Ruff provides two ways to configure which lint rules are active:

```toml
[tool.ruff.lint]
select = ["E", "F", "B"]         # replaces the defaults
extend-select = ["B", "I", "S"]  # adds to the defaults
```

*   `select` completely **replaces** the default rule set. If you write `select = ["B"]`, you lose `E4`, `E7`, `E9`, and `F` entirely.
    
*   `extend-select` **adds** rules on top of the defaults. The defaults (`E4`, `E7`, `E9`, `F`) remain active.
    

**Always use** `extend-select`**.** It's safer because you'll never accidentally drop the defaults, and if a future Ruff version adds new default rules, you'll get them automatically.

The same logic applies to `ignore` vs `extend-ignore`: prefer `extend-ignore` when adding to existing ignores, though in practice most projects just use `ignore` since there are no default ignores to preserve.

## The full lint configuration

Here's the complete `extend-select` I recommend for any professional Python application:

```toml
[tool.ruff.lint]
extend-select = [
    "A",     # flake8-builtins
    "ARG",   # flake8-unused-arguments
    "ASYNC", # flake8-async
    "B",     # flake8-bugbear
    "BLE",   # flake8-blind-except
    "C4",    # flake8-comprehensions
    "C90",   # mccabe
    "DTZ",   # flake8-datetimez
    "E",     # pycodestyle errors (full set)
    "EM",    # flake8-errmsg
    "FBT",   # flake8-boolean-trap
    "FLY",   # flynt
    "FURB",  # refurb
    "G",     # flake8-logging-format
    "I",     # isort
    "ICN",   # flake8-import-conventions
    "ISC",   # flake8-implicit-str-concat
    "INT",   # flake8-gettext
    "LOG",   # flake8-logging
    "N",     # pep8-naming
    "PERF",  # perflint
    "PGH",   # pygrep-hooks
    "PIE",   # flake8-pie
    "PL",    # pylint
    "PTH",   # flake8-use-pathlib
    "Q",     # flake8-quotes
    "RET",   # flake8-return
    "RSE",   # flake8-raise
    "RUF",   # ruff-specific rules
    "S",     # flake8-bandit
    "SIM",   # flake8-simplify
    "SLF",   # flake8-self
    "SLOT",  # flake8-slots
    "T10",   # flake8-debugger
    "T20",   # flake8-print
    "TC",    # flake8-type-checking
    "TID",   # flake8-tidy-imports
    "TRY",   # tryceratops
    "UP",    # pyupgrade
    "W",     # pycodestyle warnings
    "YTT",   # flake8-2020
]
```

That's 41 rule prefixes. Let's go through every single one.

* * *

## Rules enabled by default

Ruff only enables `F` (all pyflakes rules) and a subset of `E` (`E4`, `E7`, `E9`) by default. The rest of `E` and all of `W` are **not** enabled — Ruff omits the stylistic rules that overlap with formatters.

This is why our `extend-select` includes `"E"` and `"W"` explicitly: to activate the full set of pycodestyle checks.

### `F` — pyflakes (enabled by default)

Catches actual code problems, not just style: unused imports, undefined names, redefined unused variables, `import *` issues. Fully enabled by default.

```python
import os  # F401: 'os' imported but unused

x = 1
x = 2  # F841: local variable 'x' is assigned to but never used
```

### `E` — pycodestyle errors (partially enabled by default)

PEP 8 style errors: wrong indentation, missing whitespace around operators, lines too long, unexpected spaces. **Only** `E4`**,** `E7`**, and** `E9` **are enabled by default.** Adding `"E"` to `extend-select` activates the remaining rules (like `E1`, `E2`, `E5`).

```python
# E111 (E1xx — NOT enabled by default): indentation is not a multiple of four
if True:
  pass

# E712 (E7xx — enabled by default): comparison to True
if x == True:
    pass
```

### `W` — pycodestyle warnings (NOT enabled by default)

Style warnings: trailing whitespace, blank lines at end of file, deprecated features. Must be explicitly enabled.

```python
# W291: trailing whitespace
x = 1

# W292: no newline at end of file
```

* * *

## Code quality and bugs

### `B` — flake8-bugbear

Catches common Python gotchas that aren't technically syntax errors but are almost always bugs.

```python
# B006: mutable default argument
def add_item(item, items=[]):
    items.append(item)
    return items

# B904: within an except clause, raise from the caught exception
try:
    do_something()
except ValueError:
    raise RuntimeError("failed")  # should be: raise RuntimeError("failed") from e
```

### `C4` — flake8-comprehensions

Suggests simpler, more Pythonic comprehensions and constructor calls.

```python
# C400: unnecessary generator, use a list comprehension
list(x for x in range(10))  # -> [x for x in range(10)]

# C416: unnecessary dict comprehension, use dict()
{k: v for k, v in pairs}  # -> dict(pairs)
```

### `C90` — mccabe

Measures cyclomatic complexity — the number of independent paths through a function. When a function has too many nested `if`/`for`/`try` blocks, it becomes hard to understand and test. The default threshold is 10.

```python
# C901: function is too complex (15 > 10)
def process(data):
    if data:
        for item in data:
            if item.valid:
                try:
                    if item.type == "a":
                        ...
                    elif item.type == "b":
                        ...
                    # ... many more branches
```

### `PL` — pylint

A portable subset of pylint's rules, reimplemented in Ruff for speed. Covers things like too many arguments, too many branches, comparisons with `None`, and unused loop variables.

```python
# PLR0913: too many arguments in function definition (6 > 5)
def create_user(name, email, age, role, team, department):
    ...

# PLC0208: use a sequence type instead of a set when iterating
for x in {1, 2, 3}:
    print(x)
```

### `FURB` — refurb

Suggests modern Python idioms and API usage. Catches patterns that can be replaced with newer, cleaner alternatives.

```python
# FURB118: use operator.itemgetter instead of a lambda
sorted(items, key=lambda x: x[0])  # -> sorted(items, key=operator.itemgetter(0))
```

### `PIE` — flake8-pie

Catches miscellaneous anti-patterns: unnecessary `pass`, no-effect expressions, redundant re-imports.

```python
# PIE790: unnecessary pass statement
class MyError(Exception):
    pass  # unnecessary if the class has a docstring

# PIE804: unnecessary dict kwargs
foo(**{"bar": 1})  # -> foo(bar=1)
```

### `SIM` — flake8-simplify

Simplifies conditionals, context managers, and boolean expressions.

```python
# SIM108: use ternary operator instead of if-else block
if condition:
    x = 1
else:
    x = 2
# -> x = 1 if condition else 2

# SIM110: use any() instead of a for loop
for item in items:
    if item.valid:
        return True
return False
# -> return any(item.valid for item in items)
```

### `PERF` — perflint

Catches performance anti-patterns: unnecessary list copies, inefficient loops, redundant conversions.

```python
# PERF401: use a list comprehension instead of appending in a for loop
result = []
for x in data:
    result.append(x * 2)
# -> result = [x * 2 for x in data]
```

* * *

## Security

### `S` — flake8-bandit

The most important rule set for security-conscious projects. Detects hardcoded passwords, `eval()`, `exec()`, `pickle` usage, `subprocess` with `shell=True`, insecure TLS, and many more OWASP-relevant patterns.

```python
# S105: possible hardcoded password
password = "admin123"

# S603: subprocess call with shell=True
subprocess.call(cmd, shell=True)

# S301: pickle can execute arbitrary code
data = pickle.loads(raw)
```

### `BLE` — flake8-blind-except

Forbids bare `except Exception` and `except BaseException` without re-raising. Forces you to catch specific exceptions.

```python
# BLE001: do not catch blind exception BaseException
try:
    do_something()
except Exception:
    pass  # silently swallowing ALL exceptions
```

### `DTZ` — flake8-datetimez

Forbids creating `datetime` objects without an explicit timezone. Prevents subtle bugs when mixing naive and timezone-aware datetimes.

```python
# DTZ001: datetime.datetime() called without a tzinfo argument
datetime.datetime(2026, 1, 1)

# DTZ005: datetime.now() called without a tz argument
datetime.datetime.now()  # -> datetime.datetime.now(tz=datetime.UTC)
```

### `T10` — flake8-debugger

Catches `breakpoint()`, `pdb.set_trace()`, and other debugger calls left in the code. These should never reach production.

```python
# T100: debugger call found
breakpoint()
import pdb; pdb.set_trace()
```

* * *

## Imports and organization

### `I` — isort

Sorts imports following PEP 8 conventions: standard library first, then third-party, then local imports. Highly configurable.

```python
# Before isort
import requests
import os
from myapp import utils
import sys

# After isort
import os
import sys

import requests

from myapp import utils
```

### `TID` — flake8-tidy-imports

Bans relative imports, specific banned imports, and incorrect lazy import patterns. Relative imports make refactoring harder and create circular import issues.

```python
# TID252: relative imports are banned
from . import utils       # -> from myapp import utils
from ..models import User  # -> from myapp.models import User
```

### `ICN` — flake8-import-conventions

Enforces standard import aliases. The Python ecosystem has strong conventions (`np`, `pd`, `plt`) and this rule enforces them.

```python
# ICN001: conventional import alias
import numpy as npy  # should be: import numpy as np
import pandas as frame  # should be: import pandas as pd
```

### `TC` — flake8-type-checking

Moves imports used only in type annotations into an `if TYPE_CHECKING` block. This reduces application startup time since those imports aren't loaded at runtime. Previously called `TCH`, renamed to `TC` in Ruff v0.8.0.

```python
# TC001: move import into TYPE_CHECKING block
from myapp.models import User  # only used in type hints

def get_user() -> User:
    ...

# Should be:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from myapp.models import User
```

* * *

## Types and annotations

### `UP` — pyupgrade

Modernizes type annotation syntax and other Python constructs to use the latest available syntax for your target Python version.

```python
# UP007: use X | Y for union types (Python 3.10+)
Optional[str]  # -> str | None
Union[int, str]  # -> int | str

# UP006: use builtin type for type hints (Python 3.9+)
List[int]  # -> list[int]
Dict[str, Any]  # -> dict[str, Any]
```

### `FBT` — flake8-boolean-trap

Detects boolean positional arguments in function signatures. Booleans as positional args make call sites unreadable.

```python
# FBT001: boolean positional argument in function definition
def process(data, verbose):  # what does True mean here?
    ...
process(data, True)  # unclear

# Fix: use keyword-only argument
def process(data, *, verbose):
    ...
process(data, verbose=True)  # clear
```

### `YTT` — flake8-2020

Detects incorrect comparisons against `sys.version` and `sys.version_info`. These bugs are subtle and often go unnoticed.

```python
# YTT101: sys.version compared with string, use sys.version_info
if sys.version >= "3":  # wrong: "3" < "3.10" in string comparison!
    ...
```

* * *

## Error handling and exceptions

### `TRY` — tryceratops

Best practices for exception handling: prefer custom exception classes, don't create long inline messages, don't `except` and `raise` without adding context.

```python
# TRY003: avoid specifying long messages outside the exception class
raise ValueError("User with id {user_id} was not found in database {db}")
# Better: raise UserNotFoundError(user_id, db)

# TRY301: abstract raise to an inner function
try:
    do_complex_thing()
    raise ValueError("x")  # raise inside try body is an anti-pattern
except ValueError:
    ...
```

### `EM` — flake8-errmsg

Forces error messages to be defined as variables instead of inline strings. This improves tracebacks (the message doesn't appear twice) and avoids duplication.

```python
# EM101: exception must not use a string literal
raise ValueError("invalid input")

# Fix:
msg = "invalid input"
raise ValueError(msg)
```

### `RSE` — flake8-raise

Detects unnecessary raise patterns: `raise Exception()` without arguments, redundant `raise e`.

```python
# RSE102: unnecessary parentheses on raised exception
raise ValueError()  # -> raise ValueError
```

* * *

## Strings and logging

### `FLY` — flynt

Converts string concatenation and `.format()` calls to f-strings.

```python
# FLY002: consider f-string instead of string join
" ".join(["hello", name])  # -> f"hello {name}"

"Hello {}".format(name)  # -> f"Hello {name}"
```

### `ISC` — flake8-implicit-str-concat

Detects implicit string concatenation where two strings sit next to each other without an operator. This is usually an accidental bug, like forgetting a comma in a list.

```python
# ISC001: implicitly concatenated string on one line
x = "foo" "bar"  # did you mean "foobar" or ["foo", "bar"]?

# Common real-world bug:
items = [
    "apple"
    "banana",  # oops — "apple" and "banana" merged into "applebanana"
    "cherry",
]
```

### `G` — flake8-logging-format

Forbids f-strings and `.format()` in logging calls. Loggers should use `%s` lazy formatting so the string is only built if the message is actually emitted.

```python
# G004: logging statement uses f-string
logging.info(f"User {user.id} logged in")
# -> logging.info("User %s logged in", user.id)
```

### `LOG` — flake8-logging

Best practices for logging: use `logger` instances instead of the `logging` module directly, correct log levels, proper logger naming.

```python
# LOG001: use logging.getLogger() to get a logger
logging.info("message")  # should use: logger = logging.getLogger(__name__)

# LOG009: use logging.WARN is deprecated, use WARNING
logging.WARN  # -> logging.WARNING
```

### `Q` — flake8-quotes

Enforces consistent quote style (single or double) across the project. If you're using `ruff format`, this is already handled automatically — but it's useful as a safety net in CI.

```python
# Q000: double quotes found but single quotes preferred
x = "hello"  # -> x = 'hello' (or vice versa, depending on config)
```

### `INT` — flake8-gettext

Detects issues with internationalization strings (`_()`, `ngettext()`). Only relevant if your application supports multiple languages.

```python
# INT001: f-string is resolved before function call; consider _(...)
_(f"Hello {name}")  # the f-string resolves before _() can translate it
# -> _("Hello %s") % name
```

* * *

## Style and cleanup

### `N` — pep8-naming

Enforces PEP 8 naming conventions: `snake_case` for functions and variables, `PascalCase` for classes, `UPPER_CASE` for module-level constants.

```python
# N801: class name should use CapWords convention
class my_class:  # -> class MyClass
    pass

# N802: function name should be lowercase
def MyFunction():  # -> def my_function()
    pass
```

### `T20` — flake8-print

Detects `print()` calls in the codebase. In a professional application, you should use `logging` instead. Print statements are for debugging and should not reach production.

```python
# T201: print found
print("Processing...")  # -> logging.info("Processing...")
```

### `A` — flake8-builtins

Prevents shadowing Python built-in names. Shadowing builtins leads to confusing bugs.

```python
# A001: variable is shadowing a Python builtin
list = [1, 2, 3]  # now list() constructor is broken
id = 42            # now id() function is broken
type = "admin"     # now type() function is broken
```

### `ARG` — flake8-unused-arguments

Detects function arguments that are never used in the function body.

```python
# ARG001: unused function argument
def process(data, verbose):  # 'verbose' is never used
    return data.transform()
```

### `RET` — flake8-return

Simplifies return statements: removes unnecessary `else` after `return`, detects inconsistent returns.

```python
# RET505: unnecessary else after return
def check(x):
    if x > 0:
        return True
    else:  # unnecessary — the if already returned
        return False
# -> remove the else
```

### `PTH` — flake8-use-pathlib

Suggests `pathlib.Path` over `os.path` functions. `pathlib` is more readable and Pythonic.

```python
# PTH118: os.path.join should be replaced by Path with / operator
os.path.join("dir", "file.txt")  # -> Path("dir") / "file.txt"

# PTH123: open() should be replaced by Path.open()
open("file.txt")  # -> Path("file.txt").open()
```

### `SLF` — flake8-self

Detects access to private members (`_name`) from outside the class that defines them. Private members are an implementation detail and shouldn't be accessed externally.

```python
# SLF001: private member accessed
obj._internal_method()  # don't touch private members from outside
```

### `SLOT` — flake8-slots

Suggests adding `__slots__` to classes that inherit from immutable types like `str`, `int`, `tuple`. This reduces memory usage.

```python
# SLOT000: subclass of str should define __slots__
class Color(str):
    pass
# -> class Color(str):
#        __slots__ = ()
```

### `PGH` — pygrep-hooks

Catches sloppy suppression comments: `type: ignore` without a specific error code, `noqa` without a specific rule code, and `eval()` in strings.

```python
# PGH003: use specific rule codes with type: ignore
x: int = "hello"  # type: ignore     # bad
x: int = "hello"  # type: ignore[assignment]  # good
```

### `RUF` — ruff-specific rules

Rules unique to Ruff: unused `noqa` directives, implicit type conversions, mutable collection literals used as class variables, and more.

```python
# RUF005: consider unpacking instead of concatenation
x = [1, 2] + [3, 4]  # -> x = [1, 2, *[3, 4]]

# RUF012: mutable class variables must be annotated with ClassVar
class Config:
    items = []  # -> items: ClassVar[list] = []
```

* * *

## Async

### `ASYNC` — flake8-async

Detects async anti-patterns: using `sleep(0)` instead of `checkpoint()`, blocking the event loop with synchronous I/O, missing timeouts on network calls.

```python
# ASYNC100: trio/anyio call without cancellation point
async def fetch():
    await trio.sleep(0)  # should use trio.checkpoint()

# ASYNC210: blocking HTTP call in async function
async def get_data():
    requests.get("https://example.com")  # blocks the event loop!
    # -> use httpx or aiohttp instead
```

* * *

## Rules I deliberately exclude from Ruff (depending on the project)

Not every rule set belongs in every project. Here's what I leave out and why:

| Code | Plugin | Why I skip it |
| --- | --- | --- |
| `D` | pydocstyle | Too noisy for an application. Forcing docstrings on every private function doesn't improve code quality. If you do want it, use `convention = "google"` with selective ignores. |
| `ANN` | flake8-annotations | Redundant if you use `mypy --strict`, which already enforces type annotations everywhere. Running both creates duplicate noise. |
| `ERA` | flake8-eradicate | Too many false positives. Legitimate comments are flagged as "commented-out code." |
| `PT` | flake8-pytest-style | Only relevant if your project uses pytest. Add it if you do. |
| `DJ` | flake8-django | Django-specific rules. Only for Django projects. |
| `NPY` | numpy-specific | Only for projects using NumPy. |
| `PD` | pandas-vet | Only for projects using Pandas. |
| `AIR` | airflow-specific | Only for Airflow DAGs. |
| `FAST` | fastapi-specific | Only for FastAPI projects. |
| `COM` | flake8-commas | The Ruff formatter already handles trailing commas. Enabling both creates conflicts. |
| `INP` | flake8-no-pep420 | Flags packages without `__init__.py`. Irrelevant if you use namespace packages (PEP 420). |
| `FA` | flake8-future-annotations | Only useful if you support Python < 3.10 and need `from __future__ import annotations`. |
| `CPY` | flake8-copyright | Enforces copyright headers. Corporate-specific, not universal. |
| `TD`/`FIX` | flake8-todos/fixme | Flags TODO and FIXME comments. TODOs are a normal part of development. |
| `EXE` | flake8-executable | Checks shebang lines and file permissions. Too niche for most projects. |

* * *

## Part 3: mypy for type checking

Ruff catches style issues, bugs, and anti-patterns. But it doesn't understand types across your codebase. That's mypy's job.

### Why `strict` mode?

mypy's `--strict` flag enables all strict type checking options at once. Without it, mypy allows large parts of your code to go unchecked — untyped functions are silently ignored.

```toml
[tool.mypy]
strict = true
python_version = "3.13"
```

With `strict = true`, mypy enables all of the following flags:

*   `warn_unused_configs` — warn if a config section doesn't match any files
    
*   `disallow_any_generics` — no bare `list`, must be `list[int]`
    
*   `disallow_subclassing_any` — forbid subclassing values of type `Any`
    
*   `disallow_untyped_calls` — forbid calling untyped functions from typed ones
    
*   `disallow_untyped_defs` — every function must have type annotations
    
*   `disallow_incomplete_defs` — forbid partially typed function definitions
    
*   `check_untyped_defs` — type-check the body of untyped functions too
    
*   `disallow_untyped_decorators` — forbid decorators without type annotations
    
*   `warn_redundant_casts` — flag unnecessary `cast()` calls
    
*   `warn_unused_ignores` — flag `# type: ignore` comments that aren't needed
    
*   `warn_return_any` — flag functions that return `Any`
    
*   `no_implicit_reexport` — imports must be explicit to be re-exported
    
*   `strict_equality` — forbid comparisons between unrelated types
    
*   `strict_bytes` — strict handling of `bytes` vs `str`
    
*   `extra_checks` — enable additional checks beyond the standard strict set
    

You don't need to list these individually — `strict = true` covers them all.

### Additional options worth enabling

Beyond `strict`, a few extra options catch more issues:

```toml
[tool.mypy]
strict = true
python_version = "3.13"
namespace_packages = true
show_error_codes = true
enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
```

*   `namespace_packages`: support for PEP 420 namespace packages (no `__init__.py` required).
    
*   `show_error_codes`: show the error code in each message (e.g., `[assignment]`), making it easy to add targeted `# type: ignore[code]` comments.
    
*   `enable_error_code`: extra checks not included in `strict`:
    
    *   `ignore-without-code` — flags `# type: ignore` without a specific error code (pairs with Ruff's `PGH003`)
        
    *   `redundant-expr` — flags expressions that are always `True` or always `False`
        
    *   `truthy-bool` — flags suspicious truth-value checks on non-boolean types
        

### What about mypy plugins?

Only add plugins for libraries you actually use. Common ones:

```toml
plugins = ["pydantic.mypy"]  # only if you use pydantic
```

Don't add plugins for libraries not in your dependencies — mypy will error on startup.

### How Ruff and mypy complement each other

Some areas where they overlap and reinforce each other:

| Check | Ruff | mypy |
| --- | --- | --- |
| Unused imports | `F401` — removes them | Also flags them but can't auto-fix |
| Type annotation style | `UP` — modernizes syntax | Understands both old and new syntax |
| `type: ignore` comments | `PGH003` — requires error code | `ignore-without-code` — same check |
| Unreachable code | `SIM` — simplifies dead branches | Detects based on type narrowing |
| Missing return types | Not checked | `disallow_untyped_defs` |
| Wrong argument types | Not checked | Core feature |
| `None` safety | Not checked | Core feature |

**The key insight**: Ruff catches problems that are visible in a single file. mypy catches problems that require understanding types across your entire project. You need both.

### Don't use `ANN` — use mypy instead

Ruff's `ANN` (flake8-annotations) rule set checks for missing type annotations. But if you're using `mypy --strict`, mypy already enforces this with `disallow_untyped_defs`. Running both creates duplicate warnings for the same issue. Skip `ANN` and let mypy handle type annotations.

* * *

## Two configuration options worth knowing

### `unfixable` — warn but don't auto-fix

When you run `ruff check --fix`, Ruff auto-fixes everything it can. But sometimes you want a rule to **warn** without auto-removing code. That's what `unfixable` is for.

A common example is `ERA` (flake8-eradicate), which detects commented-out code. If you enable it with `--fix`, Ruff will silently delete those comments. If you'd rather review them manually:

```toml
[tool.ruff.lint]
extend-select = ["ERA"]
unfixable = ["ERA"]  # warn about commented-out code, but don't auto-delete it
```

This way `ruff check` shows the warnings, but `ruff check --fix` leaves them untouched.

A common mistake is combining this with `ignore = ["ERA001"]` — that silences the only rule in `ERA`, making the entire setup do nothing.

### `known-first-party` — help isort identify your packages

isort needs to know which imports belong to your project (first-party) vs third-party packages. Ruff usually detects this automatically from `pyproject.toml`, but in monorepos or when your package name differs from the project name, it can get confused.

```toml
[tool.ruff.lint.isort]
known-first-party = ["myapp"]
```

Without this, isort might sort your project's imports into the third-party group, creating unnecessary blank lines and wrong ordering:

```python
# Wrong — isort thinks 'myapp' is third-party
import requests
import myapp  # grouped with third-party

# Correct — isort knows 'myapp' is first-party
import requests

import myapp  # correctly separated as local
```

You only need this when isort misclassifies your imports. If everything looks correct without it, don't add it.

### `required-imports` — auto-inject imports into every file

isort can force a specific import to be present in every file. A common use case:

```toml
[tool.ruff.lint.isort]
required-imports = ["from __future__ import annotations"]
```

This makes `from __future__ import annotations` appear at the top of every file. That import changes how Python evaluates type annotations — they become strings instead of being evaluated at runtime (PEP 563), which allows forward references and speeds up import time.

**Should you use it?** It depends on your Python version:

*   **Python < 3.10**: useful. It lets you write `list[int]` and `X | Y` in annotations instead of `List[int]` and `Union[X, Y]`.
    
*   **Python 3.10+**: unnecessary. Modern type syntax (`X | Y`, `list[int]`) works natively without the future import.
    

If your `target-version` is `py310` or higher, skip this option — it adds a line of noise to every file for no benefit.

* * *

## The complete `pyproject.toml`

Here's everything together — formatter, linter, and type checker — in a single file:

```toml
[tool.ruff]
target-version = "py313"
line-length = 88

[tool.ruff.lint]
extend-select = [
    "A",     # flake8-builtins
    "ARG",   # flake8-unused-arguments
    "ASYNC", # flake8-async
    "B",     # flake8-bugbear
    "BLE",   # flake8-blind-except
    "C4",    # flake8-comprehensions
    "C90",   # mccabe
    "DTZ",   # flake8-datetimez
    "E",     # pycodestyle errors (full set)
    "EM",    # flake8-errmsg
    "FBT",   # flake8-boolean-trap
    "FLY",   # flynt
    "FURB",  # refurb
    "G",     # flake8-logging-format
    "I",     # isort
    "ICN",   # flake8-import-conventions
    "ISC",   # flake8-implicit-str-concat
    "INT",   # flake8-gettext
    "LOG",   # flake8-logging
    "N",     # pep8-naming
    "PERF",  # perflint
    "PGH",   # pygrep-hooks
    "PIE",   # flake8-pie
    "PL",    # pylint
    "PTH",   # flake8-use-pathlib
    "Q",     # flake8-quotes
    "RET",   # flake8-return
    "RSE",   # flake8-raise
    "RUF",   # ruff-specific rules
    "S",     # flake8-bandit
    "SIM",   # flake8-simplify
    "SLF",   # flake8-self
    "SLOT",  # flake8-slots
    "T10",   # flake8-debugger
    "T20",   # flake8-print
    "TC",    # flake8-type-checking
    "TID",   # flake8-tidy-imports
    "TRY",   # tryceratops
    "UP",    # pyupgrade
    "W",     # pycodestyle warnings
    "YTT",   # flake8-2020
]
ignore = [
    "PLR0913",  # too many arguments — sometimes unavoidable
    "PLR2004",  # magic value comparison — too many false positives
    "TRY003",   # long exception messages — sometimes clearer inline
]

[tool.ruff.lint.flake8-tidy-imports]
ban-relative-imports = "all"

[tool.ruff.lint.isort]
force-single-line = true
lines-between-types = 1
lines-after-imports = 2
# known-first-party = ["myapp"]  # uncomment if isort misclassifies your imports

[tool.mypy]
strict = true
python_version = "3.13"
namespace_packages = true
show_error_codes = true
enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"]
```

### Running everything

```bash
ruff format .          # format code
ruff check . --fix     # lint and auto-fix what's possible
mypy .                 # type check
```

In CI, use the check-only variants:

```bash
ruff format --check .  # fail if formatting needed
ruff check .           # fail on lint errors
mypy .                 # fail on type errors
```

* * *

## VS Code: format and lint on save

You can make VS Code run `ruff format` and `ruff check --fix` every time you save a file, so you never have to think about it.

### Required extensions

Install these two VS Code extensions:

*   [**Ruff**](https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff) (`charliermarsh.ruff`) — linting and formatting
    
*   [**Mypy Type Checker**](https://marketplace.visualstudio.com/items?itemName=ms-python.mypy-type-checker) (`ms-python.mypy-type-checker`) — type checking
    

### Configuration

Add this to your `.vscode/settings.json`:

```json
{
    // Ruff as the default formatter for Python
    "[python]": {
        "editor.defaultFormatter": "charliermarsh.ruff",
        "editor.formatOnSave": true,
        "editor.codeActionsOnSave": {
            "source.fixAll.ruff": "explicit",
            "source.organizeImports.ruff": "explicit"
        }
    },

    // Disable the built-in Python formatter to avoid conflicts
    "python.formatting.provider": "none",

    // Use the project's installed binaries instead of the bundled ones
    "ruff.importStrategy": "fromEnvironment",
    "mypy-type-checker.importStrategy": "fromEnvironment"
}
```

### What happens on every save

1.  `editor.formatOnSave` — runs `ruff format` on the file (fixes quotes, indentation, trailing commas, line length)
    
2.  `source.fixAll.ruff` — runs `ruff check --fix` (auto-fixes lint issues like unused imports, unsorted imports, modernizable syntax)
    
3.  `source.organizeImports.ruff` — sorts imports via the `I` rule (stdlib → third-party → local)
    
4.  **mypy** — runs in the background and shows type errors as squiggly underlines in the editor
    

### Why `"fromEnvironment"` instead of the bundled binary?

Both extensions ship with a bundled version of their tool. By default, they use that bundled binary — which may be a different version than the one installed in your project's virtual environment.

Setting `"importStrategy": "fromEnvironment"` tells the extension to use the binary from your project's environment (e.g., `.venv/bin/ruff`). This ensures that VS Code uses **the exact same version** as your CI and your teammates, avoiding "works on my machine" issues where the bundled version has different rules or behavior.

### Why `"explicit"` instead of `"always"`?

Using `"explicit"` means these code actions run on save but **only when you explicitly save** (Cmd+S). With `"always"`, they would also run on auto-save and window focus changes, which can be disruptive when you're in the middle of typing.

### Project-level vs user-level settings

Put this in `.vscode/settings.json` at the root of your project (not in your global user settings). This way every developer on the team gets the same behavior without having to configure anything. Add the `.vscode/` folder to your git repo.

* * *

## Final thoughts

The Python tooling landscape has consolidated. In 2026, you don't need a dozen tools — you need two:

*   **Ruff** for formatting and linting (replaces Black, isort, flake8 + plugins, pylint, pyupgrade)
    
*   **mypy** for type checking (nothing else does this as well)
    

Start with the full configuration above. If a specific rule is too noisy for your codebase, silence it with `ignore` — it's always better to start strict and relax than to start loose and tighten later.
