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:
- macOS + Linux
- Homebrew
- Docker
- Windows
- CI
- Manual Installation
To download and install the latest release of the Atlas CLI, simply run the following in your terminal:
curl -sSf https://atlasgo.sh | sh
Get the latest release with Homebrew:
brew install ariga/tap/atlas
To pull the Atlas image and run it as a Docker container:
docker pull arigaio/atlas
docker run --rm arigaio/atlas --help
If the container needs access to the host network or a local directory, use the --net=host flag and mount the desired
directory:
docker run --rm --net=host \
-v $(pwd)/migrations:/migrations \
arigaio/atlas migrate apply \
--url "mysql://root:pass@:3306/test"
Download the latest release and move the atlas binary to a file location on your system PATH.
GitHub Actions
Use the setup-atlas action to install Atlas in your GitHub Actions workflow:
- uses: ariga/setup-atlas@v0
with:
cloud-token: ${{ secrets.ATLAS_CLOUD_TOKEN }}
Other CI Platforms
For other CI/CD platforms, use the installation script. See the CI/CD integrations for more details.
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:
-
Diff policy. At plan time, when Atlas compares the desired state to the current state and generates the SQL migration, the
diff.skippolicy 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 aDROP TABLEorDROP COLUMN: the table or column stays in the database to preserve backward compatibility. -
Review / lint policy. At review time, if a developer or agent edits a migration file directly and adds a
DROPstatement (or the diff policy is not configured to skip drops and the desired state removes an object), thedestructivelint analyzer detects it. Running on every PR in CI, the analyzer fails the build and blocks the merge. Configured withforce = true, the check cannot be bypassed vianolintdirectives. -
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.
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:
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:
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:
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:
GitHub Actions
Run the destructive check on every pull request in GitHub Actions.
GitLab CI
Run the destructive check in your GitLab pipelines.
Bitbucket Pipelines
Run the destructive check in Bitbucket Pipelines.
Azure DevOps
Run the destructive check in Azure DevOps Pipelines.
CircleCI
Run the destructive check in your CircleCI workflows.
Locally
Run atlas migrate lint on your machine, before pushing.
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:
-- 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:
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.
- Atlas DDL (HCL)
- SQL
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] }
}
CREATE TABLE "users" (
"id" serial PRIMARY KEY,
"email_address" varchar(200),
-- atlas:renamed_from legacy_email
"deprecated_legacy_email" varchar(200)
);
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:
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:
- At plan time, the diff policy decides whether removing an
object from the schema emits a
DROPat all, so the author edits the desired state and Atlas, not the author, owns whether the drop is produced. - 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 = truemakes that check non-bypassable. - At deprecation time,
renamed_fromtogether with an allowlist on adeprecated_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
- Migration Analyzers: full list of built-in checks and codes
- Custom Schema Policy: write custom linting rules with predicates
- Setup CI/CD: configure CI for versioned migrations