Most mobile native apps (iOS/Android) support evolving businesses, and must be ready to accommodate changes. Evolving persisted data in an app is crucial for maintaining a good user experience and preventing obsolescence as business requirements change. In my personal experience with production crashes, this is one of the most common issues encountered after a production release. While removing and reinstalling the app often fixes the issue, this is not an ideal solution.
The aim of this post is to demonstrate a possible method for handling migration in non-database persisted data. We will explain how to migrate JSON stored in user defaults, though this approach could also apply to data stored in files or keychain. It's important to note that we're focusing on non-database storage because database frameworks typically provide their own migration mechanisms that should be followed.
The starting point
The base application used as the foundation for this post simply displays a button. When pressed, it saves a structure to UserDefaults
. Upon restarting the app after it has been closed, the previously stored content is retrieved from UserDefaults
.

Let me introduce the persisted structure. It has been simplified for better understanding, but is not what you would find in a real production app:
struct Person: Codable {
let name: String
let age: String
}
New requirements
The business has decided that an email address is also required. Therefore, we will proceed to implement this feature.
struct Person: Codable {
let name: String
let age: String
let email: String
}

Initially, the app stored the data in the format {name, age}
. However, it has since been updated to expect the format {name, age, email}
. This discrepancy means that previously persisted data cannot be decoded properly, leading to an exception being thrown.
A common mistake during development at this stage is to simply remove the app from the simulator, reinstall the new version, and move on, effectively ignoring the issue. This is a poor decision, as it fails to address the root problem. Eventually, this oversight will come back to haunt you when the same issue occurs on hundreds, thousands, or an unacceptably large number of real app installations. This will result in a very poor user experience.
The first step to properly address this issue is to add a version field to the data structure. This allows for better handling of future changes to the structure.
struct Person: Codable {
var version: Int {
return 1
}
let name: String
let age: String
let email: String
}
We will implement a Migration Manager responsible for handling migrations
@MainActor
protocol MigrationManagerProtocol {
func applyMigration()
}
@MainActor
final class MigrationManager: ObservableObject {
@Published var migrationPenging = true
@Published var migrationFailed = false
struct PersonV0: Codable {
let name: String
let age: String
}
typealias PersonV1 = Person
}
In this class, we will include a copy of the original Person
structure, referred to as PersonV0
. The current Person
structure is named PersonV1
extension MigrationManager: MigrationManagerProtocol {
func applyMigration() {
defer { migrationPenging = false }
applyPersonMigration()
}
private func isPersonMigrationPending() -> Bool {
let userDefaultsManager = appSingletons.userDefaultsManager
return userDefaultsManager.get(Person.self, forKey: UserDefaultsManager.key.person) == nil
}
private func applyPersonMigration() {
let userDefaultsManager = appSingletons.userDefaultsManager
guard isPersonMigrationPending() else {
return // No migration needed
}
let currentStoredPersonVersion = storedPersonVersion()
if currentStoredPersonVersion == 0,
let personV0 = userDefaultsManager.get(PersonV0.self, forKey: UserDefaultsManager.key.person) {
let person = PersonV1(name: personV0.name, age: personV0.age, email: "---")
saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
}
}
private func storedPersonVersion() -> Int {
return 0
}
...
}
The migration process begins by determining the version of the Person
structure stored using the storedPersonVersion()
function. At this stage in the application’s evolution, the version is 0
.
If the current stored version is 0
, the process involves fetching PersonV0
from UserDefaults
and performing a migration. This migration entails transforming PersonV0
into PersonV1
by adding an email
field with a default value.
struct JSONPersistedMigrationApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onAppear {
#if DEBUG
setupDebugSwift()
#endif
}
.onShake {
#if DEBUG
DebugSwift.show()
#endif
}
.task {
appSingletons.migrationManager.applyMigration()
}
}
}
Finally, we call migrationManager.applyMigration()
within the .task
modifier to ensure it executes only once during the app’s startup.
For debugging purposes, I found the DebugSwift tool very useful. I explain in more detail how to integrate this tool and its main features in the following post.
Now, build and run the app:

Migration is currently being executed, and the app is displaying data properly.

When we open the DebugView tool to review user defaults, we observe that the migration has been completed exactly as expected.

Did we really finish? Well, not yet. It is mandatory to implement a test case that ensures the Person object can be migrated from V0 to V1.

Updating an attribute
Once the infrastructure for running migrations is ready, the next changes to the persisted structure should involve either a Tic-Tac-Toe game or an easy recipe.
Now, the business has requested that we rename the «name» field to «alias.» The first step is to update the structure by increasing the version number and renaming the field.
struct Person: Codable {
var version: Int {
return 2
}
let alias: String
let age: String
let email: String
}
Second, add PersonV1 to the Migration Manager and set PersonV2 as the current Person structure.
@MainActor
final class MigrationManager: ObservableObject {
@Published var migrationPenging = true
@Published var migrationFailed = false
struct PersonV0: Codable {
let name: String
let age: String
}
struct PersonV1: Codable {
var version: Int {
return 1
}
let name: String
let age: String
let email: String
}
typealias PersonV2 = Person
}
The third step is to update storedPersonVersion()
. This time, the stored Person version could be either V0 or V1.
private func storedPersonVersion() -> Int {
let userDefaultsManager = appSingletons.userDefaultsManager
if let personV1 = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
return 1
} else {
return 0
}
}
The fourth step is to implement the migration-if-block inside applyPersonMigration
.
private func applyPersonMigration() {
let userDefaultsManager = appSingletons.userDefaultsManager
guard isPersonMigrationPending() else {
return // No migration needed
}
let currentStoredPersonVersion = storedPersonVersion()
if currentStoredPersonVersion == 0,
let personV0 = userDefaultsManager.get(PersonV0.self, forKey: UserDefaultsManager.key.person) {
let person = PersonV1(name: personV0.name, age: personV0.age, email: "---")
saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
}
if currentStoredPersonVersion <= 1,
let personV1 = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
let person = PersonV2(alias: personV1.name, age: personV1.age, email: personV1.email)
saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
}
}
It has to be an independent if-block,. In case the app were V0 both if-blocks would be executed, and in case were V1 the tha last block woud be executed.
Fifth and last step, do unit test, now appears a new test case. One for testing migration from V0 to V2 and another for V1 to V2:

Remember, five steps one after the other.
Removing an attribute
Now, we will remove the age attribute. One, update the person structure.
struct Person: Codable {
var version: Int {
return 3
}
let alias: String
let email: String
}
Two, add PersonV2 to MigrationManager and set PersonV3 as the current Person structure.
@MainActor
final class MigrationManager: ObservableObject {
@Published var migrationPenging = true
@Published var migrationFailed = false
struct PersonV0: Codable {
let name: String
let age: String
}
struct PersonV1: Codable {
var version: Int {
return 1
}
let name: String
let age: String
let email: String
}
struct PersonV2: Codable {
var version: Int {
return 2
}
let alias: String
let age: String
let email: String
}
typealias PersonV3 = Person
}
Three, update storedPersonVersion(). This time, the stored Person version could be V0, V1, or V2:
private func storedPersonVersion() -> Int {
let userDefaultsManager = appSingletons.userDefaultsManager
if let _ = userDefaultsManager.get(PersonV2.self, forKey: UserDefaultsManager.key.person) {
return 2
} else if let _ = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
return 1
} else {
return 0
}
}
Four, the migration-if-block inside applyPersonMigration:
private func applyPersonMigration() {
let userDefaultsManager = appSingletons.userDefaultsManager
guard isPersonMigrationPending() else {
return // No migration needed
}
let currentStoredPersonVersion = storedPersonVersion()
if currentStoredPersonVersion == 0,
let personV0 = userDefaultsManager.get(PersonV0.self, forKey: UserDefaultsManager.key.person) {
let person = PersonV1(name: personV0.name, age: personV0.age, email: "---")
saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
}
if currentStoredPersonVersion <= 1,
let personV1 = userDefaultsManager.get(PersonV1.self, forKey: UserDefaultsManager.key.person) {
let person = PersonV2(alias: personV1.name, age: personV1.age, email: personV1.email)
saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
}
if currentStoredPersonVersion <= 2,
let personV2 = userDefaultsManager.get(PersonV2.self, forKey: UserDefaultsManager.key.person) {
let person = PersonV3(alias: personV2.alias, email: personV2.email)
saveInUserDefaults(person, UserDefaultsManager.key.person, &migrationFailed)
}
}
Five: Perform unit tests. A new test case has now been added, which tests migration from V0 to V3, V1 to V3, and V2 to V3.

Conclusions
In this post, you learned how to migrate non-database persisted data in your app. You can find the base project for developing this post in this repository.
References
- DebugSwift: Streamline your debugging workflow
JaviOS Blog Post