Skip to main content

Command Palette

Search for a command to run...

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

Published
26 min read

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.

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

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:

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

[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.

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).

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

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

Strings and logging

FLY — flynt

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

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

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

A — flake8-builtins

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

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

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

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

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:

[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.

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

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

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

[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

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

In CI, use the check-only variants:

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 (charliermarsh.ruff) — linting and formatting

  • Mypy Type Checker (ms-python.mypy-type-checker) — type checking

Configuration

Add this to your .vscode/settings.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.