Persistence
Picking among UserDefaults, files, Keychain, Core Data and SwiftData; the Core Data stack, its threading rules and migrations, and on-disk data protection.
14 questions
JuniorTheoryVery commonWhat are the main pieces of the Core Data stack, and what does each one do?
What are the main pieces of the Core Data stack, and what does each one do?
NSManagedObjectModel describes the schema; NSPersistentStoreCoordinator maps it to the store file (usually SQLite); NSManagedObjectContext is the scratchpad for creating and editing objects; NSPersistentContainer wires them together and vends the main context.
Common mistakes
- ✗Thinking
NSManagedObjectContextwrites to disk on every property change - ✗Confusing the store coordinator's role with the context's role
- ✗Believing
NSPersistentContainerdefines the schema rather than the model
Follow-up questions
- →Which stack object do you save to persist changes to disk?
- →Why does the container vend a context bound to the main queue?
JuniorTheoryVery commonUserDefaults, Keychain, files, and Core Data/SwiftData — what goes where, and what must never go in UserDefaults?
UserDefaults, Keychain, files, and Core Data/SwiftData — what goes where, and what must never go in UserDefaults?
Small prefs and flags go in UserDefaults; secrets in the Keychain; large blobs in the file system; object graphs in Core Data or SwiftData. Never put secrets or big data in UserDefaults — an unencrypted plist read at launch.
Common mistakes
- ✗Storing auth tokens or passwords in
UserDefaults - ✗Believing
UserDefaultsis encrypted or safe for secrets - ✗Using
UserDefaultsfor large data instead of files or a database
Follow-up questions
- →Why is
UserDefaultsa poor choice for an auth token? - →When would you pick Core Data over writing your own files?
JuniorTheoryCommonWhat is an NSFetchRequest, and what does an NSFetchedResultsController give a table view?
What is an NSFetchRequest, and what does an NSFetchedResultsController give a table view?
An NSFetchRequest describes what to fetch — the entity, a predicate, and sort descriptors. An NSFetchedResultsController wraps it for a table view: it supplies sections and rows and, via its delegate, streams granular insert/delete/move/update changes.
Common mistakes
- ✗Thinking an FRC reloads the entire table instead of applying granular deltas
- ✗Confusing the fetch request (the query) with the controller (the observer)
- ✗Assuming the FRC fetches from the network rather than the local store
Follow-up questions
- →How does the FRC deliver a move versus a simple update to the table view?
- →What role does the
sectionNameKeyPathplay in an FRC?
JuniorTheoryCommonDocuments, Caches, and tmp — what is backed up, what can iOS purge, and where does a downloaded video go?
Documents, Caches, and tmp — what is backed up, what can iOS purge, and where does a downloaded video go?
Documents/ holds user data and is backed up to iCloud. Library/Caches/ holds re-creatable data — not backed up, and iOS may purge it under disk pressure. tmp/ is scratch that can vanish anytime. A re-downloadable video belongs in Caches.
Common mistakes
- ✗Putting large re-downloadable media in Documents so it bloats iCloud backup
- ✗Assuming Caches is never purged by the system
- ✗Treating tmp as durable storage
Follow-up questions
- →Why does storing re-downloadable media in Documents bloat backups or risk rejection?
- →What does setting
isExcludedFromBackupon a Documents file achieve?
JuniorTheoryCommonWhat does the Keychain store, what does kSecAttrAccessible control, and why does an item survive a reinstall?
What does the Keychain store, what does kSecAttrAccessible control, and why does an item survive a reinstall?
The Keychain is a small encrypted, hardware-backed store for secrets — tokens, passwords, keys — not large data. kSecAttrAccessible… sets when an item is readable and whether it is device-only. Items live outside the sandbox, so a reinstall reads them back.
Common mistakes
- ✗Storing large data or caches in the Keychain instead of small secrets
- ✗Thinking Keychain items are deleted when the app is uninstalled
- ✗Ignoring
kSecAttrAccessibleand leaving items readable while locked
Follow-up questions
- →What is the difference between
WhenUnlockedandAfterFirstUnlock? - →How does a
ThisDeviceOnlyaccessibility value affect backups?
MiddleTheoryCommonWhat does context.save() do, and what happens to unsaved changes if the app is killed?
What does context.save() do, and what happens to unsaved changes if the app is killed?
save() pushes that context's pending changes one level down — to its parent if it has one, else through the coordinator to the store. A child's save() reaches only its parent, which must save too to hit disk. Unsaved changes live in memory, lost on kill.
Common mistakes
- ✗Thinking a child context's
save()reaches disk without the parent saving - ✗Assuming unsaved changes survive app termination
- ✗Believing
save()on one context flushes every other context
Follow-up questions
- →Why does saving a child context not immediately write to the store file?
- →How does a background child context avoid blocking the main-queue context?
MiddleDebuggingCommonCore Data crashes forming a relationship between objects in different contexts
Core Data crashes forming a relationship between objects in different contexts
A Core Data relationship can only join two NSManagedObjects in the same context. Here note is on the main context but category came from a background context, so note.category = category crashes. Fix it by re-fetching category in the main context via its objectID.
Common mistakes
- ✗Assigning a relationship across two different
NSManagedObjectContexts - ✗Thinking the crash is a threading issue fixable with a queue hop alone
- ✗Using KVC to bypass the context check instead of translating via
objectID
Follow-up questions
- →Why is
objectIDsafe to pass between contexts when the object is not? - →When would you use
object(with:)instead ofexistingObject(with:)here?
MiddleTheoryCommonWhy is an NSManagedObject not thread-safe, and how do you pass one to another context?
Why is an NSManagedObject not thread-safe, and how do you pass one to another context?
An NSManagedObject is tied to its context and that context's queue, so touching it from another thread corrupts the context. Don't pass the object across — pass its objectID and re-fetch it in the target context, inside its perform block.
Common mistakes
- ✗Passing the
NSManagedObjectitself between threads or contexts - ✗Thinking a lock makes a managed object thread-safe
- ✗Forgetting to re-fetch by
objectIDinside the target context'sperform
Follow-up questions
- →What is the difference between
object(with:)andexistingObject(withID:)? - →Why must the re-fetch happen inside the target context's
performblock?
MiddleDesignCommonYou are starting a brand-new iOS app in 2026 that needs a local, structured, queryable store. The team is weighing SwiftData — with @Model and ModelContainer — against Core Data. The minimum deployment target is negotiable. One engineer claims SwiftData fully replaces Core Data and you should never touch Core Data again; another wants to stay on Core Data for its maturity. Decide which you would adopt and why, and be explicit about what SwiftData is actually built on, where it still lacks parity, what deployment target it forces, and — if the app already had a Core Data store from an earlier prototype — what you do with that existing data.
You are starting a brand-new iOS app in 2026 that needs a local, structured, queryable store. The team is weighing SwiftData — with @Model and ModelContainer — against Core Data. The minimum deployment target is negotiable. One engineer claims SwiftData fully replaces Core Data and you should never touch Core Data again; another wants to stay on Core Data for its maturity. Decide which you would adopt and why, and be explicit about what SwiftData is actually built on, where it still lacks parity, what deployment target it forces, and — if the app already had a Core Data store from an earlier prototype — what you do with that existing data.
Adopt SwiftData for a new app if you can require iOS 17+; fall back to Core Data where it lacks parity. It is not a replacement — it is a Swift-native layer on Core Data's stack. Existing data isn't discarded — both share one store while you migrate.
Common mistakes
- ✗Believing SwiftData is a from-scratch engine that replaces Core Data
- ✗Forgetting SwiftData's iOS 17+ deployment requirement
- ✗Assuming existing Core Data data must be wiped to adopt SwiftData
Follow-up questions
- →Which Core Data capabilities still lack a SwiftData equivalent?
- →How can a SwiftData
ModelContainerand a Core Data stack share one store?
MiddleTheoryOccasionalWhat do the Data Protection classes control, and how can complete protection break a locked-device background task?
What do the Data Protection classes control, and how can complete protection break a locked-device background task?
Data Protection encrypts files at rest; the class sets when a file is readable relative to the lock. Complete is unreadable while locked; the default CompleteUntilFirstUserAuthentication stays readable after first unlock. A 3 a.m. task reading a Complete file fails once the phone auto-locks.
Common mistakes
- ✗Leaving a background-read file at
Completeso it is unreadable when locked - ✗Thinking Data Protection controls which apps can access a file
- ✗Assuming an encrypted file is readable regardless of device lock state
Follow-up questions
- →Which protection class lets a background task read a file after the first unlock?
- →Why is the decrypt key unavailable while the device is locked?
MiddleDesignOccasionalYour shipping app stores user data in Core Data. The next release adds two attributes to an existing entity, renames one attribute, and adds a new entity with a relationship to the old one. Some users are three versions behind. You must ship the schema change so that no user loses their existing local data on update, and the migration must run automatically on launch without a visible delay for the vast majority. Explain when Core Data's lightweight migration can handle a change like this on its own, what a custom mapping model is for and when you are forced to use one, and how you version the model and validate the upgrade path so a user three versions back still arrives safely at the new schema.
Your shipping app stores user data in Core Data. The next release adds two attributes to an existing entity, renames one attribute, and adds a new entity with a relationship to the old one. Some users are three versions behind. You must ship the schema change so that no user loses their existing local data on update, and the migration must run automatically on launch without a visible delay for the vast majority. Explain when Core Data's lightweight migration can handle a change like this on its own, what a custom mapping model is for and when you are forced to use one, and how you version the model and validate the upgrade path so a user three versions back still arrives safely at the new schema.
Lightweight migration works when Core Data can infer the mapping — adding/removing attributes, making one optional, or renaming via a renaming identifier. Data transforms need a mapping model and maybe a policy. Version and test each hop.
Common mistakes
- ✗Expecting lightweight migration to handle value transformations or entity splits
- ✗Forgetting the renaming identifier when renaming an attribute
- ✗Not testing the multi-version upgrade path on real data
Follow-up questions
- →What does an
NSEntityMigrationPolicylet you do that inference cannot? - →How do staged migrations help a user several versions behind?
MiddlePerformanceOccasionalAn app writes thousands of small files; launch slows and disk grows — how do you measure and fix it?
An app writes thousands of small files; launch slows and disk grows — how do you measure and fix it?
Measure with Instruments' File Activity template plus du and enumeration timing to find the write count and hot path. Thousands of tiny files cost per-file metadata and slow scans. Fix — coalesce them into one SQLite or Core Data store.
Common mistakes
- ✗Storing one file per record instead of a single consolidated store
- ✗Ignoring per-file metadata and directory-scan cost at scale
- ✗Not profiling file I/O with Instruments before changing the storage layout
Follow-up questions
- →Why does enumerating a directory of thousands of files slow app launch?
- →How does consolidating into SQLite reduce backup and disk overhead?
SeniorDesignRareDesign the local persistence layer for an app that must be fully usable offline — reading and writing — with the server as an eventual mirror, not the immediate source of truth. Requirements: the local store is the source of truth the UI reads from and writes to instantly; user mutations made offline are captured in a durable pending-mutation queue and replayed to the server when connectivity returns; and because the same record may be edited on another device meanwhile, you need a conflict-resolution strategy for when a replayed mutation clashes with the server's version. Describe the store and the mutation log, how you drain the queue safely (ordering, retries, idempotency), and how you resolve conflicts — including how the schema of the local store and the queue evolves across app versions without dropping unsynced mutations.
Design the local persistence layer for an app that must be fully usable offline — reading and writing — with the server as an eventual mirror, not the immediate source of truth. Requirements: the local store is the source of truth the UI reads from and writes to instantly; user mutations made offline are captured in a durable pending-mutation queue and replayed to the server when connectivity returns; and because the same record may be edited on another device meanwhile, you need a conflict-resolution strategy for when a replayed mutation clashes with the server's version. Describe the store and the mutation log, how you drain the queue safely (ordering, retries, idempotency), and how you resolve conflicts — including how the schema of the local store and the queue evolves across app versions without dropping unsynced mutations.
The local store is the source of truth the UI reads and writes; every offline mutation goes into a durable, ordered log, not just onto the row. A sync worker drains it when online — in order, idempotently via a client id, with backoff retries — and resolves clashes by an explicit policy like last-write-wins or per-field merge.
Common mistakes
- ✗Treating the server as the immediate source of truth instead of the local store
- ✗Storing pending mutations in memory so they are lost on termination
- ✗Having no explicit conflict-resolution policy for replayed mutations
Follow-up questions
- →Why does the mutation log need idempotency keys when draining?
- →When is last-write-wins unacceptable and conflict-tracking version vectors worth the cost?