Migraciones de SwiftData: ligeras vs. personalizadas, y cuándo no necesitas una V2
La historia de migración de esquemas de SwiftData es una mejora estructural respecto a la de Core Data, con una trampa en la que los equipos siguen cayendo: declarar un nuevo VersionedSchema para cambios que SwiftData manejaría automáticamente mediante valores predeterminados en línea. El resultado es un crash en el dispositivo con “Duplicate version checksums across stages detected”, aunque el código se vea correcto y compile sin problemas. El modelo de migración real del framework usa tres piezas (VersionedSchema, MigrationStage, SchemaMigrationPlan) y tres tipos de migración (ligera automática, ligera declarada y personalizada)1. La mayoría de los cambios de esquema son automáticos. Algunos necesitan una etapa ligera declarada. Una pequeña minoría necesita una etapa personalizada con closures willMigrate y didMigrate.
Este post recorre el modelo de migración contrastándolo con la documentación de Apple, nombra los casos que maneja cada tipo de migración y cubre el nuevo soporte de herencia de clases en iOS 26. El enfoque es “qué declaro yo versus qué maneja SwiftData por mí”, porque esa decisión determina si la migración se publica limpiamente o se cae en el primer arranque.
TL;DR
- Las migraciones de SwiftData se componen de tres protocolos:
VersionedSchema(una instantánea de los tipos de modelo en una versión),MigrationStage(una única transición fromVersion-a-toVersion con los casos.lightweighto.custom) ySchemaMigrationPlan(lista ordenada de etapas)1. - Agregar una nueva propiedad
@Modelcon un valor predeterminado en línea (var foo: Bool = false) no requiere un nuevoVersionedSchema. SwiftData maneja la adición automáticamente como una migración ligera. Declarar una V2 para esto produce crashes con “Duplicate version checksums across stages detected”. - Las migraciones ligeras manejan: agregar/renombrar/eliminar entidades, atributos y relaciones; cambiar tipos de relación; declarar
@Attribute(originalName:)para rastrear renombrados; especificar reglas de eliminación. La mayoría de los cambios de esquema encajan aquí. - Las migraciones personalizadas (
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)) manejan transformaciones de datos: dividir una columna en dos, calcular campos derivados, mover datos entre modelos.willMigratetiene el contexto antiguo;didMigratetiene el contexto nuevo. - iOS 26 agrega herencia de clases para tipos
@Model2. Los esquemas que adoptan herencia suben a una nueva versión con una etapa ligera desde la versión previa de modelos planos.
El modelo de tres piezas
Una migración de SwiftData se compone de tres piezas.
VersionedSchema
Una instantánea de los tipos de modelo en una versión específica del esquema1. El protocolo requiere:
static var versionIdentifier: Schema.Version. Una tripleta de versión semántica (Schema.Version(1, 0, 0)).static var models: [any PersistentModel.Type]. El arreglo de tipos@Modelen esta versión.
enum SchemaV1: VersionedSchema {
static let versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Item.self]
}
@Model
final class Item {
var name: String
var createdAt: Date
init(name: String, createdAt: Date) {
self.name = name
self.createdAt = createdAt
}
}
}
El patrón de enum con tipos anidados es la convención. Cada VersionedSchema aísla sus clases de modelo en su propio espacio de nombres, de modo que múltiples esquemas con el mismo nombre de modelo puedan coexistir en el código durante una migración.
MigrationStage
Una única transición entre dos tipos VersionedSchema3. Dos casos:
.lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type). Declara una transición que SwiftData maneja sin código de la app. Los parámetros son los tiposVersionedSchemamismos (por ejemplo,SchemaV1.self), no valoresSchema.Versioncrudos..custom(fromVersion:toVersion:willMigrate:didMigrate:). Declara una transición con código que se ejecuta antes y/o después de la migración de datos. Mismos tipos de parámetros que.lightweightpara los argumentos de versión.
SchemaMigrationPlan
La lista ordenada de etapas que lleva el esquema desde cualquier versión previa hasta la actual1.
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self, SchemaV3.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
static let migrateV2toV3 = MigrationStage.custom(
fromVersion: SchemaV2.self,
toVersion: SchemaV3.self,
willMigrate: { context in
// Pre-migration: read old data, prepare it
try context.save()
},
didMigrate: { context in
// Post-migration: backfill new fields
let descriptor = FetchDescriptor<SchemaV3.Item>()
let items = try context.fetch(descriptor)
for item in items {
item.computedField = computeFromExisting(item)
}
try context.save()
}
)
}
El ModelContainer se configura tanto con el esquema actual como con el plan de migración:
let container = try ModelContainer(
for: SchemaV3.Item.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration(...)
)
SwiftData lee la versión actual del esquema del store persistente al crear el contenedor, recorre las etapas del plan desde esa versión hacia adelante hasta la actual y aplica cada etapa en orden.
Qué manejan automáticamente las migraciones ligeras
La mayoría de los cambios de esquema no requieren una etapa personalizada1:
- Agregar un atributo con un valor predeterminado.
var foo: Bool = falseen un@Modelexistente es automático. - Agregar una nueva entidad (clase de modelo). Los nuevos tipos aparecen cuando su
VersionedSchemaes el actual; los datos existentes se preservan. - Eliminar un atributo o entidad. SwiftData elimina la columna o la tabla.
- Renombrar un atributo o entidad. Agrega
@Attribute(originalName: "oldName")a la propiedad para preservar los datos; SwiftData mapea el viejo al nuevo. - Cambiar un tipo de relación. Uno-a-muchos, muchos-a-muchos, etc.
- Especificar reglas de eliminación.
@Relationship(deleteRule: .cascade)y adiciones similares son ligeras.
Para los cambios de esta lista, el patrón correcto es no declarar un nuevo VersionedSchema en absoluto si los tipos del modelo no han cambiado de otra forma. SwiftData realiza la migración ligera automáticamente sobre el esquema existente.
La trampa: agregar un campo no requiere V2
El error de migración de SwiftData más común: un desarrollador agrega una nueva propiedad con un valor predeterminado en línea (var foo: Bool = false) y luego declara un SchemaV2 que referencia los mismos tipos de modelo que SchemaV1. La compilación es limpia. El primer arranque en un dispositivo con datos V1 existentes se cae con Duplicate version checksums across stages detected, porque tanto SchemaV1 como SchemaV2 se resuelven al mismo checksum (los tipos de modelo no cambiaron de una manera que SwiftData detecte como diferente).
El patrón correcto: deja en paz el VersionedSchema existente, agrega la nueva propiedad al modelo con un valor predeterminado en línea y deja que la migración ligera automática de SwiftData se encargue. Sin MigrationPlan, sin MigrationStage, sin V2.
// V1 schema
enum SchemaV1: VersionedSchema {
@Model
final class Item {
var name: String
// BEFORE: just these two properties
var createdAt: Date
// AFTER: add a third with inline default
var isFavorite: Bool = false // Lightweight, automatic
}
}
El cambio var isFavorite: Bool = false se publica sin ninguna declaración de MigrationStage. El inicializador del ModelContainer que no pasa migrationPlan: funciona:
let container = try ModelContainer(
for: SchemaV1.Item.self,
configurations: ModelConfiguration(...)
)
El esquema V2 solo se requiere cuando un cambio no puede ser ligero (una transformación de datos, una división de modelo, una reestructuración con herencia que requiere lógica personalizada). En esos casos, V2 es real y un SchemaMigrationPlan orquesta la transición.
Cuándo se requieren migraciones personalizadas
Las migraciones personalizadas justifican su complejidad en tres casos:
1. Dividir un campo en varios. Un campo String que contiene "Last, First" se convierte en dos campos, firstName y lastName. La migración necesita leer el valor antiguo, parsearlo y escribir los nuevos campos.
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
let descriptor = FetchDescriptor<SchemaV2.Person>()
let people = try context.fetch(descriptor)
for person in people {
let parts = person.fullName.split(separator: ", ", maxSplits: 1)
person.lastName = String(parts.first ?? "")
person.firstName = String(parts.dropFirst().first ?? "")
}
try context.save()
}
)
El closure didMigrate se ejecuta contra el contexto del nuevo esquema, así que los nuevos campos son accesibles. El antiguo fullName puede necesitar diferirse para su eliminación hasta después de que los nuevos campos estén poblados; la limpieza es una etapa de seguimiento V2-a-V3.
2. Calcular campos derivados. Un nuevo @Attribute que depende de datos existentes necesita ser rellenado en el momento de la migración.
3. Mover datos entre modelos. Una reorganización donde los datos de Item se dividen entre Item y un nuevo modelo Tag requiere lógica personalizada para asignar etiquetas desde los datos antiguos.
El patrón general: ligera cuando cambia la forma del esquema; personalizada cuando cambia la forma de los datos.
willMigrate vs. didMigrate
Las etapas personalizadas tienen dos closures, llamados en momentos distintos4:
willMigrate se ejecuta antes de que SwiftData aplique la migración del esquema. El contexto del modelo que recibe el closure es el del esquema antiguo. Úsalo para capturar datos, desnormalizarlos o preparar estado auxiliar antes de que el esquema cambie por debajo.
didMigrate se ejecuta después de la migración del esquema. El contexto del modelo es el del esquema nuevo. Úsalo para rellenar campos nuevos, calcular datos derivados o finalizar la migración.
Cualquiera de los closures puede ser nil si no se necesita. La mayoría de las migraciones personalizadas usan solo didMigrate; willMigrate es útil cuando la migración necesita leer datos antiguos que no serán accesibles después de que el esquema cambie.
El closure recibe un ModelContext y puede hacer fetch, modificar y guardar. El closure es throwing; los errores se propagan fuera de la migración y la abortan.
iOS 26: herencia de clases para @Model
iOS 26 introduce herencia de clases para los modelos de SwiftData2. Los modelos ahora pueden tener relaciones padre-hijo:
@Model
class Vehicle {
var make: String
var year: Int
init(make: String, year: Int) {
self.make = make
self.year = year
}
}
@Model
final class Car: Vehicle {
var doorCount: Int
init(make: String, year: Int, doorCount: Int) {
self.doorCount = doorCount
super.init(make: make, year: year)
}
}
Los esquemas que adoptan herencia suben a una nueva versión con una etapa de migración ligera desde la versión previa de modelos planos. La transición es automática si la herencia preserva las propiedades existentes; los nuevos campos en la subclase siguen el patrón estándar de valor predeterminado en línea.
El patrón encaja en casos donde múltiples tipos @Model comparten características: un padre Vehicle con hijos Car, Truck, Motorcycle; un padre Account con hijos CheckingAccount, SavingsAccount. Las propiedades compartidas viven en el padre; las específicas viven en los hijos.
Probar migraciones
Una migración que compila no es una migración que se publica. Tres patrones de prueba que vale la pena ejecutar antes de lanzar:
1. Prueba de ida y vuelta sobre una copia de la base de datos de producción. Toma una base de datos reciente con forma de producción (o genera datos sintéticos V1 mediante pruebas), ábrela con el contenedor consciente de V2 y verifica que los datos migran correctamente. La prueba detecta bugs de migración personalizada que el verificador de tipos no puede ver.
2. La versión antigua aún se inicia. Compila la versión anterior de la app, ejecútala una vez para producir datos V1, luego compila la nueva versión y verifica que se inicie sin caerse. La prueba detecta la trampa de “Duplicate version checksums” y errores de declaración similares.
3. Recuperación ante migración fallida. ¿Qué pasa si la migración lanza una excepción? El comportamiento de SwiftData depende de la configuración del contenedor; para apps en producción, un error de migración no manejado no debería borrar silenciosamente los datos del usuario. Prueba la ruta de fallo explícitamente y decide qué hace la app (rollback, prompt al usuario, recuperación desde respaldo).
El post sobre Single Source of Truth del cluster cubre la pregunta relacionada de qué pasa cuando un store de SwiftData es reemplazado mediante sincronización entre procesos. Las migraciones son el análogo de evolución local de ese patrón.
Modos de fallo comunes
Tres patrones de los registros de fallos de SwiftData:
Declarar V2 para un cambio que SwiftData manejaría automáticamente. El crash de “Duplicate version checksums”. Solución: no declares un nuevo esquema para adiciones de propiedades con valor predeterminado en línea; deja que SwiftData las maneje automáticamente.
Código de migración personalizada que no guarda. Un closure didMigrate que modifica entidades pero no llama a context.save() produce una migración que se ejecuta una vez, descarta su trabajo y se vuelve a ejecutar en cada arranque (porque la migración aparenta estar inacabada). Solución: cada closure que modifica datos debe hacer try context.save() antes de retornar.
Renombrar una propiedad sin @Attribute(originalName:). SwiftData trata la nueva propiedad como nueva y la antigua como eliminada; los datos existentes en la propiedad antigua se descartan. Solución: declara @Attribute(originalName: "oldName") var newName: ... para que SwiftData mapee los datos a través del renombrado.
Qué significa este patrón para las apps en iOS 26+
Tres conclusiones.
-
Por defecto, sin escalera de
VersionedSchema. Agregar propiedades con valores predeterminados en línea, eliminar campos no usados, renombrar con@Attribute(originalName:). Todo ligero y automático. La escalera deVersionedSchemaes para cambios que SwiftData genuinamente no puede manejar automáticamente (transformaciones de datos, lógica personalizada, reestructuraciones por herencia). -
Usa
MigrationStage.custompara transformaciones de datos, no para cambios de forma del esquema. Los closureswillMigrateydidMigrateson para código que opera sobre datos, no para declarar que el esquema ha cambiado. Los cambios de forma del esquema fluyen a través de etapas ligeras. -
Prueba migraciones con datos V1 reales, no solo con datos de prueba sintéticos. Las migraciones que pasan en ida y vuelta sintéticos pueden fallar igualmente con datos de forma de producción que tienen casos límite (campos nullables que el esquema no cubría, datasets grandes que llegan al timeout, etc.). El costo de probar es pequeño; el costo de un crash de migración en el primer arranque es real.
El cluster completo del Apple Ecosystem: App Intents tipados; servidores MCP; la pregunta de routing; Foundation Models; la distinción runtime vs. tooling de LLM; tres superficies; el patrón single source of truth; Dos servidores MCP; hooks para desarrollo en Apple; Live Activities; el contrato de runtime de watchOS; internals de SwiftUI; el modelo mental espacial de RealityKit; disciplina de esquema en SwiftData; patrones de Liquid Glass; shipping multiplataforma; la matriz de plataformas; el framework Vision; vocabulario de Symbol Effects; inferencia con Core ML; Writing Tools API; Swift Testing; Privacy Manifest; Accesibilidad como plataforma; tipografía SF Pro; patrones espaciales en visionOS; el framework Speech; sobre qué me niego a escribir. El hub está en la Apple Ecosystem Series. Para un contexto más amplio sobre iOS con agentes de IA, consulta la guía de iOS Agent Development.
FAQ
¿Siempre necesito un SchemaMigrationPlan?
No. Las apps con una sola versión de esquema (la versión inicial, o apps que solo han hecho cambios ligeros) no necesitan un SchemaMigrationPlan. El inicializador del ModelContainer acepta los modelos del esquema directamente. El parámetro migrationPlan: se vuelve necesario la primera vez que se declara una etapa de migración personalizada (o la primera vez que el desarrollador quiere declarar una escalera de versiones explícita).
¿Cómo sé si mi cambio es ligero?
La lista elegible para migraciones ligeras de Apple1: agregar entidades/atributos/relaciones, eliminarlos, renombrar con @Attribute(originalName:), cambiar la cardinalidad de relaciones, especificar reglas de eliminación. Si el cambio encaja en uno de estos y la estructura de la clase de modelo está por lo demás sin cambios, la migración es automática y no se requiere una escalera de VersionedSchema. Si el cambio requiere transformación de datos (calcular, dividir, mover datos), es personalizada.
¿Pueden establecerse willMigrate y didMigrate a la vez?
Sí. Ambos closures son opcionales individualmente, pero ambos pueden proveerse. willMigrate se ejecuta contra el contexto del esquema antiguo antes de que SwiftData migre; didMigrate se ejecuta contra el contexto del esquema nuevo después. Los dos cubren preparación y finalización respectivamente.
¿Qué pasa si una migración lanza un error?
El error se propaga fuera de la inicialización del ModelContainer. El contenedor falla al abrirse. El comportamiento de la app depende de cómo el desarrollador maneje el error: algunas apps muestran una UI de recuperación, otras intentan restaurar desde un respaldo, otras eliminan el store corrupto y comienzan desde cero. SwiftData no elimina silenciosamente los datos del usuario en caso de fallo de migración; el fallo lo maneja la app.
¿Cómo pruebo una migración sin afectar los datos de producción?
Crea un target de prueba que cree un ModelContainer apuntado a una URL de archivo temporal, lo pueble con datos V1 y luego lo abra con el nuevo contenedor que incluye el plan de migración. Verifica que los datos migrados coincidan con lo esperado. El patrón funciona tanto en pruebas unitarias como de integración; para los resultados más realistas, usa una copia de una base de datos real con forma de producción.
¿La herencia de clases de iOS 26 funciona con esquemas existentes?
Sí, con una migración ligera. Las apps que adoptan herencia suben a una nueva versión de esquema (por ejemplo, V4) y declaran un MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self). Las propiedades de la clase padre plana permanecen, y las propiedades específicas de la subclase se agregan con valores predeterminados en línea. La migración ligera de SwiftData maneja el cambio estructural.
Referencias
-
Documentación para desarrolladores de Apple: referencias de los protocolos
VersionedSchemaySchemaMigrationPlan. El modelo de migración. Consulta también la guía relacionada Adopting SwiftData for a Core Data app para la narrativa completa de evolución del esquema. ↩↩↩↩↩↩ -
Apple Developer: SwiftData: Dive into inheritance and schema migration (sesión 291 de WWDC 2025). La introducción de la herencia de clases en SwiftData en iOS 26. ↩↩
-
Documentación para desarrolladores de Apple:
MigrationStagecon los casos.lightweight(fromVersion:toVersion:)y.custom(fromVersion:toVersion:willMigrate:didMigrate:). ↩ -
Documentación para desarrolladores de Apple:
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)para la firma del caso. La semántica de “willMigrate se ejecuta contra el contexto antiguo” y “didMigrate se ejecuta contra el contexto nuevo” está documentada en la sesión 291 de WWDC 2025 SwiftData: Dive into inheritance and schema migration, la misma sesión referenciada para la adición de herencia en iOS 26. ↩