Skip to main content

Destructive Change Policy: Prevent Accidental Data Loss with Deprecation Workflows

The Problem

DROP TABLE and DROP COLUMN are irreversible. Once the migration runs in production, the only recovery path is restoring a backup. For most teams, a single accidental drop is the worst incident their database tooling can produce.

The standard industry defense is a deprecation workflow: first remove all application references to the object so nothing is reading or writing it, then rename it to mark it as deprecated, wait long enough that no rolled-back deploy will reach for it again, and only then drop. The wait window can be hours for a small service or weeks for a regulated system with long-running batch jobs. The pattern works, but it is enforced by convention. Without tooling, every change author has to remember it.

AI coding agents (Cursor, Claude Code, Copilot, etc.) raise the stakes. Modern agents are given a goal like "remove the unused legacy_email column" and pursue it autonomously: planning the steps, editing the schema, generating the migration, opening the PR. The agent has no memory of last week's incident review, no awareness of the consumer service that still SELECTs from that column, and no instinct that the drop is irreversible. Without a deterministic check between the agent and the database, the only safety net is whoever reviews the PR noticing the DROP.

Enter: Atlas

Atlas closes this gap by managing destructive changes the same way it manages the rest of your schema: declared in code, enforced by the planner, lintable in CI, and tunable per environment. The rest of this guide walks through each piece.

To get started, install Atlas:

To download and install the latest release of the Atlas CLI, simply run the following in your terminal:

curl -sSf https://atlasgo.sh | sh

Controlling Destructive Changes with Policies

Atlas supports a range of schema policies. For preventing destructive changes, three of them matter, each running at a different point in the change flow:

  1. Diff policy. At plan time, when Atlas compares the desired state to the current state and generates the SQL migration, the diff.skip policy tells it not to emit drops. The current state can be a live database, a migration directory, or any other state Atlas supports. The object can be removed from the schema so the application can refactor and drop its references, but the generated migration never carries a DROP TABLE or DROP COLUMN: the table or column stays in the database to preserve backward compatibility.

  2. Review / lint policy. At review time, if a developer or agent edits a migration file directly and adds a DROP statement (or the diff policy is not configured to skip drops and the desired state removes an object), the destructive lint analyzer detects it. Running on every PR in CI, the analyzer fails the build and blocks the merge. Configured with force = true, the check cannot be bypassed via nolint directives.

  3. Deprecation policy. At deprecation time, when an object really is supposed to go away, a common convention is to rename it with a deprecated_ prefix and leave it in place for the deprecation window. The prefix is not mandatory; teams can pick any naming convention they want. After the window, the allowlist on the analyzer lets the matching drops through while continuing to block any other drop.

The rest of this guide configures each piece, in that order.

Strip DROPs from the Diff Entirely

The earliest point to control a destructive change is plan time, before any SQL is generated. For environments where Atlas should never emit a DROP statement at all (regulated production, shared multi-tenant databases, or environments managed by a separate team), the diff.skip policy plans the migration as if the dropped object were still part of the desired state, so no destructive statement is emitted.

atlas.hcl
env "prod" {
diff {
skip {
drop_schema = true
drop_table = true
drop_column = true
}
}
}

Now even when the desired state removes a column, atlas migrate diff and atlas schema plan produce no statement for the deletion. The column stays in the database regardless of the desired state.

To let a DBA produce the drop when it is genuinely intended, gate the skip behind an input variable and toggle it with the --var flag:

atlas.hcl
variable "destructive" {
type = bool
default = false
}

env "prod" {
diff {
skip {
drop_schema = !var.destructive
drop_table = !var.destructive
drop_column = !var.destructive
}
}
}

By default the drop is skipped. A DBA can emit it on demand, through a single explicit flag, as part of a separate audited process:

atlas migrate diff --env prod --var destructive=true

Block Destructive Changes in CI

Atlas ships with a set of migration analyzers: static checks that run against a planned migration to catch unsafe patterns before the change reaches any database. The check is invoked with atlas migrate lint, runs locally and in CI, and exits with a non-zero code when an analyzer reports an error.

The destructive analyzer is one of these checks and is enabled by default. It flags any statement that drops a table, column, schema, or other persisted object.

Because the analyzer runs on the planned migration, it does not matter how the drop was introduced. Whether the column was removed from a SQL Schema, an HCL Schema, or a schema defined in an ORM (GORM, Sequelize, SQLAlchemy, etc.), the planner produces the same DROP and the analyzer catches it.

Given this schema change, where the legacy_email column was removed from the desired state:

schema.pg.hcl
 table "users" {
schema = schema.public
column "id" { type = serial }
column "email_address" { type = varchar(200) }
- column "legacy_email" { type = varchar(200) }
primary_key { columns = [column.id] }
}

A migrate diff produces a migration with a destructive statement:

migrations/20260526120000_drop_legacy_email.sql
ALTER TABLE "users" DROP COLUMN "legacy_email";

Running atlas migrate lint against this migration fails:

atlas migrate lint --dev-url "docker://postgres/17/dev" --latest 1
Analyzing changes from version 20260526100000 to 20260526120000 (1 migration in total):

-- analyzing version 20260526120000
-- destructive changes detected:
-- L1: Dropping non-virtual column "legacy_email" https://atlasgo.io/lint/analyzers#DS103
-- ok (5.328ms)

-------------------------
-- 9.211ms
-- 1 version with errors
-- 1 schema change
-- 1 diagnostic

The same check runs in any pipeline that invokes the Atlas CLI. Pick your platform to get started:

Simulation, not just parsing

Atlas parses the migration, but it does not stop there. It also executes the planned statements against an ephemeral, real database engine and inspects the actual schema changes the engine produces. This catches drops that parsing alone would miss: a statement that calls a stored function or procedure which issues a DROP internally, or one that fires a registered event or trigger that applies further operations. Because the drop is observed in the simulated result and not only in the SQL text, the analyzer flags it regardless of how it was triggered.

Enforce the Policy (No Bypass)

By default, a developer can suppress lint findings with the atlas:nolint directive. The directive applies either to a single statement, when placed above it, or to the entire file, when placed at the top. It can suppress a specific check (like DS103, dropping a column), a whole analyzer (like destructive), or all analysis. Placed above the drop, it silences the destructive finding for that statement:

migrations/20260526120000_drop_legacy_email.sql
-- atlas:nolint destructive
ALTER TABLE "users" DROP COLUMN "legacy_email";

For most teams that is a useful escape hatch, but in regulated environments or when AI agents are opening PRs, the bypass is itself a risk. The force option on the analyzer makes the check non-suppressible:

atlas.hcl
lint {
destructive {
error = true
force = true
}
}

With force = true, the analyzer reports the destructive change and fails CI even when the migration carries -- atlas:nolint destructive. The only way through is to either remove the destructive statement or change the policy itself, which is reviewable in source control.

This is the right default for any pipeline where the migration author and the reviewer are not the same person, and the right default for any pipeline where an agent can open a PR.

A Deprecation Workflow

The policy above blocks every drop, which is too strict for the lifecycle of a real schema. Tables and columns do get removed eventually, they just need to be drained first. The deprecation workflow drains an object before it is dropped: the application first stops reading and writing it, the object is then renamed with a deprecated_ prefix and left in place for the deprecation window (long enough to cover any rollback, replica, or batch job), and only after the window passes is it dropped.

Atlas handles the rename declaratively, through the renamed_from attribute (HCL) or the -- atlas:renamed_from directive (SQL). The planner emits ALTER ... RENAME instead of DROP + CREATE, so the data is preserved.

schema.pg.hcl
table "users" {
schema = schema.public
column "id" { type = serial }
column "email_address" { type = varchar(200) }
column "deprecated_legacy_email" {
renamed_from = "legacy_email"
type = varchar(200)
}
primary_key { columns = [column.id] }
}

Atlas plans an ALTER TABLE ... RENAME COLUMN and the column keeps its data. The application is then updated to stop reading and writing deprecated_legacy_email, and the deprecation window starts.

Once the window has passed, the column is dropped. To let this drop through without disabling the analyzer entirely, add an allowlist to the destructive block that matches the deprecated_ prefix:

atlas.hcl
lint {
destructive {
force = true
allow_table {
match = "deprecated_.+"
}
allow_column {
match = "deprecated_.+"
}
}
}

Now a migration that drops users.legacy_email still fails, since it carries no deprecated_ prefix, while a migration that drops users.deprecated_legacy_email passes because it matches the pattern. The -- atlas:nolint destructive directive remains blocked by force = true, so the only way to drop a non-deprecated object is to rename it through the deprecation workflow first.

The allow_table and allow_column blocks accept any regular expression, so teams that prefer a _deprecated_ suffix, a __drop suffix, or a date-stamped pattern can encode that convention directly.

Wrapping Up

Atlas controls destructive changes at three points in the change flow, and they compose:

  1. At plan time, the diff policy decides whether removing an object from the schema emits a DROP at all, so the author edits the desired state and Atlas, not the author, owns whether the drop is produced.
  2. At review time, the destructive analyzer runs on the planned migration in CI and fails the build on any drop, whether it came from a SQL schema, an HCL schema, or an ORM, and force = true makes that check non-bypassable.
  3. At deprecation time, renamed_from together with an allowlist on a deprecated_ prefix lets a drained object through while every other drop stays blocked.

Most schemas combine these by environment: the default or deprecation-flow lint in CI, drops skipped entirely in production. The same configuration that guards a careful engineer guards an autonomous agent. Whether a developer or Claude Code edits the desired state, renames a column, or writes the DROP directly, the decision of whether data is actually dropped is made by a deterministic policy that runs on every plan, not by whoever authored the change.

Next Steps