Ruff


Introduction

Python development is evolving—today, code quality and consistency are more important than ever. Strict is the new cool.

Tools like Black have made opinionated formatting mainstream, and the need for readable, maintainable, and error-free code is greater than ever—especially as projects grow and AI-driven code/tools become more prevalent.

As The Zen of Python reminds us, “Readability counts”. The developer principle “code is written once but read many times” is still bandied about. But, maintaining high standards manually can be challenging and time-consuming. That’s where modern linters and formatters come in, helping us catch errors early and keep our codebases clean.

In this post, I’ll share my experience adopting Ruff, a fast, mode


What are Linters and Formatters


Linters

Linting is the automatic checking of your code for errors. Code that passes linting will probably run.

Technically, linting is Static Analysis, meaning they detect defects without running the code by analysing the syntax. Modern linters will also check for improvements to the code that may lead to incorrect results: suggesting more robust or accepted ways to code.

Linters reduce errors and improve the quality of your code and therefore should be enabled in IDE’s, where they continuously run in the background. Any CI/CD pipeline or pre-commit process should have linting enabled.

A linter can be thought of as a formatter with syntax rules.


Formatters

A formatter makes your code pretty, by standardising the appearance of the code. Formatters only change the presentation of the code, not the code functionality.
This makes the code easy to read for you and everyone else, and this becomes more important when working in teams.

Formatters use a set of rules for consistency. There are a number of rules formatters use, the PEP 8 – Style Guide for Python Code is one such set of standardised rules, but there are numerous anti-pattern rules followed by different formatter implementations as well.

A formatter is opinionated in what it thinks pretty code is, but usually those opinions can be enabled/disabled/ignored.


Why You Should Use Them

XCD 1695 https://xkcd.com/1695


What is Ruff?

Ruff is a fast, modern Python combined linter and formatter from Astral, the company that developed UV (see My Switch to UV).

Key features of Ruff:

  • Written in Rust….so it’s fast
  • A linter and formatter in one tool
  • Designed as a compatible replacement for multiple existing linters and formatters
  • Configurable (enable/disable) rules for linting and formatting
  • IDE integration
  • Command Line (CLI) usable
  • Works for Python >=3.7

So Ruff simplifies multiple linters and formatters into a single tool and performs those tasks fast. How fast?
Well, this graph from the Ruff website shows the time taken to lint the whole CPython codebase:

alt text source: https://docs.astral.sh/ruff

Taken from an Astral blog post, this graph shows the timings to format the ~250k line Zulip codebase:

Ruff Formatter Speed Comparison source: https://astral.sh/blog/the-ruff-formatter

Some of the tools it replaces are:


Getting Started with Ruff


Installation steps

Being an Astral tool, there are multiple methods of installing Ruff.
For me, I want to install Ruff as a system tool, so it is globally available to me all the time. I have UV installed, so I will install using that:

uv tool install ruff

alt text

If you do not have UV installed (I recommend you do, see My Switch to UV), you can install directly with PowerShell:

powershell -c "irm https://astral.sh/ruff/install.ps1 | iex"

Alternatively you can add it to your project tools as part of your Venv, or be a caveman and use other outdated install methods, e.g., PiP, PiPx:

uv add --dev ruff  # Install into project Development

pip install ruff   # Use one of the outdated install method 

See the Ruff Install Documentation if your preferred install method is not mentioned here.


Basic CLI Usage

Once installed, you can run Ruff on the CLI:

ruff check           # Recursively lint all files, from this working directory
ruff check .         # Recursively lint all files, from this working directory
ruff check test.py   # Lint specific file only 
ruff check *.py      # Lint all Python files in the current directory

ruff format          # Recursively format all files, from this working directory
ruff format .        # Recursively format all files, from this working directory
ruff format test.py  # Format specific file only
ruff format *.py     # Format all Python files in the current directory

The same is true if you did not install Ruff, but want to run the tool from within a UV cached environment via UVX:

# uvx is an alias for uv tool run
uvx ruff check       # Recursively lint all files, from this working directory
uvx ruff format      # Recursively format all files, from this working directory

That’s all there is to it, two simple commands to lint and format.
The results of running either command are the important thing. As an example, lets take this, highly contrived, simple Python file that has syntax errors and formatting issues:

 1from sys import *
 2import os, math
 3
 4num_strs = {"Zero": 0,"One":1, "Two": 2 , "Three": 3}
 5str_nums = {0: "Zero" ,1:"One",  2:"Two" , 3: "Three"}
 6
 7def TranslateNumberString(numberstring
 8):
 9
10    """
11    Example function
12    """
13    if (True,):
14        return num_strs[numberstring]
15
16from pprint import pprint
17
18if __name__ == "__main__":
19    pprint(num_strs)
20    pprint(str_nums)
21    for x in range(3):
22        try:
23            r = TranslateNumberString(str_nums[x])
24            print('Str {} = {}'.format(str_nums[x], r))
25        except:
26            print("Damn!")

ruff check --output-format concise .\example.py

So I am linting this file and I’ve made the output concise (default is full), and passed i the --isolated flag so Ruff will ignore my config files and use the defaults instead (see Configuration). I did say this was contrived).

Ruff Linter Errors (Concise)

Even from those few lines of rushed code I’ve got 8 syntax errors.

The full format explains in more detail each fault found, for example:

ruff check .\example.py

# Both lines are equivalent
ruff check example.py
ruff check --output-format full example.py  

Ruff Linter Error (Full)

The linter cannot always fix detected issues, for the rules the linter can fix see the legend in the Ruff Rules Documentation.

In this example there are 4 errors fixable automatically (those with [*]). Lets fix the easy ones automatically as they are simple errors:

ruff check --diff example.py  # Show what the changes 'would' be
ruff check --fix example.py   # Apply the changes

Auto-fix after Checking  the Diff

You may get fixes that Ruff has classed as being unsafe, indicating that the meaning of your code may change with those fixes. There are none in the example, but the commands to fix the unsafe-fixes are similar:

ruff check --unsafe-fixes --diff example.py  # Show what the changes 'would' be
ruff check --unsafe-fixes --fix example.py   # Apply the changes

Now we are down to 4 errors for our example, but there are no auto fixes for those. Lets go read the docs for each errored rule and fix them manually:

  • F403 from sys import * used; unable to detect undefined names
    • Yes, star imports are bad, change to import sys
  • F634 If test is a tuple, which is always True
    • Change to if True:
  • E402 Module level import not at top of file
    • Move import to the top
  • E722 Do not use bare except
    • Change to except Exception as e:

Sometimes when you fix an issue, you will get another, in this case I got another F401 'sys' imported but unused error, which I also fixed. You always need to run the linter again until all the errors have been resolved.

A mentioned, errors state what the problem is and give a rule number (e.g., F403) that you can look up on the Ruff Rules pages to get more information and suggestions on how to fix.

Note: When used within an IDE, hyperlinks are provided to quickly get information on the error.

Now we have all checks passing:

Diff Changes for <code>example.py</code>


ruff format .\example.py

Before we format the remaining code, lets take a look at what would be changed:

ruff format --diff .\example.py  # Show what the changes 'would' be

Ruff Format Proposed Changes

Again I am using the --isolated option to use the default rules only.
Lets accept those changes and let Ruff format the code:

ruff format .\example.py   # Apply the formatting changes

The final code is now:

from pprint import pprint

num_strs = {"Zero": 0, "One": 1, "Two": 2, "Three": 3}
str_nums = {0: "Zero", 1: "One", 2: "Two", 3: "Three"}


def TranslateNumberString(numberstring):
    """
    Example function
    """
    if True:
        return num_strs[numberstring]


if __name__ == "__main__":
    pprint(num_strs)
    pprint(str_nums)
    for x in range(3):
        try:
            r = TranslateNumberString(str_nums[x])
            print("Str {} = {}".format(str_nums[x], r))
        except Exception as e:
            print(f"How exceptional! {e}")

Although the code runs and does what was intended, there are still issues with this code. Linters and formatters are only there to help you, not replace you!


Configuration

The rules used by Ruff are derived from multiple linters and formatters, and are configurable through hierarchical TOML files.

Whether Ruff is used as a linter, a formatter or both, configuration follows the same methodology. Ruff will search for a configuration file in one of the following files .ruff.toml, ruff.toml or pyproject.toml in the closest directory and in that order of preference. Alternatively you can specify any TOML file with the --config option.

It is normal to include Ruff configuration in your project pyproject.toml file, as the Ruff configuration can be included in the projects version control.
The majority of projects using Ruff, as listed by Astral, use their existing pyproject.toml to store their Ruff configuration. For example:

FastApi using pyproject.toml
Pandas using pyproject.toml
Polars using pyproject.toml

As an aside, SCiPy has renamed their Ruff config file lint.toml and explicitly referenced it in calling Ruff with the --config option.
SciPy lint.toml
SciPy calling Ruff with the --config option

There is no reason to not have a separate ruff.toml in your project directory.

That’s all well and good, but what if we don’t have a project, but still want to check certain files on the command line. We have 4 options:

  1. Add a pyproject.toml to the directory
  2. Add a ruff.toml to the directory
  3. Use the --config option and point to an existing ruff.toml located elsewhere
  4. Have a default global ruff.toml that will be used if no other configuration file is located.

For me, #4 is the preferred option for the way I work (although I am trying to use pyproject.toml more often, even for sandbox directories). I now have a default ruff.toml in my Windows home directory and a symbolic link to the Ruff directory:

# Needs to be run as *PowerShell Admin (only Admin can create links)
# Use '-Force' in case '\AppData\Roaming\Ruff' dir has not been created
New-Item -ItemType SymbolicLink -Force -Path "$env:USERPROFILE\AppData\Roaming\Ruff\ruff.toml" -Target "$env:USERPROFILE\ruff.toml"

IMHO, Ruff is a little lacking in not allowing a home directory default/fallback configuration file. Instead it has its file discovery, one of which is the ${config_dir}/ruff/ directory. For Windows, the $(config_dir) is equivalent to %userprofile%\AppData\Roaming and if there is a pyproject.toml or ruff.toml located in the assocuated Ruff directory, that TOML will be used if no local config file is found.


Rules

Ruff rules are based around codes, [Letter Prefix][Number Code], e.g., F841.
The letter prefix indicate groups that are the source of the rule. This becomes more obvious when you look at the online Rules Documentation.

Additionally there are Settings which work alongside rules and define some rule parameters. For example, E501 is “line too long” which has a default of 88, but the Setting line-length allows you to define the length after-which this rule will be triggered, e.g.,:

# pyproject.toml
[tool.ruff]
line-length = 120  # Allow lines to be as long as 120.

The best way to start with your Ruff config is to add all rules, or the default set of group rules, and remove rules as they annoy you:

# pyproject.toml
[tool.ruff.lint]
select = ["ALL"]
# pyproject.toml
[tool.ruff.lint]
# RUFF DEFAULTS
select = [
    "F",    # pyflakes – detects syntax errors and basic mistakes
    "E4",   # pycodestyle errors (part of E group)
    "E7",   # pycodestyle E7xx errors (naming, etc.)
    "E9",   # pycodestyle E9xx errors (syntax)
]

I’ve opted to add all rules and remove them as they annoy me. Defining "ALL" will at least mean that if any new rules are implemented, I will not miss the chance for those rules to annoy me.

# ruff.toml
# Settings
line-length = 120  # Keep aligned with .editorconfig

[format]
line-ending = "lf" # Use `\n` line endings

[lint]
select = ["ALL"]

ignore = [
    "ANN",    # flake8-annotations - MyPy is checking annotations
    "S311",   # flake8-bandit - cryptographically weak `random` used
    "RET504", # flake8-return - unnecessary assignment before `return` statement
    "D200",   # pydocstyle - unnecessary multiline docstring
    "D203",   # pydocstyle - blank line before class docstring
    "D212",   # pydocstyle - docstring not on 1st line after opening quotes
    "D400",   # pydocstyle - missing trailing period in docstring
    "D401",   # pydocstyle - docstring first lines are not in an imperative mood
    "D415",   # pydocstyle - missing terminal punctuation (.,? or !)

    # "PERF401" # Perflint - `for` loop can be replaced by list comprehension

    # Consider removing these for projects/publication
    "D1",     # pydocstyle - missing docstring
    "ERA001", # eradicate - found commented out code
    "T201",   # flake8-print - `print` found
    "T203",   # flake8-print - `pprint` found
]

[lint.per-file-ignores]
"tests/*.py" = ["S101"]  # Use of `assert` detected
"test_*.py"  = ["S101"]  # 

At the moment this gets me through most of my scripts and is small enough to transfer to a pyproject.toml if needed.

There are plenty of example GitHub projects using Ruff to show examples of configuration.


Integrating Ruff into your workflow

CLI

See Basic CLI Usage.


IDE

As usual, Astral have done a great job with the Editors Setup Documentation.

I use VSCode and its pretty simple:

  1. Install and enable the Ruff Extension from the Visual Studio Marketplace.

  2. Configure Ruff to be the default and take action on save:

     // Editor: Python Specific Settings
     "[python]": {
       "editor.defaultFormatter": "charliermarsh.ruff",
       "editor.formatOnSave": true,
       "editor.codeActionsOnSave": {
         // "source.fixAll": "explicit",
         "source.organizeImports": "explicit",
       },
     },
    

You can include and exclude rules in the settings.json, but they are better placed in a project TOML file.

Once installed in VSCode, Ruff will automatically execute when you open or edit a Python file.

<code>example.py</code> in VSCode with Ruff

You can click the rule in the Problem panel and get sent to a webpage explaining that rule. You can right click the issue and select auto-fix, ignore, etc. All good stuff.


Pre-Commit

If you have pre-commit setup for your project, you can add the ruff-pre-commit to it as well

# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
  # Ruff version.
  rev: v0.12.10
  hooks:
    # Run the linter.
    - id: ruff-check
      # args: [ --fix ]
    # Run the formatter.
    - id: ruff-format

GitHub Action

If you have GitHub Actions setup for your project, you can add a ruff-action to it as well. Either as a file:

# ruff.yml
name: Ruff
on: [ push, pull_request ]
jobs:
  ruff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/ruff-action@v3

…or adding the following to your CI/CD workflow:

      - uses: astral-sh/ruff-action@v3

Tips

  • What config file is it using now?

    • CLI: add --verbose and it will one of the first things printed

    • VSCode: Add the following to your workspace settings:

      "ruff.logLevel": "debug",
      "ruff.logFile": "c:/temp/ruff.log",
      
      • The log will show which TOML is being used (there may be more than one referenced in the log)
  • How do I get Ruff to ignore this line only?

    • Sometimes not everything is fixable, or needs to be fixed
    • Add # noqa: <rule> to the line, e.g., # noqa: D201, and it will get ignored
  • I like Black, can I use it as well as Ruff?

    • CLI: No problem. They are different sections of the pyproject.toml file

      • Important: Make sure the line-length setting is the same for both or they will forever fight each other
      • Run ruff check example.py and black example.py as you normally would on the CLI
    • VSCode: You can set Black to be the Formatter and Ruff to be the Linter in the settings.json file

      # settings.json
      "ruff.lint.enable": true,
      ...
      ...
      "python.formatting.provider": "black",
      ...
      ...
      

Conclusion

Improving your code should be as painless as possible. The coverage and speed of Ruff helps greatly, but I think the biggest benefit I have seen is that I am learning from it.

The speed helps with the feedback-loop.

The use of TOML files is simple and easy to add/remove rules to.

Don’t get bogged down with it, instead embrace it as you move forward.


Further Reading