Preventing forgotten database migrations with automated tests in iOS
Learn how to automatically detect migration errors before shipping.
22 Feb 2026 · 7 min read
When working with persistence in iOS apps, schema changes are inevitable. For example when adding, removing or modifying properties, renaming attributes, changing relationships, or refactoring model structures.
On a clean install, the database is created with the newest schema so everything works. Existing users, however, already have a store created with an older schema. If the app cannot migrate that store, startup may fail. Ensuring that migrations are validated automatically can prevent breaking existing installations.
In this article, we'll look at a strategy to automatically detect migration errors before shipping.

Migrations across common frameworks
Different frameworks such as SwiftData, Core Data, Realm, or manual solutions like GRDB and SQLite, handle migrations slightly differently.
Some frameworks attempt to infer certain schema changes automatically and support lightweight migrations. Others always require explicit migration logic. The level of automation varies, but the risk of forgetting a required migration remains.
What does a meaningful migration test need to prove?
A migration test should answer the following question: Can the current app version open a database created by a previous version?
In manual testing, this is done by installing a published app version and then trying to update to the new app version that contains schema changes. To automate this in a test, we'll use the same principle.
Testing with a database from a previous version
The idea is the following:
- Add a baseline database file created with an older app version to the test target.
- Copy that file into a temporary directory during a test.
- Attempt to open it using the current persistence stack.
- Fail if opening throws an error.
Conceptually, a test might look like this:
import Testing@Testfunc previousStoreCanBeOpened() throws {let storeURL = try copyBaselineStoreToTemporaryDirectory()let container = makeProductionPersistentContainer(storeURL: storeURL)try container.loadPersistentStores()}
If a migration is missing or incompatible, loading the store throws an error and the test fails.
To cover both the full migration chain and the most realistic upgrade path, a strategy can be to maintain two baseline stores:
- A store created several versions ago (tests the entire chain)
- A store from the last released version (tests a common upgrade path)
Maintaining the "last release" store introduces additional effort, since it needs to be updated after each shipped schema change. Whether this is worth it depends on the project. For teams that already perform thorough manual upgrade testing, keeping a single older baseline may provide sufficient safety.
How do I create a database file?
The easiest way to create such a file is to run the app and let it create its database naturally. After that, the database file can be copied from the simulator or device container and added to the test target.
The exact file depends on the persistence framework:
- with Core Data, it is typically a .sqlite file (and possibly its -wal and -shm companions).
- with SwiftData, it is also backed by SQLite and follows the same structure.
- with Realm, it is a .realm file.
On the iOS simulator, the database can usually be found inside the app container under:
~/Library/Developer/CoreSimulator/Devices/<DeviceID>/data/Containers/Data/Application/<AppID>/
Can the baseline store be generated automatically?
Updating and replacing the baseline store after a schema change does involve some manual work. At first glance, it might seem cleaner to generate the baseline store automatically during the test run instead of maintaining fixture files. Conceptually, that could look something like this:
import Testing@Testfunc migrationFromPreviousSchemaSucceeds() throws {let oldStoreURL = try createStoreWithPreviousSchema()try openStoreWithCurrentSchema(at: oldStoreURL)}
It's easy to automate creating a store file. The hard part is creating it with an older schema.
To programmatically generate a "v2 store", the test must still have access to the "v2 model definition". For example, in Core Data, that usually means keeping older .xcdatamodel versions around and loading them explicitly. In Realm, we would need to keep the older objects somehow.
So it introduces maintenance work of a different kind:
- Older model versions must remain in the project and stay consistent with what was actually shipped.
- Tests need a reliable way to load a specific historical model version.
Because of that, in most cases, storing baseline files is the clearer and easier solution.



Newsletter
Like to support my work?
Say hi
Related tags
Articles with related topics
Latest articles and tips



