Migraciones de SwiftData: lightweight vs. personalizadas, y cuándo no necesitas un V2
El enfoque de SwiftData para la migración de esquemas es una mejora estructural frente al 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 el mensaje “Duplicate version checksums across stages detected”, aunque el código se viera correcto y compilara sin errores. El modelo de migración real del framework usa tres piezas (VersionedSchema, MigrationStage, SchemaMigrationPlan) y tres tipos de migración (automática lightweight, lightweight declarada, personalizada)1. La mayoría de los cambios de esquema son automáticos. Algunos necesitan una etapa lightweight declarada. Una pequeña minoría necesita una etapa personalizada con los closures willMigrate y didMigrate.
Este artículo recorre el modelo de migración a la luz de la documentación de Apple, nombra los casos que maneja cada tipo de migración y cubre el nuevo soporte para herencia de clases de iOS 26. El marco mental es “qué declaro yo frente a qué maneja SwiftData por mí”, porque esa decisión determina si la migración se publica limpiamente o se cae en el primer lanzamiento.
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 de fromVersion a toVersion con los casos.lightweighto.custom) ySchemaMigrationPlan(la 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 lightweight. Declarar un V2 para ello produce crashes de tipo “Duplicate version checksums across stages detected”. - Las migraciones lightweight manejan: agregar, renombrar y 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 los tipos
@Model2. Los esquemas que adoptan la herencia suben a una nueva versión con una etapa lightweight a partir de la versión anterior de modelo plano.
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 de esquema específica1. El protocolo requiere:
static var versionIdentifier: Schema.Version. Una terna 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 asigna un espacio de nombres a sus clases de modelo, de modo que varios esquemas con el mismo nombre de modelo puedan coexistir en el código base 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 tiposVersionedSchemaen sí mismos (por ejemplo,SchemaV1.self), no valoresSchema.Versionen bruto..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. Los argumentos de versión usan los mismos tipos de parámetro que.lightweight.
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 con el esquema actual y con el plan de migración:
let container = try ModelContainer(
for: SchemaV3.Item.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration(...)
)
SwiftData lee la versión de esquema actual del almacén 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 las migraciones lightweight automáticamente
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 conservan. - Eliminar un atributo o una entidad. SwiftData descarta la columna o la tabla.
- Renombrar un atributo o una entidad. Agrega
@Attribute(originalName: "oldName")a la propiedad para conservar los datos; SwiftData mapea lo antiguo a lo nuevo. - Cambiar el tipo de una relación. De uno a varios, de varios a varios, etc.
- Especificar reglas de eliminación.
@Relationship(deleteRule: .cascade)y adiciones similares son lightweight.
Para los cambios de esta lista, el patrón correcto es no declarar un nuevo VersionedSchema en absoluto si por lo demás los tipos de modelo no cambian. SwiftData realiza la migración lightweight automáticamente contra el esquema existente.
La trampa: agregar un campo no requiere un 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 lanzamiento en un dispositivo con datos V1 existentes se cae con Duplicate version checksums across stages detected, porque tanto SchemaV1 como SchemaV2 resuelven al mismo checksum (los tipos de modelo no cambiaron de una forma que SwiftData perciba como distinta).
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 lightweight automática de SwiftData la maneje. No hace falta MigrationPlan, ni MigrationStage, ni 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 de ModelContainer que no pasa migrationPlan: funciona:
let container = try ModelContainer(
for: SchemaV1.Item.self,
configurations: ModelConfiguration(...)
)
El esquema V2 solo es necesario cuando un cambio no puede ser lightweight (una transformación de datos, una división de modelo, una reestructuración por herencia que requiere lógica personalizada). En esos casos, el V2 es real y un SchemaMigrationPlan orquesta la transición.
Cuándo se requieren las 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 están accesibles. Es posible que el fullName antiguo deba diferirse para su eliminación hasta después de que se hayan poblado los nuevos campos; la limpieza es una etapa V2-a-V3 posterior.
2. Calcular campos derivados. Un nuevo @Attribute que depende de datos existentes necesita rellenarse (backfill) en el momento de la migración.
3. Mover datos entre modelos. Una reorganización en la que los datos de Item se reparten entre Item y un nuevo modelo Tag requiere lógica personalizada para asignar las etiquetas a partir de los datos antiguos.
El patrón general: lightweight 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 de modelo que recibe el closure es el contexto 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 de modelo es el del esquema nuevo. Úsalo para rellenar nuevos campos, calcular datos derivados o finalizar la migración.
Cualquiera de los dos 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 estarán accesibles después de que cambie el esquema.
El closure recibe un ModelContext y puede consultar, 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 la herencia de clases para los modelos de SwiftData2. Ahora los modelos 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 la herencia suben a una nueva versión con una etapa de migración lightweight a partir de la versión anterior de modelo plano. La transición es automática si la herencia conserva 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 varios 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; los detalles específicos viven en los hijos.
Probar las migraciones
Una migración que compila no es una migración que se puede publicar. Tres patrones de prueba que vale la pena ejecutar antes del lanzamiento:
1. Prueba de ida y vuelta sobre una copia de la base de datos de producción. Toma una base de datos con forma reciente de producción (o genera datos V1 sintéticos mediante pruebas), ábrela con el contenedor compatible con V2 y verifica que los datos migran correctamente. La prueba detecta bugs de migración personalizada que el verificador de tipos no puede.
2. La versión anterior sigue lanzándose. Compila la versión anterior de la app, ejecútala una vez para producir datos V1, luego compila la nueva versión de la app y verifica que se lanza sin caerse. La prueba detecta la trampa de “Duplicate version checksums” y errores de declaración similares.
3. Recuperación ante una 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 explícitamente la ruta de fallo y decide qué hace la app (rollback, solicitar al usuario, recuperar desde una copia de seguridad).
El artículo de Fuente única de la verdad de este cluster cubre la pregunta relacionada de qué pasa cuando un almacén de SwiftData se reemplaza mediante sincronización entre procesos. Las migraciones son el análogo de evolución local de ese patrón.
Publicar migraciones entre procesos y mostrar el progreso
Dos detalles operativos que la documentación no destaca pero que el equipo de SwiftData mencionó en la WWDC 20265: dónde se ejecutan las migraciones cuando una app tiene widgets o extensions, y cómo manejar una UI de progreso cuando una migración se ejecuta.
Un proceso es dueño de la migración. Los widgets y las extensions no obtienen los mismos recursos de runtime que la app principal, así que no pueden realizar una migración de forma segura. La recomendación es mantener el SchemaMigrationPlan1 completamente fuera de los targets del widget y la extension, y nunca migrar desde ellos. Elige un proceso, normalmente la app principal, como dueño de la base de datos. Si un widget abre el contenedor y el almacén en disco está en un esquema sin versión (más antiguo), la apertura da error. Trata ese error como la señal de que se requiere una migración: muestra una UI que pida al usuario abrir la app principal, deja que la app realice la migración y haz que la app escriba la versión de esquema migrada en un UserDefault compartido. El widget lee ese valor la próxima vez y abre el contenedor en la versión a la que la app ya migró. El patrón mantiene a un único escritor a cargo y evita que dos procesos compitan por evolucionar el mismo archivo.
El progreso se calcula a partir del número de etapas, no del tiempo del reloj. SwiftData no expone ninguna API dedicada al progreso de la migración5. Para manejar un indicador de progreso, cuenta el número total de etapas de migración personalizadas en el plan y sobrescribe el handler didMigrate4 por etapa para que cada etapa reporte su posición, “etapa N de M”. El número refleja las etapas completadas, no el tiempo transcurrido, así que la barra avanza en pasos discretos en vez de hacerlo de forma suave. La decisión de diseño que la acompaña es qué muestra la app durante la migración: un simple spinner se lee como un atasco y los usuarios lo rechazan. Mantén la app parcialmente utilizable donde los datos lo permitan, o como mínimo describe qué agrega cada etapa (las nuevas funciones que la migración desbloquea), de modo que la espera se lea como progreso hacia algo en vez de tiempo muerto.
Modos de fallo comunes
Tres patrones de los registros de fallos de SwiftData:
Declarar un 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 lanzamiento (porque la migración parece no haberse terminado). Solución: todo closure que modifique datos debe llamar a 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 de iOS 26+
Tres conclusiones.
-
Por defecto, sin una escalera de
VersionedSchema. Agregar propiedades con valores predeterminados en línea, eliminar campos no usados, renombrar con@Attribute(originalName:). Todo lightweight y automático. La escalera deVersionedSchemaes para los cambios que SwiftData genuinamente no puede manejar de forma automática (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 los datos, no para declarar que el esquema cambió. Los cambios de forma del esquema fluyen a través de etapas lightweight. -
Prueba las migraciones con datos V1 reales, no solo con datos de prueba sintéticos. Las migraciones que pasan en idas y vueltas sintéticas aún pueden fallar con datos con forma de producción y casos límite (campos nulables que el esquema no cubrió, conjuntos de datos grandes que alcanzan el timeout, etc.). El costo de probar es pequeño; el costo de un crash de migración en el primer lanzamiento es real.
El cluster completo de Apple Ecosystem: App Intents tipados; servidores MCP; la pregunta de enrutamiento; Foundation Models; la distinción entre LLM de runtime y de tooling; tres superficies; el patrón de fuente única de la verdad; dos servidores MCP; hooks para desarrollo en Apple; Live Activities; el runtime de watchOS; los internos de SwiftUI; el modelo mental espacial de RealityKit; la disciplina de esquemas de SwiftData; los patrones de Liquid Glass; la publicación multiplataforma; la matriz de plataformas; el framework Vision; Symbol Effects; la inferencia con Core ML; la API de Writing Tools; Swift Testing; el Privacy Manifest; la accesibilidad como plataforma; la tipografía de SF Pro; los patrones espaciales de visionOS; el framework Speech; sobre qué me niego a escribir. El hub está en la serie Apple Ecosystem. Para un contexto más amplio de iOS con agentes de IA, consulta la guía de desarrollo de agentes en iOS.
Preguntas frecuentes
¿Siempre necesito un SchemaMigrationPlan?
No. Las apps con una sola versión de esquema (el lanzamiento inicial, o apps que solo han hecho cambios lightweight) no necesitan un SchemaMigrationPlan. El inicializador de 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 lightweight?
La lista de cambios elegibles para lightweight de Apple1: agregar entidades, atributos o relaciones; eliminarlos; renombrar con @Attribute(originalName:); cambiar la cardinalidad de una relación; especificar reglas de eliminación. Si el cambio encaja en uno de estos y la estructura de la clase de modelo no cambia por lo demás, la migración es automática y no se requiere una escalera de VersionedSchema. Si el cambio requiere transformación de datos (calcular, dividir o mover datos), es personalizada.
¿Se pueden establecer tanto willMigrate como didMigrate?
Sí. Ambos closures son opcionales por separado, pero los dos pueden proporcionarse. 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, respectivamente, la preparación y la finalización.
¿Qué pasa si una migración lanza un error?
El error se propaga fuera de la inicialización del ModelContainer. El contenedor no logra 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 una copia de seguridad y otras borran el almacén corrupto y empiezan de cero. SwiftData no borra silenciosamente los datos del usuario ante un fallo de migración; el fallo le corresponde manejarlo a la app.
¿Cómo pruebo una migración sin afectar los datos de producción?
Crea un target de prueba que arme un ModelContainer apuntando 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 obtener 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 lightweight. Las apps que adoptan la 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 lightweight 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 de esquemas. ↩↩↩↩↩↩↩↩ -
Apple Developer: SwiftData: Dive into inheritance and schema migration (sesión 291 de la WWDC 2025). La introducción de la herencia de clases de 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 que willMigrate se ejecuta contra el contexto antiguo y didMigrate contra el contexto nuevo está documentada en la sesión 291 de la WWDC 2025 SwiftData: Dive into inheritance and schema migration, la misma sesión referenciada para la adición de la herencia en iOS 26. ↩↩↩ -
WWDC 2026 SwiftData Group Lab (sesión 8017). Parafraseado a partir de una grabación transcrita localmente del WWDC 2026 SwiftData Group Lab; Apple no publica subtítulos oficiales para los labs. El control de la migración para widgets y extensions (un proceso es dueño de la migración, la ruta de error es la señal de migración, la versión migrada se almacena en un
UserDefault) y la técnica de progreso por número de etapas (sobrescribir el handlerdidMigratepor etapa para reportar la etapa N de M, dado que no existe una API de progreso dedicada) fueron descritos por el panel de ingeniería de SwiftData. Los símbolosSchemaMigrationPlanydidMigratedeMigrationStage.customestán confirmados contra la documentación para desarrolladores de Apple citada en 1 y 4; la ausencia de una API de progreso dedicada refleja el propio marco del panel durante el lab. ↩↩