Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

NSMetadataQuery on macOS: Live Search, Result Batches, and Scope

Use NSMetadataQuery for indexed file search with explicit scopes, predicates, gather/update phases, result batching, and cancellation.

NSMetadataQuery searches metadata indexed by Spotlight and reports an initial result set followed by live updates. It is useful when a Mac app needs to find matching files in supported indexed scopes, but it is not a recursive filesystem walker, an app-owned database, or a guarantee that every file is indexed and current. The query’s results are a changing projection of metadata services; the app still needs to handle files disappearing, access restrictions, and stale URLs.

Do not confuse NSMetadataQuery with Core Spotlight. Core Spotlight indexes records an app submits on purpose; metadata queries search the system’s indexed file metadata. Use the API that matches the source of truth. If the app owns records in a database, query that database. If the feature asks for files with indexed metadata in selected locations, configure an NSMetadataQuery and explain its search scope.

Define the query before starting it

Set a predicate before starting. Use searchScopes to narrow the search to locations the user expects, and request only the attributes needed to display or filter results. A broad scope can produce surprising results and unnecessary work. Predicate keys and operators must match Spotlight metadata attributes; a syntactically valid predicate with an unsupported key may return no useful results.

import Foundation

final class FileSearch {
    private let query = NSMetadataQuery()
    private var tokens: [NSObjectProtocol] = []

    func start() -> Bool {
        query.searchScopes = [NSMetadataQueryUserHomeScope]
        query.predicate = NSPredicate(
            format: "%K CONTAINS[cd] %@",
            NSMetadataItemFSNameKey,
            "report"
        )
        query.operationQueue = .main
        let center = NotificationCenter.default
        tokens.append(center.addObserver(forName: .NSMetadataQueryDidFinishGathering,
                                         object: query, queue: .main) { [weak self] _ in
            self?.consumeSnapshot()
        })
        tokens.append(center.addObserver(forName: .NSMetadataQueryDidUpdate,
                                         object: query, queue: .main) { [weak self] _ in
            self?.consumeSnapshot()
        })
        return query.start()
    }

    private func consumeSnapshot() {
        query.disableUpdates()
        defer { query.enableUpdates() }
        let urls = query.results.compactMap { ($0 as? NSMetadataItem)?.value(forAttribute: NSMetadataItemURLKey) as? URL }
        NSLog("Current indexed file results: %d", urls.count)
    }

    func stop() {
        query.stop()
        for token in tokens { NotificationCenter.default.removeObserver(token) }
        tokens.removeAll()
    }
}

The example uses the home scope as a clear demonstration, not as a universal search policy. A production feature should select the supported scope that matches its user experience and verify that the returned URL is accessible before acting on it. The query and notification observers must remain owned while the search is active.

Initial gathering and live updates are different phases

Starting a query begins an initial gathering phase. The query posts a finish-gathering notification when that phase completes; later changes arrive through update notifications. A progress indicator should distinguish “still gathering” from “finished with zero matches.” Empty results during initial gathering do not prove that no matching files exist.

The update notification can represent additions, removals, or changes. Re-read a consistent result snapshot when notified instead of applying deltas that the app cannot validate. disableUpdates() and enableUpdates() can bracket result consumption so an update does not mutate the query while the view model enumerates it. Copy the values the UI needs into immutable app models; do not hold result-array indexes as stable file identity.

Treat result objects and indexes as short-lived views. If the app needs to open a result later, capture the URL and enough display metadata for the current interaction, then revalidate access at action time. An indexed file can be moved, deleted, or become unavailable before the user clicks it. A result count is useful for presentation, but it should not be interpreted as a count of every file on the machine or as evidence that indexing is complete.

NSMetadataQuery has a notification batching interval. A longer interval can reduce update churn for rapidly changing search results but increases the delay before UI refresh. Tune this to the interaction, not to a presumed filesystem event rate. Debounce expensive preview generation separately from query-result notification so the search remains responsive.

Keep search results separate from file operations

An NSMetadataItem provides indexed attribute values. The file may have moved or become unavailable between query delivery and user selection. Resolve the URL again when the user acts, handle missing files, and use the app’s normal file coordination and permission paths before reading or editing. A Spotlight result is not a file descriptor and does not grant additional access.

Avoid assuming a result order is permanent. Sorting can change when metadata updates. Use the URL or a carefully normalized file identity as a lookup key for the current snapshot, but do not serialize an array index or display name as a durable identifier. If two files have the same name, their containing URLs and volume context distinguish the user-visible entries.

For a live search box, changing the predicate while running causes a query restart and discards the current results. Use that deliberately: debounce keystrokes, show a gathering state, and ignore callbacks associated with an older search generation. Rapidly replacing the predicate on each character without policy can make a query appear to flicker or never settle.

Build predicates from fixed metadata keys and parameterized values. Do not concatenate user-entered text into a predicate format string, because percent signs or format tokens in a query can change how the predicate is parsed. Normalize whitespace and define whether matching is case-sensitive or diacritic-sensitive as part of the search experience. For multiple constraints, group them explicitly so an OR branch cannot accidentally bypass a required scope or type condition.

Use metadata attributes that are indexed and meaningful for the target file types. A search for a file-name attribute is different from a search over document contents, and not every file provider or volume exposes equivalent metadata. Make “no results” distinct from “index unavailable” where the app can detect the difference. Offer a direct folder browse or user-selected file path as a fallback rather than promising Spotlight can enumerate an unindexed location.

When the UI supports grouping or value lists, treat those as query-derived aggregates that can change during live updates. Do not sum grouped counts and result count without accounting for duplicate group membership. Stable sort descriptors improve presentation consistency, but they do not convert the search result into a durable ordering contract. Re-sort the app’s copied snapshot only when it has a clear product reason and preserve the user’s current selection by file identity rather than row number.

Use searchScopes as a product boundary as well as a performance setting. A query scoped to user-visible locations is easier to explain than an implicit search everywhere, and the chosen scope should match the file access workflow that follows. Do not broaden scope automatically after a no-result response; that changes what the user asked the app to search. Let the user opt into a broader location or select a folder explicitly.

Attribute availability depends on indexing and the file’s metadata. If the UI offers filters for kind, modification date, or content type, verify the expected keys and comparison semantics against representative files. Present filters as hints when providers can omit metadata, and let the user clear them without losing the query text. This keeps a metadata query from making unsupported promises about exhaustive file discovery.

Query ownership, cancellation, and privacy

Retain the query, its delegate if used, and observer tokens in one search coordinator. Stop the query when the feature is dismissed or its owner ends. Remove notification observers on teardown and guard callbacks with a generation token so a late event cannot repopulate a closed result list. Do not create a new live query for every view update.

Metadata can expose filenames and attributes the user may consider sensitive. Limit scopes, keep results inside the local feature unless the user explicitly exports them, and do not log complete result paths by default. Do not use a metadata search as an access-control boundary or claim that a file is hidden merely because Spotlight did not return it.

Acceptance checks

Test a query with no predicate, invalid attribute names, empty matches, slow initial gathering, files added and removed during live updates, predicate changes while running, duplicate names, inaccessible results, cancellation during gathering, and reopening the search view. Assert that every callback updates only the active generation and that observers are removed after stop.

Measure time to first result, initial gather duration, result count, update batch frequency, snapshot-copy cost, and cancellation latency. Validate scopes and metadata keys on supported macOS versions and with Spotlight indexing enabled and disabled for test fixtures. A reliable metadata query treats indexing as asynchronous system state, separates result display from file access, and stops work when its owner goes away.

Related:

Sources:

Comments