| | |

Serialization 03 ๐Ÿ“„ Schema Evolution, Backwards Compatibility, and Config Migrations

โฑ๏ธ Estimated read: 11 min | |

Configuration schemas change. A field is added, a key is renamed, a nested table becomes an array. The file on disk was written by an older version of the software, and the new version must either read it correctly or fail with a message the user can act on. Schema evolution is the discipline of managing those changes without breaking existing installations, and for YAML and TOML configuration the problem is harder than for databases because there is no server enforcing constraintsโ€”only the parser and the application code.

This chapter covers the compatibility taxonomy that determines which changes are safe, the expand-contract pattern that makes breaking changes deployable, the migration mechanisms that transform old files into new shapes, and the tooling that automates the process for YAML and TOML specifically. The sections that follow walk through backward-compatible changes, the expand-contract phases, and the migration implementation patterns that preserve user data and comments.

Key point: Adding optional fields is backward-compatible and requires no migration; renaming or removing fields is breaking and requires an expand-contract migration that reads old shapes, writes new shapes, and only removes the old path after every reader has moved.


Why schema evolution exists

The silent failure problem. A configuration file without a version field is interpreted as whatever shape the current code expects. When a field is renamed, the old file parses successfully but the field is ignored, and the application runs with a default value the user never chose. The failure is silent until the behavior diverges. Version fields and migration chains exist to make the divergence visible and correctable .

The comment preservation problem. TOML and YAML files are hand-edited. Comments explain why a value is set, and formatting makes the file scannable. A naive migration that deserializes the file, transforms the data structure, and re-serializes it loses every comment and every formatting choice. The toml_edit crate and similar libraries exist specifically to preserve comments and whitespace during programmatic edits, and migration frameworks that ignore this produce files the user no longer recognizes .

The multi-version problem. During a rolling deployment, old and new code run simultaneously. A config file written by the new code must be readable by the old code until the rollout completes, or the old instances crash on startup. The expand-contract pattern addresses this by making every change in two steps: first add the new structure alongside the old, then remove the old structure only after all readers have moved .


a. Backward-compatible versus breaking changes

The compatibility taxonomy is the foundation of every migration decision. A change is backward-compatible if old readers can still interpret the new file, or if new readers can interpret old files without data loss. It is breaking if either direction fails.

Adding a new optional field with a default is backward-compatible. The old reader ignores the field; the new reader supplies the default when the field is absent. Adding a new table is likewise compatible. Renaming a field, removing a field, changing a field’s type, or adding a required field without a default are breaking because the old reader cannot interpret the new shape or the new reader cannot find the data it needs .

ChangeCompatibleReason
Add optional field with defaultYesOld readers ignore; new readers default
Add new tableYesNo existing path changes
Rename fieldNoOld path disappears
Remove fieldNoNew reader loses data
Change type (string โ†’ int)NoParser may accept, semantics change
Add required field without defaultNoOld files fail validation

The version field in the configuration file is the mechanism that lets the application know which rules apply. A config_version field, checked on load, determines whether a migration is needed before the data is validated against the current schema . Without a version field, the application guesses, and the guess is wrong as soon as the first breaking change ships.


b. The expand-contract pattern for configuration

The expand-contract pattern, borrowed from database schema migration, makes breaking changes deployable without downtime. The pattern has three phases, each a separate deployment .

Expand: Add the new structure alongside the old. For a field rename, this means adding the new key while the old key still exists. For a type change, it means adding a new field with the new type and keeping the old field. Both old and new code can read the file; old code writes only the old key, new code writes both.

Migrate: Backfill the new structure from the old. For a field rename, copy the old key’s value to the new key where the new key is absent. For a configuration file, this is a migration function that runs on load and writes the file back in the new shape, or a one-time CLI command that transforms files in place.

Contract: Remove the old structure. Only after every reader has been updated to the new key can the old key be deleted. In a configuration context, this means the migration that removes the old key ships only after the release that stopped reading it.

The critical constraint is that each phase must be a separate release, and the contract phase cannot ship until the expand phase has been deployed long enough that every file in the wild has been migrated. For configuration files, this means the application must tolerate both shapes during the transition window, reading the new key first and falling back to the old key if the new one is absent.


c. Migration implementation for YAML and TOML

A migration is a function that receives the old configuration data and returns the new configuration data. The function must be idempotent (running it twice produces the same result), must preserve comments where possible, and must be testable independently of the application.

The fluxconf library for Pydantic-backed YAML configuration illustrates the pattern. Migrations are keyed by a string with an integer prefix that determines execution order, and they run automatically on read() . A migration can be a JSON Patch (RFC 6902) for simple structural edits or a Python function for conditional logic.

# JSON Patch migration: rename host to hostname
migrations = {
    "1_rename_host": [
        {"op": "move", "from": "/hostname", "path": "/host"}
    ],
    "2_add_port": [
        {"op": "add", "path": "/port", "value": 8080}
    ],
}

For TOML, the migrable tool provides declarative migrations with comment-preserving edits via go-toml-edit, rollback through down operations, and a dry-run mode for previewing changes before writing . A migration file declares the operation, the path, and the default value.

# migrations/0001_add-debug.toml
description = "add debug flag to server section"

[[structure]]
op = "add_field"
path = "server.debug"
type = "boolean"
default = false
down = { op = "remove_field", path = "server.debug" }

The version field in the configuration is updated after a successful migration, and the original file is backed up before any modification. These two rules prevent a failed migration from destroying the only copy of the user’s configuration and prevent the application from re-running migrations that have already been applied .


Complete Example Session

# ============================================
# PART 1: THE OLD CONFIGURATION (V1)
# ============================================
# config.yaml (v1 shape)
# version: 1
# server:
#   hostname: localhost
#   port: 8080

# ============================================
# PART 2: THE NEW CONFIGURATION (V2)
# ============================================
# config.yaml (v2 shape, after expand phase)
# version: 2
# server:
#   host: localhost        # new key
#   hostname: localhost    # old key kept for compatibility
#   port: 8080

# ============================================
# PART 3: THE MIGRATION FUNCTION (PYTHON)
# ============================================
def migrate_v1_to_v2(data: dict) -> dict:
    """Copy hostname to host, keep both keys."""
    server = data.get("server", {})
    if "hostname" in server and "host" not in server:
        server["host"] = server["hostname"]
    data["version"] = 2
    return data

# ============================================
# PART 4: THE LOAD PATH
# ============================================
import yaml

def load_config(path: str) -> dict:
    with open(path) as f:
        data = yaml.safe_load(f)

    version = data.get("version", 1)  # absent means v1
    if version < 2:
        data = migrate_v1_to_v2(data)
        # write back the migrated file
        with open(path, "w") as f:
            yaml.dump(data, f, sort_keys=False)

    return data

# ============================================
# PART 5: THE CONTRACT PHASE (LATER RELEASE)
# ============================================
# Remove hostname from the file and from the migration
# Only after every deployment reads host, not hostname

The parts covered the old and new shapes during the expand phase, the migration function that copies the old key to the new key, the load path that runs the migration automatically, and the contract phase that removes the old key.


Quick Reference

Compatibility Taxonomy

ChangeClassificationMigration Required
Add optional fieldCompatibleNo
Add new tableCompatibleNo
Rename fieldBreakingYes (expand-contract)
Remove fieldBreakingYes (contract only)
Change typeBreakingYes
Add required fieldBreakingYes

Expand-Contract Phases

PhaseActionReader Behavior
ExpandAdd new structure, keep oldReads new first, falls back to old
MigrateBackfill new from oldReads new, ignores old
ContractRemove oldReads new only

Migration Tooling

ToolFormatKey Feature
fluxconfYAML (Pydantic)JSON Patch + function migrations
migrableTOMLComment-preserving, rollback
toml_editTOMLPreserves comments/formatting

Best Practices

โœ… Do This:

# Include a version field
version: 2                                                   # โœ…

# Keep old keys during expand phase
server:
  host: localhost
  hostname: localhost                                        # โœ…

# Back up before migrating
cp config.yaml config.yaml.bak                               # โœ…

# Run migrations on load, not on write
if version < CURRENT: data = migrate(data)                   # โœ…

โŒ Don’t Do This:

# No version field
server:
  host: localhost                                            # โŒ (cannot detect old files)

# Remove old key in the same release as the new key
server:
  host: localhost                                            # โŒ (old readers break)

# Deserialize and re-serialize without comment preservation
yaml.dump(yaml.safe_load(f))                                 # โŒ (loses comments)

# Run migration without idempotency check
data["version"] = 2  # no guard                             # โŒ (may re-run)

Common Pitfalls

PitfallWhy It HappensFix
Old config parses but field ignoredNo version field or migrationAdd version and check on load
Comments lost after migrationRe-serialization without preserving libraryUse toml_edit or migrable
Old code crashes on new fileContract phase shipped too earlyWait one deployment cycle
Migration runs twiceNo idempotency guardCheck version before transforming
Required field breaks old filesAdded without defaultAdd optional with default first

Real-World Examples

1. Version field in YAML

version: 2

2. Version field in TOML

config_version = "2.0.0"

3. JSON Patch migration (fluxconf)

{"op": "move", "from": "/hostname", "path": "/host"}

4. Declarative TOML migration (migrable)

[[structure]]
op = "add_field"
path = "server.debug"
default = false

5. Comment-preserving edit (toml_edit)

# toml_edit loads and writes without losing comments

6. Backup before migration

cp config.yaml config.yaml.bak

7. Idempotent migration guard

if data.get("version", 1) < 2:
    data = migrate(data)

8. Fallback read during expand

host = server.get("host") or server.get("hostname")

9. Contract after rollout

# Remove hostname only after all readers use host

10. Dry-run preview

migrable migrate --dry-run

Visual

Compatibility Spectrum

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  CHANGE TYPES                                           โ”‚
โ”‚                                                         โ”‚
โ”‚  COMPATIBLE                    BREAKING                 โ”‚
โ”‚  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                    โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€                 โ”‚
โ”‚  Add optional field            Rename field             โ”‚
โ”‚  Add new table                 Remove field             โ”‚
โ”‚  Widen type                    Change type              โ”‚
โ”‚                                Add required field       โ”‚
โ”‚                                                         โ”‚
โ”‚  No migration                  Migration required       โ”‚
โ”‚  Deploy anytime                Expand-contract          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Expand-Contract Timeline

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  THREE RELEASES                                         โ”‚
โ”‚                                                         โ”‚
โ”‚  Release N: EXPAND                                      โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”‚
โ”‚  โ”‚ Old key: present                         โ”‚           โ”‚
โ”‚  โ”‚ New key: added, written by new code      โ”‚           โ”‚
โ”‚  โ”‚ Readers: read new, fall back to old      โ”‚           โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ”‚
โ”‚         โ”‚                                               โ”‚
โ”‚         โ–ผ                                               โ”‚
โ”‚  Release N+1: MIGRATE                                   โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”‚
โ”‚  โ”‚ Migration backfills new from old         โ”‚           โ”‚
โ”‚  โ”‚ All files in the wild are now v2         โ”‚           โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ”‚
โ”‚         โ”‚                                               โ”‚
โ”‚         โ–ผ                                               โ”‚
โ”‚  Release N+2: CONTRACT                                  โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”‚
โ”‚  โ”‚ Old key removed                          โ”‚           โ”‚
โ”‚  โ”‚ Readers: new only                        โ”‚           โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Migration Load Path

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  ON LOAD                                                โ”‚
โ”‚                                                         โ”‚
โ”‚  Read file                                              โ”‚
โ”‚         โ”‚                                               โ”‚
โ”‚         โ–ผ                                               โ”‚
โ”‚  Check version field                                    โ”‚
โ”‚         โ”‚                                               โ”‚
โ”‚    โ”Œโ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”                                          โ”‚
โ”‚    โ–ผ         โ–ผ                                          โ”‚
โ”‚  Current   Old                                          โ”‚
โ”‚    โ”‚         โ”‚                                          โ”‚
โ”‚    โ–ผ         โ–ผ                                          โ”‚
โ”‚  Validate  Migrate                                      โ”‚
โ”‚             โ”‚                                           โ”‚
โ”‚             โ–ผ                                           โ”‚
โ”‚          Write back                                     โ”‚
โ”‚             โ”‚                                           โ”‚
โ”‚             โ–ผ                                           โ”‚
โ”‚          Validate                                       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Comment Preservation

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  NAIVE MIGRATION              PRESERVING MIGRATION      โ”‚
โ”‚                                                         โ”‚
โ”‚  parse โ†’ dict                 toml_edit / migrable      โ”‚
โ”‚       โ”‚                            โ”‚                    โ”‚
โ”‚       โ–ผ                            โ–ผ                    โ”‚
โ”‚  transform                    in-place edit            โ”‚
โ”‚       โ”‚                            โ”‚                    โ”‚
โ”‚       โ–ผ                            โ–ผ                    โ”‚
โ”‚  serialize                    write back               โ”‚
โ”‚       โ”‚                            โ”‚                    โ”‚
โ”‚       โ–ผ                            โ–ผ                    โ”‚
โ”‚  Comments LOST                Comments KEPT             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
Compatible changesAdd optional field, add table, widen type
Breaking changesRename, remove, type change, required without default
Expand phaseAdd new alongside old, readers fall back
Migrate phaseBackfill new from old
Contract phaseRemove old after all readers updated
Version fieldRequired to detect old files
Comment preservationtoml_edit, migrable
Backup ruleCopy before any migration

Key takeaways:

  • The compatibility taxonomy decides everything. Adding an optional field is safe; renaming or removing a field is not. The classification determines whether a migration is needed and which pattern applies .
  • Expand-contract makes breaking changes deployable. Add the new structure first, backfill it, and remove the old structure only after every reader has moved. Each phase is a separate release .
  • A version field is the entry point. Without it, the application cannot know whether a file needs migration, and the failure mode is silent: the old field is ignored and the default takes over.
  • Comment preservation is not optional for hand-edited configs. A migration that loses comments produces a file the user no longer recognizes. toml_edit and migrable exist to prevent this .
  • Migration must be idempotent and backed up. Running the same migration twice must produce the same result, and the original file must be copied before modification so a failed migration does not destroy the only copy.

Remember: Configuration schema evolution is the same problem as database schema evolution, without the database. There is no server to enforce constraints and no transaction log to roll back. The version field is the only signal, the migration function is the only transformation, and the expand-contract timeline is the only safe path for breaking changes. When a field must change its name, add the new name first, keep the old name readable, and remove it only after the last deployment that reads it has shipped.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!