SwiftSync is a sync layer for SwiftData apps.
Define your models once, read from local SwiftData, and let SwiftSync handle the repetitive sync and export work in between.
- Convention-first JSON -> SwiftData mapping
- Deterministic diffing for inserts, updates, and deletes
- Automatic relationship syncing for nested objects and foreign keys
- Export back into API-ready JSON
- Offline push (local → server) via SwiftData History — no offline fields, last-writer-wins
- Reactive local reads for SwiftUI and UIKit
import SwiftData
import SwiftSync
@Syncable
@Model
final class User {
@Attribute(.unique) var id: Int
var email: String?
var createdAt: Date?
var updatedAt: Date?
var notes: [Note]
init(id: Int, email: String? = nil, createdAt: Date? = nil, updatedAt: Date? = nil, notes: [Note] = []) {
self.id = id
self.email = email
self.createdAt = createdAt
self.updatedAt = updatedAt
self.notes = notes
}
}
@Syncable
@Model
final class Note {
@Attribute(.unique) var id: Int
var text: String
var user: User?
init(id: Int, text: String, user: User? = nil) {
self.id = id
self.text = text
self.user = user
}
}[
{
"id": 6,
"email": "shawn@ovium.com",
"created_at": "2014-02-14T04:30:10+00:00",
"updated_at": "2014-02-17T10:01:12+00:00",
"notes": [
{
"id": 301,
"text": "Call supplier before Friday"
},
{
"id": 302,
"text": "Prepare Q1 budget review"
}
]
}
]Create the container:
let syncContainer = try SyncContainer(for: User.self, Note.self)Then sync the payload:
let payload = try await getUsers()
try await syncContainer.sync(payload: payload, as: User.self)import SwiftUI
import SwiftSync
struct UsersView: View {
let syncContainer: SyncContainer
@SyncQuery(
User.self,
in: syncContainer,
sortBy: [SortDescriptor(\User.id)]
)
private var users: [User]
var body: some View {
List {
ForEach(users) { user in
Section(user.email ?? "User \\(user.id)") {
ForEach(user.notes) { note in
Text(note.text)
}
}
}
}
}
}import SwiftData
import SwiftSync
@Syncable
@Model
final class User {
@Attribute(.unique) var id: Int
var name: String
var company: Company?
init(id: Int, name: String, company: Company? = nil) {
self.id = id
self.name = name
self.company = company
}
}
@Syncable
@Model
final class Company {
@Attribute(.unique) var id: Int
var name: String
var user: User?
init(id: Int, name: String, user: User? = nil) {
self.id = id
self.name = name
self.user = user
}
}[
{
"id": 6,
"name": "Shawn Merrill",
"company": {
"id": 0,
"name": "Facebook"
}
}
]let syncContainer = try SyncContainer(for: User.self, Company.self)
let payload = try await getUsers()
try await syncContainer.sync(payload: payload, as: User.self)import SwiftUI
import SwiftSync
struct UsersView: View {
let syncContainer: SyncContainer
@SyncQuery(
User.self,
in: syncContainer,
sortBy: [SortDescriptor(\User.id)]
)
private var users: [User]
var body: some View {
List(users) { user in
VStack(alignment: .leading, spacing: 4) {
Text(user.name)
Text(user.company?.name ?? "No company")
.foregroundStyle(.secondary)
}
}
}
}Add the package in Xcode:
File->Add Package Dependencies...- Use this URL:
https://github.com/3lvis/SwiftSync.git
- Add the
SwiftSynclibrary product to your app target.
If you use Package.swift directly:
.package(url: "https://github.com/3lvis/SwiftSync.git", from: "1.0.0")Then import:
import SwiftSyncRequirements: Xcode 17+, Swift 6.2, iOS 17+ / macOS 14+
Quick Start already covers the default root-collection path.
The sections below cover the next cases you are likely to hit in a real app:
- One-to-many relationships linked by IDs instead of nested child objects
- Parent-scoped sync for child collections returned under one parent
- Single-item sync for detail payloads and mutation responses
- Relationship payload shapes beyond the simple nested to-many example
- Customization points for mapping, reads, export, and dates
Table of contents:
- One-to-Many w/ child IDs
- Parent-Scoped Sync
- Single-Item Sync
- Property Mapping and Customization
- Reactive Reads
- Exporting JSON
- Date Handling
- Demo App
- Further Reading
- License
Use this shape when the parent JSON does not include full child objects and only sends their IDs.
@Syncable
@Model
public final class Project {
@Attribute(.unique) public var id: String
public var name: String
public var tasks: [Task]
}
@Syncable
@Model
public final class Task {
@Attribute(.unique) public var id: String
public var title: String
@NotExport
public var project: Project?
}[
{
"id": "C3E7A1B2-1001-0000-0000-000000000001",
"name": "Account Security Controls",
"task_ids": [
"C3E7A1B2-3001-0000-0000-000000000001",
"C3E7A1B2-3001-0000-0000-000000000002"
]
}
]try await syncContainer.sync(payload: payload, as: Project.self)This works when those tasks already exist in SwiftData, or are synced elsewhere. SwiftSync uses the *_ids list to connect the relationship without needing full task objects in the same JSON.
@SyncModel(Project.self, id: projectID, in: syncContainer)
private var project: Project?This pattern is useful for APIs that return lightweight parent objects and keep the full child records on a separate endpoint.
Use parent-scoped sync when an endpoint returns the children for a single parent, such as /projects/{id}/tasks.
@Syncable
@Model
public final class Task {
@Attribute(.unique) public var id: String
public var title: String
public var projectID: String
@NotExport
public var project: Project?
}[
{
"id": "C3E7A1B2-3001-0000-0000-000000000001",
"title": "Add session timeout controls to account settings",
"project_id": "C3E7A1B2-1001-0000-0000-000000000001"
}
]try await syncContainer.sync(
payload: payload,
as: Task.self,
parent: project,
relationship: \Task.project
)The explicit relationship: key path tells SwiftSync which parent these rows belong to. It compares changes only inside that parent’s set of rows, not across the whole table.
let taskPublisher = SyncQueryPublisher(
Task.self,
relationship: \Task.project,
relationshipID: projectID,
in: syncContainer
)See Parent Scope for the full rules.
Use sync(item:) when an endpoint returns one model at a time, such as a detail screen or the response from saving an edit.
It also works well when that same response includes child data that belongs to that one model.
@Syncable
@Model
public final class Item {
@Attribute(.unique) public var id: String
public var title: String
public var taskID: String
@NotExport
public var task: Task?
}{
"id": "C3E7A1B2-3001-0000-0000-000000000001",
"title": "Add session timeout controls to account settings",
"items": [
{
"id": "C3E7A1B2-4001-0000-0000-000000000001",
"task_id": "C3E7A1B2-3001-0000-0000-000000000001",
"title": "Document requirements"
}
]
}try await syncContainer.sync(item: payload, as: Task.self)
try await syncContainer.sync(
payload: itemPayload,
as: Item.self,
parent: task,
relationship: \Item.task
)@SyncModel(Task.self, id: taskID, in: syncContainer)
private var task: Task?This keeps the detail flow simple:
- Update the task from the single-object response
- Update that task's checklist items from the nested array
- Let both list and detail screens keep reading from the same SwiftData data
Convention-first mapping is the default. Reach for overrides only when local naming intentionally differs from the backend.
Let's say you have a Task model. The backend sends a top-level description field and a nested state object, but you want the local model to stay straightforward and Swift-friendly:
{
"id": 42,
"title": "Ship README rewrite",
"description": "Tighten up the property mapping docs and examples.",
"state": {
"id": "in_progress",
"label": "In Progress"
}
}You can keep title convention-based, rename description locally, and flatten the nested state object into plain properties on Task:
@Syncable
@Model
final class Task {
@Attribute(.unique) var id: Int
var title: String
@RemoteKey("description")
var details: String?
@RemoteKey("state.id")
var state: String
@RemoteKey("state.label")
var stateLabel: String
init(
id: Int,
title: String,
details: String? = nil,
state: String,
stateLabel: String
) {
self.id = id
self.title = title
self.details = details
self.state = state
self.stateLabel = stateLabel
}
}In that example:
titleneeds no annotation because the local name already matches the backend keydetailsmaps back todescription, which avoids overloading a common Swift model property namestatereads fromstate.id, so your app can work with a flat status identifierstateLabelreads fromstate.label, which keeps the display string available without storing a nested type locally
Use these annotations when you need them:
- Rely on convention when names already line up
- Use
@RemoteKeywhen the local property name intentionally differs - Use deep paths when the backend nests values but your local model should stay flat
- Use
@PrimaryKeyor@PrimaryKey(remote:)when identity is notid - Use
@NotExportwhen a property should not be written back out
See Property Mapping Contract for the complete mapping rules.
sync(payload:) takes [String: Any], which is fine when you decode and sync on the same actor. But [String: Any] is not Sendable, so if a payload has to cross an actor boundary — e.g. you decode a response on one actor and sync it on another — Swift 6 will flag the hop.
SyncJSON is the carrier for that case: a Sendable, structured JSON value that conforms to SyncPayloadConvertible, so it feeds sync directly. Box your JSON once, carry it across actors, and read it back with keyed accessors:
let payload = try SyncJSON(dictionary: responseDictionary) // Sendable; crosses actors freely
try await syncContainer.sync(payload: [payload], as: User.self)
payload.string("id") // typed read
payload.objectArray("items") // nested objectsIt preserves null (so a sync can clear a field) and the underlying value shapes, exactly like the dictionary would.
SwiftSync is built around local reactive reads. That means your views do not fetch directly from the network and then hold onto that response as UI state. Instead, sync writes backend changes into SwiftData, and the UI reads from SwiftData as its source of truth.
The "reactive" part is that those reads stay fresh automatically. When a sync updates the local store, @SyncQuery and @SyncModel observe the relevant changes, refetch from the local container, and let SwiftUI re-render with current data.
This is the core pattern:
- Sync or mutation code writes backend results into the local
SyncContainer - Screens read from the local store instead of reading from transport responses
- The UI updates when the local rows it depends on change
Use @SyncQuery for list reads and @SyncModel for detail reads:
@SyncQuery(
Task.self,
in: syncContainer,
sortBy: [
SortDescriptor(\Task.updatedAt, order: .reverse),
SortDescriptor(\Task.id)
]
)
var tasks: [Task]For one row by ID, use @SyncModel:
@SyncModel(Task.self, id: taskID, in: syncContainer)
var task: Task?For rows that belong to one parent, pass relationship and relationshipID so the query stays scoped to that relationship path:
@SyncQuery(
Task.self,
relationship: \.project,
relationshipID: projectID,
in: syncContainer,
sortBy: [SortDescriptor(\Task.id)]
)
var tasks: [Task]UIKit is supported through SyncQueryPublisher and SyncModelPublisher, with the same idea: read locally, let sync update the store, and let the UI react to store changes.
See Reactive Reads for the full patterns and tradeoffs.
Use export when a local draft needs to become a request body, usually for create and update flows.
The main surface is object export:
let body = syncContainer.export(draft)That gives you a JSON-ready dictionary using the same key mapping, nested relationship handling, and date formatting rules that sync uses in the other direction.
Typical flow:
- Build or edit a draft locally
- Export that draft right before save
- Send the exported body to your backend
- Sync the backend response back into the container so the UI keeps reading from local state
This is the pattern used in the demo task form as well. Keep the draft isolated while the user edits, export it when they tap save, and let the post-save sync update the shared store.
If your backend expects a different key style or date format, configure the container first:
let syncContainer = SyncContainer(
modelContainer,
keyStyle: .camelCase,
dateFormatter: formatter
)
let body = syncContainer.export(draft)Defaults:
- Snake_case keys
- Relationships included as inline arrays/objects
- ISO-style UTC dates
- Nils exported as
null
Best practices:
- Export a draft object, not the live screen state from network responses
- Prefer exporting right before create or update requests so the payload reflects the latest local edits
- Treat the exported object as a transport body, then re-sync the confirmed backend response into SwiftData
- Use
@RemoteKey,@NotExport, and container formatting options to keep transport concerns out of your UI code
See FAQ and Property Mapping Contract for more on export.
sync and export cover the pull side: pull server state into SwiftData, and turn a draft into a request body. Push is the outbound counterpart for offline-first apps — edit locally while disconnected, then reconcile with the server when you reconnect.
There are no offline fields and no side table. SwiftSync reads the store's own change journal — SwiftData History (iOS 18+) — to find what changed locally, so the consumer adds nothing to the model. The one requirement: mark the identity @Attribute(.preserveValueOnDeletion) so a deleted row's id survives in history and its deletion can be pushed. That attribute is the offline opt-in — its presence switches this model's pull semantics to "honor pending local edits"; a plain @Syncable model without it keeps "server is authoritative".
@Syncable @Model
final class Task {
@Attribute(.unique, .preserveValueOnDeletion) var id: String // ← enables offline
var title: String
// no syncRemoteID, no isDeleted flag, no SyncOfflineModel conformance
}The identity is the only id: it's client-generated and the backend adopts it, so push is an idempotent upsert keyed by that id — there is no separate server id to map home. Make local edits with plain SwiftData (context.insert / mutate / context.delete); SwiftSync tracks them via history.
pendingChanges partitions the un-pushed local changes (history authored by you, since SwiftSync's internal per-type history token) into inserts, updates, and deletes. A row inserted and deleted before it was ever pushed is dropped (the server never saw it).
withPendingChanges runs one pass inside a scope SwiftSync manages (the with… idiom: it sets up the pending changes, your closure does the work, it commits the bookmark on the way out). It hands your closure a Sendable SyncPendingChanges (so SwiftData objects never cross into a network call — you own the request); the closure does the network work and returns the failures ([SyncPendingChangesFailure]), which the method returns straight back to you. Everything else is confirmed by complement — the client id is the identity the server adopts, so a push is an idempotent upsert with no acknowledgements to echo back. Only when the closure reports no failures does it advance its internal history token. It writes no per-row state; a failed (or otherwise un-pushed) change stays pending and is re-detected next call. Surface the returned failures however you like (discard / edit / retry).
let failures = try await SwiftSync.withPendingChanges(for: Task.self, in: syncContainer.mainContext) { pending in
// map pending.inserts / pending.updates / pending.deletes (the ids) to upsert/delete
// requests, call your API, and return a SyncPendingChangesFailure for each rejected id
try await api.push(pending)
}
// No history token to persist — SwiftSync owns it. `failures` is empty on a fully clean pass.withPendingChanges is the storage primitive — SwiftSync brackets the local-change detection and the history token; you own the network call and decide when to push. Orchestration (push-before-pull, draining on reconnect, surfacing the failures inbox) is the app's job, not the library's — see the demo's DemoSyncEngine and docs/project/architecture.md for the layering.
Inbound pulls are tagged internally so a freshly-pulled row is never mistaken for a local edit; an un-pushed local insert survives a pull that omits it, and a newer local edit isn't clobbered by an older server version (last-writer-wins). Offline requires a persistent store (history is unavailable to ephemeral stores).
SwiftSync handles common API date formats out of the box, so most apps do not need any date setup to get started.
On import, it accepts common ISO8601 variants, date-only strings, YYYY-MM-DD HH:mm:ss, fractional seconds, and unix timestamps.
On export, it uses an ISO-style UTC formatter by default. That means the same SyncContainer can usually parse backend dates on the way in and emit API-ready dates on the way back out without extra configuration.
If your backend expects a different outbound format, configure the container with a custom DateFormatter:
let syncContainer = SyncContainer(
modelContainer,
dateFormatter: formatter
)Use that when the server expects a specific non-default string format for create or update payloads.
The demo app shows the full workflow end to end. It is there to show the pieces working together, not to explain every concept in the README.
It includes:
- Project-scoped task sync
- Task detail sync with nested items
- To-one and to-many relationship updates
- Local reactive reads in SwiftUI and UIKit
- Create/edit flows that export local models back into payloads
Open SwiftSync.xcworkspace if you want to see those cases working together in a single app.
SwiftSync is released under the MIT License.


