Introducción: ¿Qué es SwiftData y por qué debería importarte?
Cuando Apple presentó SwiftData con iOS 17, muchos desarrolladores (yo incluido) levantamos una ceja. ¿Otro framework de persistencia? ¿De verdad necesitábamos uno más? Pues resulta que sí. SwiftData no es simplemente una capa bonita encima de Core Data — es una reimaginación completa del sistema de persistencia, diseñada desde cero para integrarse de manera nativa con SwiftUI y aprovechar al máximo las macros de Swift 5.9 y posteriores.
Si alguna vez trabajaste con Core Data, ya conoces el dolor: archivos .xcdatamodeld con editores gráficos que a veces se corrompen, subclases de NSManagedObject con propiedades @NSManaged, predicados basados en cadenas de texto que el compilador ni se molesta en verificar, y toda esa ceremonia de configuración con NSPersistentContainer. Honestamente, era mucho boilerplate.
SwiftData elimina todo eso. Defines modelos con clases Swift normales decoradas con @Model, haces consultas con #Predicate verificado en tiempo de compilación, y configuras el contenedor de datos con una sola línea de código. Así de simple.
Con cada iteración — iOS 17, iOS 18 y ahora iOS 26 — SwiftData ha madurado bastante. Las novedades de iOS 26 incluyen herencia de modelos, mejoras en el historial de cambios y optimizaciones de rendimiento que lo convierten en una opción sólida para producción. En esta guía vamos a cubrir SwiftData de principio a fin, usando como ejemplo práctico una app de gestión de viajes llamada ViajesApp.
Configuración Inicial
La configuración de SwiftData en tu proyecto es notablemente sencilla comparada con Core Data. No necesitas crear ningún archivo de modelo de datos ni configurar un stack de persistencia complejo. Todo se hace en código Swift puro, que ya es un gran alivio.
Los componentes fundamentales
SwiftData se construye sobre cuatro pilares esenciales:
- @Model — La macro que transforma una clase Swift en un modelo persistente, generando automáticamente la conformidad con
PersistentModely la observabilidad de propiedades. - ModelContainer — El contenedor responsable de la configuración del almacenamiento: dónde se guardan los datos, qué esquema se utiliza y si el almacén es en memoria o en disco.
- ModelContext — El contexto donde se realizan todas las operaciones CRUD. Rastrea cambios y los persiste en el almacén.
- @Query — El property wrapper que reemplaza a
@FetchRequestde Core Data, proporcionando consultas declarativas y reactivas en SwiftUI.
Configurando el ModelContainer en tu App
La forma más común de configurar SwiftData es aplicar el modificador .modelContainer en el punto de entrada de tu aplicación. Esto crea el contenedor y lo inyecta automáticamente en el entorno de SwiftUI, junto con un ModelContext principal.
import SwiftUI
import SwiftData
@main
struct ViajesApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
// Configura el contenedor con todos los modelos de la app
.modelContainer(for: [Viaje.self, Destino.self, Actividad.self])
}
}
¿Ves? Una sola línea. Si vienes de Core Data, probablemente estés sonriendo ahora mismo.
Ahora, si necesitas más control sobre la configuración — por ejemplo, para usar almacenamiento en memoria durante pruebas o habilitar CloudKit — puedes crear el contenedor manualmente:
import SwiftUI
import SwiftData
@main
struct ViajesApp: App {
let container: ModelContainer
init() {
let schema = Schema([Viaje.self, Destino.self, Actividad.self])
let configuracion = ModelConfiguration(
"ViajesDB",
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: true,
cloudKitDatabase: .automatic
)
do {
container = try ModelContainer(
for: schema,
configurations: [configuracion]
)
} catch {
fatalError("No se pudo crear el ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
Una vez configurado el contenedor, cualquier vista dentro de la jerarquía puede acceder al contexto mediante el entorno:
struct ContentView: View {
// Acceso al contexto de datos desde el entorno
@Environment(\.modelContext) private var contexto
var body: some View {
NavigationStack {
ListaViajesView()
}
}
}
Modelado de Datos
Aquí es donde SwiftData realmente brilla. Definir modelos es radicalmente más simple que en Core Data. No hay archivos .xcdatamodeld, no hay editor gráfico, no hay generación de subclases. Escribes clases Swift estándar y agregas la macro @Model. Punto.
Definiendo modelos con @Model
Cada modelo es una clase Swift decorada con @Model. La macro genera automáticamente el código necesario para la persistencia, incluyendo conformidad con PersistentModel y Observable. Todos los tipos almacenables de Swift están soportados: String, Int, Double, Bool, Date, Data, URL, UUID, enums con Codable, y structs con Codable.
import SwiftData
import Foundation
// Enum Codable para el estado del viaje
enum EstadoViaje: String, Codable, CaseIterable {
case planificando = "Planificando"
case enCurso = "En curso"
case completado = "Completado"
case cancelado = "Cancelado"
}
@Model
final class Viaje {
// Propiedad única: no puede haber dos viajes con el mismo identificador
@Attribute(.unique) var identificador: UUID
var nombre: String
var descripcion: String
var fechaInicio: Date
var fechaFin: Date
var estado: EstadoViaje
var presupuesto: Double
// Almacenamiento externo para datos grandes como imágenes
@Attribute(.externalStorage) var imagenPortada: Data?
// Relación uno-a-muchos con eliminación en cascada
@Relationship(deleteRule: .cascade, inverse: \Destino.viaje)
var destinos: [Destino] = []
// Propiedad transitoria: no se persiste en el almacén
@Attribute(.ephemeral) var esFavorito: Bool = false
// Propiedad computada: no se almacena
var duracionEnDias: Int {
Calendar.current.dateComponents([.day], from: fechaInicio, to: fechaFin).day ?? 0
}
init(
nombre: String,
descripcion: String = "",
fechaInicio: Date,
fechaFin: Date,
presupuesto: Double = 0
) {
self.identificador = UUID()
self.nombre = nombre
self.descripcion = descripcion
self.fechaInicio = fechaInicio
self.fechaFin = fechaFin
self.estado = .planificando
self.presupuesto = presupuesto
}
}
Fíjate en cómo todo se lee como Swift normal. No hay magia oculta (bueno, la macro hace su magia, pero al menos es magia comprensible).
Opciones de @Attribute
La macro @Attribute ofrece varias opciones para controlar cómo se almacenan las propiedades:
- .unique — Garantiza unicidad. Si insertas un objeto con un valor duplicado, SwiftData realiza un upsert (actualiza el existente en lugar de crear uno nuevo).
- .externalStorage — Almacena los datos en un archivo externo en lugar de directamente en la base de datos SQLite. Ideal para imágenes, archivos PDF u otros blobs grandes.
- .spotlight — Indexa la propiedad para búsquedas en Spotlight, permitiendo que los usuarios encuentren contenido de tu app desde la búsqueda del sistema.
- .ephemeral — Marca la propiedad como transitoria; no se persiste en el almacén.
- .transformable(by:) — Permite usar un
ValueTransformerpersonalizado para tipos que no son directamente almacenables.
Relaciones con @Relationship
Las relaciones entre modelos se definen con @Relationship. SwiftData soporta relaciones uno-a-uno, uno-a-muchos y muchos-a-muchos. Las reglas de eliminación controlan qué sucede con los objetos relacionados cuando se elimina el objeto padre.
@Model
final class Destino {
var nombre: String
var pais: String
var latitud: Double
var longitud: Double
var notas: String
// Relación inversa al viaje
var viaje: Viaje?
// Relación con actividades: si se elimina el destino, se eliminan sus actividades
@Relationship(deleteRule: .cascade, inverse: \Actividad.destino)
var actividades: [Actividad] = []
init(nombre: String, pais: String, latitud: Double = 0, longitud: Double = 0) {
self.nombre = nombre
self.pais = pais
self.latitud = latitud
self.longitud = longitud
self.notas = ""
}
}
@Model
final class Actividad {
var titulo: String
var fecha: Date
var costoEstimado: Double
var completada: Bool
// Relación inversa al destino
var destino: Destino?
init(titulo: String, fecha: Date, costoEstimado: Double = 0) {
self.titulo = titulo
self.fecha = fecha
self.costoEstimado = costoEstimado
self.completada = false
}
}
Las reglas de eliminación disponibles son:
- .cascade — Elimina todos los objetos relacionados cuando se elimina el padre. Es la que usarás más a menudo.
- .nullify — Establece la relación en
nilen los objetos huérfanos (comportamiento por defecto). - .deny — Impide la eliminación si existen objetos relacionados.
- .noAction — No realiza ninguna acción sobre los objetos relacionados. Úsala con cuidado, puede dejar datos inconsistentes.
Operaciones CRUD
Todas las operaciones de datos en SwiftData se realizan a través del ModelContext. El contexto rastrea automáticamente los cambios en los objetos y los persiste cuando es necesario. Vamos a ver cada operación con ejemplos concretos.
Crear (Create)
Para crear un nuevo objeto, simplemente instancias la clase y la insertas en el contexto. Nada de NSEntityDescription ni constructores raros:
struct CrearViajeView: View {
@Environment(\.modelContext) private var contexto
@State private var nombre = ""
@State private var fechaInicio = Date()
@State private var fechaFin = Date().addingTimeInterval(86400 * 7)
@State private var presupuesto = ""
var body: some View {
Form {
TextField("Nombre del viaje", text: $nombre)
DatePicker("Fecha de inicio", selection: $fechaInicio, displayedComponents: .date)
DatePicker("Fecha de fin", selection: $fechaFin, displayedComponents: .date)
TextField("Presupuesto", text: $presupuesto)
.keyboardType(.decimalPad)
Button("Crear Viaje") {
crearViaje()
}
.disabled(nombre.isEmpty)
}
}
private func crearViaje() {
let viaje = Viaje(
nombre: nombre,
fechaInicio: fechaInicio,
fechaFin: fechaFin,
presupuesto: Double(presupuesto) ?? 0
)
// Insertar en el contexto
contexto.insert(viaje)
// Agregar un destino al viaje recién creado
let destino = Destino(nombre: "Barcelona", pais: "España", latitud: 41.3851, longitud: 2.1734)
destino.viaje = viaje
contexto.insert(destino)
// SwiftData guarda automáticamente, pero puedes forzar el guardado
do {
try contexto.save()
} catch {
print("Error al guardar: \(error)")
}
}
}
Leer (Read) con @Query
El property wrapper @Query es la forma declarativa de obtener datos en vistas SwiftUI. Soporta ordenamiento, filtrado y animaciones. Y lo mejor: la vista se actualiza automáticamente cuando los datos cambian.
struct ListaViajesView: View {
// Consulta todos los viajes ordenados por fecha de inicio descendente
@Query(sort: \Viaje.fechaInicio, order: .reverse)
private var viajes: [Viaje]
@Environment(\.modelContext) private var contexto
var body: some View {
List {
ForEach(viajes) { viaje in
NavigationLink(destination: DetalleViajeView(viaje: viaje)) {
VStack(alignment: .leading, spacing: 4) {
Text(viaje.nombre)
.font(.headline)
Text("\(viaje.estado.rawValue) • \(viaje.duracionEnDias) días")
.font(.caption)
.foregroundStyle(.secondary)
Text("Presupuesto: \(viaje.presupuesto, format: .currency(code: "EUR"))")
.font(.caption2)
}
}
}
.onDelete(perform: eliminarViajes)
}
.navigationTitle("Mis Viajes")
}
private func eliminarViajes(en indices: IndexSet) {
for index in indices {
contexto.delete(viajes[index])
}
}
}
Lectura programática con FetchDescriptor
Fuera de las vistas SwiftUI, o cuando necesitas mayor control, puedes usar FetchDescriptor directamente con el contexto. Esto te da mucha más flexibilidad:
// Obtener viajes activos con un presupuesto mayor a 1000 EUR
func obtenerViajesActivos(contexto: ModelContext) throws -> [Viaje] {
let predicado = #Predicate { viaje in
viaje.estado == .enCurso && viaje.presupuesto > 1000
}
var descriptor = FetchDescriptor(
predicate: predicado,
sortBy: [SortDescriptor(\Viaje.fechaInicio, order: .forward)]
)
// Limitar los resultados a los primeros 10
descriptor.fetchLimit = 10
// Incluir solo las propiedades necesarias para optimizar la consulta
descriptor.propertiesToFetch = [\.nombre, \.fechaInicio, \.presupuesto]
return try contexto.fetch(descriptor)
}
// Contar viajes sin obtener los objetos completos
func contarViajesCompletados(contexto: ModelContext) throws -> Int {
let predicado = #Predicate { viaje in
viaje.estado == .completado
}
let descriptor = FetchDescriptor(predicate: predicado)
return try contexto.fetchCount(descriptor)
}
Actualizar (Update)
Actualizar un objeto en SwiftData es, sinceramente, casi demasiado fácil. Como los modelos son clases observables, basta con modificar sus propiedades directamente. El contexto detecta automáticamente los cambios. Sin setValue:forKey:, sin notificaciones manuales.
struct DetalleViajeView: View {
@Bindable var viaje: Viaje
@Environment(\.modelContext) private var contexto
var body: some View {
Form {
Section("Información del viaje") {
TextField("Nombre", text: $viaje.nombre)
DatePicker("Inicio", selection: $viaje.fechaInicio, displayedComponents: .date)
DatePicker("Fin", selection: $viaje.fechaFin, displayedComponents: .date)
}
Section("Estado") {
Picker("Estado", selection: $viaje.estado) {
ForEach(EstadoViaje.allCases, id: \.self) { estado in
Text(estado.rawValue).tag(estado)
}
}
}
Section("Destinos (\(viaje.destinos.count))") {
ForEach(viaje.destinos) { destino in
Text("\(destino.nombre), \(destino.pais)")
}
}
}
.navigationTitle(viaje.nombre)
}
}
Sí, leíste bien. Solo usas $viaje.nombre con @Bindable y SwiftData se encarga del resto. Los cambios se persisten automáticamente.
Eliminar (Delete)
Para eliminar un objeto, usas el método delete() del contexto. Si el modelo tiene relaciones con .cascade, los objetos relacionados se eliminan automáticamente:
// Eliminar un viaje específico
func eliminarViaje(_ viaje: Viaje, contexto: ModelContext) {
contexto.delete(viaje)
// Los destinos y actividades se eliminan automáticamente por la regla .cascade
}
// Eliminar todos los viajes cancelados
func limpiarViajesCancelados(contexto: ModelContext) throws {
try contexto.delete(model: Viaje.self, where: #Predicate { viaje in
viaje.estado == .cancelado
})
}
Filtrado y Búsqueda con #Predicate
Una de las mejoras más significativas de SwiftData sobre Core Data es el macro #Predicate. A diferencia de NSPredicate, que usa cadenas de texto sin verificación del compilador (y cuántos bugs hemos tenido por un typo ahí...), #Predicate es completamente type-safe. Si escribes mal el nombre de una propiedad o usas un tipo incompatible, el compilador te lo dice de inmediato.
Predicados básicos
// Buscar viajes por nombre (contiene texto)
let busqueda = "Europa"
let predicadoBusqueda = #Predicate { viaje in
viaje.nombre.localizedStandardContains(busqueda)
}
// Viajes dentro de un rango de fechas
let hoy = Date()
let enUnMes = Calendar.current.date(byAdding: .month, value: 1, to: hoy)!
let predicadoFechas = #Predicate { viaje in
viaje.fechaInicio >= hoy && viaje.fechaInicio <= enUnMes
}
// Viajes con presupuesto en un rango específico
let minimo: Double = 500
let maximo: Double = 3000
let predicadoPresupuesto = #Predicate { viaje in
viaje.presupuesto >= minimo && viaje.presupuesto <= maximo
}
Predicados complejos y combinados
Puedes construir predicados sofisticados combinando múltiples condiciones. Las variables capturadas desde el ámbito externo se inyectan automáticamente, lo cual está muy bien pensado:
struct BuscadorViajesView: View {
@State private var textoBusqueda = ""
@State private var estadoFiltro: EstadoViaje? = nil
@State private var presupuestoMinimo: Double = 0
// @Query con predicado dinámico
@Query private var viajes: [Viaje]
init() {
// La configuración inicial de @Query se puede hacer en el init
}
var viajesFiltrados: [Viaje] {
viajes.filter { viaje in
let coincideTexto = textoBusqueda.isEmpty ||
viaje.nombre.localizedCaseInsensitiveContains(textoBusqueda)
let coincideEstado = estadoFiltro == nil || viaje.estado == estadoFiltro
let coincidePresupuesto = viaje.presupuesto >= presupuestoMinimo
return coincideTexto && coincideEstado && coincidePresupuesto
}
}
var body: some View {
List(viajesFiltrados) { viaje in
Text(viaje.nombre)
}
.searchable(text: $textoBusqueda, prompt: "Buscar viajes...")
}
}
// Función para construir predicados dinámicamente
func construirPredicado(
texto: String,
estado: EstadoViaje?,
presupuestoMinimo: Double
) -> Predicate {
if let estado {
return #Predicate { viaje in
viaje.nombre.localizedStandardContains(texto) &&
viaje.estado == estado &&
viaje.presupuesto >= presupuestoMinimo
}
} else {
return #Predicate { viaje in
viaje.nombre.localizedStandardContains(texto) &&
viaje.presupuesto >= presupuestoMinimo
}
}
}
Eso sí, hay una limitación importante que vale la pena mencionar: #Predicate no soporta operaciones arbitrarias de Swift dentro del closure. Solo puedes usar operaciones que SwiftData puede traducir a consultas SQLite: comparaciones, operaciones lógicas, métodos de String como contains, hasPrefix y localizedStandardContains, y verificaciones de colecciones básicas. Si necesitas lógica más compleja, tendrás que filtrar en memoria después del fetch.
Optimización con #Index y #Unique
A partir de iOS 18, SwiftData introdujo dos macros bastante potentes para optimizar el rendimiento de las consultas y garantizar la integridad de los datos a nivel compuesto: #Index y #Unique. Si tu app maneja cantidades significativas de datos, estas dos van a ser tus mejores amigas.
Índices con #Index
Los índices mejoran drásticamente el rendimiento de las consultas en propiedades que se usan frecuentemente para filtrar u ordenar. Sin un índice, SwiftData tiene que escanear toda la tabla para encontrar coincidencias. Con un índice, la búsqueda es logarítmica. La diferencia puede ser enorme en tablas con miles de registros.
import SwiftData
@Model
final class Viaje {
@Attribute(.unique) var identificador: UUID
var nombre: String
var fechaInicio: Date
var fechaFin: Date
var estado: EstadoViaje
var presupuesto: Double
@Relationship(deleteRule: .cascade, inverse: \Destino.viaje)
var destinos: [Destino] = []
init(nombre: String, fechaInicio: Date, fechaFin: Date, presupuesto: Double = 0) {
self.identificador = UUID()
self.nombre = nombre
self.fechaInicio = fechaInicio
self.fechaFin = fechaFin
self.estado = .planificando
self.presupuesto = presupuesto
}
}
// Definir índices para consultas frecuentes
extension Viaje {
// Índice simple en una propiedad
static let indiceEstado = #Index([\.estado])
// Índice compuesto para consultas que filtran por estado y ordenan por fecha
static let indiceEstadoFecha = #Index([\.estado, \.fechaInicio])
// Índice para búsquedas por nombre
static let indiceNombre = #Index([\.nombre])
}
Restricciones de unicidad compuesta con #Unique
Mientras que @Attribute(.unique) aplica unicidad a una sola propiedad, #Unique permite definir restricciones de unicidad compuesta — combinaciones de propiedades que deben ser únicas en conjunto. Piénsalo así: puedes tener dos destinos llamados "Centro Histórico", pero no dos con el mismo nombre en el mismo país.
@Model
final class Destino {
var nombre: String
var pais: String
var latitud: Double
var longitud: Double
var notas: String
var viaje: Viaje?
@Relationship(deleteRule: .cascade, inverse: \Actividad.destino)
var actividades: [Actividad] = []
init(nombre: String, pais: String, latitud: Double = 0, longitud: Double = 0) {
self.nombre = nombre
self.pais = pais
self.latitud = latitud
self.longitud = longitud
self.notas = ""
}
}
// Un destino es único por la combinación de nombre y país
extension Destino {
static let unicidadNombrePais = #Unique([\.nombre, \.pais])
}
Cuando intentas insertar un objeto que viola una restricción #Unique, SwiftData realiza automáticamente un upsert: actualiza el registro existente con los nuevos valores en lugar de crear un duplicado. Esto resulta especialmente útil cuando importas datos de un servidor y necesitas manejar duplicados de forma elegante, sin tener que escribir lógica de deduplicación manual.
Herencia de Modelos en iOS 26
Bueno, esto fue una de las novedades más esperadas de la WWDC 2025, y con razón. Hasta iOS 18, todos los modelos de SwiftData debían ser clases final sin herencia. Era una limitación considerable si tu dominio requería jerarquías de tipos. En iOS 26, por fin puedes crear jerarquías de clases con @Model, lo que abre posibilidades enormes para modelar dominios complejos.
Definiendo una jerarquía de modelos
Imaginemos que en nuestra app de viajes queremos modelar diferentes tipos de actividades. Con herencia de modelos, podemos crear una clase base y subclases especializadas:
import SwiftData
// Clase base: Actividad genérica
@Model
class Actividad {
var titulo: String
var fecha: Date
var costoEstimado: Double
var completada: Bool
var destino: Destino?
init(titulo: String, fecha: Date, costoEstimado: Double = 0) {
self.titulo = titulo
self.fecha = fecha
self.costoEstimado = costoEstimado
self.completada = false
}
}
// Subclase: Actividad de tipo restaurante
@Model
final class ActividadRestaurante: Actividad {
var nombreRestaurante: String
var tipoCocina: String
var tieneReserva: Bool
init(
titulo: String,
fecha: Date,
costoEstimado: Double,
nombreRestaurante: String,
tipoCocina: String
) {
self.nombreRestaurante = nombreRestaurante
self.tipoCocina = tipoCocina
self.tieneReserva = false
super.init(titulo: titulo, fecha: fecha, costoEstimado: costoEstimado)
}
}
// Subclase: Actividad de tipo excursión
@Model
final class ActividadExcursion: Actividad {
var duracionHoras: Double
var nivelDificultad: Int // 1-5
var requiereEquipo: Bool
init(
titulo: String,
fecha: Date,
costoEstimado: Double,
duracionHoras: Double,
nivelDificultad: Int
) {
self.duracionHoras = duracionHoras
self.nivelDificultad = nivelDificultad
self.requiereEquipo = false
super.init(titulo: titulo, fecha: fecha, costoEstimado: costoEstimado)
}
}
// Subclase: Actividad de tipo vuelo
@Model
final class ActividadVuelo: Actividad {
var aerolinea: String
var numeroVuelo: String
var aeropuertoOrigen: String
var aeropuertoDestino: String
init(
titulo: String,
fecha: Date,
costoEstimado: Double,
aerolinea: String,
numeroVuelo: String,
origen: String,
destino: String
) {
self.aerolinea = aerolinea
self.numeroVuelo = numeroVuelo
self.aeropuertoOrigen = origen
self.aeropuertoDestino = destino
super.init(titulo: titulo, fecha: fecha, costoEstimado: costoEstimado)
}
}
Consultando con herencia
La herencia de modelos permite consultas polimórficas, que es exactamente lo que esperarías. Puedes consultar la clase base para obtener todas las actividades, o consultar una subclase específica para obtener solo ese tipo:
struct ListaActividadesView: View {
// Consulta todas las actividades (incluyendo subclases)
@Query(sort: \Actividad.fecha) private var todasLasActividades: [Actividad]
// Consulta solo las excursiones
@Query(sort: \ActividadExcursion.fecha) private var excursiones: [ActividadExcursion]
var body: some View {
List {
Section("Todas las actividades") {
ForEach(todasLasActividades) { actividad in
HStack {
// Podemos verificar el tipo concreto
if actividad is ActividadRestaurante {
Image(systemName: "fork.knife")
} else if actividad is ActividadExcursion {
Image(systemName: "figure.hiking")
} else if actividad is ActividadVuelo {
Image(systemName: "airplane")
} else {
Image(systemName: "star")
}
Text(actividad.titulo)
}
}
}
Section("Excursiones (\(excursiones.count))") {
ForEach(excursiones) { excursion in
VStack(alignment: .leading) {
Text(excursion.titulo)
Text("Dificultad: \(excursion.nivelDificultad)/5 • \(excursion.duracionHoras, specifier: "%.1f")h")
.font(.caption)
}
}
}
}
}
}
Ten en cuenta que la herencia de modelos requiere una migración de esquema si actualizas una app existente. Los modelos que antes eran final deben eliminarse de esa restricción antes de ser usados como clases base, y la migración debe manejar la nueva estructura de la tabla en SQLite. No es complicado, pero es un paso que no puedes saltarte.
Historial de Cambios (SwiftData History)
El API de historial de SwiftData, introducido en iOS 18 y mejorado en iOS 26, permite rastrear todos los cambios realizados en tus modelos a lo largo del tiempo. Esto es particularmente útil para sincronización con servidores, auditoría de cambios y funcionalidades de deshacer/rehacer avanzadas.
Habilitando y consultando el historial
import SwiftData
// Configurar el contenedor con historial habilitado
let schema = Schema([Viaje.self, Destino.self, Actividad.self])
let configuracion = ModelConfiguration(schema: schema)
// Obtener las transacciones del historial
func procesarCambiosRecientes(contexto: ModelContext) throws {
// Obtener las transacciones desde el último token procesado
let descriptor = HistoryDescriptor()
let transacciones = try contexto.fetchHistory(descriptor)
for transaccion in transacciones {
// Cada transacción contiene cambios individuales
for cambio in transaccion.changes {
switch cambio {
case let insercion as DefaultHistoryInsert:
// Un viaje fue creado
print("Viaje insertado con ID: \(insercion.modelID)")
case let actualizacion as DefaultHistoryUpdate:
// Un viaje fue actualizado
let propiedadesModificadas = actualizacion.updatedProperties
print("Viaje actualizado. Propiedades cambiadas: \(propiedadesModificadas)")
case let eliminacion as DefaultHistoryDelete:
// Un viaje fue eliminado - acceder a valores tombstone
let valores = eliminacion.tombstoneValues
if let nombre = valores["nombre"] as? String {
print("Viaje eliminado: \(nombre)")
}
default:
break
}
}
}
}
// Obtener el token actual para futuras consultas incrementales
func obtenerTokenActual(contexto: ModelContext) throws -> DefaultHistoryToken? {
let descriptor = HistoryDescriptor()
let transacciones = try contexto.fetchHistory(descriptor)
return transacciones.last?.token
}
Valores tombstone
Cuando un objeto es eliminado, SwiftData puede preservar ciertos valores del objeto eliminado como tombstone values. Esto resulta muy útil para sincronización, donde necesitas saber qué se eliminó y enviar esa información a un servidor. Para habilitarlos, marca las propiedades con @Attribute(.preserveValueOnDeletion):
@Model
final class Viaje {
@Attribute(.unique, .preserveValueOnDeletion) var identificador: UUID
@Attribute(.preserveValueOnDeletion) var nombre: String
var descripcion: String
var fechaInicio: Date
var fechaFin: Date
var estado: EstadoViaje
var presupuesto: Double
// ... resto de la definición
}
Sincronización con CloudKit
SwiftData se integra con CloudKit de manera transparente, permitiendo sincronizar datos entre dispositivos del mismo usuario con mínima configuración. Sin embargo (y esto es importante), hay requisitos y limitaciones que debes conocer antes de lanzarte a implementarlo.
Configuración de CloudKit
Para habilitar la sincronización con CloudKit necesitas:
- Activar la capability de CloudKit en tu target de Xcode.
- Activar la capability de Background Modes con Remote notifications.
- Configurar un contenedor de CloudKit en el portal de desarrolladores de Apple.
- Usar
.cloudKitDatabase(.automatic)en tuModelConfiguration.
Requisitos para modelos compatibles con CloudKit
Los modelos sincronizados con CloudKit tienen restricciones significativas que más de uno ha descubierto de la manera difícil:
- Todas las propiedades deben tener valores por defecto o ser opcionales. CloudKit no garantiza que todos los campos estén disponibles durante la sincronización.
- No puedes usar
@Attribute(.unique)en modelos sincronizados. La unicidad no es compatible con la naturaleza distribuida de CloudKit. - Las relaciones deben ser opcionales. Los objetos relacionados podrían sincronizarse en momentos diferentes.
- Los tipos de propiedades deben ser compatibles con
CKRecord: tipos primitivos,String,Date,Data,URL, y enums/structsCodable.
// Modelo compatible con CloudKit
@Model
final class ViajeSync {
var identificador: UUID = UUID()
var nombre: String = ""
var descripcion: String = ""
var fechaInicio: Date = Date()
var fechaFin: Date = Date()
var estado: EstadoViaje = .planificando
var presupuesto: Double = 0
@Attribute(.externalStorage)
var imagenPortada: Data? = nil
// Relación opcional (requerido para CloudKit)
@Relationship(deleteRule: .cascade)
var destinos: [DestinoSync]? = []
init(nombre: String, fechaInicio: Date, fechaFin: Date) {
self.identificador = UUID()
self.nombre = nombre
self.fechaInicio = fechaInicio
self.fechaFin = fechaFin
}
}
@Model
final class DestinoSync {
var nombre: String = ""
var pais: String = ""
var viaje: ViajeSync? = nil
init(nombre: String, pais: String) {
self.nombre = nombre
self.pais = pais
}
}
// Configuración del contenedor con CloudKit
let schema = Schema([ViajeSync.self, DestinoSync.self])
let configuracion = ModelConfiguration(
schema: schema,
cloudKitDatabase: .automatic
)
let container = try ModelContainer(for: schema, configurations: [configuracion])
Un detalle que vale la pena mencionar: la sincronización con CloudKit solo funciona de forma fiable en dispositivos físicos con una cuenta de iCloud activa. En el simulador es bastante impredecible. Además, la sincronización es eventual — los cambios no se reflejan instantáneamente entre dispositivos, así que diseña tu interfaz para manejar ese retraso con gracia.
Concurrencia con @ModelActor
Las operaciones pesadas de datos — importaciones masivas, transformaciones complejas, sincronización con servidores — nunca deben ejecutarse en el hilo principal. Eso ya lo sabemos todos, pero SwiftData lo hace más fácil que nunca con la macro @ModelActor, que crea actores que operan de forma segura en hilos secundarios con su propio ModelContext.
Creando un actor para operaciones en segundo plano
import SwiftData
@ModelActor
actor GestorDeDatos {
// El ModelActor proporciona automáticamente:
// - modelContainer: ModelContainer
// - modelExecutor: ModelExecutor
// - modelContext: ModelContext (aislado a este actor)
// Importar viajes desde un JSON del servidor
func importarViajes(desde datos: [ViajeDTO]) throws -> Int {
var importados = 0
for dto in datos {
let viaje = Viaje(
nombre: dto.nombre,
fechaInicio: dto.fechaInicio,
fechaFin: dto.fechaFin,
presupuesto: dto.presupuesto
)
modelContext.insert(viaje)
// Insertar destinos asociados
for destinoDTO in dto.destinos {
let destino = Destino(
nombre: destinoDTO.nombre,
pais: destinoDTO.pais,
latitud: destinoDTO.latitud,
longitud: destinoDTO.longitud
)
destino.viaje = viaje
modelContext.insert(destino)
}
importados += 1
// Guardar en lotes para optimizar memoria
if importados % 100 == 0 {
try modelContext.save()
}
}
// Guardar los restantes
try modelContext.save()
return importados
}
// Operación de limpieza en segundo plano
func eliminarViajesAntiguos(anteriorA fecha: Date) throws -> Int {
let predicado = #Predicate { viaje in
viaje.fechaFin < fecha && viaje.estado == .completado
}
let descriptor = FetchDescriptor(predicate: predicado)
let viajesAntiguos = try modelContext.fetch(descriptor)
let cantidad = viajesAntiguos.count
for viaje in viajesAntiguos {
modelContext.delete(viaje)
}
try modelContext.save()
return cantidad
}
// Calcular estadísticas de forma asíncrona
func calcularEstadisticas() throws -> EstadisticasViajes {
let todosLosViajes = try modelContext.fetch(FetchDescriptor())
let totalPresupuesto = todosLosViajes.reduce(0) { $0 + $1.presupuesto }
let completados = todosLosViajes.filter { $0.estado == .completado }.count
let enCurso = todosLosViajes.filter { $0.estado == .enCurso }.count
return EstadisticasViajes(
totalViajes: todosLosViajes.count,
completados: completados,
enCurso: enCurso,
presupuestoTotal: totalPresupuesto,
presupuestoPromedio: todosLosViajes.isEmpty ? 0 : totalPresupuesto / Double(todosLosViajes.count)
)
}
}
// Estructura para los datos transferibles
struct ViajeDTO: Codable, Sendable {
let nombre: String
let fechaInicio: Date
let fechaFin: Date
let presupuesto: Double
let destinos: [DestinoDTO]
}
struct DestinoDTO: Codable, Sendable {
let nombre: String
let pais: String
let latitud: Double
let longitud: Double
}
struct EstadisticasViajes: Sendable {
let totalViajes: Int
let completados: Int
let enCurso: Int
let presupuestoTotal: Double
let presupuestoPromedio: Double
}
Usando el actor desde la interfaz de usuario
struct PanelAdminView: View {
@Environment(\.modelContext) private var contexto
@State private var importando = false
@State private var mensaje = ""
var body: some View {
VStack(spacing: 20) {
Button("Importar viajes desde servidor") {
Task {
await importarDesdeServidor()
}
}
.disabled(importando)
if importando {
ProgressView("Importando...")
}
Text(mensaje)
.foregroundStyle(.secondary)
}
}
private func importarDesdeServidor() async {
importando = true
defer { importando = false }
// Crear el actor con el contenedor de la app
let gestor = GestorDeDatos(modelContainer: contexto.container)
do {
// Simular obtención de datos del servidor
let datosServidor = try await obtenerDatosDelServidor()
let cantidad = try await gestor.importarViajes(desde: datosServidor)
mensaje = "Se importaron \(cantidad) viajes exitosamente"
} catch {
mensaje = "Error al importar: \(error.localizedDescription)"
}
}
private func obtenerDatosDelServidor() async throws -> [ViajeDTO] {
// Aquí iría la llamada de red real
return []
}
}
Un punto crítico que no puedo dejar de enfatizar: los objetos de SwiftData (PersistentModel) no son Sendable y no deben cruzar límites de aislamiento. Si necesitas pasar datos entre el actor y la interfaz de usuario, usa el PersistentIdentifier del modelo y vuelve a obtener el objeto en el contexto correcto, o usa estructuras intermedias como los DTOs mostrados arriba. Ignorar esto te va a dar crashes difíciles de depurar.
Migración de Esquemas
Cuando modificas tus modelos — añades propiedades, cambias tipos, reorganizas relaciones — necesitas una estrategia de migración para que los datos existentes de tus usuarios no se pierdan. Esto no es opcional: si no manejas las migraciones, tu app va a hacer crash en la actualización. SwiftData proporciona un sistema de migración bastante robusto basado en VersionedSchema y SchemaMigrationPlan.
Definiendo versiones del esquema
import SwiftData
// Versión 1: Esquema original
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Viaje.self]
}
@Model
final class Viaje {
var nombre: String
var fechaInicio: Date
var fechaFin: Date
init(nombre: String, fechaInicio: Date, fechaFin: Date) {
self.nombre = nombre
self.fechaInicio = fechaInicio
self.fechaFin = fechaFin
}
}
}
// Versión 2: Agregamos presupuesto y estado
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Viaje.self]
}
@Model
final class Viaje {
var nombre: String
var fechaInicio: Date
var fechaFin: Date
var presupuesto: Double // Nueva propiedad
var estado: String // Nueva propiedad (usamos String para simplificar)
init(nombre: String, fechaInicio: Date, fechaFin: Date, presupuesto: Double = 0) {
self.nombre = nombre
self.fechaInicio = fechaInicio
self.fechaFin = fechaFin
self.presupuesto = presupuesto
self.estado = "planificando"
}
}
}
// Versión 3: Agregamos identificador único y relación con destinos
enum SchemaV3: VersionedSchema {
static var versionIdentifier = Schema.Version(3, 0, 0)
static var models: [any PersistentModel.Type] {
[Viaje.self, Destino.self]
}
@Model
final class Viaje {
@Attribute(.unique) var identificador: UUID
var nombre: String
var fechaInicio: Date
var fechaFin: Date
var presupuesto: Double
var estado: String
@Relationship(deleteRule: .cascade)
var destinos: [Destino] = []
init(nombre: String, fechaInicio: Date, fechaFin: Date, presupuesto: Double = 0) {
self.identificador = UUID()
self.nombre = nombre
self.fechaInicio = fechaInicio
self.fechaFin = fechaFin
self.presupuesto = presupuesto
self.estado = "planificando"
}
}
@Model
final class Destino {
var nombre: String
var pais: String
var viaje: Viaje?
init(nombre: String, pais: String) {
self.nombre = nombre
self.pais = pais
}
}
}
Plan de migración
El SchemaMigrationPlan define el orden de las versiones y las etapas de migración entre ellas. Es como un mapa de ruta que SwiftData sigue para llevar los datos de una versión a otra:
enum PlanDeMigracion: SchemaMigrationPlan {
// Orden cronológico de las versiones del esquema
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self, SchemaV3.self]
}
// Definir las etapas de migración
static var stages: [MigrationStage] {
[migracionV1aV2, migracionV2aV3]
}
// Migración ligera: las nuevas propiedades tienen valores por defecto
static let migracionV1aV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
// Migración personalizada: necesitamos generar UUIDs para registros existentes
static let migracionV2aV3 = MigrationStage.custom(
fromVersion: SchemaV2.self,
toVersion: SchemaV3.self,
willMigrate: nil,
didMigrate: { contexto in
// Después de la migración, asignar UUID a viajes que no lo tengan
let descriptor = FetchDescriptor()
let viajes = try contexto.fetch(descriptor)
for viaje in viajes {
if viaje.identificador == UUID(uuidString: "00000000-0000-0000-0000-000000000000") {
viaje.identificador = UUID()
}
}
try contexto.save()
}
)
}
// Configurar el contenedor con el plan de migración
@main
struct ViajesApp: App {
let container: ModelContainer
init() {
do {
container = try ModelContainer(
for: SchemaV3.Viaje.self, SchemaV3.Destino.self,
migrationPlan: PlanDeMigracion.self
)
} catch {
fatalError("Error al configurar la migración: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
Las migraciones ligeras (.lightweight) funcionan automáticamente cuando los cambios son simples: agregar propiedades con valores por defecto, eliminar propiedades o renombrar propiedades (con la ayuda de @Attribute(originalName:)). Las migraciones personalizadas (.custom) te dan control total para transformar datos durante la migración, con closures que se ejecutan antes (willMigrate) y después (didMigrate) del proceso. Mi recomendación: empieza siempre con migraciones ligeras y solo recurre a las personalizadas cuando realmente las necesites.
Mejores Prácticas y Consideraciones
Ya hemos cubierto todas las funcionalidades principales de SwiftData. Ahora toca hablar de lo que realmente marca la diferencia entre una implementación que "funciona" y una que está lista para producción.
Rendimiento y optimización
- Usa índices para consultas frecuentes. Si una vista ordena o filtra por una propiedad específica repetidamente, define un
#Indexpara esa propiedad. La diferencia de rendimiento puede ser brutal en colecciones grandes. - Limita las consultas con
fetchLimit. Nunca cargues todos los registros si solo necesitas mostrar los primeros 20. UsaFetchDescriptorconfetchLimity considera la paginación para listas largas. - Aprovecha
propertiesToFetch. Si solo necesitas el nombre y la fecha de un viaje para una lista, no cargues todas las propiedades incluyendo la imagen de portada en almacenamiento externo. Tu memoria RAM te lo agradecerá. - Guarda en lotes durante importaciones masivas. Llamar a
save()después de cada inserción individual es extremadamente ineficiente. Agrupa las operaciones y guarda cada 50-100 registros. - Usa autosave con cuidado. SwiftData guarda automáticamente en ciertos momentos (cuando la app entra en segundo plano, por ejemplo). Si necesitas control preciso sobre cuándo se persisten los cambios, desactiva el autoguardado con
ModelContext.autosaveEnabled = falsey llama asave()manualmente.
Arquitectura y diseño
- Separa la lógica de datos de la UI. Aunque SwiftData se integra hermosamente con SwiftUI mediante
@Query, las operaciones complejas de datos deben vivir en actores o servicios dedicados, no en las vistas. - No cruces límites de aislamiento con modelos. Los objetos
PersistentModelestán vinculados a suModelContext. Para pasar datos entre actores, usaPersistentIdentifiero structs intermedios. En serio, esto es más importante de lo que parece. - Diseña pensando en la migración desde el día uno. Incluso si tu primera versión es simple, encapsula tus modelos en un
VersionedSchemadesde el principio. Agregar versionamiento retroactivamente es bastante más complicado. - Prueba con almacenamiento en memoria. Para pruebas unitarias, configura
ModelConfiguration(isStoredInMemoryOnly: true). Esto garantiza que cada test comience con un estado limpio y sea mucho más rápido.
Errores comunes y cómo evitarlos
- No inicializar el contenedor en el punto correcto. El modificador
.modelContainerdebe estar en laScene, no dentro de una vista individual. Si lo colocas en una vista interna, cada vez que esa vista se recree se puede instanciar un nuevo contenedor. Esto causa bugs sutiles y difíciles de rastrear. - Usar
@Queryfuera de SwiftUI. El property wrapper@Querysolo funciona dentro de vistas SwiftUI. Para código imperativo, usaFetchDescriptorcon elModelContext. - Olvidar los requisitos de CloudKit. Si planeas sincronizar con CloudKit, asegúrate desde el principio de que todos los campos tengan valores por defecto y que las relaciones sean opcionales. Convertir modelos no compatibles después es doloroso (créeme).
- Acceder a propiedades de relaciones en hilos incorrectos. Las propiedades lazy de las relaciones se cargan bajo demanda. Si accedes a
viaje.destinosen un hilo diferente al del contexto, obtendrás un crash. Siempre accede a las relaciones dentro del actor correcto. - No manejar errores de
save(). Las operaciones de guardado pueden fallar por restricciones de unicidad, espacio en disco o errores de validación. Siempre envuelvesave()en undo-catchy maneja los errores apropiadamente.
¿Cuándo usar SwiftData vs alternativas?
SwiftData es la opción ideal cuando:
- Tu app usa SwiftUI como framework principal de interfaz.
- Necesitas persistencia local con soporte para relaciones entre modelos.
- Quieres sincronización transparente con CloudKit.
- Tu target mínimo es iOS 17 o superior.
Considera alternativas cuando:
- Necesitas soportar versiones anteriores a iOS 17 — Core Data sigue siendo la opción para retrocompatibilidad.
- Tu app usa UIKit extensivamente — aunque SwiftData funciona con UIKit, su integración es menos fluida que con SwiftUI.
- Necesitas control total sobre SQLite — bibliotecas como GRDB o SQLite.swift ofrecen acceso directo a SQL.
- Solo almacenas datos simples clave-valor —
UserDefaultso el sistema de archivos pueden ser más que suficientes.
El futuro de SwiftData
Con cada versión de iOS, SwiftData se vuelve más completo y robusto. La adición de herencia de modelos en iOS 26 cierra una de las brechas más importantes que tenía frente a Core Data. Las mejoras continuas en el historial de cambios, los índices compuestos y las macros de optimización dejan claro el compromiso de Apple con este framework como el futuro de la persistencia en su ecosistema.
Si aún no has migrado de Core Data a SwiftData, creo que este es un buen momento para empezar. Con las herramientas y conocimientos que hemos cubierto en esta guía — desde la configuración inicial hasta la migración de esquemas, la concurrencia con actores y la sincronización con CloudKit — tienes todo lo necesario para construir una capa de datos sólida, eficiente y lista para producción. SwiftData no es solo una mejora incremental sobre Core Data; es la forma en que Apple quiere que pensemos sobre la persistencia de datos en Swift, y honestamente, después de trabajar con él durante un tiempo, cuesta imaginar volver atrás.