dbt-labs / dbt-labs/dbt

[v2 Bug] Fusion `dbt lint --fix` returns exit code 0 even when it modified files — pre-commit can't detect the fix

Open
#15,520 0 comments 0 reactions 0 assignees View on GitHub
triage
Dominant language
Rust
Stars
13.8k
Forks
2.6k
Avg merge
21h 31m
Merged PRs (30d)
56

Description

## Summary

`dbt lint --fix` exits with status **`0`** even when it applied auto-fixes and modified files on disk. This is the standard "auto-formatter" pre-commit contract violation: hooks like `prettier`, `black`, `ruff --fix`, and `sqlfluff fix` all exit **non-zero when they rewrite a file**, so pre-commit can catch the fix, block the commit, and prompt the user to re-stage. With `dbt lint --fix` exiting `0`, a pre-commit hook silently rewrites tracked files and lets the commit through with un-staged changes on disk.

## Environment

- **OS:** macOS 26.5 (build 25F71)
- **CPU:** ARM (Apple M1 Pro, `arm64`)
- **dbt distribution and version:** dbt-fusion `2.0.0-preview.196` — native Rust binary, installed via `pip install dbt` (pip package `dbt==2.0.0rc196`)
- **Adapter:** Snowflake

## Reproduction

The project's `.sqlfluff` explicitly configures both CP01 (keyword case) and CP02 (identifier case) to `lower` — so these are not defaults being silently loosened, they are an actively-declared style policy:

```ini
# .sqlfluff (excerpt)
[sqlfluff]
dialect = snowflake
templater = dbt

[sqlfluff:rules:capitalisation.keywords]
# Keywords — drives CP01 / KeywordCaseMismatch (dbt0107)
capitalisation_policy = lower

[sqlfluff:rules:capitalisation.identifiers]
# Unquoted identifiers — drives CP02 / IdentifierCaseMismatch (dbt0167)
extended_capitalisation_policy = lower
```

Start with a file that has fixable violations (uppercase keywords, trailing whitespace):

```sql
-- models/base/beehive/beehive_deals/_LINT_REPRO.sql
SELECT *
FROM {{ ref('beehive_deals') }}
WHERE ID = 1
```

Run `dbt lint --fix` on the project and check the exit code and the file:

```bash
$ md5 models/base/beehive/beehive_deals/_LINT_REPRO.sql
MD5 (...) = c214df7df40b80a431506b73f0953c26

$ dbt lint --fix; echo "exit=$?"
...
Fixed 4 violation(s) in models/base/beehive/beehive_deals/_LINT_REPRO.sql
Finished 'lint' with 1 warning for target 'development' [2.9s]
exit=0

$ md5 models/base/beehive/beehive_deals/_LINT_REPRO.sql
MD5 (...) = dbd7c0e2fe7b4c408aeac97bfec8ceff # file was rewritten

$ cat models/base/beehive/beehive_deals/_LINT_REPRO.sql
select *
from {{ ref('beehive_deals') }}
where id = 1 # SELECT → select, FROM → from, WHERE → where
```

The file was modified on disk (4 violations fixed, keywords lowercased) and dbt still exits `0`.

For contrast, the sibling behaviors are correct:

| Scenario | Files modified? | Exit code | Correct? |
|---|---|---|---|
| `--fix` applied fixes | **Yes** | **0** | ❌ this bug |
| `--fix` clean file, nothing to fix | No | 0 | ✅ |
| `--fix` with unfixable errors (e.g. `SyntaxInvalid`) | No | 1 | ✅ |

## Expected behavior

Match the standard auto-formatter contract: **exit non-zero (e.g. `1`) whenever `--fix` modified at least one file**. This is how `prettier --write`, `ruff format`, `black`, and `sqlfluff fix` all behave, and it's what the `pre-commit` framework relies on to detect "files were rewritten, block the commit, re-stage".

Concretely, the exit-code table should become:

| Scenario | Files modified? | Exit code |
|---|---|---|
| `--fix` applied fixes | Yes | **≠ 0** |
| `--fix` clean, nothing to fix | No | 0 |
| `--fix` unfixable errors remain | No | ≠ 0 |
| `--fix` applied fixes AND unfixable errors remain | Yes | ≠ 0 |

(No change requested to the exit code of plain `dbt lint` without `--fix` — warnings-only staying `0` there is fine.)

## Impact

- **Pre-commit hooks**: A `.pre-commit-config.yaml` entry like `entry: dbt lint --fix` cannot function as a normal auto-fixer. The hook rewrites tracked files but reports success, so the commit goes through with the *original* (pre-fix) content staged and the fixed content sitting un-staged in the working tree. This is the exact failure mode `pre-commit` was designed to prevent.
- **CI auto-format jobs**: A CI step that runs `dbt lint --fix` and then `git diff --exit-code` to detect drift works, but the more idiomatic `dbt lint --fix || exit 1` pattern does not.
- **Editor "format on save" tooling**: Tools that key off exit code cannot tell whether the file was rewritten.

## Related

- [#15519](https://github.com/dbt-labs/dbt-core/issues/15519) — `dbt lint --select` flag is ignored (same command, different bug)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.