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.

Three Ways to Handle a False Positive Three panels. Global exclusion removes the rule for everyone and hides future real problems. File-level disable hides every other finding in the file. Per-statement suppression with a reason and approval silences exactly one finding and leaves a reviewable record. Three Ways to Handle a False Positive Global exclusion rule off for every file next real violation ships no record of why erodes the gate File-level disable all rules off in the file hides unrelated findings easy to copy-paste too broad Per-statement + reason one rule, one statement reason in the comment approved by DB owner precise and auditable
Only the narrowest suppression, with a reason and a reviewer, keeps the rule working for the next migration.

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.

Life of a Suppression Five steps. The linter reports a finding; the author adds a per-statement suppression naming the rule with a reason; CI lists all suppressions in the pull request; a database owner approves via CODEOWNERS; the suppression is counted in monthly metrics. Life of a Suppression STEP 1 Finding rule + statement STEP 2 Suppress one rule id + reason STEP 3 Surface in PR CI lists suppressions STEP 4 Owner approval CODEOWNERS STEP 5 Count monthly per rule
Every suppression is visible in the pull request and counted afterwards — nothing is silenced quietly.

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.

Suppression Rate by Rule (Example Quarter) Bar chart of the share of findings suppressed per rule. require-concurrent-index-creation 12 percent, ban-drop-column 64 percent, constraint-missing-not-valid 5 percent, prefer-robust-stmts 38 percent, changing-column-type 9 percent. Suppression Rate by Rule (Example Quarter) ban-drop-column 64% prefer-robust-stmts 38% require-concurrent-index-creation 12% changing-column-type 9% constraint-missing-not-valid 5% share of findings suppressed (illustrative)
A high suppression rate is a signal: 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.