SwiftData vs Core Data en un proyecto iOS nuevo: cuándo migrar y cuándo todavía no
Cada vez que arrancamos un proyecto iOS nuevo con persistencia local, la primera pregunta técnica que resolvemos ya no es "¿usamos Core Data?" sino "¿usamos SwiftData o Core Data?". SwiftData llegó como una capa moderna sobre el mismo motor de persistencia que Core Data usa desde hace años, con una sintaxis declarativa pensada para convivir con SwiftUI. La promesa es menos código repetitivo: sin NSManagedObject, sin NSFetchRequest, sin configurar un NSPersistentContainer a mano. En la práctica, esa promesa se cumple para una parte de los proyectos y se queda corta para otra, y la diferencia no es de gustos sino de qué tan lejos necesitamos llegar con el modelo de datos.
Qué gana un proyecto nuevo con SwiftData
El cambio más visible es la definición del modelo. En Core Data, un modelo de datos vive en un archivo .xcdatamodeld editado con una herramienta gráfica separada del código, y cada entidad necesita una subclase de NSManagedObject generada o escrita a mano. En SwiftData, el modelo es una clase Swift normal con el macro @Model:
import SwiftData
@Model
final class Tarea {
var titulo: String
var completada: Bool
var fechaLimite: Date?
init(titulo: String, completada: Bool = false, fechaLimite: Date? = nil) {
self.titulo = titulo
self.completada = completada
self.fechaLimite = fechaLimite
}
}
No hay archivo de modelo separado que mantener sincronizado con el código, y las relaciones entre entidades se declaran con propiedades normales de Swift en vez de configurarse en el editor gráfico. Las consultas también cambian de forma: en vez de construir un NSFetchRequest con predicados basados en strings, SwiftData usa el macro #Predicate, que valida las expresiones en tiempo de compilación:
let limite = Date()
let descriptor = FetchDescriptor<Tarea>(
predicate: #Predicate { $0.completada == false && $0.fechaLimite ?? .distantFuture < limite }
)
let vencidas = try contexto.fetch(descriptor)
Un predicado de Core Data escrito como string ("completada == NO AND fechaLimite < %@") solo falla en tiempo de ejecución si el nombre de un campo cambió y nadie actualizó el string. Con #Predicate, ese mismo error lo detecta el compilador. Para un equipo que arranca un proyecto desde cero, sin datos heredados ni un modelo Core Data ya en producción, esto elimina una categoría entera de bugs que antes solo aparecían al ejecutar la app.
La integración con SwiftUI también es más directa. El property wrapper @Query reemplaza al NSFetchedResultsController para mantener una vista sincronizada con los cambios del store:
struct ListaTareas: View {
@Query(sort: \Tarea.fechaLimite) private var tareas: [Tarea]
var body: some View {
List(tareas) { tarea in
Text(tarea.titulo)
}
}
}
Con Core Data, lograr el mismo resultado exige envolver un NSFetchedResultsController en un ObservableObject propio, código que en SwiftData simplemente no hace falta escribir.
Dónde SwiftData todavía se queda corto
El punto donde SwiftData pierde frente a Core Data es el control fino sobre migraciones de esquema. Core Data separa explícitamente los modelos versionados: se define un .xcdatamodeld por versión, un NSMappingModel que describe cómo pasar de una versión a la siguiente, y se puede escribir lógica de migración personalizada campo por campo cuando un cambio no es trivial, como dividir un campo de texto libre en dos campos estructurados. SwiftData sí soporta migraciones, con SchemaMigrationPlan y migraciones ligeras automáticas para cambios simples, pero cuando la migración necesita transformar datos de una forma que no es un mapeo directo, el margen de control expuesto es menor, y la documentación de casos no triviales es todavía más escasa que la de Core Data, que lleva más de una década resolviendo estos mismos problemas.
Otro punto de fricción es el trabajo con múltiples configuraciones de almacenamiento sobre el mismo modelo, algo que Core Data resuelve con distintos NSPersistentStoreDescription apuntando a la misma NSManagedObjectModel: por ejemplo, un store cifrado para datos sensibles y otro sin cifrar para caché descartable, coexistiendo bajo el mismo NSPersistentContainer. En SwiftData esa flexibilidad existe de forma más limitada, y forzarla suele terminar pareciéndose a rodear el framework en vez de usarlo como está pensado.
Por último, SwiftData depende de una versión mínima de iOS más alta que Core Data. Un proyecto que todavía necesita dar soporte a versiones de iOS anteriores a las que introdujeron SwiftData no tiene esta decisión disponible: la respuesta ya viene dada por el rango de dispositivos que el cliente necesita cubrir.
En qué tipo de proyecto seguimos prefiriendo Core Data
Elegimos Core Data cuando el proyecto hereda un modelo de datos ya existente con historial de migraciones en producción, porque reescribir ese modelo en SwiftData significaría además migrar a todos los usuarios actuales, con el riesgo que eso implica, sin una ganancia clara a cambio. También lo elegimos cuando el rango de versiones de iOS a soportar excluye SwiftData, o cuando el proyecto necesita varias configuraciones de almacenamiento con reglas de cifrado distintas conviviendo en la misma app, un patrón que hemos usado en apps que separan datos sensibles del resto de la caché local.
Para un proyecto nuevo sin ese historial, con un modelo de datos que no anticipamos que necesite migraciones complejas en el corto plazo y con SwiftUI como capa de interfaz, SwiftData nos ahorra código real sin que hayamos encontrado todavía una limitación que nos obligue a dar marcha atrás.
Lo que no funcionó: mezclar los dos frameworks en el mismo proyecto
En un proyecto probamos usar SwiftData para las entidades nuevas y mantener Core Data para el modelo heredado, en paralelo, con la idea de migrar entidad por entidad con el tiempo. SwiftData puede leer un store SQLite existente, y en teoría permite coexistir con un NSPersistentContainer apuntando al mismo archivo. En la práctica, mantener dos capas de persistencia activas sobre el mismo store duplicó la superficie de errores de sincronización: cambios hechos desde el lado de Core Data no siempre eran visibles de inmediato para los @Query del lado de SwiftData, y depurar cuál de los dos frameworks tenía la versión "correcta" de un objeto en un momento dado costaba más tiempo del que ahorraba tener el código nuevo en SwiftData. Terminamos revirtiendo esa entidad a Core Data y dejando la coexistencia solo para el caso de migrar el modelo completo de una vez, no para mantener los dos frameworks activos de forma permanente.
La pregunta que usamos para decidir
Antes de escribir el modelo de datos de un proyecto nuevo, preguntamos tres cosas: si hay un modelo heredado que migrar, si el rango de iOS a soportar incluye SwiftData, y si el proyecto necesita configuraciones de almacenamiento múltiples o migraciones de esquema con transformación de datos no trivial. Si las tres respuestas son favorables a SwiftData, arrancamos ahí. Si cualquiera no lo es, seguimos con Core Data, no porque sea la opción más nueva, sino porque en ese proyecto específico es la que da más control sobre el punto donde sabemos que vamos a necesitarlo.