Core Spotlight on macOS: Stable Identity, Incremental Indexing, and Recovery
Design Core Spotlight indexing around durable item IDs, explicit domains, bounded batches, deletion, reindex recovery, and app-owned search results.
Core Spotlight is an app-owned search index, not a mirror of the filesystem and not a database for the app’s canonical records. An app converts records it already owns into CSSearchableItem values, submits those values to a CSSearchableIndex, and later maps a selected result back to a record that still exists. Spotlight may display indexed metadata without launching the app, but the application remains responsible for the current object, authorization, and destination behavior.
That distinction should shape the whole pipeline. Treat the index as a derived, eventually refreshed projection of durable app state. A successful indexing callback means the indexing request completed without a reported error; it is not a transaction that atomically commits the app database and Spotlight together. If the app saves a note and crashes before indexing it, recovery must be able to discover the mismatch and submit it again.
For applications targeting macOS 26 and later that already model content as App Intents entities, Core Spotlight also supports indexing IndexedEntity values directly and querying them through IndexedEntityQuery, including reindex recovery. The CSSearchableItem flow below remains appropriate for existing indexes and content that is not represented as an app entity. Keep one canonical record identity and use Apple’s entity-association path when combining an app entity with an existing searchable item, rather than maintaining two unrelated index identities for the same content.
Choose identity before choosing metadata
Every item needs an identifier that the app can resolve later. Use a stable database primary key or another durable identifier, namespaced if multiple record types share one index. Do not use a title, array position, display string, or random UUID generated on every refresh. If a record is renamed, the identifier should remain the same so submitting it updates the logical item rather than creating a duplicate.
Use domainIdentifier to group related items that have a coherent lifecycle, such as one account, one library, or one document collection. It is useful for removing a group when that domain disappears, but it is not a security boundary or a replacement for authorization in the app. When a person removes a record, delete its searchable identifier; when an entire domain is removed, delete that domain’s items. A stale search result must resolve to a safe “no longer available” outcome rather than resurrecting deleted content.
Keep metadata concise and searchable. A title, content description, content type, and appropriate searchable text can make a useful result. Keep your original record as the source of truth and avoid indexing full content that the app cannot safely disclose through system search. Prefer attributes that help a person identify the result over dumping every database field into metadata. Normalize at the application boundary, but preserve meaningful punctuation, language, and names rather than assuming ASCII tokens.
import CoreSpotlight
import UniformTypeIdentifiers
struct SearchRecord {
let id: String
let title: String
let summary: String
let updatedAt: Date
}
func searchableItem(for record: SearchRecord) -> CSSearchableItem {
let attributes = CSSearchableItemAttributeSet(contentType: .text)
attributes.title = record.title
attributes.contentDescription = record.summary
attributes.contentModificationDate = record.updatedAt
return CSSearchableItem(
uniqueIdentifier: "note:\(record.id)",
domainIdentifier: "notes",
attributeSet: attributes
)
}
The prefix makes the identifier’s record type explicit and prevents an ID such as 42 from colliding with an unrelated entity type. Keep the mapping stable across application upgrades. If an identifier scheme must change, plan a controlled delete-and-reindex rather than silently leaving old IDs searchable forever.
Submit only committed state
The correct indexing point is after a durable save succeeds. If UI edits are still in memory, an index operation can publish content that disappears when the save fails. Conversely, waiting for a later full scan creates an avoidable period where a newly created object cannot be found. Make the persistence layer emit a change record or an outbox entry after commit, then let one indexing coordinator consume it.
Submitting the same identifier again updates it, so retrying an uncertain request can be made idempotent when the payload is derived from current canonical state. Do not rely on callback ordering among unrelated operations: serialize mutations for a custom index, or sequence them through one actor/operation queue. Otherwise an older delayed update can overwrite metadata from a newer edit. Give each queued operation a model revision and discard work that no longer matches the current record revision.
For a small app, a named index and a single-item update may be enough:
let index = CSSearchableIndex(name: "MainContent")
func index(record: SearchRecord, completion: @escaping (Error?) -> Void) {
index.indexSearchableItems([searchableItem(for: record)]) { error in
completion(error)
}
}
Keep the CSSearchableIndex instance under one owner and report errors to the app’s indexing queue. Avoid treating a callback as permission to drop every record of local reconciliation state. If the call fails, keep enough durable progress information to retry without requiring the user to reopen every object.
Bound large rebuilds with client state
A full rebuild after migration, corruption, or an explicit recovery request should not create one enormous in-memory array and submit it as a single operation. Read canonical records in bounded pages, convert them to items, submit a batch, and persist a checkpoint that identifies the last canonical record or database revision included. Apple’s Core Spotlight guidance describes custom-index batch operations and client state so an app can resume after interruption. Client state is a small checkpoint, not a copy of the full database; keep it within the documented size limit and version its encoding.
For each batch, define its checkpoint semantics precisely. Record a cursor only after the corresponding indexing operation reports success. If the process exits between index success and checkpoint persistence, replaying the batch is safe when IDs and item data are deterministic. If it exits before indexing success, retry that same batch. Never advance a cursor merely because items were placed in a local queue.
Custom indexes support batch updates; the default index does not support all custom-index capabilities. Choose a named index deliberately and use the APIs documented for the deployment SDK. Do not start overlapping batch operations against one custom index. A serial coordinator makes client-state ordering and error recovery much easier to prove.
Deletion, expiration, and reindex requests
When a record is deleted, remove the matching searchable item after the database delete commits. If the delete fails, do not erase the search result as if the canonical object were gone. When a whole collection is deleted, use its domainIdentifier for group cleanup. expirationDate is useful for results that are intentionally temporary, but it is not a substitute for explicit deletion when the app knows an object has ceased to exist.
Provide the reindexing support required by Apple’s current Core Spotlight guidance so the system can request that an app regenerate its index. With CSSearchableIndexDelegate, handle both full-index and identifier-specific reindex requests. Acknowledge a request only after the requested items and any client-state checkpoint have been saved successfully; the system may ask again after a crash, so replay must be safe. The handler should rebuild from the canonical store, use bounded batches, and tolerate a request that arrives while a normal update is already running. One safe policy is to coalesce a reindex request into a single serialized rebuild generation and cancel or supersede stale incremental work. Do not respond by indexing whatever happens to be cached in a view controller.
Index migrations should be explicit. If metadata rules change, update records in place where possible. If the identifier or domain model changes, define which old groups are removed and how the new inventory is populated. Keep the old search path usable until the replacement generation succeeds when product behavior allows it; if not, document the temporary search gap and make the next rebuild retryable.
Result handling is an application boundary
Search-result user information should contain the minimum routing data needed to find the app-owned object. On activation, parse it as untrusted input, validate the record type and identifier, load the current record, and re-check the app’s access rules. The indexed title and description can be stale. A deleted item, changed account, or invalid identifier should lead to an ordinary not-found state, not an unchecked force unwrap.
Use Core Spotlight queries only when the app needs to search its own indexed content from inside the app. Spotlight indexing and in-app query presentation are related but separate responsibilities. Do not assume that every indexed item is immediately visible on every device or that search ordering is a stable ranking contract. Build UI that can handle zero results, delayed results, and stale items.
Operational acceptance checks
Test create, rename, metadata-only update, delete-one, delete-domain, app reinstall or restore behavior where relevant, interrupted batch, failed batch, and reindex request. After each operation, query for both the old and new terms and confirm that the app opens the intended canonical record. Kill the process at checkpoint boundaries and verify that restarting replays work without duplicates or skipped rows.
Record a rebuild generation, cursor, batch size, record count, start time, last successful checkpoint, and error category. Avoid logging private record text. Alert on a rebuild that stops making progress or repeatedly fails at the same cursor. Measure time to index a representative corpus, but do not mistake a fast callback for proof that the UI result is instantly available system-wide.
The reliable design is an idempotent projection with durable source records, stable identifiers, explicit deletion, serialized index mutations, and restartable checkpoints. Core Spotlight provides the searchable index; the application still owns consistency, lifecycle, and the truth of every result.
Related:
- Spotlight Internals: How macOS Indexes and Searches Your Files
- FSEvents on macOS: Persistent Change Journals, Event Coalescing, and Rescan Boundaries
Sources: