Skip to main content

PG.L1.DROP_COLUMN — The old app version is still selecting it

  • Category: safety
  • Level: 1
  • Confidence: deterministic
  • Downtime class: online
  • Stability: stable
  • Suites: lint
  • Applies to: PostgreSQL 18

Dropping a column is two problems wearing one statement.

The first is permanence: once the deploy is past, the values are unreachable. The second is timing, and it is the one that causes the incident — during a rolling deploy the old application version is still running, and it is still writing select … legacy_flag …. Those queries start failing the moment the migration lands, before a single instance of the new version has finished starting.

PostgreSQL does not rewrite the table, which makes it worse rather than better

ALTER TABLE … DROP COLUMN does not physically remove the data or rewrite the relation — the column is marked dropped in the catalog and the operation is near-instant. So the change is cheap to perform and impossible to reverse: the statement gives you no time to notice, and no way back.

That is why the class is online. This operation costs no lock time worth planning around; it costs compatibility with code that is already deployed.

Flagged

Schema::table('customers', fn (Blueprint $table) => $table->dropColumn('legacy_flag'));

Preferred

// Ship a release that stops reading the column first, then drop it in a separate
// later migration annotated #[SqlensAllowDestructive]. Until then, deprecate it:
Schema::table('customers', fn (Blueprint $table) => $table->renameColumn('legacy_flag', 'legacy_flag_unused'));

The rename makes the column unusable by accident while leaving the data in place — so a release that still referenced it fails loudly in staging rather than silently in production, and the eventual drop has a rollback path until the moment it happens.

Expand, then contract

This rule is the contract half of the pattern the whole level-4 band is built around: add the new shape, migrate the readers, ship, then remove the old shape. The two halves belong in separate deploys — a single migration that adds and drops has no window in which both versions work.

Saying yes on purpose

#[SqlensAllowDestructive('locale moved to user_preferences in 3.1; no shipped release reads it')]
final class DropCustomersLegacyFlag extends Migration { /* … */ }

The reason is the point. It is what tells the next person that the two-deploy dance already happened rather than that somebody was in a hurry.

Sources

  • PostgreSQL 18 — ALTER TABLEDROP COLUMN marks the column dropped in the catalog without rewriting the table; access to the values is gone regardless

The fix material this rule carries

A finding from this rule carries machine-readable fix material, using this sequence:

  • deploy_window_drop — Drop across two deploy windows so a rollback in between still finds what it needs.

The payload is material for you or an agent to apply. SQLens writes no migration and runs no DDL. See the remediation payload for every field, the placeholder semantics, and the version rules a consumer has to follow.