# Set up Ruff for formatting and checking your code

This tutorial sets up [Ruff](https://pydevtools.com/handbook/reference/ruff.md) in a uv project: format your Python code, check it for errors, keep the settings in `pyproject.toml`, and run the whole thing on every commit.

## Set up Ruff in 30 seconds

Inside an existing project, four commands get you a working setup:

```bash
uv add --dev ruff           # install Ruff as a development dependency
uv run ruff check .         # report lint errors
uv run ruff check --fix .   # apply the fixes Ruff can make safely
uv run ruff format .        # reformat every Python file
```

That is the whole daily loop. The rest of this tutorial builds it from an empty project, explains what each command prints, and adds configuration and a pre-commit hook.

## Prerequisites

Before starting, make sure you have uv installed on your system. You can install it following the [installation guide](https://pydevtools.com/handbook/how-to/how-to-install-uv.md).

{{< callout type="info" >}}
You do not need Python installed - uv will handle installing it automatically.
{{< /callout >}}

## Create a sample project

Create a new project to demonstrate Ruff:

```console
$ uv init --no-package ruff-demo
Initialized project `ruff-demo` at `/path/to/ruff-demo`
$ cd ruff-demo
```

If you see `error: command not found: uv`, finish the [installation guide](https://pydevtools.com/handbook/how-to/how-to-install-uv.md) and reopen your shell.

Notice the new `main.py` file alongside `pyproject.toml`, `.python-version`, and a fresh `.git/` directory. `--no-package` keeps the project layout flat (a `main.py` at the top level instead of a `src/` package). Open `main.py` and replace its contents with the following messy code:

```python {filename="main.py"}
import sys,os
from pathlib    import Path
import json

def hello(name:str='World'):
    print(f'Hello, {name}! Ruff checks this file for lint errors and formats it consistently.')
    unused_var = 42

if __name__=='__main__':
    hello()
```

This code has several problems Ruff can find:

* Unsorted and poorly formatted imports
* Imports that are never used
* An unused local variable
* Missing whitespace around operators
* Single quotes where the formatter wants double

## Install Ruff into the project

The `--dev` flag marks Ruff as a {{< term "dev-dependency" "development dependency" >}}, keeping it out of production installs:

```console
$ uv add --dev ruff
Using CPython 3.14.6
Creating virtual environment at: .venv
Resolved 2 packages in 3ms
Installed 1 package in 1ms
 + ruff==0.16.2
```

Your exact Python and Ruff versions may differ. Notice the new `.venv/` directory: `uv add` created the project's {{< term "virtual-environment" "virtual environment" >}} because this is the first {{< term "dependency" >}}. Future `uv` commands reuse it instead of touching your system Python.

## Configure Ruff in pyproject.toml

Ruff's [default rule set](https://docs.astral.sh/ruff/default-rules/) already covers Pyflakes, flake8-bugbear, pyupgrade, flake8-simplify, import sorting, and more than 30 other rule families, so a new project is useful with no configuration at all. Configuration is for the settings you want to differ from those defaults.

Open the [pyproject.toml](https://pydevtools.com/handbook/reference/pyproject.toml.md) file and add this to the bottom:

```toml
[tool.ruff]
line-length = 100
```

`line-length` sets the column the formatter wraps at, and the ceiling the `E501` lint rule uses if you enable it. Ruff's default is 88; raising it to 100 is the difference between the `print` line in `main.py` (95 characters) staying on one line and being split across three.

Settings are grouped by what they affect:

| Table | Controls | Example setting |
|-------|----------|-----------------|
| `[tool.ruff]` | Both the linter and the formatter | `line-length`, `target-version`, `exclude` |
| `[tool.ruff.lint]` | Which rules run | `extend-select`, `ignore`, `per-file-ignores` |
| `[tool.ruff.format]` | How code is laid out | `quote-style`, `indent-style`, `docstring-code-format` |

Keeping these in `pyproject.toml` means the project's dependencies, its build metadata, and its lint policy live in one file that `uv add`, `uv run`, and Ruff all read. Ruff also accepts a standalone `ruff.toml` or `.ruff.toml`, which is worth reaching for only when a directory has no `pyproject.toml` to put the settings in.

To enable rule families beyond the defaults, see [how to configure recommended Ruff defaults](https://pydevtools.com/handbook/how-to/how-to-configure-recommended-ruff-defaults.md).

## Check your code with `ruff check`

`uv run` executes the command inside the project's virtual environment, ensuring all dependencies are installed first:

```console
$ uv run ruff check .
I001 [*] Import block is un-sorted or un-formatted
 --> main.py:1:1
  |
1 | / import sys,os
2 | | from pathlib    import Path
3 | | import json
  | |___________^
4 |
5 |   def hello(name:str='World'):
  |
help: Organize imports
  |
  - import sys,os
  - from pathlib    import Path
1 | import json
2 + import os
3 + import sys
4 + from pathlib import Path
5 +
6 |
  |

F401 [*] `sys` imported but unused
 --> main.py:1:8
  |
1 | import sys,os
  |        ^^^
2 | from pathlib    import Path
3 | import json
  |
help: Remove unused import
  |
  - import sys,os
1 | from pathlib    import Path
  |

... (four more diagnostics: F401 for `os`, `pathlib.Path`, and `json`, plus F841 for `unused_var`)

Found 6 errors.
[*] 5 fixable with the `--fix` option (1 hidden fix can be enabled with the `--unsafe-fixes` option).
```

Each diagnostic gives you the rule code, the offending span, a `help:` line, and a preview of the edit Ruff would make. The `[*]` marker means Ruff can apply that edit for you. Five of the six carry it; the sixth sits behind `--unsafe-fixes` because deleting an assignment can change what the program does.

Apply the safe fixes:

```console
$ uv run ruff check --fix .
F841 Local variable `unused_var` is assigned to but never used
 --> main.py:4:5
  |
2 | def hello(name:str='World'):
3 |     print(f'Hello, {name}! Ruff checks this file for lint errors and formats it consistently.')
4 |     unused_var = 42
  |     ^^^^^^^^^^
5 |
6 | if __name__=='__main__':
  |
help: Remove assignment to unused variable `unused_var`

Found 5 errors (4 fixed, 1 remaining).
No fixes available (1 hidden fix can be enabled with the `--unsafe-fixes` option).
```

The count drops from six to five because Ruff re-checks the file as it edits: removing the four unused imports also empties the import block, so `I001` resolves without a separate fix of its own. Open `main.py` and you will see all four imports gone, because nothing referenced them. The `unused_var = 42` line stayed:

```python {filename="main.py"}

def hello(name:str='World'):
    print(f'Hello, {name}! Ruff checks this file for lint errors and formats it consistently.')
    unused_var = 42

if __name__=='__main__':
    hello()
```

Spacing and quotes still look messy. That is the formatter's job, not the linter's.

## Tell `ruff check` and `ruff format` apart

Ruff ships two commands that people often expect to be one:

| | `ruff check` | `ruff format` |
|---|---|---|
| Job | Finds problems | Rewrites layout |
| Catches | Unused imports, unsorted imports, bugs, outdated syntax | Whitespace, quote style, line breaks, trailing commas |
| Changes behavior | Can, when a fix removes code | Never |
| Writes to disk | Only with `--fix` | Always, unless `--check` or `--diff` |
| Replaces | flake8, isort, pyupgrade | Black |
| Configured under | `[tool.ruff.lint]` | `[tool.ruff.format]` |

Run the linter first and the formatter second. A lint fix can delete an import or collapse a statement, and the formatter then lays out whatever is left.

## Format your code with `ruff format`

```console
$ uv run ruff format .
1 file reformatted, 1 file left unchanged
```

The unchanged file is `pyproject.toml`; Ruff reads it for `[project]` metadata rules and counts it alongside the Python files.

Reopen `main.py`. Spacing around `=` and `==`, double quotes, and a blank line between the function and the `if __name__` block now match standard Python style:

```python {filename="main.py"}
def hello(name: str = "World"):
    print(f"Hello, {name}! Ruff checks this file for lint errors and formats it consistently.")
    unused_var = 42


if __name__ == "__main__":
    hello()
```

The `print` line is 95 characters and survives intact because of the `line-length = 100` setting added earlier. Drop that setting and the formatter falls back to 88 columns, splitting the call across three lines.

{{< callout type="info" >}}
Ruff's formatter is deterministic: it produces the same output regardless of the input formatting, helping maintain a consistent style across your project.
{{< /callout >}}

One diagnostic is still outstanding. Delete the `unused_var = 42` line yourself, then confirm the file is clean:

```console
$ uv run ruff check .
All checks passed!
```

## Check formatting without rewriting files

On a build server you want to know whether code is formatted, not to have it quietly rewritten. `--check` reports and exits non-zero instead of editing:

```console
$ uv run ruff format --check .
2 files already formatted
```

Add a sloppy function to the end of `main.py` to watch it fail:

```python {filename="main.py"}
def  bad( x ):
    return   x
```

```console
$ uv run ruff format --check .
unformatted: File would be reformatted
  --> main.py:9:5
   |
8  |
   - def  bad( x ):
   -     return   x
9  + def bad(x):
10 +     return x
   |

1 file would be reformatted, 1 file already formatted
```

The command exits with status 1, which fails the CI job. Run `uv run ruff format .` to clean it up again. Pair `--check` with `uv run ruff check .` (no `--fix`) to get the lint half of the same gate; see [setting up GitHub Actions with uv](https://pydevtools.com/handbook/tutorial/setting-up-github-actions-with-uv.md) for the full workflow.

## Run Ruff before every commit

To run Ruff automatically before each Git commit, create a `.pre-commit-config.yaml` file:

```yaml {filename=".pre-commit-config.yaml"}
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.16.2
  hooks:
    - id: ruff-check
      args: [ --fix ]
    - id: ruff-format
```

Keep `rev` in step with the Ruff version in `pyproject.toml`, or the hook and your local runs will disagree about what counts as formatted.

Install pre-commit and the hooks:

```console
$ uvx pre-commit install
Installed 10 packages in 19ms
pre-commit installed at .git/hooks/pre-commit
```

If you see `An error has occurred: InvalidConfigError: ... is not a git repository`, the project is missing a `.git/` directory. `uv init` creates one automatically, so this only happens if you started outside a project root or deleted `.git/`. Run `git init` and try again.

Check the hooks against the whole project without waiting for a commit:

```console
$ uvx pre-commit run --all-files
ruff check...............................................................Passed
ruff format..............................................................Passed
```

From here, every `git commit` lints and formats the staged files first.
