Contacts on macOS: Bounded Fetches, Change History, and Cache Recovery
Query macOS Contacts efficiently with minimal keys, immutable snapshots, durable change-history tokens, authorization checks, and explicit cache rebuilds.
The Contacts framework exposes the user’s address book through a store, fetch requests, and immutable contact values. Its design favors read-only access, but a naïve full fetch can still load many unnecessary properties, block the main thread, or leave an application showing stale names after the user changes a card.
A production integration treats access, fetching, caching, and change tracking as separate responsibilities. Request permission in context, fetch only the keys needed for a screen, move synchronous store I/O off the main thread, and make stale-cache recovery a normal code path rather than an exceptional afterthought.
Ask for access only when the feature needs it
Before exposing a feature that reads or edits contacts, check the authorization status for the Contacts entity. When permission has not been determined, request access at the point where the user invokes the feature and include the required usage description in the app’s property list. A previous denial, restriction, or later Settings change must be represented in the UI. Do not repeatedly prompt or treat a failed fetch as an empty address book.
Authorization behavior can differ by platform release and the level of access the person grants. Build the UI around the status returned by the OS rather than assuming that an old .authorized flow covers every modern system state. If a product supports a limited-contact selection experience, implement the corresponding ContactsUI flow for the OS versions where Apple documents it; do not assume that the same selection UI exists on every Mac version.
Fetch the smallest useful projection
CNContactFetchRequest requires a set of keys to fetch. Requesting names, phone numbers, postal addresses, images, and notes for every row in a search result increases work and data exposure. Choose the keys according to the feature. Fetch the display name for a picker; request phone or email fields only when the corresponding operation needs them.
import Contacts
import Foundation
func fetchDisplayNames() throws -> [String] {
let store = CNContactStore()
let request = CNContactFetchRequest(keysToFetch: [
CNContactGivenNameKey as CNKeyDescriptor,
CNContactFamilyNameKey as CNKeyDescriptor,
CNContactFormatter.descriptorForRequiredKeys(for: .fullName)
])
request.unifyResults = true
request.sortOrder = .givenName
var names: [String] = []
try store.enumerateContacts(with: request) { contact, _ in
let name = CNContactFormatter.string(
from: contact,
style: .fullName
) ?? ""
names.append(name)
}
return names
}
This synchronous function is suitable as a data-access example, not as a main-thread view operation. Contacts documentation recommends avoiding the main thread for fetch methods because they perform I/O. Run it in a background task or dedicated service, then return immutable results to the UI. If authorization is absent, this function can throw; callers should represent that state rather than silently returning an empty array.
For larger stores, enumeration avoids retaining every CNContact object at once. Convert records into a small app-owned value type as you enumerate. If a UI needs incremental search, use a Contacts predicate and debounce user input rather than repeatedly enumerating the entire database for each keystroke.
Unified contacts and identifiers
The address book can contain linked records from multiple accounts. unifyResults controls whether fetches return a unified contact representing linked records or individual records. Choose one model for the feature and keep it consistent in UI selection and caching. An identifier for a unified record is not the same as a source record’s identifier, so do not switch the setting while assuming stored IDs retain identical meaning.
Use CNContact.identifier to refer back to a contact through Contacts APIs, but handle a missing record on later fetch. Deletion, account changes, merging, and authorization changes can invalidate local assumptions. Names, email addresses, and phone numbers are mutable data, not stable primary keys. Do not key an app database on a display name or use a phone number as an implicit identity link.
If the app edits a contact, make the write boundary explicit. Fetch a mutable contact or construct a CNSaveRequest, change only user-approved fields, and report save errors. Keep the original values if a conflict or failure needs a user-visible repair path. A cache refresh must not silently overwrite edits made in the Contacts app or another account provider.
Choose notification refresh or change history
CNContactStoreDidChange tells the app that the Contacts store changed; it is not a list of changed records. For a small in-memory view, treat it as an invalidation signal, release cached objects, and refetch the current query. Coalesce notifications so several account sync updates trigger one refresh rather than a burst of full scans.
For an application that maintains a durable local index, use CNChangeHistoryFetchRequest and persist the opaque currentHistoryToken from a successful CNFetchResult. Supply that token as the next request’s startingToken. If no token exists, a full initial synchronization begins with the framework’s drop-everything event and additions for the current database. The framework may coalesce redundant changes, so the consumer should apply the supplied events as a synchronization feed, not as a record of every individual UI gesture.
Apply history updates idempotently. If an event requests dropping prior state, clear the derived index before processing the new additions. Save the new token only after all corresponding local updates commit successfully. If the process crashes midway, replay from the previous token and ensure repeated updates do not create duplicates. If history fetch fails or the token cannot be used, rebuild the index from a fresh snapshot and store the new token only after the rebuild is complete.
The change-history API is more precise than the broad notification, but it does not eliminate reconciliation. The local database and Contacts store are separate persistence systems. Use a local transaction so records and their token advance together; otherwise a crash can move the cursor past changes that were never saved locally.
Privacy, threading, and memory
Treat names, addresses, phone numbers, notes, images, and groups as personal information. Fetch no more than the feature needs, do not log contact contents for diagnostics, and define retention for any copied records. A framework authorization grant is not product consent to upload a user’s address book to a server.
CNContact values are immutable and thread-safe, which makes it practical to pass a minimal fetched value to another execution context. Store I/O still belongs off the main thread. Do not pass a mutable contact between unrelated tasks while one is changing it. If a contact image is required, request its image data only for the selected record or visible row rather than eagerly decoding every image in a directory.
Bound work for large datasets. A contact can contain many email addresses or phone numbers; normalize only the values needed by the feature and preserve labeled-value labels if the UI must distinguish work from home. Avoid storing large blobs in a broad cache. If the view pages results, make the page contract explicit and ensure the next fetch cannot produce duplicate rows after the store changes.
Acceptance and recovery tests
Test an empty store, a large store, linked contacts, missing optional properties, denied and restricted access, authorization changes, contact deletion during an open view, account removal, store-change notifications, malformed cache data, token replay, dropped local transactions, and a full rebuild. Verify that the UI distinguishes no matches from permission or I/O errors.
Measure fetch duration, number of properties requested, cache size, full rebuild time, change-history processing time, and stale identifier rate. Assert that the app never saves an advanced history token before the local projection commits. Test with localized names, mononyms, multiple scripts, contacts without names, and labeled values to prevent a display-specific schema from corrupting stored identity.
The Contacts framework provides a supported and privacy-mediated view of a changing database. Efficient macOS applications fetch narrow immutable projections, synchronize through tokens only when needed, and remain correct when a cache must be discarded and rebuilt.
Keep change application atomic
When consuming change-history events into a local search index, process one fetch result in a transaction. Apply adds and updates by the identifier supplied by Contacts, apply deletes idempotently, and handle a drop-everything event by clearing the derived index before accepting subsequent additions. Only after the local transaction commits should the app persist the result’s new history token. Advancing the token first creates a gap: if the process exits between token persistence and the data write, the next run may start after changes it never applied.
If the local store cannot commit, discard the partially applied transaction and retry from the previous token. If replay is no longer possible or an identifier can no longer be fetched, rebuild a fresh snapshot and replace the old index atomically. Keep a generation number on every refresh so an older full fetch cannot overwrite a newer change-history update that finished first.
Normalize presentation without rewriting source data
Names are culturally varied and may be absent, mononymous, or stored with phonetic components. Use CNContactFormatter for display rather than joining given and family names with a hard-coded space. Preserve the original labels on phone numbers and email addresses if the user must select a particular value. Do not assume every contact has a nonempty name or a valid email address.
When deduplicating your own derived records, use the Contacts identifier and unified-record policy, not fuzzy string matching on names. Two people can share a name, and one person can have several linked source records. If the store reports a structural change, invalidate cached object references and rebuild the relationship model rather than trying to infer merges from display text.
Related:
- EventKit on macOS: Event Store Access, Recurrence, and Change Recovery
- App Intents on macOS: System Actions, Parameters, and Shortcut Contracts
Sources: