DEV Community

Cover image for Your Python Pre-commit Hook Took 18 Seconds. Ruff Does It in 0.3.
Shayan Holakouee
Shayan Holakouee

Posted on

Your Python Pre-commit Hook Took 18 Seconds. Ruff Does It in 0.3.

Here is a pre-commit hook I used to have:

black .
isort .
flake8 .
pylint src/
Enter fullscreen mode Exit fullscreen mode

Four tools. Four configs. Four things that could break. And on a mid-sized project, about 18 seconds of waiting every time I committed.

Now I have this:

ruff check . --fix
ruff format .
Enter fullscreen mode Exit fullscreen mode

Two commands. One tool. 0.3 seconds. Same coverage, fewer headaches.

That tool is Ruff, and if you're still running the old stack, this article will convince you to switch.


What Is Ruff?

Ruff is a Python linter and formatter written in Rust. It was built by Astral, the same team behind uv. The pitch is simple: replace Flake8, Black, isort, pyupgrade, and autoflake with one binary that runs 10 to 100 times faster than any of them individually.

It's not a wrapper around those tools. Ruff re-implements their rules directly in Rust, which is why it's so fast and why it has no Python dependencies at all.

As of 2025, Ruff ships with over 900 lint rules. It's used in production by NumPy, Pandas, FastAPI, Airflow, and thousands of other projects.


Installing Ruff

# Via pip
pip install ruff

# Via uv (recommended)
uv add --dev ruff

# Standalone binary (no Python required)
curl -LsSf https://astral.sh/ruff/install.sh | sh
Enter fullscreen mode Exit fullscreen mode

Check it works:

ruff --version
# ruff 0.x.x
Enter fullscreen mode Exit fullscreen mode

The Two Commands You'll Use Every Day

Ruff has two main modes: check (linting) and format (formatting).

# Lint your code
ruff check .

# Lint and auto-fix what can be fixed
ruff check . --fix

# Format your code (Black-compatible)
ruff format .

# Check formatting without changing files
ruff format . --check
Enter fullscreen mode Exit fullscreen mode

That's the whole surface area for day-to-day use. Everything else is configuration.


What Does It Actually Catch?

Let's take a messy file:

import os
import sys
import json

def greet(name):
    x = 42
    if name == None:
        return "nobody"
    msg = "Hello, %s" % name
    return msg
Enter fullscreen mode Exit fullscreen mode

Run ruff check messy.py:

messy.py:3:1: F401 `json` imported but unused
messy.py:6:5: F841 Local variable `x` is assigned to but never used
messy.py:7:12: E711 Comparison to `None` (use `is None`)
messy.py:8:11: UP031 Use f-string instead of `%`-formatting
Found 4 errors.
Enter fullscreen mode Exit fullscreen mode

Now run ruff check messy.py --fix and look at the result:

import os
import sys


def greet(name):
    if name is None:
        return "nobody"
    msg = f"Hello, {name}"
    return msg
Enter fullscreen mode Exit fullscreen mode

Unused import removed. == None fixed to is None. %-formatting upgraded to an f-string. All automatically.

The x = 42 unused variable is flagged but not auto-fixed because removing a variable assignment could theoretically change behavior in edge cases. Ruff is conservative about what it touches automatically.


Replacing isort

Import sorting is built in. No separate tool, no separate config.

# Before
import sys
import os
from collections import defaultdict
import json
from typing import Optional
Enter fullscreen mode Exit fullscreen mode
# After ruff check . --fix
import json
import os
import sys
from collections import defaultdict
from typing import Optional
Enter fullscreen mode Exit fullscreen mode

Standard library, third-party, and local imports get grouped and sorted correctly. Same behavior as isort, no extra setup.

Enable it explicitly in your config if it's not running by default:

[tool.ruff.lint]
select = ["I"]  # isort rules
Enter fullscreen mode Exit fullscreen mode

Configuration in pyproject.toml

Ruff reads from pyproject.toml, which means no separate .flake8, .isort.cfg, or setup.cfg. One file rules everything.

A solid starting config:

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

[tool.ruff.lint]
select = [
    "E",    # pycodestyle errors
    "W",    # pycodestyle warnings
    "F",    # pyflakes
    "I",    # isort
    "B",    # flake8-bugbear
    "UP",   # pyupgrade
    "RUF",  # ruff-specific rules
]
ignore = [
    "E501",  # line too long (handled by formatter)
]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"
Enter fullscreen mode Exit fullscreen mode

The select field is where you pick which rule categories to enable. By default Ruff only runs E and F rules, which is already solid coverage. Adding B (bugbear) and UP (pyupgrade) gets you a lot more value with very little noise.


Rule Categories Worth Knowing

Ruff organizes its 900+ rules into prefixed categories. Here are the ones worth enabling on most projects:

Prefix Origin What It Catches
F Pyflakes Unused imports, undefined names, redefined vars
E / W pycodestyle PEP 8 style issues
I isort Import ordering
B flake8-bugbear Common bugs and design issues
UP pyupgrade Outdated syntax (pre-f-strings, old union types)
N pep8-naming Naming conventions
RUF Ruff-native Rules with no upstream equivalent
SIM flake8-simplify Code that can be simplified
ANN flake8-annotations Missing type annotations

You can look up any rule at docs.astral.sh/ruff/rules. Each one has an explanation and an example.


Ignoring Rules Inline

Sometimes you genuinely need to break a rule. The syntax is the same as Flake8:

import os  # noqa: F401
Enter fullscreen mode Exit fullscreen mode

Or disable for an entire block:

# ruff: noqa: E501
some_very_long_string = "this line is intentionally long because it has to be"
Enter fullscreen mode Exit fullscreen mode

Or skip formatting for a section:

# fmt: off
matrix = [
    1, 0, 0,
    0, 1, 0,
    0, 0, 1,
]
# fmt: on
Enter fullscreen mode Exit fullscreen mode

Editor Integration

VS Code

Install the Ruff extension and add this to your settings:

{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "charliermarsh.ruff",
  "[python]": {
    "editor.codeActionsOnSave": {
      "source.fixAll.ruff": "explicit",
      "source.organizeImports.ruff": "explicit"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Format on save, import sort on save, lint feedback in the gutter. Done.

Neovim

Via conform.nvim and nvim-lint, or through the LSP with nvim-lspconfig. The Ruff LSP server (ruff server) ships with the Ruff binary.


Pre-commit Integration

This is where Ruff's speed makes the biggest practical difference. Replace your old hooks with:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.9.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
Enter fullscreen mode Exit fullscreen mode

Two hooks, no Python dependencies, sub-second feedback. Your teammates will stop disabling pre-commit.


Migrating From the Old Stack

If you're coming from Black + isort + Flake8, the migration path is low-friction.

Ruff's formatter is designed to be Black-compatible, so your diffs should be minimal after switching. Start by running both and comparing output:

black --check .
ruff format --check .
Enter fullscreen mode Exit fullscreen mode

For linting, start conservative: just enable E, F, and I. Fix the violations, then expand select to add more rule categories as your team is ready.

You can also silence existing violations in bulk without fixing them using # noqa comments or per-file-ignores, which lets you enforce Ruff on new code while leaving legacy files alone.


One Honest Caveat

Ruff does not do type checking. It does not replace mypy or pyright. It can catch some type-annotation issues (via the ANN rules) and enforce annotation style, but it does not perform type inference.

If you want static type checking, you still need mypy or pyright alongside Ruff. They solve different problems.


The Bottom Line

Ruff is not an incremental improvement on Python linting. It's a full reset: one tool, one config file, no dependency conflicts, no wrapper scripts, and feedback fast enough that you stop dreading the pre-commit hook.

If you're starting a new project, add it as a dev dependency on day one and configure it in pyproject.toml. If you're on an existing project, swap in the formatter first since it's Black-compatible and then layer in linting rules from there.

The Python ecosystem moved slowly on tooling for a long time. Ruff is what it looks like when someone decides to stop accepting that.


Enjoyed this? I wrote a similar piece on uv, the package manager from the same team, if you want to go further down the Astral rabbit hole. Drop a reaction or share it with someone still waiting 18 seconds for their pre-commit to finish.

Top comments (0)