How to configure ruff
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.
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
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.
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 ]
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. ]
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.
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
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!)
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
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
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:
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
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.