How to configure ruff

Given the smörgåsbord of rules and plugins that ruff supports, it’s hard to figure out which settings are good for your average python project.

Configuration

First, configure ruff with a pyproject.toml file at the root of your project.

[tool.ruff]
line-length = 100
target-version = "py311"

pyproject.toml is the future of python tool configuration: it’s easy to see that your line length and python version are set consistently across your linter, auto-formatter, and import sorter.

All commandline options go into the config file. Then call ruff like this (perhaps in a Makefile or a github actions workflow) with the folders that need checking:

ruff check ./src ./tests

How rules work

Ruff (and python linting in general) is based around rules. Each rule is known by a code: for example the F841 rule checks for a variable that has a result saved to it but then never used.

Many of these codes are standardised across different linters, so you can typically google a lint code to find a description of the rule. You can also search the ruff rules page.

The letter(s) at the start of the rule code are used to group similar rules together. For example, all rules starting with DJ relate to best practices when working with the django web framework. In many parts of ruff, you can use either rule codes like DJ01 to refer to a single rule, or the prefix like DJ to apply to all rules in the DJ group.

Enabled rules

Rules are enabled with the select option. It’s best to start with just a few rule groups enabled: run ruff and fix any issues before adding any more rules.

Rule groups E and F are enabled by default, and cover the most bang-for-your-buck python issues. Add a comment for every rule you put in the config file, as the letter system is easy to forget long-term.

select = [
    "E",  # pycodestyle
    "F",  # pyflakes
]

Once any issues are fixed, you can add more groups. The list of supported groups is long and increasing, but many are only for specific frameworks or so picky that they’re unlikely to materially improve your code. It’s fine to choose a few that look helpful. This is a good start:

select = [
    "A",  # prevent using keywords that clobber python builtins
    "B",  # bugbear: security warnings
    "E",  # pycodestyle
    "F",  # pyflakes
    "ISC",  # implicit string concatenation
    "UP",  # alert you when better syntax is available in your python version
    "RUF",  # the ruff developer's own rules
]

Ignored rules

Ruff isn’t perfect: linters can throw errors for perfectly fine code, or may include rules that you disagree with.

Your linter works for you! The ultimate goal is to improve your code not to make a linter happy. So ruff provides a few different ways to suppress errors.

You can suppress a lint error using a comment with the format # noqa: <rule_code>. You should accompany any noqa override with an explanation for why the rule ought to be ignored.

# Configure logging before importing the rest of the app, so import errors
# and logs are correctly handled. Supress import order lint rule.
from myapp import config
logging.dictConfig(config)
from myapp import backend, models  # noqa: E402

Ruff lint rules can also be disabled project-wide. Disable rules that you disagree with, rules that conflict with a third-party API you have no control over, or rules that would be too time-consuming to fix right now (you can always come back to them later!)

For example, if you use black or a similar code formatter, you may want to skip any format-related rules (and just trust your formatter). Rules can be disabled using the ignore key in pyproject.toml. I start most projects with:

ignore = [
    "E712",  # Allow using if x == False, as it's not always equivalent to if x.
    "E501",  # Supress line-too-long warnings: trust black's judgement on this one.
    "UP017",  # Allow timezone.utc instead of datetime.UTC.
]

and for django projects add

ignore = [
    "E712",  # Allow using if x == False, as it's not always equivalent to if x.
    "E501",  # Supress line-too-long warnings: trust black's judgement on this one.
    "A003",  # Allow shawoding class attribute: django uses id.
    "B904",  # Allow unchained exceptions: it's fine to raise 404 in django.
]

Before disabling a rule project-wide, it’s helpful to run the linter as normal and read through the warnings, in case the project-level ignore is suppressing a useful warning.

Fixing

Unlike most linters, ruff can automatically fix your code! However, unlike code formatters, ruff performs non-cosmetic changes which may subtly change how your code functions.

If you have complete faith in your test suite, great! Don’t just add --fix to your ruff command though: that could break some code and it’s corresponding test in the same way, hiding the failing code behind a passing test. Instead, adopt ruff fixing like this

  • Run ruff with fixing just on your code, not on your tests: ruff check --fix ./src.
  • Check the un-ruffed tests still pass.
  • Now ruff all your stuff: ruff check --fix ./src ./tests
  • Check the tests still pass.
  • Use ruff fixing going forward.

If you only “mostly but not entirely” trust your test suite, a more realistic approach is to first enable ruff’s fixing (by adding --fix to your ruff command, but without any fixable rules in pyproject.toml

fixable = []

No fixing will be done yet with this config. But going forward, when you get a ruff warning that you know can be unambigously fixed on your codebase, add it to the fixable list. I often end up with these rules:

fixable = [
    "F401",  # Remove unused imports.
    "NPY001",  # Fix numpy types, which are removed in 1.24.
    "RUF100",  # Remove unused noqa comments.
]

Formatting (fixing) vs testing (checking)

So how does this fit into other parts of your python tooling?

With fixing enabled, ruff is playing two roles here:

  • As a formatter we want ruff to modify (fix) our code, but don’t care if the code will pass testing.
  • As a linter we want ruff to test our code, but any issues should be raised as errors and not silently fixed (otherwise running the linter on bad code in CI might pass, as ruff might fix some warnings locally that aren’t in the repo!)

Using pyproject.toml makes it easy to have ruff do these dual duties with a consistent configuration. I like to have a make fmt command for code formatting and a separate make test command in a Makefile

fmt:
    isort ./src ./tests
    ruff check --fix-only ./src ./tests
    black ./src ./tests

test:
    ruff check ./src ./tests
    black --check ./src ./tests
    pytest

In CI you can just run make test.

If you’re not using a Makefile you can specify the commands out. For example, your GitHub ci.yaml might look like:

name: build
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v1
    - name: Setup
      run: pip install -r requirements.txt
    - name: Lint
      run: ruff check ./src ./tests
    - name: Check formatting
      run: black --check ./src ./tests
    - name: Test
      run: pytest

In all of the above examples I have ruff as the first test command: you want to have your fastest tests first so you get early feedback on any issues, and save on CI minutes for failed tests. Some kinds of issues (like python syntax errors) will be detected by basically any tool in your test chain. I typically go in the following order:

  • ruff
  • black
  • isort
  • mypy
  • pytest or manage.py test

Versioning

You should pin the versions for all your dependencies (using a tool like pip-compile).

But you should especially pin the version for ruff. New rules are being added: so without version pinning you tests will just start failing one day soon, and you’ll have to be fixing lint errors on previously-fine code while trying to rush out an unrelated quick fix.

Ruff doesn’t have any dependencies, so just adding

ruff==0.0.270

to your requirements.txt file will lock things down without contributing to dependency hell.

Every few months, replace 0.0.270 with the latest version on pypy then fix any new issues in one sitting.