Schema migration provides a pure, algebraic system for transforming data between schema versions.
The migration system enables:
- Type-safe migrations: Define transformations between typed schemas
- Dynamic migrations: Operate on untyped
DynamicValuefor flexibility - Reversibility: All migrations can be structurally reversed
- Serialization: Migrations are pure data that can be serialized and stored
- Path-aware errors: Detailed error messages with exact location information
A typed migration from schema A to schema B:
import zio.blocks.schema._
import zio.blocks.schema.migration._
// Needed when (de)serializing DynamicMigration / MigrationAction / DynamicSchemaExpr
import zio.blocks.schema.migration.MigrationSchemas._
case class PersonV1(name: String, age: Int)
case class PersonV2(fullName: String, age: Int, country: String)
object PersonV1 { implicit val schema: Schema[PersonV1] = Schema.derived }
object PersonV2 { implicit val schema: Schema[PersonV2] = Schema.derived }
val migration: Migration[PersonV1, PersonV2] =
Migration
.newBuilder[PersonV1, PersonV2]
.renameField(MigrationBuilder.paths.field("name"), MigrationBuilder.paths.field("fullName"))
.addField(MigrationBuilder.paths.field("country"), "US")
.buildPartialAn untyped, serializable migration operating on DynamicValue:
val dynamicMigration = migration.dynamicMigration
import zio.blocks.chunk.Chunk
// Apply to DynamicValue directly
val oldValue: DynamicValue = DynamicValue.Record(Chunk(
"name" -> DynamicValue.Primitive(PrimitiveValue.String("John")),
"age" -> DynamicValue.Primitive(PrimitiveValue.Int(30))
))
val newValue: Either[MigrationError, DynamicValue] = dynamicMigration(oldValue)Individual migration actions are represented as an algebraic data type:
| Action | Description |
|---|---|
AddField |
Add a new field with a default value |
DropField |
Remove a field |
RenameField |
Rename a field |
TransformValue |
Transform a value using an expression |
Mandate |
Make an optional field mandatory |
Optionalize |
Make a mandatory field optional |
ChangeType |
Convert between primitive types |
Join |
Combine multiple fields into one |
Split |
Split one field into multiple |
RenameCase |
Rename a case in a variant/enum |
TransformCase |
Transform within a specific case |
TransformElements |
Transform all elements in a sequence |
TransformKeys |
Transform all keys in a map |
TransformValues |
Transform all values in a map |
Identity |
No-op action |
Serializable expressions for value transformations:
// Literal value
val lit = DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(42)))
// Path extraction
val path = DynamicSchemaExpr.Path(DynamicOptic.root.field("name"))
// Arithmetic
val doubled = DynamicSchemaExpr.Arithmetic(
path,
DynamicSchemaExpr.Literal(DynamicValue.Primitive(PrimitiveValue.Int(2))),
DynamicSchemaExpr.ArithmeticOperator.Multiply
)
// String operations
val concat = DynamicSchemaExpr.StringConcat(expr1, expr2)
val length = DynamicSchemaExpr.StringLength(stringExpr)
// Type coercion
val coerced = DynamicSchemaExpr.CoercePrimitive(intExpr, "String")The builder provides a fluent API for constructing migrations:
Migration
.newBuilder[OldType, NewType]
// Record operations
.addField(path, defaultExpr)
.dropField(path, defaultForReverse)
.renameField(fromPath, toPath)
.transformField(path, transform, reverseTransform)
.mandateField(path, default)
.optionalizeField(path)
.changeType(path, converter, reverseConverter)
.joinFields(targetPath, sourcePaths, combiner, splitter)
.splitField(sourcePath, targetPaths, splitter, combiner)
// Enum operations
.renameCase(path, from, to)
.transformCase(path, caseName, nestedActions)
// Collection operations
.transformElements(path, transform, reverseTransform)
.transformKeys(path, transform, reverseTransform)
.transformValues(path, transform, reverseTransform)
// Build
.build // Full validation
.buildPartial // Skip validationFor more ergonomic, type-safe paths, import the selector syntax extensions:
import zio.blocks.schema.migration.MigrationBuilderSyntax._
val migration: Migration[PersonV1, PersonV2] =
Migration
.newBuilder[PersonV1, PersonV2]
.renameField(_.name, _.fullName)
.addField(_.country, "US")
.buildPartialSelector syntax supports optic-like projections such as:
.when[T],.each,.eachKey,.eachValue,.wrapped[T],.at(i),.atIndices(is*),.atKey(k),.atKeys(ks*)
Use the paths object for constructing paths:
import MigrationBuilder.paths
paths.field("name") // Single field
paths.field("address", "street") // Nested field
paths.elements // Sequence elements
paths.mapKeys // Map keys
paths.mapValues // Map valuesAll migrations can be reversed:
val forward: Migration[A, B] = ...
val backward: Migration[B, A] = forward.reverse
// Law: forward ++ backward should be identity (structurally)Migrations can be composed:
val v1ToV2: Migration[V1, V2] = ...
val v2ToV3: Migration[V2, V3] = ...
val v1ToV3: Migration[V1, V3] = v1ToV2 ++ v2ToV3
// or
val v1ToV3: Migration[V1, V3] = v1ToV2.andThen(v2ToV3)Migrations return Either[MigrationError, DynamicValue]:
migration.apply(value) match {
case Right(newValue) => // Success
case Left(errors) =>
errors.errors.foreach { error =>
println(s"At ${error.path}: ${error.message}")
}
}Error types include:
FieldNotFound- A required field was not found in the source valueFieldAlreadyExists- A field already exists when trying to add itNotARecord- Expected a record but found a different kind of valueNotAVariant- Expected a variant but found a different kind of valueTypeConversionFailed- Primitive type conversion failedDefaultValueMissing- Default value not resolvedPathNavigationFailed- Cannot navigate the pathActionFailed- General action failure
- Use
buildPartialduring development, switch tobuildfor production validation - Provide meaningful reverse transforms for
TransformValueactions - Keep migrations small and focused - compose multiple simple migrations
- Test both forward and reverse directions
- Store migrations alongside schema versions for reproducibility