CI/CD / Promotion and environment contracts / Lesson 11
Database changes and compatibility windows
Plan schema and data evolution across versions that may coexist during release.
On this page
Lesson goals
- Explain expand, migrate, and contract as separate decisions.
- Assess rollback compatibility without executing SQL.
Application versions share durable state
A package rollback restores application bytes, not automatically database schema or business data. During a rolling release, old and new Parcel instances may read and write the same rows. A column rename can break old readers immediately even if the new version works perfectly in an empty test database. Begin with a compatibility matrix: old application against old and expanded schemas, new application against expanded schema, and any required rollback combination. Also include background workers and external consumers that live longer than web requests.
Add compatibility before depending on it
An expansion introduces a structure that existing code can tolerate, such as a nullable new column. New code can read the new representation with a carefully defined fallback while old code remains valid. The illustrative PostgreSQL statement below is not an online-safety guarantee. ALTER TABLE lock modes, rewrite behavior, defaults, constraints, table size, concurrent workload, and server version affect operational risk. Database owners must review an actual migration plan with bounded waiting and recovery behavior in an authorized environment.
ALTER TABLE parcel_label
ADD COLUMN recipient_display text;
This assumes a synthetic existing PostgreSQL table. It is not executed and provides no prediction of lock duration, rewrite cost, or production availability. A nullable addition preserves the old recipient column while the application transition is designed.
Move data with a resumable contract
A backfill may take longer than application adoption and should have an explicit progress boundary, batch strategy, validation, and ownership. Repeating a batch must not duplicate business effects. Decide how new writes interact with partially migrated rows and how conflicts are resolved. Dual writes can introduce consistency bugs if one side succeeds and the other fails; they are not a free compatibility mechanism. A deterministic local transformation helps explain the intended mapping, but it does not test transactional or locking behavior on a database server.
Make reader behavior visible
The complete model below permits both old and transitional readers during expansion. The new reader falls back only when the new value is absent; it does not blindly treat an empty string as missing. Real requirements may choose another rule, but that decision must be explicit. Validate transformed data for meaning as well as row counts. A backfill that copies every row can still corrupt character encoding, ordering, or domain semantics while producing a reassuring completion percentage.
rows = [{"recipient": "Ada", "recipient_display": None}, {"recipient": "Lin", "recipient_display": "LIN"}]
def old_reader(row):
return row["recipient"]
def new_reader(row):
value = row.get("recipient_display")
return row["recipient"] if value is None else value
print([old_reader(r) for r in rows])
print([new_reader(r) for r in rows])
['Ada', 'Lin']
['Ada', 'LIN']
The in-memory example does not access a database and does not implement dual writes. It demonstrates an explicit null fallback while both schemas remain represented.
Remove old structure only after evidence
Contraction removes the obsolete column or path after all consumers have migrated and the rollback horizon has been reconsidered. This is a separate release decision, not an automatic final line of the expansion script. Confirm no old workers, reports, integrations, or emergency rollback candidates still require it. Retain data recovery evidence appropriate to the change. Backups are valuable only when restoration has been rehearsed against the relevant consistency requirements; a filename called backup does not prove recoverability or acceptable data loss.
Common errors and fixes
Rollback starts but old code cannot read the database.
- Why it happens
- A destructive schema change removed the old contract.
- How to fix it
- Use a compatible recovery path and involve the database owner.
- Prevent it next time
- Separate expansion, data movement, and contraction decisions.
A backfill repeats external business actions.
- Why it happens
- The migration was not idempotent or its progress boundary was ambiguous.
- How to fix it
- Design a resumable state transformation without replaying side effects.
- Prevent it next time
- Review retries and partial completion before any authorized execution.
A migration tested on an empty database stalls under workload.
- Why it happens
- Functional syntax testing was mistaken for operational lock evidence.
- How to fix it
- Assess lock and rewrite behavior on representative authorized test data.
- Prevent it next time
- Review actual engine version, workload, and bounded execution policy.
Check your understanding
1. When can recipient be removed from the teaching schema?
Show worked solution
Only after all readers and writers use recipient_display, data validation is complete, no required old consumer remains, and the recovery policy permits losing compatibility with old packages. Treat removal as a separately reviewed contraction release.
2. Why does the model distinguish None from an empty string?
Show worked solution
An empty string may be an intentional stored value, while None represents absence in the declared contract. Using a truthiness fallback would conflate the two. Domain requirements should define the meaning before a migration or reader silently changes it.
Key takeaways
- Durable state outlives package replacement.
- Compatibility and recovery must span the entire mixed-version window.