22 Minutes
SwiftData Tutorial: Swift Data Storage for iOS Apps
Fix Bugs Faster! Log Collection Made Easy
Since its debut in June 2023, SwiftData has fundamentally changed how Apple developers approach persistence. Devs the world over love it for its versatility, its declarative ease and its powerful querying system.
But if you’re new, SwiftData can take some getting used to. Failures can feel less transparent and relationships can play out differently to how you might expect.
So in this tutorial we’ll show you how SwiftData works and how to:
- Build a working SwiftData example step by step
- Perform CRUD operations with confidence
- Decide whether SwiftData is the right persistence framework for your project
Want to jump to a specific section?
Here are the three pillars of the article if you want to jump straight there.
- How to start using SwiftData step by step
- Should you use SwiftData in production?
- Troubleshooting common SwiftData problems
And here’s the full, granular list of contents.
Table of Contents
- What is SwiftData and what does it solve?
- How SwiftData handles data storage
- Swift data tutorial: how to use SwiftData
- SwiftData example: build a simple to-do list app
- Advanced SwiftData queries and relationships
- Should you use SwiftData in production apps?
- Debugging and troubleshooting SwiftData
- Frequently asked questions about SwiftData
- Final thoughts on using SwiftData
What is SwiftData and what does it solve?
SwiftData is Apple’s Swift-native persistence framework introduced at WWDC 2023 alongside iOS 17. It allows developers to define data models directly in Swift using @Model, while the framework manages local storage automatically.
Instead of configuring a traditional storage layer manually, we define models directly in Swift and let the framework handle persistence.
SwiftData solves several long-standing painpoints:
- Reduces Core Data boilerplate and configuration overhead
- Uses native Swift syntax like
@Modelinstead of complex model editors - Integrates tightly with SwiftUI state and data flow
- Simplifies migrations and schema updates
- Makes local data persistence easier for small and mid-sized apps
Most important of all, it keeps the benefits of structured local storage without forcing us into legacy patterns.
Ok. Now what is a SwiftData @Model class?
A SwiftData @Model class defines one type of data our app wants to store permanently.
Think of it as a blueprint for stored information. If we are building a to-do app, each to-do item is one stored record. For example:
@Model
class TodoItem {
var title: String
var isCompleted: Bool
}
Conceptually:
- The class (
TodoItem) behaves like a table - Each property (
title,isCompleted) behaves like a column - Each instance we create becomes one stored row
Crucially, we do not design tables manually or write SQL.
We describe the structure using Swift properties, and SwiftData converts it into persistent storage automatically.
How SwiftData handles data storage
When the app launches, SwiftData initializes a model container attached to the SwiftUI scene.
From that moment, the following sequence happens:
- The app registers every
@Modeltype included in the container. - SwiftData prepares or opens the persistent store on disk.
- A
modelContextis created to manage in-memory objects. - When we create or modify a model instance, the context tracks those changes.
- When we call
save(), the context writes the changes to the persistent store. - On the next app launch, stored records are loaded back into memory as model objects.
At runtime, we interact with normal Swift objects.
Behind the scenes, SwiftData does all the nuts and bolts stuff: it handles schema management, change tracking, and disk persistence automatically.
Understanding modelContext, insert(), and save()
You’ll be using modelContext a lot.
This is the object that manages model objects during runtime. It tracks inserts, updates, and deletions in memory before they are written to persistent storage.
- We access the context using
@Environment(\\.modelContext). - We create or modify model instances.
- The context automatically records those changes.
- Calling
try context.save()commits the pending changes to disk.
@Environment(\\.modelContext) private var context
Button("Add item") {
// Create a new model object
let item = TodoItem(title: "Try Bugfender for free")
// Add it to the context (currently only in memory)
context.insert(item)
// Commit the change to persistent storage
try? context.save()
}
Without calling save(), changes remain in memory. Once saved, they are written to persistent storage and remain available across app launches.
Swift data tutorial: how to use SwiftData
Before we start, we need to confirm the technical baseline.
SwiftData requires:
- Xcode 15 or later
- Swift 5.9 or newer
- A minimum deployment target of iOS 17, iPadOS 17, or macOS 14
If your app can only support iOS 16 or earlier (or it has to work with those iterations for some reason), bad news: SwiftData won’t work on those devices. You would need to either raise the deployment target to iOS 17+ or maintain a separate persistence solution for older systems.
Also, worth noting that SwiftData integrates most naturally with SwiftUI.
Now we’ve clarified these requirements, we can move to the first step: setting up SwiftData in the project.
Step 1: Create a new project with SwiftData storage
- Go to File → New → Project.
- Choose iOS → App and click Next.
- Select SwiftUI as the interface.
- Choose Swift as the language.
- In the Storage dropdown, select SwiftData.
- Click Create.
When we choose SwiftData as the storage option, Xcode automatically configures the project for us. It sets the deployment target to iOS 17+, generates a sample @Model type, and injects a .modelContainer(for:) modifier into the @main app file.
It also wires a default modelContext into the SwiftUI environment, so our views can immediately read and write data.
At this point, persistence is already integrated into the app lifecycle.
Step 2: Create a SwiftData @Model class
Now we get to define what type of data the app should store.
- In Xcode, go to File → New → File.
- Choose Swift File.
- Give the file a name that represents one stored item (for example,
TodoItem.swift). - Click Create.
Inside that file, import SwiftData and declare a class marked with @Model:
import SwiftData
@Model
class ExampleItem {
var name: String
}
The @Model attribute tells SwiftData that this type should be persisted. Each property inside the class becomes part of the stored structure.
In the full example later, we’ll implement a concrete model (such as a to-do item) and connect it to the user interface.
Step 3: Access the modelContext in a SwiftUI view
Once we’ve defined a model, we need to put it to work.
In other words, we need a way to create, modify, and persist instances of it.
This is done through the modelContext.
- Open the SwiftUI view where you want to interact with stored data (for example,
ContentView.swift). - Add access to the context using
@Environment(\\.modelContext).
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\\.modelContext) private var context
var body: some View {
Text("Ready to insert data")
}
}
The modelContext is automatically available because the app’s entry point attaches a .modelContainer(...).
In the next step, we’ll use this context to perform basic database operations.
Step 4: Perform basic CRUD operations in SwiftData
In SwiftData, every CRUD operation follows the same lifecycle: modify the modelContext, then call save() to persist the change.
The context tracks changes in memory. Nothing is written to disk until save() runs.
1) Create inserts a new model instance into the context:
let object = ExampleModel(property: value)
context.insert(object)
try? context.save()
2) Read is handled reactively with @Query, which fetches and keeps results in sync:
@Query private var objects: [ExampleModel]
3) Update modifies a tracked object and then saves:
object.property = newValue
try? context.save()
4) Delete removes an object from the context and commits the change:
context.delete(object)
try? context.save()
Keep in mind that we only use @State for temporary UI input before creating a persistent model:
@State private var inputValue = ""
Next, let’s combine these pieces into a complete working example.
SwiftData example: build a simple to-do list app
This example demonstrates the full CRUD lifecycle: create, read, update, and delete records using SwiftData.
First, create a model file named TodoItem.swift:
import SwiftData
@Model
class TodoItem {
var title: String
var isCompleted: Bool = false
init(title: String) {
self.title = title
}
}
Next, update ContentView.swift:
import SwiftUI
import SwiftData
struct ContentView: View {
// Access the SwiftData model context (used to insert, delete, and save data)
@Environment(\\.modelContext) private var context
// Automatically fetch all TodoItem objects from the model container
// This keeps the UI in sync with stored data
@Query private var items: [TodoItem]
// Temporary state to hold the text typed into the input field
@State private var newTitle = ""
var body: some View {
VStack(spacing: 12) {
// Top input section (TextField + Add button)
HStack {
// Text input for a new todo item
TextField("New item…", text: $newTitle)
.textFieldStyle(.roundedBorder)
// Button to add a new item
Button("Add") {
// Remove extra spaces and line breaks
let title = newTitle.trimmingCharacters(in: .whitespacesAndNewlines)
// Prevent inserting empty items
guard !title.isEmpty else { return }
// Create and insert a new TodoItem into SwiftData
context.insert(TodoItem(title: title))
// Save changes to persistent storage
try? context.save()
// Clear the input field
newTitle = ""
}
// Disable button if input is empty
.disabled(newTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
// List of stored todo items
List {
// Loop through all fetched items
ForEach(items) { item in
HStack {
// Display the item title
Text(item.title)
Spacer()
// Button to toggle completion state
Button(item.isCompleted ? "✓" : "○") {
// Toggle boolean property
item.isCompleted.toggle()
// Save updated state
try? context.save()
}
}
}
// Enable swipe-to-delete
.onDelete { indexSet in
// Delete selected items from context
for index in indexSet {
context.delete(items[index])
}
// Save after deletion
try? context.save()
}
}
}
.padding()
}
}
// SwiftUI preview using an in-memory SwiftData container
#Preview {
ContentView()
// In-memory container avoids saving data permanently during preview
.modelContainer(for: TodoItem.self, inMemory: true)
}
Finally, confirm that your @main app file (in our example, SwiftDataDemoApp) attaches the model container:
import SwiftUI
import SwiftData
@main
struct SwiftDataDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: TodoItem.self)
}
}
And then just run the app with Cmd + R.
You can now:
- Add items (Create).
- See them listed automatically (Read).
- Toggle completion (Update).
- Swipe to delete (Delete).
This small example covers the complete SwiftData lifecycle in a real working app.
Advanced SwiftData queries and relationships
Basic CRUD is great for data persistence. But as your app grows, you’ll run into new constraints:
- Loading every record becomes inefficient. Large datasets slow down views and increase memory usage, so you need to fetch only relevant data.
- Saving after every small change wastes resources. When many objects must change together, batching updates improves performance and consistency.
- Data stops being isolated. Objects depend on each other, requiring clear relationships to keep structure maintainable.
- As features expand, your data model must evolve without breaking existing records.
SwiftData provides mechanisms to address these scaling and performance concerns. The following sections show how.
Advanced queries: sorting, filtering and fetching
Basic @Query usage fetches all stored objects automatically, but often that’s counter-productive: real apps rarely need everything at once.
SwiftData allows us to control what gets loaded and how it is ordered using sorting and filtering.
We can:
- Sort results by a property (for example, alphabetically or by date)
- Filter records using type-safe predicates
- Fetch only matching subsets instead of the entire dataset
Example with sorting:
@Query(sort: \\TodoItem.title)
private var items: [TodoItem]
Example with filtering:
@Query(filter: #Predicate { $0.isCompleted == false })
private var pendingItems: [TodoItem]
Instead of writing SQL, we describe intent using Swift syntax. SwiftData translates these expressions into efficient fetch operations behind the curtain. This keeps the API clean and still gives us precise control over what data is retrieved.
Transactions and bulk operations
When multiple changes need to succeed or fail together, transactions help keep our data consistent.
Instead of saving after every small change, we can group several inserts, updates, or deletions into a single commit.
For example, we might:
- Insert several records at once
- Update multiple objects in a loop
- Delete a group of related items
for item in items {
item.isCompleted = true
}
try? context.save()
In this case, all updates are staged in memory and written to disk in one operation when save() is called. If saving fails, none of the changes are permanently applied.
When working with larger datasets, it’s best to batch our changes. Instead of saving repeatedly inside loops, we modify objects first and call save() only once. This reduces unnecessary disk writes, improves performance and reduces the risk of partial updates.
Managing relationships and complex models
Most real applications connect their data together rather than storing isolated records.
- A task belongs to a project.
- A comment belongs to a post.
SwiftData allows us to define these relationships directly in our models using standard Swift properties, without manually configuring foreign keys.
@Model
class Project {
var name: String
var tasks: [Task] = []
}
@Model
class Task {
var title: String
var project: Project?
}
Here, a single Project can contain multiple Task objects, forming a one-to-many relationship. SwiftData understands this structure and manages the linking automatically.
We can navigate between related objects in code, while persistence, identity tracking, and referential integrity are handled under the hood.
Should you use SwiftData in production apps?
SwiftData is production-ready, but it aligns better with certain project profiles.
| SwiftData is ideal when… | SwiftData is not ideal when… |
|---|---|
| You are building a new iOS 17+ app where most users update quickly (e.g., fitness tracker, habit app, event companion app) | Your audience includes a significant number of users on iOS 15 or 16 (e.g., banking, public sector, enterprise apps) |
| The app is SwiftUI-first, using modern navigation and state-driven UI patterns | The codebase is UIKit-heavy with established data controllers and custom persistence layers |
| You are shipping features frequently and models evolve every sprint | You already maintain a large, stable Core Data stack with custom migrations and performance tuning |
| You need local storage for drafts, favorites, cached API data, or simple relational models | You require deep SQL control, advanced indexing, or cross-platform schema parity (iOS + Android + backend) |
| A small-to-mid team wants simpler persistence and lower maintenance overhead | The project depends on a heavily customized or shared database layer |
SwiftData fits best in modern, forward-looking Apple platform apps. The main constraint is ecosystem compatibility, not stability.
How SwiftData compares to traditional Swift database approaches
| Approach | Key characteristics / differences |
|---|---|
| Core Data (direct use) | Powerful and mature, but requires manual configuration of entities, contexts, and fetch requests. More boilerplate and steeper learning curve. |
| SQLite (raw or via wrappers) | Full control over schema and queries. Requires writing SQL and managing migrations manually. More flexible, less Swift-native. |
| Realm | Object-based database with simpler APIs than Core Data. Third-party dependency with its own data model system. |
| Custom persistence layers | Maximum control and customization. Higher maintenance cost and greater risk of architectural complexity. |
| SwiftData | Native Swift syntax with @Model, automatic schema handling, tight SwiftUI integration, built on Core Data infrastructure. Reduced boilerplate and modern API design. |
In short, SwiftData simplifies persistence by reducing configuration and boilerplate, while Core Data and SQLite offer deeper control at the cost of additional complexity.
What changes for your team when adopting SwiftData
Fundamentally, it’s about how you and your colleagues handle persistence. Instead of maintaining a separate model layer and configuration, you shift to code-first models and SwiftUI-first data flow.
You may also notice the following differences too:
- Less time spent on persistence setup and wiring in new features
- Fewer moving parts to debug (no model editor files, less glue code)
- Onboarding shifts from “learn Core Data concepts” to “learn SwiftData patterns”
- OS target becomes a hard constraint (iOS 17+), which may block adoption
- Existing persistence code often needs refactoring, not a drop-in swap
- Debugging data issues moves toward inspecting queries and context saves, not configuration
For teams shipping on modern OS versions, scalability improves mainly through simpler maintenance and fewer persistence-specific footguns.
Debugging and troubleshooting SwiftData
SwiftData issues usually come from how we manage the model context and queries, not from the storage engine itself. Most bugs are behavioral rather than structural.
Here’s a quick list of the most common problems to be mindful of.
| Common problem | Solution / Fix |
|---|---|
| Data disappears after restarting the app | Ensure try context.save() is called after inserts, updates, or deletes |
| New objects do not appear in the UI | Confirm the object was inserted into the correct modelContext and that @Query targets the right model |
@Query returns empty results | Check filter predicates, sort descriptors, and model property names |
| Duplicate records appear | Avoid inserting the same object multiple times without checking existing state |
| Relationship data seems inconsistent | Verify both sides of the relationship are correctly defined and saved |
| UI becomes slow with many records | Add filtering or sorting to limit the dataset instead of loading everything |
Most debugging centers on context usage, save timing, and query configuration rather than low-level storage failures.
Monitoring SwiftData in production
In production, data issues often appear only on real user devices, not in the simulator. Silent save failures, inconsistent state after backgrounding, or unexpected empty fetch results can be difficult to reproduce locally.
We should capture:
- Errors thrown from
context.save() - Unexpected empty query results
- Large fetches that impact performance
- Repeated inserts causing duplicate records
- Relationship updates affecting multiple models
In production environments, remote log collection becomes essential to diagnose persistence issues outside local development.
With Bugfender, you can capture save() failures, slow fetches, and unexpected empty queries directly from real user devices.
Try Bugfender for free and start monitoring SwiftData behavior proactively, so you can catch problems before they reach your audience.
Frequently asked questions about SwiftData
Can SwiftData be used without SwiftUI?
Yes. While SwiftData integrates most naturally with SwiftUI, it can also be used in UIKit-based apps. However, the automatic data flow features like @Query and environment-injected modelContext are designed primarily for SwiftUI.
How does SwiftData handle data migrations?
SwiftData supports lightweight schema migrations automatically when model changes are compatible. For more complex structural changes, migration planning is still required, similar to Core Data.
Is SwiftData suitable for large datasets?
SwiftData can handle large datasets, but performance depends on how queries are structured. Filtering, sorting, and limiting fetch scope are essential to avoid loading excessive records into memory.
Does SwiftData support background operations?
Yes. SwiftData supports background work through model contexts, but developers must manage concurrency carefully to avoid conflicts between contexts.
Can SwiftData sync data across devices?
SwiftData handles local persistence only. For cross-device sync, it must be combined with technologies like CloudKit or a custom backend.
Does SwiftData use SQLite?
SwiftData is built on top of Core Data, which commonly uses SQLite as its underlying storage engine. Developers interact with Swift-native models, while the framework manages persistence and storage automatically.
Final thoughts on using SwiftData
SwiftData aligns local persistence with Swift-native patterns and SwiftUI data flow.
For teams targeting iOS 17 and newer, it removes much of the historical friction around model configuration and context management while preserving the reliability of Apple’s underlying persistence stack.
It is not a universal replacement for every database strategy, especially in legacy or cross-platform scenarios. But for modern, forward-looking apps, SwiftData offers a pragmatic balance between simplicity and capability.
The real decision is architectural alignment. If your deployment targets and UI stack match its strengths, SwiftData becomes less about persistence plumbing and more about shipping features efficiently.
Expect The Unexpected!
Debug Faster With Bugfender