Skip to content

How Ruff Config File Discovery Works: Hierarchical Configuration and extend Inheritance

I was setting up a Python monorepo recently and ran into a confusing situation. I had a pyproject.toml in my root directory with Ruff settings, and another config in a subdirectory for a specific project. To my surprise, Ruff wasn’t combining the settings from both files—it was only using one!

This was my first real encounter with Ruff’s hierarchical configuration system, and it works differently than what I expected coming from ESLint. Let me share what I learned.

The “Closest” Config Wins

Ruff’s configuration discovery is straightforward but has important implications:

my-monorepo/
├── pyproject.toml # Root config (line-length = 88)
├── services/
│ ├── api/
│ │ └── ruff.toml # Closest to api/ files
│ └── worker/
│ └── pyproject.toml # Closest to worker/ files
└── packages/
└── utils/
└── .ruff.toml # Closest to utils/ files

For any Python file, Ruff looks for a configuration file by searching upward from the file’s directory. The closest config file (the first one found) is used. That’s it—no merging, no cascading of settings from multiple files.

If you have pyproject.toml in your root with Ruff settings, but ruff.toml in a subdirectory, the subdirectory’s config completely wins for files in that subdirectory.

What Files Does Ruff Look For?

Ruff searches for configuration files in this order:

  1. ruff.toml
  2. .ruff.toml
  3. pyproject.toml (but only if it contains a [tool.ruff] section!)

That last point caught me off guard. If your pyproject.toml has no [tool.ruff] section, Ruff simply ignores it and keeps searching upward.

pyproject.toml
[project]
name = "my-package"
version = "0.1.0"
# Ruff will IGNORE this file because there's no [tool.ruff] section!
pyproject.toml
[project]
name = "my-package"
version = "0.1.0"
[tool.ruff]
line-length = 88
# Now Ruff will find and use this config

Unlike ESLint: No Implicit Merging

Coming from JavaScript development, I expected Ruff to behave like ESLint—merge settings from parent configs with child configs. That’s not how Ruff works.

Root pyproject.toml: line-length = 88
Child ruff.toml: select = ["E", "F"]
Result for child dir: line-length = DEFAULT (not 88!)

The child config completely replaces the parent config. There’s no inheritance or merging by default.

This design choice makes Ruff’s behavior more predictable and faster (no need to walk up the entire directory tree and merge configurations). But it means you need to be explicit about inheritance when you want it.

Enter the extend Field

To inherit settings from a parent config, use the extend field:

pyproject.toml (parent directory)
[tool.ruff]
line-length = 88
target-version = "py310"
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]
ruff.toml (child directory)
# Inherit from parent, then override specific settings
extend = "../pyproject.toml"
line-length = 100 # Override parent's 88
[lint]
# Parent already has ["E4", "E7", "E9", "F"]
# Let's add more rules
select = ["E4", "E7", "E9", "F", "I", "UP"]

The extend field creates an explicit inheritance chain. The child config starts with all the parent’s settings, then applies its own overrides. This is how you achieve “merge-like” behavior in Ruff.

Path Resolution: Relative to the Config File

Here’s another “gotcha”: all paths in a config file are resolved relative to that config file’s directory, not the project root or current working directory.

services/api/ruff.toml
extend = "../../pyproject.toml" # Correct: relative to this file
[lint]
# This path is also relative to this config file
exclude = ["./legacy/", "./generated/"]

If you move a config file, you need to update any relative paths inside it.

What If No Config Exists?

If Ruff finds no configuration file (no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]), it uses:

  1. User-level config at ~/.config/ruff/ruff.toml (or equivalent on your platform)
  2. Built-in defaults if no user config exists

This fallback ensures Ruff always has settings to work with, even in ad-hoc scripts.

CLI Flags Override Everything

Command-line flags have the highest priority and override any config file setting:

Terminal
# Override line-length regardless of what's in config files
ruff check --line-length=90
# Override rule selection
ruff check --select=ALL
# Combine multiple overrides
ruff check --line-length=90 --select=ALL --exclude="tests/"

This is useful for one-off commands or CI pipelines where you want slightly different behavior without modifying config files.

A Practical Monorepo Setup

Let me show you a common pattern that works well for Python monorepos:

pyproject.toml (repository root)
[tool.ruff]
line-length = 88
target-version = "py311"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # Pyflakes
"I", # isort
"UP", # pyupgrade
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
services/api/ruff.toml
# Inherit base config from root
extend = "../../pyproject.toml"
# API-specific overrides
[lint]
select = ["E", "F", "I", "UP", "FAST"] # Add FAST rules for FastAPI
[lint.per-file-ignores]
"tests/*" = ["S101"] # Allow assert in tests
packages/utils/ruff.toml
# Another inheritance example
extend = "../../pyproject.toml"
line-length = 120 # Utils can have longer lines

This gives you a shared baseline with project-specific customization.

Common Mistakes to Avoid

1. Expecting automatic merging

WRONG assumption: "My child config will add to parent settings"
REALITY: Child config replaces parent unless you use 'extend'

2. Incorrect relative paths

WRONG - path from project root
extend = "pyproject.toml" # This looks in current dir, not project root!
[lint]
exclude = ["tests/"] # Relative to THIS config, not project root

3. Empty [tool.ruff] section confusion

pyproject.toml
[tool.ruff] # Empty section - Ruff uses defaults but STOPS searching
# This counts as "found a config" even with no settings

An empty [tool.ruff] section still counts as finding a config file. Ruff won’t continue searching upward.

Why did Ruff choose this design?

Ruff is built for speed. The “closest config wins” approach means Ruff only needs to find one file per lint target, not walk the entire directory tree merging configurations. This makes Ruff significantly faster than tools with cascading configs.

How does this compare to other Python linters?

  • Flake8: Uses a single config file (setup.cfg, tox.ini, or .flake8) found via standard config discovery
  • Pylint: Has init-hook and extension-pkg-allow-list for path manipulation
  • Black: Similar to Ruff—finds pyproject.toml with [tool.black] and uses that

Ruff’s approach is most similar to Black, but the extend field gives it more flexibility.

Summary

In this post, I explained how Ruff’s configuration file discovery works. The key insight is that Ruff uses the “closest” config file with no implicit merging—you need to explicitly use the extend field to inherit settings from a parent config. All paths in a config file resolve relative to that config file’s directory, and CLI flags override everything.

This model is simpler and faster than ESLint’s cascading configuration, but it requires you to be more intentional about sharing settings across a monorepo. Once you understand the model, it’s straightforward to set up a clean configuration hierarchy.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments