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
- The Problem: Too Many Tools, Too Many Rules
- Why Ruff Won
- What ruff Actually Does
- Installing and Configuring ruff
- Running ruff: Check, Fix, Format
- Mode 1: Check for violations
- Mode 2: Auto-fix violations
- Mode 3: Format code
- Understanding Rule Categories (Finding What You Need)
- Pre-Commit Hook Patterns
- Pre-commit Hooks: Automation Before Git
- Install the pre-commit framework
- Create `.pre-commit-config.yaml` in your repo root
- Install the hooks into your git repository
- Run hooks on all files (useful in CI)
- Test it yourself
- Real-World Example: Fixing Actual Problems
- Common Quality Mistakes
- Editor Integration: Real-Time Feedback
- VS Code
- PyCharm
- Neovim / Vim
- Pre-commit in CI/CD Pipelines
- Common Violations and How to Fix Them
- F401: Unused Imports
- F841: Unused Variables
- E501: Line Too Long
- C901: Function Too Complex
- I001: Unsorted Imports
- Troubleshooting: When ruff Disagrees With You
- 1. Disable a rule for one line
- 2. Disable a rule for a block
- 3. Disable a rule project-wide
- 4. Override specific rules for test files
- Making it a Habit: The Workflow
- Beyond ruff: A Full Quality Pipeline
- Advanced Configuration: Fine-Tuning for Your Project
- Selective Enforcement by Directory
- Excluding Files and Directories
- Extending Coverage with Additional Rule Sets
- Debugging Ruff Issues: When Things Go Wrong
- Ruff Ignoring Your Configuration
- Performance Tuning for Large Codebases
- Understanding Rule Conflicts
- Integration Patterns: Beyond the Basics
- Continuous Integration Strategies
- IDE Workflow Optimization
- Real-World Case Study: Migrating a Messy Codebase
- Performance Comparison: Before and After
- Troubleshooting Tips and Gotchas
- Gotcha 1: ruff format changes files unexpectedly
- Gotcha 2: Pre-commit hooks slow down commits
- Gotcha 3: Conflicting configurations
- Gotcha 4: Different versions between local and CI
- 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.
pip install ruffCheck the version:
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.
[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 = 10What'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 formatwill 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:
EandW= flake8 style rulesF= Pyflakes (undefined names, unused imports)I= isort functionalityC= McCabe complexityUP= 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:
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.
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.
ruff check . --fixRuff handles what it can:
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 = NoneUnused 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.
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):
ruff format . --check # Just check, don't modifyUnderstanding Rule Categories (Finding What You Need)
Ruff has a lot of rules. Rather than memorizing codes, understand the categories:
| Code | Meaning | Examples |
|---|---|---|
E | pycodestyle errors | E302 (blank lines), E261 (spacing) |
W | pycodestyle warnings | W293 (blank lines with whitespace) |
F | Pyflakes | F401 (unused), F821 (undefined name) |
I | isort (imports) | I001 (import sorting) |
C | Complexity | C901 (function too complex) |
UP | pyupgrade (syntax modernization) | UP009 (use f-strings) |
S | Bandit (security) | S101 (assert), S303 (pickle) |
N | pep8-naming | N802 (variable name) |
D | pydocstyle | D100 (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:
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
pip install pre-commitCreate .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.
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
--fixto 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
pre-commit installThis sets up git hooks so the checks run automatically.
Run hooks on all files (useful in CI)
pre-commit run --all-filesTest 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.
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:
git diff myfile.py # See what changed
git add myfile.py
git commit -m "Add new feature" # Should pass this timeReal-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:
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:
passRun ruff check:
ruff check complex_example.pyOutput:
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:
ruff check complex_example.py --fixAfter auto-fix:
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:
passNotice 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
xflagged (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:
- Open Extensions (Ctrl+Shift+X)
- Search "Ruff"
- Install the official Astral extension
Configuration (in .vscode/settings.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+):
- Go to Settings → Tools → Python Integrated Tools
- Check "Ruff"
- 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:
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):
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:
- uses: pre-commit/action@v3.0.0This 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.
import sys # Never used
import os
# Fix: Remove itRuff auto-fixes this, but double-check that the removal doesn't break anything.
F841: Unused Variables
def fetch_data():
result = expensive_call()
x = 42 # Assigned but never used
return resultEither use the variable or remove it. If you need the import for side effects, prefix it with underscore:
_ = expensive_call() # Intentionally ignoring returnE501: 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.
def very_long_function_name_that_exceeds_line_length(parameter1, parameter2, parameter3):
passRuff format handles this automatically:
def very_long_function_name_that_exceeds_line_length(
parameter1, parameter2, parameter3
):
passC901: Function Too Complex
def process(x):
if x > 0:
if x > 10:
if x > 100:
return "huge"
return "large"
return "medium"
return "small"Refactor into helper functions:
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.
import z
import a
from typing import ListRuff auto-fixes this to:
import a
import z
from typing import ListTroubleshooting: When ruff Disagrees With You
Sometimes ruff will flag something you think is fine. You have options:
1. Disable a rule for one line
x = 1 # noqa: F841The # noqa: F841 comment tells ruff to ignore unused variable violation on that line.
2. Disable a rule for a block
# ruff: noqa: E501
def my_function_with_a_very_long_name_that_exceeds_the_line_length_but_is_intentional():
pass
# ruff: noqa3. Disable a rule project-wide
Update pyproject.toml:
[tool.ruff.lint]
ignore = ["E501", "F841"] # Add codes here4. Override specific rules for test files
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["F401", "S101"] # Ignore unused imports and asserts in tests
"*_test.py" = ["S101"] # Ignore asserts in test filesThe 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:
- Write code (with editor showing real-time ruff warnings)
- Run
ruff check . --fixlocally before committing - Commit (pre-commit hooks run automatically)
- Push (CI runs ruff again as a safety net)
- 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:
[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:
[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:
[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:
ruff config
# Shows what ruff actually loadedThis 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:
ruff check . --cache-dir /tmp/ruff-cacheRuff 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 preferencesI(import sorting) might conflict with manual import organization
Solution: Be explicit about what you want:
[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 = falseIntegration 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.
# GitHub Actions
- name: Lint Check
run: ruff check . --exit-zero # Warn but don't failMedium teams (5-20 developers): Enforce strictly in CI. Require fixes before merge.
- name: Lint Check
run: ruff check . # Fail if any issues
- name: Format Check
run: ruff format --check . # Strict format enforcementLarge teams (20+ developers): Use pre-commit framework with server-side validation. Auto-merge formatted PRs if only style changed:
- 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):
{
"[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)
ruff check . --statistics
# Shows what violations existOutput 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:
[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:
# 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:
| Metric | Before | After |
|---|---|---|
| Time to lint full codebase | 45 seconds | 2 seconds |
| Format + lint in CI | 2 minutes | 10 seconds |
| Manual formatting time/PR | 5 minutes | 0 minutes |
| Lint violations caught | 20% (most found in review) | 95% (caught before commit) |
| Code review cycle time | 30 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:
[tool.ruff.format]
quote-style = "single" # Use 'string' instead of "string"
indent-width = 2 # Use 2 spaces instead of 4Gotcha 2: Pre-commit hooks slow down commits
If pre-commit is taking >5 seconds, optimize:
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:
rm .flake8 .pylintrc setup.cfg
# Keep only pyproject.tomlGotcha 4: Different versions between local and CI
Always pin versions:
# .pre-commit-config.yaml
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.5 # Specific version# pyproject.toml
ruff==0.1.5Mismatched 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
# noqacomments 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.