Ruff as a Linter in CI: Fast Python Code Quality Gating
Ruff is a Python linter and formatter written in Rust that runs 10-100× faster than the tools it replaces. It implements rules from Flake8, isort, pylint, pyupgrade, and more — consolidating what used to be 5+ tools into one. For CI pipelines, Ruff's speed makes code quality gating essentially free in terms of pipeline time.
Why Ruff Has Become the Default
Before Ruff, a typical Python CI linting stack looked like:
- Flake8 (PEP 8 style)
- isort (import sorting)
- Black (formatting)
- pylint (deeper analysis)
- pyupgrade (modernize syntax)
Each tool needed its own configuration, its own pip install, its own CI step. A medium-sized Python project spent 30-60 seconds just on linting. Ruff runs the equivalent checks in 1-3 seconds on the same codebase.
Installing Ruff
pip install ruff
# Or as a standalone binary (no Python dependency)
curl -LsSf https://astral.sh/ruff/install.sh <span class="hljs-pipe">| shBasic Configuration
Ruff reads from pyproject.toml or ruff.toml:
# pyproject.toml
[tool.ruff]
target-version = "py312"
line-length = 88
src = ["src", "tests"]
[tool.ruff.lint]
# Enable rule sets
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"N", # pep8-naming
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
]
ignore = [
"E501", # line too long (handled by formatter)
"B008", # function calls in argument defaults (FastAPI pattern)
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"] # assert statements OK in tests
[tool.ruff.format]
quote-style = "double"
indent-style = "space"Running Ruff
# Check for lint errors (exit code 1 if any found)
ruff check src/
<span class="hljs-comment"># Auto-fix fixable issues
ruff check --fix src/
<span class="hljs-comment"># Format code (like Black)
ruff format src/
<span class="hljs-comment"># Check formatting without modifying (for CI)
ruff format --check src/
<span class="hljs-comment"># Show all rules
ruff rule --allWhat Ruff Catches
Ruff's rule sets cover a huge range:
F-rules (Pyflakes):
import os # F401: imported but unused
def foo():
x = 1 # F841: local variable assigned but never used
return 2
print("never") # F811: redefinition of unused nameB-rules (Bugbear):
# B006: mutable default argument — common Python gotcha
def add_item(item, items=[]):
items.append(item)
return items
# B007: loop variable not used
for i in range(10):
print("hello") # Should be `_` if i is unused
# B023: function defined in loop doesn't bind loop variable
handlers = [lambda: i for i in range(5)] # All return 4UP-rules (pyupgrade):
# UP007: use X | Y instead of Optional[X] (Python 3.10+)
from typing import Optional
def foo(x: Optional[str]) -> None: # -> def foo(x: str | None) -> None
pass
# UP035: deprecated import
from typing import List, Dict # -> list, dict in Python 3.9+SIM-rules (simplify):
# SIM108: use ternary operator
if condition:
x = 1
else:
x = 2
# -> x = 1 if condition else 2
# SIM117: use single with statement
with open("a") as f:
with open("b") as g: # -> with open("a") as f, open("b") as g:
passGitHub Actions Integration
# .github/workflows/lint.yml
name: Lint
on: [push, pull_request]
jobs:
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/ruff-action@v1
with:
args: "check --output-format=github"
- uses: astral-sh/ruff-action@v1
with:
args: "format --check"The official astral-sh/ruff-action downloads a pre-built Ruff binary — no Python setup needed. Linting a large codebase takes under 5 seconds.
Alternatively, with a Python environment:
- name: Install Ruff
run: pip install ruff
- name: Lint
run: ruff check --output-format=github src/ tests/
- name: Check formatting
run: ruff format --check src/ tests/--output-format=github produces annotations directly in the GitHub PR diff view.
Migrating from Flake8 + isort + Black
Ruff has a migration guide and auto-generates equivalent configuration from existing setup files. For a quick migration:
# Check what Flake8 issues Ruff would catch
ruff check --<span class="hljs-keyword">select E,W,F src/
<span class="hljs-comment"># Check what isort issues Ruff would catch
ruff check --<span class="hljs-keyword">select I src/
<span class="hljs-comment"># Check formatting differences vs Black
ruff format --check --diff src/Replace your setup.cfg or .flake8 Flake8 config with Ruff's equivalent in pyproject.toml. Most Flake8 plugins have Ruff equivalents:
| Old Tool | Ruff Rule Set |
|---|---|
| flake8-bugbear | B |
| flake8-comprehensions | C4 |
| flake8-simplify | SIM |
| pep8-naming | N |
| flake8-type-checking | TCH |
| flake8-annotations | ANN |
Ruff as a Pre-commit Hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.0
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-formatThe --exit-non-zero-on-fix flag fails the hook if Ruff made auto-fixes — forcing the developer to review and re-commit. Without it, Ruff silently fixes issues and the commit proceeds.
Enforcing Rules Incrementally
For existing codebases with many violations, use # noqa comments or per-file ignores to suppress known issues while preventing new ones:
# Add noqa comments to all existing violations (one-time migration)
ruff check --add-noqa src/
<span class="hljs-comment"># Then tighten over time by removing noqa comments module by moduleTrack suppressed violations:
# Count remaining suppressions
grep -r <span class="hljs-string">"noqa" src/ <span class="hljs-pipe">| <span class="hljs-built_in">wc -lIn CI, you can set a threshold and fail if suppressions increase:
NOQA_COUNT=$(grep -r "# noqa" src/ <span class="hljs-pipe">| <span class="hljs-built_in">wc -l)
MAX_ALLOWED=50
<span class="hljs-keyword">if [ <span class="hljs-string">"$NOQA_COUNT" -gt <span class="hljs-string">"$MAX_ALLOWED" ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"Too many noqa suppressions: $NOQA_COUNT (max: <span class="hljs-variable">$MAX_ALLOWED)"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fiRuff with Mypy: Complementary Tools
Ruff and Mypy solve different problems:
- Ruff: style, common bugs, import organization, code simplification
- Mypy: type correctness, null safety, API contracts
Use both together. They don't overlap significantly:
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install ruff mypy types-requests
- name: Lint (Ruff)
run: ruff check --output-format=github src/
- name: Format check (Ruff)
run: ruff format --check src/
- name: Type check (Mypy)
run: mypy src/ --strictConnecting Code Quality to Test Quality
Ruff gates code quality. But formatted, lint-free code doesn't guarantee correct behavior. Pair static analysis with behavioral tests — HelpMeTest provides end-to-end behavioral testing for your Python services, ensuring that clean code actually does what it's supposed to do in production.
Summary
- Ruff replaces Flake8, isort, pylint, pyupgrade, and more in a single fast binary
- Configure in
pyproject.tomlunder[tool.ruff.lint]and[tool.ruff.format] --output-format=githubproduces inline PR annotations in GitHub Actionsruff check --fixauto-fixes most issues; use--add-noqafor bulk suppression on migration- Run alongside Mypy — they complement each other, covering style + types
- Pre-commit hook with
--exit-non-zero-on-fixprevents committing unfixed violations