Ruff as a Linter in CI: Fast Python Code Quality Gating

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">| sh

Basic 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 --all

What 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 name

B-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 4

UP-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:
        pass

GitHub 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-format

The --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 module

Track suppressed violations:

# Count remaining suppressions
grep -r <span class="hljs-string">"noqa" src/ <span class="hljs-pipe">| <span class="hljs-built_in">wc -l

In 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">fi

Ruff 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/ --strict

Connecting 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.toml under [tool.ruff.lint] and [tool.ruff.format]
  • --output-format=github produces inline PR annotations in GitHub Actions
  • ruff check --fix auto-fixes most issues; use --add-noqa for bulk suppression on migration
  • Run alongside Mypy — they complement each other, covering style + types
  • Pre-commit hook with --exit-non-zero-on-fix prevents committing unfixed violations

Read more