astral-sh/ruff · error

Circular configuration detected: {chain}

Error message

Circular configuration detected: {chain}

What it means

Ruff resolves configuration by following `extends` chains from an initial config path. If walking the chain revisits a configuration file already processed, extending is circular and cannot converge, so resolution bails with the visited chain (paths joined by ' extends ').

Source

Thrown at crates/ruff_workspace/src/resolver.rs:361

    initial_config_path: &Path,
    transformer: &dyn ConfigurationTransformer,
    origin: ConfigurationOrigin,
) -> Result<Configuration> {
    resolve_configuration_with_cache(initial_config_path, transformer, origin, None)
}

fn resolve_configuration_with_cache(
    initial_config_path: &Path,
    transformer: &dyn ConfigurationTransformer,
    origin: ConfigurationOrigin,
    configuration_cache: Option<&ConfigurationCache>,
) -> Result<Configuration> {
    let relativity = Relativity::from(origin);
    let mut configurations = indexmap::IndexMap::new();
    let mut next = Some(fs::normalize_path(initial_config_path));
    while let Some(path) = next {
        if configurations.contains_key(&path) {
            bail!(format!(
                "Circular configuration detected: {chain}",
                chain = configurations
                    .keys()
                    .chain([&path])
                    .map(|p| format!("`{}`", p.display()))
                    .join(" extends "),
            ));
        }

        let project_root = relativity.resolve(&path);
        let load = || {
            let options = pyproject::load_options(&path).with_context(|| {
                if configurations.is_empty() {
                    format!(
                        "Failed to load configuration `{path}`",
                        path = path.display()
                    )
                } else {

View on GitHub (pinned to 26f38c119c)

Solutions

  1. Inspect the printed chain and remove the `extends` entry that closes the loop
  2. Move shared settings into a single base config that all others extend (a DAG, not cycles)
  3. Resolve symlink loops between config files, then re-run Ruff

Example fix

# before (base.toml extends a.toml, a.toml extends base.toml)
# a.toml
[extends]
path = "base.toml"

# after: only the child extends the base
# a.toml — remove the extends block referencing base.toml's ancestor chain
[lint]
select = ["E", "F"]
Defensive patterns

Strategy: validation

Validate before calling

import tomllib
from pathlib import Path

def check_no_cycles(path: Path, seen=None) -> None:
    seen = seen or set()
    p = path.resolve()
    if p in seen:
        raise SystemExit(f"Circular configuration detected at {p}")
    cfg = tomllib.loads(p.read_text())
    seen.add(p)
    ext = cfg.get("extends")
    if isinstance(ext, str):
        check_no_cycles((p.parent / ext).resolve(), seen)

Try / catch

import subprocess
p = subprocess.run(["ruff", "check", "."], capture_output=True, text=True)
if p.returncode != 0 and "Circular configuration detected" in p.stderr:
    fix_extends_chain(); retry()

Prevention

When it happens

Trigger: Configuration A extends B and B (directly or transitively) extends A; a config file extends itself; symlinked or normalized paths (`fs::normalize_path`) that loop back to an ancestor config.

Common situations: Shared team configs that extend each other for convenience; monorepo setups where a base config accidentally re-extends a child config; symlink loops between config directories.

Related errors


AI-assisted analysis of astral-sh/ruff@26f38c119c (2026-09-05). Data as JSON: /api/errors/3225537946e267d7. Report an issue: GitHub.