Preventing Destructive Changes in Declarative Schema Diffs
A merge conflict in schema.sql was resolved by taking “theirs”, and the resolution quietly removed a table definition that only existed on your branch. The next declarative apply computed the difference faithfully: the table exists in production and not in the desired state, so it must go. DROP TABLE customer_notes ran in a few milliseconds, and three years of support history disappeared. In a versioned workflow somebody would have had to write that drop; in a declarative one, the absence of a line is enough. This guide builds the layered defence that makes destructive changes impossible to ship by accident while keeping them possible on purpose — the guardrail list from Declarative Schema Management, implemented.
Symptom / Error Signatures
You need these guardrails if any of the following has happened, or could:
- A plan in a pull request contains
DROP TABLE,DROP COLUMN,DROP SCHEMA, orALTER COLUMN ... TYPEthat narrows a type, and the pull request description does not mention it. - A reviewer approved a long plan in which the destructive statement was line 40 of 60.
- The declarative tool runs with an auto-approve flag in CI and no linting step.
- After a deploy, application errors such as
relation "customer_notes" does not existorcolumn "legacy_code" does not existappear — the destructive change has already run.
When guardrails are in place, the symptoms become tool messages instead of incidents: Skeema reports Detected 1 unsafe change and refuses to push; Atlas’s lint reports a destructive-change diagnostic; a custom CI check prints the offending statement and fails the job.
Root Cause Analysis
Declarative tools are, by design, complete: they converge the database to the desired state, and removal is part of convergence. Three properties turn that into data loss. The desired state is edited by humans and merged by version control, which has no notion of “this deletion is dangerous”. The plan is reviewed as a whole, and destructive statements look typographically identical to harmless ones. And the tool cannot distinguish an intentional removal from an accidental one, because both are the same diff.
| Destructive change | Why it happens by accident | Data impact |
|---|---|---|
DROP TABLE |
definition lost in a merge or file split | all rows lost |
DROP COLUMN |
column removed from model before code stopped reading it | column data lost; running code errors |
| rename seen as drop + add | column renamed in the desired state | old values lost; new column empty |
narrowing ALTER TYPE |
varchar(255) edited to varchar(64) |
fails or truncates, depending on engine and mode |
DROP INDEX on a unique index |
index definition removed | uniqueness no longer enforced |
The defence therefore has to live outside the desired state: in tool policy that refuses destructive statements by default, in linting that classifies each planned statement, and in a review gate that makes destructive changes visually and procedurally distinct.
Immediate Mitigation
1. Remove auto-approve from every production apply today. Until the layers below exist, a human reads every plan. Generate plans with a dry run and apply only saved, reviewed SQL.
2. Turn on the tool’s own destructive-change refusal. In Atlas, add skip rules so drops are never emitted in the normal path. In Skeema, the default already refuses unsafe changes; make sure nobody set allow-unsafe in a .skeema file. In sqldef, table drops are only emitted when --enable-drop-table is passed, so keep that flag out of CI.
# INI · .skeema file for the production environment · checked into git
# WARNING: never set allow-unsafe here; pass --allow-unsafe on the command line for one reviewed push.
[production]
host=prod-mysql.internal
schema=shop
allow-unsafe=0
safe-below-size=10M
3. Add a plan scan to CI that fails on destructive statements. A simple pattern check over the generated SQL is crude but effective, and it works for any tool.
# Shell · CI job after plan generation · reads plan.sql, touches no database
# WARNING: this is a backstop, not a parser; keep the tool policy and linter as the primary layers.
if grep -nEi "DROP[[:space:]]+(TABLE|SCHEMA|COLUMN|DATABASE)|ALTER[[:space:]]+COLUMN.*TYPE" plan.sql; then
if [ "${ALLOW_DESTRUCTIVE_LABEL:-false}" != "true" ]; then
echo "destructive statement in plan; add the 'destructive-migration' label with justification"; exit 1
fi
fi
4. Run the tool’s linter. Atlas ships analyzers that classify destructive changes (the DS family of diagnostics, such as a dropped column), backward-incompatible changes (BC, such as a renamed column) and data-dependent changes (MF, such as a new unique index that may fail on existing duplicates). Skeema’s skeema lint covers table-definition hygiene. Fail CI on the destructive and backward-incompatible classes.
Permanent Fix / Long-Term Pattern
Make destructive changes a separate, visible workflow rather than something a diff can smuggle in. The pattern that holds up is: drops are skipped or refused by default in tool policy; any pull request whose plan contains a destructive statement must carry an explicit label, a written justification and a second approver; the drop happens in its own pull request, never alongside additive changes; and before approval, someone confirms that no deployed application version reads the object and that a recoverable copy of the data exists.
That last point connects declarative workflows to the contract phase of Expand and Contract Methodology. A column is removed from the desired state only after the application has stopped reading and writing it for at least one full release, and — for anything valuable — after its data has been archived. The same retention thinking appears in recovering data after an irreversible migration, which is where you end up if all three layers fail. For MySQL tables above a size threshold, Skeema’s safe-below-size lets tiny tables be dropped freely in development while production-sized ones always require the override.
Verification Checklist
Frequently Asked Questions
Why not just disable drops permanently? Because unused structure accumulates, and some drops are necessary to finish an expand-and-contract migration or to remove sensitive data. The goal is not to make drops impossible but to make them deliberate: a separate change with an explicit override and evidence that the data is unused and recoverable.
Does skipping drops leave the schema out of sync? Yes, intentionally and temporarily. The live database keeps the object that the desired state no longer mentions, and drift checks will report it. Track these as pending contract steps and remove them with a reviewed drop once the preconditions are met.
Can a linter catch every destructive change? No. Pattern and analyzer based checks catch explicit drops and narrowing type changes well, but a rename seen as drop plus add, or a changed default that alters application behaviour, may need human judgement. That is why linting is one layer among three rather than the whole defence.
How should renames be handled in a declarative workflow? As an expand-and-contract sequence: add the new column in one change, dual-write and backfill, switch reads, and only then remove the old column from the desired state in a separate, labelled change. Editing the name in place produces a drop and an add.