How do linters and formatters like ruff, flake8, and black fit into a Python workflow?

6 minbeginnertoolinglintingformattingruff

Quick Answer

Formatters (black, and ruff format) auto-rewrite code to a consistent style, eliminating style debates and manual formatting effort entirely. Linters (flake8, pylint, ruff check) statically analyze code for likely bugs, unused imports/variables, and style violations, without changing the code themselves. ruff is a newer Rust-based tool that combines both linting and formatting (compatible with black's style) at dramatically higher speed than the older Python-based tools it's replacing.

Detailed Answer

Formatters: no more style debates

# before black
def   f(x,y ,z):
    return{"a":x,'b':y,"c" :z}

# after `black .`
def f(x, y, z):
    return {"a": x, "b": y, "c": z}

black (and ruff format, which implements a compatible style) rewrite code automatically to one consistent format — quote style, spacing, line length, trailing commas. The point isn't that this particular style is objectively best. It's that a team stops spending review time and mental energy on formatting bikeshedding entirely, since the tool decides and everyone's code converges to the same look.

Linters: catching likely bugs and code smells

import os          # F401: 'os' imported but unused
import sys

def process(items):
    result = []
    for item in items:
        result.append(itme.value)   # F821: undefined name 'itme' (a typo!)
    return reuslt                     # F821: undefined name 'reuslt' (another typo!)

flake8/pylint/ruff check statically scan code for problems that wouldn't necessarily crash immediately (an unused import) or would crash only when that code path actually runs (a typo in a rarely-exercised branch), catching these at commit/CI time instead of in production. Rule sets typically cover unused imports/variables, undefined names, common bug patterns (except: bare clauses, mutable default arguments), and complexity/style conventions (PEP 8 line length, naming).

ruff: the modern consolidation

ruff check .      # lint (replaces flake8, isort, pyupgrade, and more, via one tool)
ruff format .      # format (black-compatible)

ruff is written in Rust and reimplements the rules of dozens of previously-separate Python linting/formatting plugins (flake8 plus its common plugin ecosystem, isort for import sorting, pyupgrade for modernizing syntax) in a single binary that runs 10-100x faster than the pure-Python tools it replaces. This speed matters enough in practice that ruff has become the default choice for new projects, often alongside or instead of both flake8 and black.

Fitting into a workflow: pre-commit and CI

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.0
    hooks:
      - id: ruff
      - id: ruff-format

The standard setup runs linting/formatting automatically at commit time (via pre-commit) so problems are caught before they even reach a PR, and again in CI as a required check. No code merges without passing both the formatter's consistency check and the linter's static analysis.

Static analysis vs type checking: complementary, not overlapping

Linters catch things like unused variables and style; mypy/pyright (covered separately) catch type mismatches. Both typically run in the same CI pipeline, each covering a different class of problem that the other doesn't.

Formatters (black/ruff format) eliminate style debates by auto-rewriting code to one consistent look. Linters (flake8/ruff check) statically catch likely bugs and code smells without changing code. ruff consolidates most of this Python tooling ecosystem into one fast Rust-based tool, and both are typically wired into pre-commit hooks and CI so no code merges without passing them.