Suppressing Lint False Positives with Reviewed Exceptions
The migration linter blocked a CREATE INDEX without CONCURRENTLY on a table created in the same file — a harmless statement on an empty table. It blocked a DROP COLUMN that was the final, planned contract step of a three-release rename. The team’s response was to add those rules to the global exclusion list, and a month later a real non-concurrent index build on a large table shipped without a single warning. Every linter produces findings that are wrong for a specific case, and the way a team handles them decides whether the linter stays useful. Global exclusions and “just disable it for now” erode it quickly; a precise, reviewed exception process keeps it strict for everyone else. This guide designs that process: per-statement suppressions, mandatory reasons, approval rules, expiry, and metrics. It belongs to Migration Linting & Static Analysis.
Symptom / Error Signatures
The exception process is failing when:
- The linter’s configuration accumulates excluded rules with no explanation in the history.
- Migration files carry blanket disables at the top.
- Authors routinely add suppressions to get CI green, and reviewers approve them without comment.
- An incident is traced to a rule that had been excluded months earlier “because of a false positive”.
- Or the opposite: the linter is so noisy and suppressions so hard to get that teams bypass it entirely for “urgent” changes.
Root Cause Analysis
Static linters are deliberately conservative: they flag a statement when it can be dangerous, because they lack the context — table sizes, whether a table is new, where the change sits in an expand-and-contract sequence — to know that it is. Some findings are therefore false positives for a specific case while still being right in general. The question is not whether exceptions exist, but how narrow and visible they are.
Four properties make an exception process healthy:
| Property | Implementation | Why |
|---|---|---|
| Narrow | suppression names one rule on one statement | other rules and statements stay checked |
| Justified | a reason is required in the suppression | reviewers can judge it; history explains it |
| Reviewed | suppressions need approval from a DB owner (CODEOWNERS) | prevents “make CI green” suppressions |
| Measured | suppression counts per rule are tracked | frequent suppressions reveal bad rules or bad patterns |
Tools support the mechanics differently: Squawk supports per-statement ignore comments in recent versions, Atlas supports atlas:nolint directives, strong_migrations uses safety_assured blocks, and custom rule runners can implement whatever format you choose.
Immediate Mitigation
1. Replace global exclusions with per-statement suppressions. Review each excluded rule: if it is wrong for your stack, keep it excluded and document why; if it was excluded for one migration, re-enable it and suppress that statement instead.
2. Adopt a suppression format that requires a reason. For example, with Squawk-style ignore comments:
-- PostgreSQL · migration creating a new table and its index in one transaction
-- squawk-ignore require-concurrent-index-creation reason: table created in this migration, empty
CREATE TABLE loyalty_events (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, customer_id bigint NOT NULL);
CREATE INDEX loyalty_events_customer_idx ON loyalty_events (customer_id);
-- ROLLBACK PATH: DROP TABLE loyalty_events;
For a custom runner, enforce the reason: reject suppressions without text after reason:.
3. Surface suppressions in CI output. A small step lists every suppression added by the pull request, so reviewers see them without reading every file.
# Shell · CI job · lists suppressions added in this pull request
git diff origin/main...HEAD -- 'db/migrations/*.sql' | grep -E '^\+.*(squawk-ignore|atlas:nolint|lint-ignore)' \
|| echo "no suppressions added"
4. Require approval from database owners for pull requests that add suppressions, through CODEOWNERS on the migrations directory or a CI check that requests a specific reviewer group.
Permanent Fix / Long-Term Pattern
Treat suppressions as data. Count them per rule each month; a rule suppressed on most occurrences is either miscalibrated for your codebase — replace it with a narrower custom rule, as in writing custom migration lint rules — or a sign that a risky pattern is common and needs a better template. Add expiry to suppressions that exist for a transition (“allowed until the contract release on 2026-11-01”) and fail CI after the date. Keep the global exclusion list short, commented and reviewed like code.
Distinguish legitimately planned hazards from accidents. A DROP COLUMN in the contract phase of an expand-and-contract migration is expected; the suppression reason should reference the plan and the evidence — for example, the release that stopped reading the column and the query-statistics check — as described in dropping constraints safely during the contract phase. Emergency bypasses of the gate should be rarer still: logged, time-boxed and reviewed after the fact, consistent with the approval flows in requiring DBA approval for high-risk migrations.
ban-drop-column is mostly planned contract steps and needs a plan-aware rule; prefer-robust-stmts needs a better template.Finally, make the exception process fast. A reviewed suppression that takes a day to approve teaches people to avoid the linter; one that takes ten minutes teaches them to use it. Name a rotating reviewer for migration exceptions, set an expectation for response time, and keep the approval to the question that matters — is the reason true, and is the statement safe in this context — rather than a general re-review of the whole change.
Verification Checklist
Frequently Asked Questions
Is it ever right to exclude a rule globally? Yes, when the rule does not fit your stack at all — for example, a type-preference rule you have consciously rejected. Document the decision next to the exclusion so it is not mistaken for a shortcut.
Who should approve suppressions?
Someone who owns database reliability for the affected tables — a DBA, a platform engineer or a designated reviewer group — not the author’s teammate by default. CODEOWNERS on the migrations directory is a simple way to enforce it.
What makes a good suppression reason? One that lets a reviewer verify it: “table created in this migration, empty”, “contract step of rename plan RFC-42; no reads since release 2026.09.10 per pg_stat_statements”. “False positive” alone is not a reason.
How do we handle urgent fixes blocked by the linter? Use a suppression with a reason and an expedited approval, not a bypass of the gate. If bypasses are needed, log them and review each one afterwards; frequent bypasses mean the rules or the approval path need fixing.