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.
[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-lengthAdds 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
selectcompletely replaces the default rule set. If you writeselect = ["B"], you loseE4,E7,E9, andFentirely.extend-selectadds 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 filesdisallow_any_generics— no barelist, must belist[int]disallow_subclassing_any— forbid subclassing values of typeAnydisallow_untyped_calls— forbid calling untyped functions from typed onesdisallow_untyped_defs— every function must have type annotationsdisallow_incomplete_defs— forbid partially typed function definitionscheck_untyped_defs— type-check the body of untyped functions toodisallow_untyped_decorators— forbid decorators without type annotationswarn_redundant_casts— flag unnecessarycast()callswarn_unused_ignores— flag# type: ignorecomments that aren't neededwarn_return_any— flag functions that returnAnyno_implicit_reexport— imports must be explicit to be re-exportedstrict_equality— forbid comparisons between unrelated typesstrict_bytes— strict handling ofbytesvsstrextra_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__.pyrequired).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 instrict:ignore-without-code— flags# type: ignorewithout a specific error code (pairs with Ruff'sPGH003)redundant-expr— flags expressions that are alwaysTrueor alwaysFalsetruthy-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]andX | Yin annotations instead ofList[int]andUnion[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 formattingMypy 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
editor.formatOnSave— runsruff formaton the file (fixes quotes, indentation, trailing commas, line length)source.fixAll.ruff— runsruff check --fix(auto-fixes lint issues like unused imports, unsorted imports, modernizable syntax)source.organizeImports.ruff— sorts imports via theIrule (stdlib → third-party → local)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.

