August 29, 2025
Python Code Quality DevOps

Code Quality in Python: ruff, pre-commit hooks, and Linting

You've written tests, added type hints, configured logging, and packaged your code. So why does your Python project still feel... messy?

The answer lies in automated code quality enforcement. Your tests catch runtime bugs, but they can't catch unused imports, inconsistent formatting, or code complexity that'll make future you weep. That's where linting and code quality tools come in, and if you're still running flake8, black, and isort as separate tools, we've got great news: there's a faster, simpler way.

Enter ruff, a single, blazingly fast Rust-powered tool that replaces flake8, black, isort, and several others all at once. Combined with pre-commit hooks, you can enforce code quality automatically before code even hits git. By the end of this article, your codebase will be cleaner, more consistent, and harder to mess up.

But let's be honest about why code quality automation matters beyond just aesthetics. Every hour your team spends debating formatting in code review is an hour not spent discussing architecture, catching logic bugs, or shipping features. Inconsistent style across a codebase creates cognitive overhead, your brain has to context-switch between different conventions rather than focusing on what the code actually does. And when a new developer joins? They either adopt the existing mess or introduce their own standards, making everything worse. Automated quality enforcement solves all of this by making style decisions once, enforcing them everywhere, and removing the human judgment call entirely. You configure it, you forget it, and your codebase stays clean regardless of who's writing code or how tired they are at 11pm.


Table of Contents
  1. The Problem: Too Many Tools, Too Many Rules
  2. Why Ruff Won
  3. What ruff Actually Does
  4. Installing and Configuring ruff
  5. Running ruff: Check, Fix, Format
  6. Mode 1: Check for violations
  7. Mode 2: Auto-fix violations
  8. Mode 3: Format code
  9. Understanding Rule Categories (Finding What You Need)
  10. Pre-Commit Hook Patterns
  11. Pre-commit Hooks: Automation Before Git
  12. Install the pre-commit framework
  13. Create `.pre-commit-config.yaml` in your repo root
  14. Install the hooks into your git repository
  15. Run hooks on all files (useful in CI)
  16. Test it yourself
  17. Real-World Example: Fixing Actual Problems
  18. Common Quality Mistakes
  19. Editor Integration: Real-Time Feedback
  20. VS Code
  21. PyCharm
  22. Neovim / Vim
  23. Pre-commit in CI/CD Pipelines
  24. Common Violations and How to Fix Them
  25. F401: Unused Imports
  26. F841: Unused Variables
  27. E501: Line Too Long
  28. C901: Function Too Complex
  29. I001: Unsorted Imports
  30. Troubleshooting: When ruff Disagrees With You
  31. 1. Disable a rule for one line
  32. 2. Disable a rule for a block
  33. 3. Disable a rule project-wide
  34. 4. Override specific rules for test files
  35. Making it a Habit: The Workflow
  36. Beyond ruff: A Full Quality Pipeline
  37. Advanced Configuration: Fine-Tuning for Your Project
  38. Selective Enforcement by Directory
  39. Excluding Files and Directories
  40. Extending Coverage with Additional Rule Sets
  41. Debugging Ruff Issues: When Things Go Wrong
  42. Ruff Ignoring Your Configuration
  43. Performance Tuning for Large Codebases
  44. Understanding Rule Conflicts
  45. Integration Patterns: Beyond the Basics
  46. Continuous Integration Strategies
  47. IDE Workflow Optimization
  48. Real-World Case Study: Migrating a Messy Codebase
  49. Performance Comparison: Before and After
  50. Troubleshooting Tips and Gotchas
  51. Gotcha 1: ruff format changes files unexpectedly
  52. Gotcha 2: Pre-commit hooks slow down commits
  53. Gotcha 3: Conflicting configurations
  54. Gotcha 4: Different versions between local and CI
  55. Summary

The Problem: Too Many Tools, Too Many Rules

Before we praise ruff, let's acknowledge what developers had to deal with before it existed.

You'd need:

  • flake8 to catch syntax errors, undefined names, and complexity issues
  • black to auto-format code into a consistent style
  • isort to organize imports alphabetically
  • pylint (optional) for even stricter style checking
  • Configuration files for each tool, often with conflicting settings

Running all these tools meant:

  • Slow CI pipelines (each tool scans the whole codebase)
  • Configuration hell (four different ways to specify rules)
  • Conflicts (isort and black fighting over import formatting)
  • Developer friction (waiting for tools to run locally)

Then ruff arrived in 2022 and changed the game. Written in Rust (the language that keeps stealing Python's lunch money for speed), ruff is 10-100x faster than the traditional Python tools while actually more capable.


Why Ruff Won

The Python linting ecosystem before ruff was a case study in tooling fragmentation. You had flake8 for style, black for formatting, isort for imports, pyupgrade for syntax modernization, bandit for security scanning, each tool doing one job, each needing its own config, each introducing its own overhead. Teams would spend more time configuring their quality pipeline than actually benefiting from it. Black and isort would conflict. Flake8 would flag things black reformatted. Pylint would be so slow developers disabled it locally and only ran it in CI, meaning they'd discover violations at the worst possible moment.

Ruff solved this by reimplementing the most valuable parts of all these tools in a single Rust binary. Not just porting the logic, actually rewriting it in a language designed for performance. The result is a tool that runs a full codebase scan in the time it used to take flake8 to scan a single file. Because everything runs in one process with one parse of your code, there are no conflicts between tools. Import sorting and formatting are coordinated. Rules don't fight each other.

Beyond speed, ruff won on ecosystem adoption because it made the right tradeoffs. It doesn't try to replace mypy (type checking) or pytest (testing), those are separate concerns. It focuses narrowly on style, formatting, and common error patterns, and it does those things better than any collection of individual tools. The project is backed by Astral, a well-funded company with a clear roadmap, so the tool gets regular updates and the Python community has confidence it won't be abandoned. When a tool solves your problem this well and is clearly here to stay, adoption follows quickly, and today, ruff is the de facto standard for Python linting.


What ruff Actually Does

Ruff combines three categories of functionality into one:

1. Linting – It scans your code for:

  • Undefined variables and names
  • Unused imports, variables, and function arguments
  • Syntax errors and style violations
  • Complexity issues (nested loops, function length)
  • Security problems and common pitfalls
  • All 900+ rules from flake8, pyupgrade, and more

2. Formatting – It auto-fixes code style to match black's standards (no bikeshedding about curly braces allowed).

3. Import organization – It sorts and organizes imports like isort, handling circular dependencies and grouped conventions.

Best part? It does all three in a single pass, orders of magnitude faster than running each tool separately.


Installing and Configuring ruff

Installation is a single command, and ruff is available everywhere, local development, CI, Docker containers. You don't need to install it system-wide; a per-project installation in your virtual environment keeps versions consistent across your team.

bash
pip install ruff

Check the version:

bash
ruff --version
# ruff 0.1.5 (or whatever the latest is)

Now let's configure it properly. Ruff reads settings from your pyproject.toml (the modern Python standard for all configuration). The configuration lives alongside your other tool settings, pytest config, mypy config, build system config, all in one file that you commit to version control. This is how you ensure everyone on your team, and every CI runner, uses exactly the same rules.

toml
[tool.ruff]
line-length = 100
target-version = "py39"
 
[tool.ruff.lint]
select = [
    "E",    # pycodestyle errors
    "W",    # pycodestyle warnings
    "F",    # Pyflakes (undefined names, unused imports)
    "I",    # isort (import organization)
    "C",    # McCabe complexity
    "UP",   # pyupgrade (modernize Python syntax)
    "S",    # Bandit (security issues)
]
ignore = [
    "E501",  # Line too long (ruff format will handle this)
    "S101",  # Use of assert (necessary for tests)
]
 
[tool.ruff.lint.mccabe]
max-complexity = 10

What's happening here?

  • line-length – Wrap lines at 100 characters (opinionated but reasonable)
  • target-version – Tells ruff which Python version you support (influences what upgrades it suggests)
  • select – The rule codes to enforce. Each letter is a category (E=errors, W=warnings, F=undefined names, etc.)
  • ignore – Rules you explicitly don't want (we skip E501 since ruff format will handle line wrapping)
  • max-complexity – McCabe complexity threshold (functions with 10+ branches get flagged)

The beauty of this approach? You're not guessing at rules. The codes map directly to tools you might've used before:

  • E and W = flake8 style rules
  • F = Pyflakes (undefined names, unused imports)
  • I = isort functionality
  • C = McCabe complexity
  • UP = pyupgrade (e.g., convert f-string formatting instead of .format())
  • S = Security checks

Running ruff: Check, Fix, Format

Ruff has three main modes. Let's say you have a file with some issues. This is the kind of code that slips through without automated enforcement, nothing here would fail your tests, but it all degrades maintainability over time:

python
import sys
import os
import json
from typing import Optional
 
x = 1  # unused variable
 
def calculate(a, b, c):  # function too long
    result = a + b
    if c:
        result = result * c
    return result
 
foo = None  # undefined name 'foo' (typo)

Mode 1: Check for violations

Running ruff check gives you a report of every violation with its exact location and the rule code that triggered it. The [*] markers are important, they tell you which problems ruff can fix automatically without any human intervention.

bash
ruff check .

Output:

example.py:1:1: F401 [*] `sys` imported but unused
example.py:2:1: F401 [*] `os` imported but unused
example.py:5:1: F841 [*] Local variable `x` is assigned to but never used
example.py:7:1: E302 expected 2 blank lines, found 1

2 errors, 2 warnings found [2 fixable with `ruff check --fix`]

The [*] marker means "ruff can auto-fix this." Some violations (like undefined names) you'll have to fix yourself.

Mode 2: Auto-fix violations

Adding --fix tells ruff to not just report problems but actually rewrite the affected lines. It only applies safe, mechanical fixes, it won't guess at your intent or make changes that could alter behavior.

bash
ruff check . --fix

Ruff handles what it can:

python
import json
from typing import Optional
 
def calculate(a, b, c):  # function too long
    result = a + b
    if c:
        result = result * c
    return result
 
foo = None

Unused imports? Gone. Unused variables? Removed. Formatting issues? Fixed. You still need to handle the logical errors (like the undefined foo variable, that's on you).

Mode 3: Format code

Formatting is separate from linting and handles the purely aesthetic concerns, indentation, quote style, trailing commas, line wrapping. You can run one without the other, though running both gives you the full benefit.

bash
ruff format .

This applies black-compatible formatting, wrapping long lines, normalizing quotes, spacing consistency, etc. It's separate from linting (you can use one without the other):

bash
ruff format . --check  # Just check, don't modify

Understanding Rule Categories (Finding What You Need)

Ruff has a lot of rules. Rather than memorizing codes, understand the categories:

CodeMeaningExamples
Epycodestyle errorsE302 (blank lines), E261 (spacing)
Wpycodestyle warningsW293 (blank lines with whitespace)
FPyflakesF401 (unused), F821 (undefined name)
Iisort (imports)I001 (import sorting)
CComplexityC901 (function too complex)
UPpyupgrade (syntax modernization)UP009 (use f-strings)
SBandit (security)S101 (assert), S303 (pickle)
Npep8-namingN802 (variable name)
DpydocstyleD100 (missing docstring)

You don't need to enable all of them. Start with E, W, F, and I (the essentials), then add others based on your team's needs.

To see what a specific code means:

bash
ruff rule F401
# Shows detailed explanation of F401 (unused imports)

Pre-Commit Hook Patterns

The real power of pre-commit isn't just "run ruff before commit", it's the composition of multiple lightweight checks that together catch entire categories of problems. The key insight is that different checks have different costs. Ruff is nearly free (milliseconds). Trailing whitespace detection is trivial. YAML validation is fast. Mypy is slower but critical. Running everything together means a commit takes maybe 5-10 seconds total, and that's a worthwhile trade for catching issues before they enter your git history.

A few patterns worth knowing: Use stages: [commit] to run fast checks on every commit and reserve heavy checks (full test suite, mypy) for push or CI only. Use pass_filenames: false for formatters that need to see the whole project, not just changed files. And always pin your hook revisions to a specific version tag, never use latest or a branch name, because a tool update upstream can silently change behavior and break your pipeline in ways that are painful to debug. The .pre-commit-config.yaml file should be treated with the same care as your pyproject.toml, it's part of your project's contract with itself.


Pre-commit Hooks: Automation Before Git

Here's the game-changer: pre-commit hooks run ruff (and other tools) automatically every time you try to commit code.

No more "oops, I forgot to run the linter." The hook prevents bad code from entering your repository in the first place.

Install the pre-commit framework

bash
pip install pre-commit

Create .pre-commit-config.yaml in your repo root

The configuration file declares which hooks to run and in what order. Each entry pulls a specific version of a hook from a git repository, so you get reproducible behavior across machines and CI environments, no "works on my machine" surprises.

yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.1.5
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
 
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: end-of-file-fixer
      - id: trailing-whitespace
      - id: check-yaml
      - id: check-added-large-files
 
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.7.0
    hooks:
      - id: mypy
        additional_dependencies: ["types-all"]

What's happening here?

  • ruff-pre-commit – The official ruff hook (with --fix to auto-correct issues)
  • ruff-format – The formatter
  • pre-commit-hooks – Basic sanity checks (no trailing whitespace, valid YAML, etc.)
  • mypy – Type checking (we've covered this in article 44)

Install the hooks into your git repository

bash
pre-commit install

This sets up git hooks so the checks run automatically.

Run hooks on all files (useful in CI)

bash
pre-commit run --all-files

Test it yourself

Try committing code with a violation. The first time this happens can feel jarring, your commit gets blocked, but once you see the hook auto-fix the issue and show you exactly what changed, you'll understand why this pattern is so valuable.

bash
git add myfile.py
git commit -m "Add new feature"

If there are linting issues, the commit fails:

ruff.....................................................................Failed
- hook id: ruff
- exit code: 1

myfile.py:1:1: F401 [*] `unused_module` imported but unused

An auto-fixable issue was detected.
Hook `ruff` modified some files. Review the changes and try committing again.

The hook fixed the issue for you, so just review the changes and commit again:

bash
git diff myfile.py  # See what changed
git add myfile.py
git commit -m "Add new feature"  # Should pass this time

Real-World Example: Fixing Actual Problems

Let's work through a realistic Python file with multiple violations. This is the kind of file you'll encounter when inheriting existing code, not catastrophically broken, but carrying years of accumulated technical debt that makes it harder to work with:

python
import sys
import os
from datetime import datetime
from typing import Optional, Dict
import json
from pathlib import Path
 
def process_data(users, limit=10):
    """Process user data."""
    for i, user in enumerate(users):
        if i < limit:
            if user['active']:
                if user['premium']:
                    print(f"Premium user: {user['name']}")
                else:
                    print(f"Regular user: {user['name']}")
            else:
                print(f"Inactive: {user['name']}")
    return
 
results = None
x = {"a": 1, "b": 2}
 
class DataProcessor:
    pass

Run ruff check:

bash
ruff check complex_example.py

Output:

complex_example.py:1:1: F401 [*] `sys` imported but unused
complex_example.py:2:1: F401 [*] `os` imported but unused
complex_example.py:7:1: I001 [*] Import block is unsorted or unformatted
complex_example.py:9:1: E302 [*] expected 2 blank lines, found 1
complex_example.py:9:1: C901 `process_data` is too complex
complex_example.py:20:1: F841 [*] Local variable `x` is assigned to but never used
complex_example.py:22:1: E302 [*] expected 2 blank lines, found 1
complex_example.py:22:1: E305 [*] expected 2 blank lines after class or function definition, found 1

Run the fix:

bash
ruff check complex_example.py --fix

After auto-fix:

python
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
 
import json
 
def process_data(users, limit=10):
    """Process user data."""
    for i, user in enumerate(users):
        if i < limit:
            if user['active']:
                if user['premium']:
                    print(f"Premium user: {user['name']}")
                else:
                    print(f"Regular user: {user['name']}")
            else:
                print(f"Inactive: {user['name']}")
    return
 
results = None
 
class DataProcessor:
    pass

Notice what changed:

  • Unused imports (sys, os) removed
  • Remaining imports organized: stdlib first (datetime, pathlib), then third-party (json), then typing
  • Blank lines normalized (2 before functions, 2 after class definitions)
  • Unused variable x flagged (you'll remove it manually)

The complexity warning remains, that's something you need to refactor yourself (breaking the nested if statements into helper functions). This is the right call on ruff's part: removing the warning would require understanding your intent, and that's a human judgment, not a mechanical fix.


Common Quality Mistakes

Even with ruff installed, teams consistently make the same mistakes in how they adopt code quality tooling. The biggest one is treating # noqa as a get-out-of-jail-free card. A # noqa comment tells ruff to ignore a violation on that line, and it has legitimate uses, but in many codebases it becomes a way to suppress warnings without understanding them. If you're adding # noqa more than once or twice a week, that's a signal you need to revisit your rule configuration, not silence individual violations.

The second common mistake is installing ruff but not pre-commit, or installing pre-commit but not enforcing it in CI. The pre-commit framework only works as a deterrent if there's a consequence for bypassing it. Without CI enforcement, developers can use git commit --no-verify to skip hooks entirely, and under deadline pressure they will. Your CI pipeline is the backstop that makes local enforcement meaningful.

Third: ignoring the complexity warnings. C901 violations, functions that exceed the McCabe complexity threshold, are the easiest to dismiss ("my function works, stop complaining") and the most important to fix. Complex functions are where bugs hide, where tests fail to reach edge cases, and where new developers get lost. When ruff flags a function as too complex, treat it as a refactoring opportunity, not an annoyance to suppress.


Editor Integration: Real-Time Feedback

Writing code and waiting for CI to fail is slow. Instead, integrate ruff into your editor for instant feedback. The key benefit isn't just convenience, it's that you fix issues in the same mental context where you created them, rather than hours or days later when you've moved on.

VS Code

Install the Ruff extension:

  1. Open Extensions (Ctrl+Shift+X)
  2. Search "Ruff"
  3. Install the official Astral extension

Configuration (in .vscode/settings.json):

json
{
  "[python]": {
    "editor.defaultFormatter": "charliermarsh.ruff",
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
      "source.organizeImports": true
    }
  }
}

Now when you save a Python file, ruff automatically formats and fixes issues.

PyCharm

PyCharm has built-in ruff support (version 2023.1+):

  1. Go to Settings → Tools → Python Integrated Tools
  2. Check "Ruff"
  3. Configure rule selection

PyCharm will highlight violations in real-time.

Neovim / Vim

If you're using Neovim with LSP, add ruff to your language server config:

lua
require("lspconfig").ruff_lsp.setup({
  init_options = {
    settings = {
      args = { "--select=E,W,F,I" }
    }
  }
})

Pre-commit in CI/CD Pipelines

Your CI/CD pipeline should enforce code quality before merging. The local pre-commit hooks catch issues early, but CI is the authoritative gate, it's what actually blocks a merge when something slips through. Think of them as defense in depth: hooks at the developer's machine, and CI as the final checkpoint that everyone's output has to pass. Add this to your GitHub Actions workflow (.github/workflows/lint.yml):

yaml
name: Code Quality
 
on: [push, pull_request]
 
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-python@v4
        with:
          python-version: "3.11"
 
      - name: Install ruff
        run: pip install ruff
 
      - name: Lint with ruff
        run: ruff check .
 
      - name: Format check with ruff
        run: ruff format --check .

Or use the community pre-commit action:

yaml
- uses: pre-commit/action@v3.0.0

This runs all your pre-commit hooks in CI, ensuring no unformatted code gets merged.


Common Violations and How to Fix Them

F401: Unused Imports

Unused imports are one of the most common violations and usually the easiest to fix. They accumulate over time as code evolves, you refactor a function, remove a dependency, and forget to clean up the import. Ruff catches all of them automatically.

python
import sys  # Never used
import os
 
# Fix: Remove it

Ruff auto-fixes this, but double-check that the removal doesn't break anything.

F841: Unused Variables

python
def fetch_data():
    result = expensive_call()
    x = 42  # Assigned but never used
    return result

Either use the variable or remove it. If you need the import for side effects, prefix it with underscore:

python
_ = expensive_call()  # Intentionally ignoring return

E501: Line Too Long

Long lines are a formatting issue, not a logic issue, so ruff format handles them automatically without any manual intervention. The formatter is smart about how it wraps, it'll break function signatures at logical boundaries, not just at an arbitrary character count.

python
def very_long_function_name_that_exceeds_line_length(parameter1, parameter2, parameter3):
    pass

Ruff format handles this automatically:

python
def very_long_function_name_that_exceeds_line_length(
    parameter1, parameter2, parameter3
):
    pass

C901: Function Too Complex

python
def process(x):
    if x > 0:
        if x > 10:
            if x > 100:
                return "huge"
            return "large"
        return "medium"
    return "small"

Refactor into helper functions:

python
def process(x):
    if x <= 0:
        return "small"
    if x <= 10:
        return "medium"
    if x <= 100:
        return "large"
    return "huge"

I001: Unsorted Imports

Sorted imports aren't just aesthetic, they make it easier to scan imports at a glance, reduce merge conflicts (sorted order is deterministic), and help catch duplicate imports. Ruff fixes this automatically, so there's no excuse for letting it drift.

python
import z
import a
from typing import List

Ruff auto-fixes this to:

python
import a
import z
from typing import List

Troubleshooting: When ruff Disagrees With You

Sometimes ruff will flag something you think is fine. You have options:

1. Disable a rule for one line

python
x = 1  # noqa: F841

The # noqa: F841 comment tells ruff to ignore unused variable violation on that line.

2. Disable a rule for a block

python
# ruff: noqa: E501
def my_function_with_a_very_long_name_that_exceeds_the_line_length_but_is_intentional():
    pass
# ruff: noqa

3. Disable a rule project-wide

Update pyproject.toml:

toml
[tool.ruff.lint]
ignore = ["E501", "F841"]  # Add codes here

4. Override specific rules for test files

toml
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["F401", "S101"]  # Ignore unused imports and asserts in tests
"*_test.py" = ["S101"]  # Ignore asserts in test files

The last option is crucial, test files have different standards (asserts are fine, unused imports might be test fixtures).


Making it a Habit: The Workflow

Here's the rhythm of working with ruff and pre-commit:

  1. Write code (with editor showing real-time ruff warnings)
  2. Run ruff check . --fix locally before committing
  3. Commit (pre-commit hooks run automatically)
  4. Push (CI runs ruff again as a safety net)
  5. Merge (only if all checks pass)

This catches 90% of style issues before code review, letting reviewers focus on logic and architecture instead of nitpicking formatting.


Beyond ruff: A Full Quality Pipeline

Ruff handles style and common errors, but a complete quality pipeline also includes:

  • pytest (unit tests) – Does it work?
  • mypy (type checking) – Are types correct?
  • ruff (linting + formatting) – Is it clean and consistent?
  • coverage (test coverage) – Are we testing enough?

We've covered testing (articles 41-43) and types (article 44). Ruff rounds out the picture with automated style enforcement.

Next article (49) dives into Docker, where you'll package this carefully-crafted Python code into isolated, reproducible containers.


Advanced Configuration: Fine-Tuning for Your Project

As your project matures, you might want more nuanced control over ruff's behavior. Here are some advanced patterns:

Selective Enforcement by Directory

Different directories might have different standards. For example, test files are often more lenient:

toml
[tool.ruff.lint]
select = ["E", "W", "F", "I", "C"]
 
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["F401", "F841", "S101"]
"migrations/*" = ["E501"]
"scripts/*" = ["S101", "S603"]

This allows:

  • Tests to use unused imports (for fixtures) and assertions
  • Database migrations to have long lines
  • Scripts to use assertions and shell commands

Excluding Files and Directories

Sometimes you need ruff to skip certain files entirely:

toml
[tool.ruff]
exclude = [
    ".git",
    ".venv",
    "venv",
    "__pycache__",
    "build",
    "dist",
    ".eggs",
    "*.egg-info",
    "migrations",
]

Extending Coverage with Additional Rule Sets

Want stricter enforcement? Add more rule codes:

toml
[tool.ruff.lint]
select = [
    "E",    # pycodestyle errors
    "W",    # pycodestyle warnings
    "F",    # Pyflakes
    "I",    # isort
    "C",    # McCabe complexity
    "UP",   # pyupgrade
    "S",    # Bandit security
    "N",    # pep8-naming (variable naming conventions)
    "D",    # pydocstyle (docstring checks)
    "RUF",  # ruff-specific rules
    "B",    # flake8-bugbear (common bugs)
]

Each rule set brings additional strictness:

  • N catches naming violations (snake_case variables, PascalCase classes)
  • D requires docstrings on public functions and classes
  • B catches potential bugs like mutable default arguments
  • RUF includes ruff-specific improvements

Start conservative, then add rules as your team adopts the practices.


Debugging Ruff Issues: When Things Go Wrong

Ruff Ignoring Your Configuration

If ruff isn't respecting your pyproject.toml settings:

bash
ruff config
# Shows what ruff actually loaded

This displays the effective configuration, helping identify if ruff is reading the wrong file or inheriting unexpected defaults.

Performance Tuning for Large Codebases

Even though ruff is fast, massive projects might benefit from caching:

bash
ruff check . --cache-dir /tmp/ruff-cache

Ruff only re-checks files that changed. For CI pipelines, you can persist this cache across runs.

Understanding Rule Conflicts

Sometimes rules can conflict. For example:

  • E501 (line too long) conflicts with formatting preferences
  • I (import sorting) might conflict with manual import organization

Solution: Be explicit about what you want:

toml
[tool.ruff.lint]
select = ["E", "W", "F", "I", "C", "UP", "S"]
ignore = ["E501"]  # Let formatter handle line length
 
[tool.ruff.format]
line-length = 100
skip-magic-trailing-comma = false

Integration Patterns: Beyond the Basics

Continuous Integration Strategies

Different strategies for different team sizes:

Small teams (1-5 developers): Use pre-commit hooks to enforce locally. CI acts as a safety net.

yaml
# GitHub Actions
- name: Lint Check
  run: ruff check . --exit-zero # Warn but don't fail

Medium teams (5-20 developers): Enforce strictly in CI. Require fixes before merge.

yaml
- name: Lint Check
  run: ruff check . # Fail if any issues
 
- name: Format Check
  run: ruff format --check . # Strict format enforcement

Large teams (20+ developers): Use pre-commit framework with server-side validation. Auto-merge formatted PRs if only style changed:

yaml
- name: Run pre-commit
  run: pre-commit run --all-files
 
- name: Auto-fix formatting
  if: failure()
  run: |
    ruff check . --fix
    ruff format .
    git add .
    git commit -m "style: auto-fix formatting"

IDE Workflow Optimization

Set up your IDE to fix issues on save:

VS Code (.vscode/settings.json):

json
{
  "[python]": {
    "editor.defaultFormatter": "charliermarsh.ruff",
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
      "source.fixAll.ruff": "explicit"
    }
  }
}

PyCharm (Settings → Editor → Code Style): Set line length to match your pyproject.toml, enable ruff on save.

This workflow means you never manually fix formatting, the IDE does it for you.


Real-World Case Study: Migrating a Messy Codebase

Let's say you inherited a Python project with no linting. Here's how to introduce ruff without chaos:

Phase 1: Audit (Day 1)

bash
ruff check . --statistics
# Shows what violations exist

Output might show:

F401: 237 unused imports
E302: 184 blank line issues
W291: 92 trailing whitespace

Phase 2: Safety Mode (Day 2-3)

Don't break everything. Start in "report only" mode:

toml
[tool.ruff.lint]
ignore = [
    "F401",  # Unused imports (too many to fix immediately)
    "E302",  # Blank lines (formatting, safe to ignore now)
]
select = [
    "F821",  # Undefined names (must fix)
    "S101",  # Security (must fix)
]

Focus on real bugs (undefined names) before style issues.

Phase 3: Gradual Enforcement (Week 2+)

Enable rules one by one:

bash
# Week 2: Add E/W (formatting)
# Week 3: Add I (import sorting)
# Week 4: Add C (complexity)
# Week 5: Add UP (syntax modernization)

Each week, fix the new category, then commit.

Phase 4: Pre-commit Adoption (Month 2)

Once the codebase is clean, install pre-commit hooks so future changes stay clean.

This phased approach prevents burnout and allows time for team adoption.


Performance Comparison: Before and After

Here's what a typical team sees after adopting ruff + pre-commit:

MetricBeforeAfter
Time to lint full codebase45 seconds2 seconds
Format + lint in CI2 minutes10 seconds
Manual formatting time/PR5 minutes0 minutes
Lint violations caught20% (most found in review)95% (caught before commit)
Code review cycle time30 min (style bikeshedding)10 min (logic review only)

The biggest win: style issues vanish from code review. Humans focus on logic, architecture, and algorithms instead of debating semicolons.


Troubleshooting Tips and Gotchas

Gotcha 1: ruff format changes files unexpectedly

Ruff format is opinionated (like black). If you disagree with its choices, override in pyproject.toml:

toml
[tool.ruff.format]
quote-style = "single"  # Use 'string' instead of "string"
indent-width = 2        # Use 2 spaces instead of 4

Gotcha 2: Pre-commit hooks slow down commits

If pre-commit is taking >5 seconds, optimize:

yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.1.5
    hooks:
      - id: ruff
        args: [--fix]
        stages: [commit] # Only run at commit, not at push
      - id: ruff-format
        stages: [commit]

Also consider running heavier checks (mypy, pytest) only on push, not commit.

Gotcha 3: Conflicting configurations

If you have both .flake8 and pyproject.toml, ruff might read the wrong one. Remove old config files:

bash
rm .flake8 .pylintrc setup.cfg
# Keep only pyproject.toml

Gotcha 4: Different versions between local and CI

Always pin versions:

yaml
# .pre-commit-config.yaml
- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.1.5 # Specific version
bash
# pyproject.toml
ruff==0.1.5

Mismatched versions can cause "works locally but fails in CI" headaches.


Summary

Ruff is a game-changer. It replaces flake8, black, and isort with a single, Rust-powered tool that's orders of magnitude faster. Paired with pre-commit hooks, it becomes part of your development workflow, catching issues before they hit git.

Key takeaways:

  • ruff check finds violations, ruff check --fix auto-corrects them
  • ruff format handles code style (black-compatible)
  • Configure it once in pyproject.toml, then forget about it
  • Pre-commit hooks ensure code quality automatically
  • Editor integration gives real-time feedback while you write
  • Use # noqa comments judiciously for exceptions
  • Phase in rules gradually when adopting on existing codebases
  • Monitor performance and adjust CI strategies for your team size

The deeper lesson here is about where you invest friction. If bad code is easy to commit and good code requires effort, you'll get bad code at scale, not because developers are careless, but because deadlines are real and convenience wins. Ruff and pre-commit flip that calculus: clean code becomes the path of least resistance, and sloppy code gets blocked before it can compound into technical debt. The five minutes you spend configuring this today will save hours across every future pull request, code review, and debugging session. Set it up once, commit the config, and let the automation do the work.

Your future self will thank you when reviewing pull requests that are already formatted perfectly and free of common bugs.

Next up: Docker and containerization. You've built clean, tested, well-documented code. Now let's package it in a way that runs identically everywhere.

Need help implementing this?

We build automation systems like this for clients every day.

Discuss Your Project