Quickstart - Schema Migration¶
Schema evolves over time.
SpecStar helps you handle schema changes safely — without losing data.
⏱️ Estimated time: 5 minutes
When do you need migration?¶
Schema changes can be divided into two categories:
- Backward compatible changes (no migration needed)
- Breaking changes (migration required)
1. Backward compatible changes¶
Some schema changes are automatically handled by SpecStar at runtime.
These include:
- adding new attributes with default values
- changing default values
- expanding Union / Literal types with new variants
Example¶
Original schema:
class Issue(msgspec.Struct):
title: str
description: str | None = None
status: Literal["open", "in_progress", "resolved"] = "open"
Updated schema:
class Issue(msgspec.Struct):
title: str
description: str | None = None
status: Literal["open", "in_progress", "resolved"] = "open"
priority: Literal["low", "medium", "high"] = "medium"
No migration is required.
When SpecStar reads older data, missing fields are automatically filled using default values.
2. Breaking changes¶
Breaking changes require explicit migration.
These include:
- adding new attributes without default values
- removing existing attributes
- narrowing Union / Literal types (removing variants)
- changing field types
Don't make a breaking change without bumping the schema version. If you alter the struct incompatibly but keep the same version, existing rows can no longer be decoded. The list endpoints defensively skip such rows (one bad row won't fail the whole page), but
/{model}/countstill counts them — socountand the list disagree. By default SpecStar logs a warning (… skipped N undecodable resource(s) that /count still counts …). You can change this withon_decode_error(skip/error/raw) — see API conventions. The real fix is to give the new shape a new version and a migration step, as below.
3. Define versioned schemas¶
Keep the old schema and introduce a new version.
class IssueV1(msgspec.Struct):
title: str
description: str | None = None
status: Literal["open", "in_progress", "resolved"] = "open"
class Issue(msgspec.Struct): # v2
title: str
priority: Literal["low", "medium", "high"] # new required field
description: str | None = None
status: Literal["open", "in_progress", "resolved"] = "open"
4. Define migration logic¶
Write a function that converts old data into the new schema.
def migrate_v1_to_v2(old: IssueV1) -> Issue:
return Issue(
title=old.title,
priority="medium", # default for existing data
description=old.description,
status=old.status,
)
5. Register the migration¶
Attach the migration step when registering the new schema:
from datetime import datetime
from specstar import spec, Schema
spec.configure(default_user="migration", default_now=datetime.utcnow)
spec.add_model(
Schema(Issue, "v2").step(
"v1",
migrate_v1_to_v2,
source_type=IssueV1,
)
)
Programmatic
resource_manager.migrate(...)/.switch(...)calls require an operation context (current user + clock). Passdefault_user/default_nowtoconfigure()as above, or wrap the call withrm.using(user=..., now=...). Without it, write methods raiseMissingOperationContextError.
This tells SpecStar how to upgrade data from v1 → v2.
6. Execute migration¶
The migration endpoints are not registered by default. Opt in by adding
MigrateRouteTemplate before add_model():
from specstar import spec
from specstar.crud.route_templates.migrate import MigrateRouteTemplate
spec.add_route_template(MigrateRouteTemplate())
This mounts POST /{model_name}/migrate/execute,
POST /{model_name}/migrate/test, and
POST /{model_name}/migrate/single/{resource_id} (the path segment follows
model_naming; e.g. an Issue model is mounted under /issue).
Run migration via API:
curl -X POST http://127.0.0.1:8000/issue/migrate/execute \
-H "Content-Type: application/json" \
-d '{
"limit": 10000
}'
This will:
- scan existing resources
- convert old revisions using your migration function
- store them as new revisions in the latest schema
7. What happens after migration?¶
After migration:
- all new writes use the latest schema (
v2) - old data is preserved as historical revisions
- your system continues to support revision history seamlessly
Reads apply migrations lazily (you don't have to migrate first)¶
You don't need to run the explicit migration above just to read old data:
when a row is stored at an older version, reads (GET, list, get() /
list_resources()) apply the registered step(...) migrations on the fly
and return the current-version object. So count and the list agree, and a
correctly-registered migration never surfaces as a 422.
Lazy migration is read-only — it does not rewrite storage:
revision_info.schema_versionkeeps the stored version (honest about what is persisted), even thoughdatais returned in the current shape.- SpecStar logs a one-time warning the first time it migrates on read.
- Run the explicit
migrate()(section 6) to persist the upgrade and bump the storedschema_version.
(If a stored version has no registered migration path to the current schema,
the read falls back to the on_decode_error
policy.)
Why this matters¶
Schema changes are one of the hardest parts of maintaining a system.
With SpecStar:
- backward-compatible changes require no action
- breaking changes are explicit and controlled
- migration logic is versioned alongside your schema
- historical data is never lost
You evolve your schema without breaking your system.
What’s next¶
If your application is evolving in place, backend setup becomes an important follow-up so migrations run against durable storage.
Next Steps: