← Todos los artículos

El costo real de SwiftData es la disciplina de esquema

El ShoppingItem de Get Bananas es el ejemplo canónico de por qué importa la disciplina de esquema en SwiftData. El esquema original no incluía una marca de tiempo lastModified; agregarla más tarde exigió una forma de migración específica, porque ya había datos en disco, y el campo se hizo opcional precisamente para corregir un fallo de migración que apareció cuando se agregó por primera vez como no opcional.1

La API de SwiftData son dos macros. @Model sobre una clase la convierte en un tipo persistente. @Attribute(.unique) sobre una propiedad le da una restricción de unicidad. El framework oculta la gestión del stack de Core Data, el baile de los value transformers y el boilerplate de NSManagedObjectContext. Lo que el framework no oculta es la migración de esquema; solo la vuelve declarativa en lugar de imperativa. El costo de no prestar atención a las migraciones es ese bug que borra los datos de un usuario en una actualización de rutina.

La tesis: SwiftData es barato para empezar y caro cuando se migra con descuido. La disciplina está en el nombrado, la opcionalidad y VersionedSchema desde el primer día, no desde el día en que te das cuenta de que debiste haberlo hecho.

En resumen

  • La macro @Model convierte una clase en un tipo persistente de SwiftData. El framework genera el esquema en tiempo de compilación a partir de las declaraciones de propiedades.
  • Agregar una nueva propiedad opcional es una migración sin esfuerzo: la migración ligera de SwiftData se encarga. Agregar una propiedad no opcional a un esquema existente exige un VersionedSchema junto con un MigrationPlan que le indique al framework cómo poblar el nuevo campo en las filas existentes.
  • Saltarse VersionedSchema desde el primer día se paga así: cualquier cambio de esquema no trivial en la v2 arriesga perder la base de datos de un usuario, porque la ruta ligera es conservadora y se detiene cuando no puede inferir la migración.
  • @Attribute(.unique) es la herramienta correcta para claves naturales (un UUID que generaste, un ID externo que importaste). @Relationship es la herramienta correcta para referencias padre/hijo. Ambas son macros que generan por debajo la plomería adecuada de Core Data.2

Qué hace realmente @Model

Un tipo de SwiftData es una clase de Swift con la macro @Model aplicada. El ShoppingItem de Get Bananas muestra la forma canónica:

import Foundation
import SwiftData

@Model
final class ShoppingItem {
    @Attribute(.unique) var id: UUID
    var name: String
    var amount: String
    var section: String
    var isChecked: Bool
    var isOptional: Bool
    var sortOrder: Int
    var lastModified: Date?

    init(id: UUID = UUID(), name: String, amount: String, section: String,
         isOptional: Bool = false, sortOrder: Int = 0) {
        self.id = id
        self.name = name
        self.amount = amount
        self.section = section
        self.isChecked = false
        self.isOptional = isOptional
        self.sortOrder = sortOrder
        self.lastModified = Date()
    }
}

Tres detalles de esa forma que la API oculta.

@Model no exige una declaración de esquema aparte para el store persistente. SwiftData lee la definición de la clase en tiempo de compilación y sintetiza el esquema. Las propiedades de la clase se convierten en los atributos del modelo; sus tipos de Swift se convierten en los tipos de columna. No hay ningún archivo .xcdatamodeld que mantener (aunque el NSManagedObjectModel subyacente de Core Data sigue existiendo y es lo que sostiene el esquema en tiempo de ejecución).2

@Attribute(.unique) es una restricción sobre una sola columna, no una declaración de PRIMARY KEY. La identidad persistente de SwiftData es el PersistentIdentifier, generado automáticamente por fila. La declaración @Attribute(.unique) le dice al framework: «esta columna guarda como máximo una fila por valor». Cuando insertas un modelo con un valor .unique que ya existe, SwiftData hace un upsert: la fila existente se actualiza en lugar de rechazarse. Esa semántica importa en el código de producto: .unique no es una validación de interfaz que impida enviar duplicados; es una garantía de almacenamiento de «como máximo uno» que fusiona en silencio. El patrón id: UUID de arriba es el recomendado para la sincronización entre procesos (cuando quieres un identificador estable que sobreviva a la desaparición del PersistentIdentifier interno del proceso), y el comportamiento de upsert es exactamente lo que quieres cuando el mismo UUID llega por dos rutas de sincronización.

Las clases @Model son tipos por referencia, no tipos por valor. Mutar una propiedad en una instancia de ShoppingItem dispara el seguimiento de cambios de SwiftData; el framework registra el cambio y lo persiste en el siguiente guardado del contexto. La integración con SwiftUI mediante @Query vuelve a renderizar cualquier vista que observe el predicado correspondiente. El patrón se parece a @Observable (tratado en What SwiftUI Is Made Of), con persistencia por encima.

Los campos opcionales son la migración barata

El campo lastModified: Date? de ShoppingItem es opcional, y esa opcionalidad sostiene todo el peso. El campo se agregó después de publicar la v1 para dar soporte a la sincronización entre dispositivos y a la resolución de conflictos; las filas que ya existían en los dispositivos de los usuarios no tenían ningún valor de lastModified. Un campo opcional sin valor por defecto deja que la migración ligera de SwiftData resuelva la adición sin escribir nada de código de migración: las filas existentes reciben nil; las nuevas reciben lo que fije el init.3

La ruta de migración ligera es la ruta cortés del framework. SwiftData inspecciona el nuevo esquema y el store persistente, infiere el cambio compatible más pequeño y lo aplica. La migración es automática; el usuario no ve nada; la app arranca con normalidad sobre los datos existentes. Los casos que la ruta ligera resuelve con limpieza:

  • Agregar una propiedad opcional
  • Eliminar una propiedad (los datos se descartan; las lecturas existentes ya no ven la columna)
  • Renombrar un atributo que el framework puede emparejar con una pista (usando @Attribute(originalName: ...))
  • Renombrar una clase @Model que el framework puede emparejar (usando @Model.originalName o una pista)

Los casos en los que la ruta ligera se detiene:

  • Agregar a un esquema existente una propiedad no opcional sin valor por defecto (las filas existentes no tienen ningún valor con el que poblarla)
  • Cambiar el tipo de una propiedad (por ejemplo, IntString)
  • Dividir un modelo en dos, o fusionar dos en uno
  • Cualquier cosa que exija lógica propia para migrar

Cuando la ruta ligera se detiene, el comportamiento seguro es hacer fallar la migración. El comportamiento inseguro sería descartar la base de datos y empezar de cero; el framework es conservador y se niega a hacerlo en silencio. El usuario ve la app fallar al arrancar con un error de migración; quien desarrolla ve una traza que apunta al desajuste de esquema; nadie pierde datos, pero todos pierden confianza.

El costo de saltarse VersionedSchema desde el primer día aparece en la frontera v2 → v3, cuando agregas la tercera función cuyo cambio de esquema excede lo que la ruta ligera maneja.

VersionedSchema y MigrationPlan: la disciplina del primer día

VersionedSchema declara una versión concreta del esquema del modelo. MigrationPlan declara cómo migrar de una versión a la siguiente.4 La forma:

import SwiftData

enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] = [ShoppingItemV1.self]
}

enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] = [ShoppingItemV2.self]
}

enum AppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] = [
        SchemaV1.self,
        SchemaV2.self,
    ]

    static var stages: [MigrationStage] = [
        MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)
    ]
}

Las propias clases de modelo se mudan al espacio de nombres del esquema versionado:

extension SchemaV1 {
    @Model
    final class ShoppingItemV1 { /* v1 fields */ }
}

extension SchemaV2 {
    @Model
    final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}

El ModelContainer se construye con el plan de migración:

let container = try ModelContainer(
    for: ShoppingItemV2.self,
    migrationPlan: AppMigrationPlan.self,
    configurations: ModelConfiguration("ShoppingList")
)

El plan de migración le da al framework un grafo tipado de cómo evoluciona el esquema. Cuando la app en v2 arranca contra una base de datos en v1, el framework recorre el plan de migración, aplica las etapas nombradas y lleva la base de datos a v2. Cuando publiques la v3, agregas SchemaV3.self a schemas y una nueva MigrationStage entre v2 y v3. El modelo completo de migración —qué cambios son automáticos, cuáles necesitan una etapa declarada y el fallo de suma de comprobación que te llevas por declarar una V2 que no necesitabas— es el tema del artículo complementario SwiftData migrations: lightweight vs custom.

La disciplina consiste en publicar VersionedSchema ya en la v1, incluso cuando solo hay una versión. Eso cuesta un archivo extra y una declaración enum extra. No hacerlo cuesta que el primer cambio de esquema no trivial de la v2 exija envolver la v1 retroactivamente en un VersionedSchema, algo posible pero que requiere cuidado para reproducir la forma exacta de la v1, de modo que el framework identifique los datos existentes como SchemaV1. Tu yo futuro, ocupado con la v2, pagará el impuesto; tu yo presente puede pagarlo una vez y olvidarse.

MigrationStage personalizada para los casos difíciles

Las migraciones ligeras cubren la mayoría de los cambios aditivos. Los cambios de tipo, las divisiones, las fusiones y los poblados condicionales necesitan una MigrationStage.custom:

static var stages: [MigrationStage] = [
    MigrationStage.custom(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self,
        willMigrate: { context in
            // Read v1 rows; stage any derived state to a transient store
            // (UserDefaults / temp file) since the v1 and v2 contexts do
            // not share state, and didMigrate cannot read v1.
            let v1Items = try context.fetch(FetchDescriptor<ShoppingItemV1>())
            stageDerivedState(from: v1Items)
        },
        didMigrate: { context in
            // Populate v2-only fields on existing rows
            let v2Items = try context.fetch(FetchDescriptor<ShoppingItemV2>())
            for item in v2Items where item.lastModified == nil {
                item.lastModified = Date()
            }
            try context.save()
        }
    )
]

Los dos closures se disparan antes y después de que el framework aplique la migración estructural. willMigrate se ejecuta contra el esquema v1; didMigrate se ejecuta contra el esquema v2. El cuerpo del closure es código SwiftData normal (fetch descriptors, guardados del model context, las mismas API que usa la app en ejecución), operando contra un contexto transitorio de migración.

El patrón que sobrevive a producción mantiene willMigrate vacío y pone toda la lógica de poblado en didMigrate. Leer datos de v1 dentro de willMigrate está permitido, pero el esquema v2 todavía no existe desde la perspectiva del framework, así que cualquier cálculo hay que dejarlo en un almacén transitorio que el closure didMigrate pueda leer. La regla más simple: las migraciones estructurales son trabajo del framework; poblar los campos exclusivos de la v2 en las filas existentes es trabajo de didMigrate.

Cuándo @Attribute y @Relationship se ganan su nombre

Dos macros hacen casi todo el trabajo de decoración de esquema en las clases @Model.

@Attribute decora una sola propiedad con una restricción o una pista:

  • @Attribute(.unique) impone unicidad, como en ShoppingItem.id
  • @Attribute(.externalStorage) guarda blobs Data grandes fuera de la base de datos (datos de imagen, búferes de audio)
  • @Attribute(originalName: "old_field_name") empareja una propiedad con una columna renombrada durante la migración
  • @Attribute(.transformable(by: ...)) aplica un ValueTransformer a un tipo que no es Codable

La disciplina correcta: usar .unique para campos que de verdad deban ser únicos (un UUID que generaste, un ID externo), .externalStorage para cualquier blob que pase de unos pocos KB, y originalName cuando un cambio de nombre de propiedad en la v2 perdería, de otro modo, los datos de la v1.

@Relationship decora una propiedad que apunta a otra clase @Model o a una colección de ellas:

@Model
final class List {
    var name: String

    @Relationship(deleteRule: .cascade, inverse: \ShoppingItem.list)
    var items: [ShoppingItem] = []
}

@Model
final class ShoppingItem {
    var name: String
    var list: List?
}

deleteRule: .cascade significa que borrar la List padre borra todas las filas ShoppingItem hijas. El parámetro inverse: le dice al framework qué propiedad del hijo apunta de vuelta al padre; el framework la usa para un mantenimiento bidireccional predecible. SwiftData a veces puede inferir la inversa por su cuenta, e inverse: nil está soportado para relaciones explícitamente unidireccionales, pero el valor seguro por defecto es declarar inverse: siempre que la inferencia resulte ambigua.5

La disciplina correcta: declarar las relaciones con un deleteRule explícito (el valor por defecto es .nullify, que rara vez es lo que quieres) y declarar inverse: siempre que la relación sea bidireccional (en lugar de confiar en la inferencia del framework). Los valores implícitos suelen estar mal; la forma explícita es un parámetro extra y un bug ahorrado para siempre.

Cruzar una frontera de actor: envía el identificador, no el grafo

Una clase @Model no es Sendable, y lo correcto es dejar de intentar que lo sea. La instancia es una referencia a un grafo de objetos vivo que sostiene un ModelContext; el framework no puede prometer que ese grafo sea seguro de leer desde otro actor, así que el tipo se deja deliberadamente sin Sendable. Forzar la conformidad no hace desaparecer la condición de carrera; la esconde.7

El patrón que funciona es enviar la identidad y los valores simples, y volver a consultar del otro lado. PersistentIdentifier sí es Sendable, así que cruza la frontera limpiamente. Extrae los valores escalares que necesite el destino (un nombre, un flag, un delta en una estructura pequeña), pásalos junto al identificador, y deja que el actor receptor vuelva a obtener el modelo desde su propio contexto usando ese identificador:

// On the source actor: extract identity + plain values, never the model.
let id: PersistentIdentifier = item.persistentModelID
let snapshot = ItemSnapshot(name: item.name, isChecked: item.isChecked)

// On the destination actor: re-fetch from this context, then mutate.
let fetched = destinationContext.model(for: id) as? ShoppingItem

El modo de fallo que hay que evitar es pasar el propio grafo del modelo. Cuando parte del grafo cruza la frontera, el receptor obtiene un modelo que se hidrata solo parcialmente del otro lado: las relaciones y las propiedades cargadas de forma diferida que nunca se materializaron en el contexto origen se resuelven contra el contexto equivocado (o no se resuelven), y los bugs que siguen son de los silenciosos. El identificador más los valores extraídos es el contrato seguro; el grafo no lo es. Un ModelActor encapsula esta disciplina al poseer un contexto y entregar valores en lugar de instancias.7

Sincronización con CloudKit y la trampa del entitlement de App Group

Mover un store de SwiftData a un contenedor de App Group para que un widget o una extensión pueda leerlo interactúa con la sincronización de CloudKit de una manera que muerde a las apps después de publicarlas. Dos hechos hacen que el resto se deduzca solo.

Primero, la ubicación del store. Con la ModelConfiguration por defecto, SwiftData copia por ti el store existente al contenedor de App Group cuando una app pasa de no tener grupo a tenerlo; la redacción de Apple es que SwiftData «copies the existing store to the app group container».8 Con una URL de store personalizada, la ubicación es tuya: copias el archivo al nuevo contenedor y apuntas ahí la configuración tú mismo. La ruta por defecto es la cómoda precisamente porque el framework hace la copia; la ruta personalizada cambia esa comodidad por control.

Segundo, el entitlement. Todo miembro del App Group que lea un store sincronizado con CloudKit debe llevar el mismo entitlement de CloudKit, porque cada uno de esos procesos sincronizará ese contenedor por su cuenta. Ese requisito es la trampa: un widget o una extensión no tiene el presupuesto de ejecución ni la ventana en primer plano para llevar adelante una sincronización verbosa, y darle el entitlement de CloudKit lo obliga a intentarlo. La solución es dividir en dos instancias de ModelConfiguration: un store sincronizado (con el entitlement de CloudKit, propiedad de la app principal) y un store local en el contenedor de App Group que el widget y las extensiones leen sin sincronizar nunca. Pon la sincronización donde una app en primer plano pueda hacerla bien, y mantén fuera de la ruta de sincronización los datos compartidos solo para lectura.8

Qué construiría de otra manera

Tres patrones que las apps del clúster o bien publican, o bien desearían haber publicado.

Publica VersionedSchema desde la v1. Toda clase @Model que llegue a producción debería vivir dentro de un VersionedSchema desde el primer día. El costo es un enum envolvente por versión de esquema. El beneficio es que el primer cambio no trivial de la v2 se convierte en una línea añadida a MigrationPlan.schemas en lugar de una refactorización retroactiva de dos días.

Haz opcional cada marca de tiempo. Campos como lastModified, createdAt y updatedAt, que existen para la sincronización entre dispositivos o la resolución de conflictos, deberían ser opcionales en la v1 si el producto v1 no los necesita. La opcionalidad mantiene barata la migración a la v2 (cuando sí los necesites). Rellenarlos en las filas existentes durante didMigrate es un bucle; hacerlos no opcionales desde la v1 es una restricción que puede romper el relleno retroactivo sobre datos de usuarios.

Usa UUID como clave natural, no el PersistentIdentifier. El PersistentIdentifier de SwiftData vive dentro del proceso. La sincronización entre dispositivos, la integración con MCP (tratada en Two Agent Ecosystems, One Shopping List) y cualquier referencia fuera del proceso necesitan un identificador estable. Un UUID con @Attribute(.unique) tiene la forma correcta; el PersistentIdentifier interno del proceso tiene la forma equivocada para todo lo que cruce una frontera de proceso.

Cuándo @Model es la respuesta equivocada

Tres casos en los que SwiftData no es la herramienta adecuada:

Estado clave/valor de un solo registro. La configuración de la app, el idioma elegido por el usuario, la marca de tiempo de la última sincronización. Usa UserDefaults o NSUbiquitousKeyValueStore (tratados en Five Apple Platforms, Three Shared Files). La sobrecarga de SwiftData para una sola fila es ceremonia desperdiciada; los almacenes clave-valor son el sustrato correcto.

Datos con autoridad en el servidor y sin escrituras sin conexión. Una lista obtenida de una API REST y mostrada solo para lectura. SwiftData es excesivo si la fuente de verdad es el servidor y la caché local es solo una caché. Basta con una instantánea Codable en Documents/ más un arreglo en memoria; el impuesto de migración de SwiftData no vale la pena si los datos no tienen que sobrevivir a un reinicio total.

Coordinación entre procesos. SwiftData opera dentro de un proceso. Un servidor MCP que corre fuera de la app de iOS no puede leer ni escribir el contenedor de SwiftData de la app. El estado entre procesos necesita otra forma: un archivo JSON en iCloud Drive, un contenedor de App Group compartido, o una capa de sincronización explícita que tienda un puente entre procesos. (Get Bananas combina SwiftData con JSON en iCloud Drive exactamente por esta razón.)6

Los datos son blobs grandes que cambian poco. Un archivo de audio de 10 MB, un conjunto de imágenes de 50 MB. Usa @Attribute(.externalStorage) si los blobs viven dentro de filas de SwiftData; si no, usa el sistema de archivos directamente y guarda en SwiftData metadatos que apunten a URL de archivos.

Cuándo Core Data sigue ganando

SwiftData es una capa sobre Core Data, no un reemplazo de todo lo que hace, y tres años después hay un conjunto concreto de trabajos que sigue perteneciendo al framework más antiguo. Elegir Core Data para uno de ellos no es una decisión heredada; es la decisión correcta hoy.

Agregados del lado de la base de datos. SwiftData no tiene equivalente a los fetches de Core Data basados en NSExpression, los que empujan sum, average, min y max hasta SQLite para que la base de datos los calcule sin cargar filas9. En SwiftData obtienes y reduces en memoria, lo que arruina la idea en una tabla grande. La salida de emergencia documentada es la coexistencia: Apple describe ejecutar «two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store», lo que permite que el lado de Core Data ejecute el agregado empujado a SQL contra el archivo que posee SwiftData9. La mecánica, incluido el requisito de NSPersistentHistoryTrackingKey, se trata en SwiftData performance is a storage problem.

Compartir y la base de datos pública de CloudKit. La sincronización automática con iCloud de SwiftData se apoya por debajo en NSPersistentCloudKitContainer, y el contenedor que configura refleja tu store en la base de datos privada de CloudKit del usuario10. La colaboración entre distintos usuarios de iCloud mediante CKShare, y la publicación en la base de datos pública, son capacidades documentadas de Core Data + CloudKit sin ninguna API a nivel de SwiftData al momento de las betas de iOS 2710. Una app cuya función central son las listas compartidas o los documentos colaborativos, o baja a Core Data para los stores sincronizados, o construye la capa de CloudKit a mano.

Actualizaciones por lotes a nivel de store. El NSBatchUpdateRequest de Core Data reescribe las filas coincidentes directamente en el store sin cargar objetos11. El ModelContext de SwiftData tiene la mitad del borrado (delete(model:where:) acepta un predicado) pero ninguna contraparte para actualizar por lotes, así que reescribir masivamente un campo en SwiftData implica materializar cada modelo afectado.

Un objetivo de despliegue por debajo de iOS 17. SwiftData exige iOS 17; Core Data llega tan atrás como cualquier app siga soportando, y su contenedor de CloudKit hasta iOS 131012. Una base de código con una cola larga de sistemas operativos no puede elegir.

Vale la pena nombrar lo que se cayó de esta lista: «necesitas NSFetchedResultsController fuera de una vista» fue un punto a favor de Core Data hasta que las betas de iOS 27 agregaron ResultsObserver, que observa un fetch en cualquier parte de la app mediante Swift Observation13. La lista de huecos se encoge versión a versión. La apuesta: empezar las apps nuevas en SwiftData, adoptar la coexistencia para los trabajos de arriba, y considerar un stack completo de Core Data como respuesta solo cuando el compartir, la base de datos pública o el objetivo de despliegue lo impongan.

Qué significa el patrón para las apps que publican en iOS 26+

Tres conclusiones.

  1. Las macros son la parte fácil. Las migraciones son el costo. @Model y @Attribute son declaraciones de dos líneas que ocultan mucha plomería de Core Data. La disciplina de migración es lo que de verdad pagas a lo largo de la vida de la app; diseña la v1 pensando en la v2.

  2. VersionedSchema desde el primer día no es negociable en apps que se publican. El enum envolvente es un archivo extra. El costo retroactivo de agregarlo después es mucho mayor.

  3. Los campos opcionales y las relaciones explícitas son el seguro barato. Marcas de tiempo opcionales para los metadatos de sincronización, deleteRule e inverse: explícitos en las relaciones. Ambas son declaraciones diminutas que compran mucha flexibilidad para la v2.

El clúster completo del ecosistema Apple: App Intents tipados para Apple Intelligence; servidores MCP para agentes entre distintos LLM; la pregunta del enrutamiento entre ambos; Foundation Models para el LLM en el dispositivo y el protocolo Tool; Live Activities para la máquina de estados de la pantalla de bloqueo en iOS; el contrato de ejecución de watchOS en el Apple Watch; las entrañas de SwiftUI para el sustrato del framework; el modelo mental espacial de RealityKit para las escenas de visionOS; los patrones de Liquid Glass para la capa visual; la publicación multiplataforma para el alcance entre dispositivos. El hub está en la Apple Ecosystem Series. Para un contexto más amplio sobre iOS con agentes de IA, consulta la iOS Agent Development guide.

Preguntas frecuentes

¿Cuál es la diferencia entre @Model y el NSManagedObject de Core Data?

@Model es una macro de Swift que genera por debajo la plomería de NSManagedObject. SwiftData usa Core Data como store de respaldo, así que el modelo en tiempo de ejecución es el mismo; la diferencia está en la superficie. @Model elimina el archivo .xcdatamodeld, la ceremonia de los value transformers y la gestión del ciclo de vida del NSManagedObjectContext. Obtienes el mismo store persistente con una API con forma de Swift.

¿Necesito VersionedSchema si nunca pienso cambiar el esquema?

Si tu app podría publicar una v2, sí. Si es una demo de una sola vez, no. VersionedSchema desde la v1 cuesta una declaración enum extra. Agregarlo retroactivamente en la v2 cuesta reproducir la forma exacta del esquema v1 para que el framework reconozca los datos existentes, algo posible pero propenso a errores. La mayoría de las apps publicadas acabarán necesitando un cambio de esquema; presupuéstalo en la v1.

¿Cuándo debería usar @Attribute(.unique)?

Cuando el campo es una clave natural de la fila: un UUID que generaste, un ID externo que importaste, un slug que asignaste. SwiftData trata .unique como un upsert: si insertas un modelo cuyo valor .unique ya existe, la fila existente se actualiza en lugar de añadirse una nueva. Esa semántica es la que vuelve seguras las rutas de sincronización tipo upsert (el mismo UUID llegando desde dos dispositivos); es también la razón por la que .unique es la herramienta equivocada en campos de visualización como title, porque dos usuarios que escriban el mismo título fusionarían sus filas en silencio en lugar de producir dos registros distintos.

¿Cómo manejo un campo no opcional agregado a un esquema existente?

Usa una MigrationStage.custom con un closure didMigrate que pueble el campo en las filas existentes. O, más fácil: declara el campo como opcional en la nueva versión del esquema y rellénalo de forma diferida al acceder. La opcionalidad es la migración más barata; las adiciones no opcionales necesitan lógica de poblado explícita.

¿Qué es PersistentIdentifier frente a mi propio UUID?

PersistentIdentifier es el ID de fila interno del proceso en SwiftData; se genera automáticamente y sobrevive mientras dure el proceso en ejecución. Tu propio UUID con @Attribute(.unique) es un identificador estable entre procesos y entre dispositivos. Usa PersistentIdentifier para referencias internas del proceso dentro de la app. Usa un UUID para todo lo que cruce una frontera de proceso (sincronización entre dispositivos, integraciones externas, herramientas MCP, llamadas de red).

¿Cuándo debería seguir eligiendo Core Data en lugar de SwiftData?

Cuatro casos al momento de las betas de iOS 27: agregados del lado de la base de datos (los fetches con NSExpression que le faltan a SwiftData), compartir entre usuarios de iCloud mediante CKShare o la base de datos pública de CloudKit (la sincronización de SwiftData cubre la base de datos privada), actualizaciones por lotes a nivel de store (NSBatchUpdateRequest) y objetivos de despliegue por debajo de iOS 1791011. Para el caso de los agregados no tienes que abandonar SwiftData: ejecuta un stack de Core Data coexistente contra el mismo archivo de store.

Referencias


  1. Get Bananas del autor, una app SwiftUI de lista de compras que combina SwiftData con sincronización JSON por iCloud Drive y un servidor MCP. El modelo ShoppingItem evolucionó a lo largo del primer ciclo de desarrollo; el campo lastModified: Date? se agregó después del esquema inicial (commit 268a00d del 1 de diciembre de 2025, «Make lastModified optional to fix migration crash») porque hacerlo no opcional rompía la migración cuando las filas existentes no tenían ningún valor con el que poblarlo. 

  2. Apple Developer, “SwiftData” y “Adding and editing persistent data in your app”. La macro @Model, la superficie de restricciones de @Attribute y la relación con el NSManagedObjectModel de Core Data. 

  3. Apple Developer, “Preserving your app’s model data across launches” y “Adopting SwiftData for a Core Data app”. La semántica de la migración ligera y qué hace que el framework se detenga. 

  4. Apple Developer, “VersionedSchema” y “SchemaMigrationPlan”. Las declaraciones de esquemas versionados, las definiciones de etapas de migración y el constructor de ModelContainer que acepta un plan de migración. 

  5. Apple Developer, “Defining data relationships with enumerations and model classes” y “Schema.Relationship”. La macro @Relationship, las opciones de deleteRule (.cascade, .nullify, .deny, .noAction) y el papel del parámetro inverse: en el mantenimiento de relaciones bidireccionales. 

  6. Análisis del autor en Two Agent Ecosystems, One Shopping List, 29 de abril de 2026, y Five Apple Platforms, Three Shared Files. Los patrones de sincronización entre procesos y entre dispositivos de Get Bananas y Return que complementan (y a veces reemplazan) a SwiftData dentro de un flujo de trabajo multiproceso. 

  7. Apple Developer, “PersistentIdentifier” (conforma a Sendable) y “ModelActor”. El equipo de SwiftData confirmó durante el SwiftData Group Lab de la WWDC 2026 que los objetos @Model no son Sendable y que no hay que forzarlos a conformar, porque son un grafo de referencias que vive dentro de un contexto; el contrato de frontera recomendado es pasar el PersistentIdentifier, que sí es Sendable, junto con los valores simples extraídos y volver a consultar en el contexto de destino, y pasar el grafo del modelo deja al receptor con un objeto parcialmente hidratado. Parafraseado a partir de una grabación transcrita localmente del SwiftData Group Lab de la WWDC 2026; Apple no publica subtítulos oficiales de los labs. 

  8. Apple Developer, “Adopting SwiftData for a Core Data app”, que indica que con la configuración por defecto «SwiftData copies the existing store to the app group container», mientras que una URL de store personalizada deja la ubicación en tus manos. El requisito del entitlement de CloudKit para los miembros de un App Group y la división en dos ModelConfiguration (una sincronizada, otra local) para mantener widgets y extensiones fuera de la ruta de sincronización se describieron durante el SwiftData Group Lab de la WWDC 2026. Parafraseado a partir de una grabación transcrita localmente del SwiftData Group Lab de la WWDC 2026; Apple no publica subtítulos oficiales de los labs. 

  9. Apple, sesión 10189 de la WWDC 2023, “Migrate to SwiftData”, origen del planteamiento de coexistencia («two completely separate persistent stacks, one Core Data stack and one SwiftData stack, talking to the same persistent store»), y Apple Developer, “NSExpression”, el mecanismo detrás de los fetches de agregados empujados a SQL de Core Data, para los que SwiftData no ofrece equivalente. El hueco fue confirmado por el panel de ingeniería de SwiftData en el SwiftData Group Lab de la WWDC 2026 (parafraseado a partir de una grabación transcrita localmente). 

  10. Apple Developer, “Syncing model data across a person’s devices”, que indica que «SwiftData uses the NSPersistentCloudKitContainer class from Core Data to handle CloudKit synchronization»; “NSPersistentCloudKitContainer” (iOS 13.0+), cuyo resumen describe el reflejo de «select persistent stores to a CloudKit private database»; y “Sharing Core Data objects between iCloud users”, la ruta documentada de Core Data para la colaboración basada en CKShare. La documentación de SwiftData no expone ninguna API de compartición ni de base de datos pública al momento de las betas de iOS 27. 

  11. Apple Developer, “NSBatchUpdateRequest” y “ModelContext.delete(model:where:includeSubclasses:)”, el borrado por lotes basado en predicados de SwiftData. La documentación del ModelContext de SwiftData no lista ninguna contraparte para actualizaciones por lotes. 

  12. Disponibilidad por plataforma según la documentación de Apple Developer: SwiftData (iOS 17.0+) y Core Data (iOS 3.0+). 

  13. Apple Developer, “ResultsObserver” (beta de iOS 27.0), que «observes and tracks changes to a collection of persistent models in a model context» y conforma a Observable, cubriendo el papel de observación fuera de una vista que antes exigía el NSFetchedResultsController de Core Data. 

Artículos relacionados

Migraciones de SwiftData: ligeras o personalizadas, y cuándo no necesitas un V2

El modelo de migración de SwiftData usa VersionedSchema, MigrationStage y SchemaMigrationPlan. La mayoría de los cambios…

19 min de lectura

SwiftData en iOS 27: Observación e historial

iOS 27 da a SwiftData observación de cambios con ResultsObserver, historial persistente con HistoryObserver y almacenami…

14 min de lectura

La capa de limpieza es el verdadero mercado de los agentes de IA

Charlie Labs pasó de construir agentes a limpiar lo que dejan. El mercado de agentes va de la generación a la prueba: la…

18 min de lectura