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/ filesFor 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:
ruff.toml.ruff.tomlpyproject.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.
[project]name = "my-package"version = "0.1.0"
# Ruff will IGNORE this file because there's no [tool.ruff] section![project]name = "my-package"version = "0.1.0"
[tool.ruff]line-length = 88# Now Ruff will find and use this configUnlike 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 = 88Child 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:
[tool.ruff]line-length = 88target-version = "py310"
[tool.ruff.lint]select = ["E4", "E7", "E9", "F"]# Inherit from parent, then override specific settingsextend = "../pyproject.toml"
line-length = 100 # Override parent's 88
[lint]# Parent already has ["E4", "E7", "E9", "F"]# Let's add more rulesselect = ["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.
extend = "../../pyproject.toml" # Correct: relative to this file
[lint]# This path is also relative to this config fileexclude = ["./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:
- User-level config at
~/.config/ruff/ruff.toml(or equivalent on your platform) - 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:
# Override line-length regardless of what's in config filesruff check --line-length=90
# Override rule selectionruff check --select=ALL
# Combine multiple overridesruff 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:
[tool.ruff]line-length = 88target-version = "py311"
[tool.ruff.lint]select = [ "E", # pycodestyle errors "F", # Pyflakes "I", # isort "UP", # pyupgrade]
[tool.ruff.format]quote-style = "double"indent-style = "space"# Inherit base config from rootextend = "../../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# Another inheritance exampleextend = "../../pyproject.toml"
line-length = 120 # Utils can have longer linesThis 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
extend = "pyproject.toml" # This looks in current dir, not project root!
[lint]exclude = ["tests/"] # Relative to THIS config, not project root3. Empty [tool.ruff] section confusion
[tool.ruff] # Empty section - Ruff uses defaults but STOPS searching
# This counts as "found a config" even with no settingsAn empty [tool.ruff] section still counts as finding a config file. Ruff won’t continue searching upward.
Related Knowledge
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-hookandextension-pkg-allow-listfor 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