iOS Development

Let SQLite Do the Work: Efficient Data Fetching in Core Data

·10 min read

When building iOS apps that handle large datasets, how you fetch and filter data can make or break your user experience. The difference between a snappy app and a sluggish one often comes down to a simple principle: push work to the database layer.

In this post, I'll share patterns I've found effective for Core Data fetching—ones that leverage SQLite's query engine instead of doing expensive work in Swift.

The Problem: In-Memory Filtering

Here's a pattern I see frequently in SwiftUI apps:

@FetchRequest(
    sortDescriptors: [NSSortDescriptor(keyPath: \Item.createdAt, ascending: false)]
)
private var allItems: FetchedResults<Item>

var filteredItems: [Item] {
    allItems.filter { item in
        item.createdAt >= startDate &&
        item.createdAt <= endDate &&
        selectedTags.contains(item.tag)
    }
}

This works fine with 100 records. But what happens with 10,000+ records accumulated over time?

  1. Core Data loads all objects into memory
  2. Swift iterates through each one, checking conditions
  3. Your UI stutters while this happens on the main thread

The fix isn't complicated—we just need to let SQLite handle the filtering.

NSPredicate: Your Gateway to SQL

Core Data's NSPredicate isn't just a Swift filtering mechanism. When you attach a predicate to a fetch request, Core Data compiles it into a SQL WHERE clause. The filtering happens in SQLite before objects ever touch your app's memory.

This isn't just a performance trick—it's explicitly recommended by Apple. From WWDC 2010's "Optimizing Core Data Performance":

"SQL databases are good at filtering, they're good at sorting, doing calculations and they do it all without having to instantiate any objects or malloc anything. Let the database do it if you can."

// This predicate...
let predicate = NSPredicate(
    format: "createdAt >= %@ AND createdAt <= %@",
    startDate as NSDate,
    endDate as NSDate
)

// ...becomes something like this SQL:
// SELECT * FROM ZITEM WHERE ZCREATEDAT >= ? AND ZCREATEDAT <= ?

Common Predicate Patterns

Here are predicate patterns that translate efficiently to SQL:

// Equality
NSPredicate(format: "status == %@", "active")

// Range queries
NSPredicate(format: "price >= %@ AND price <= %@", minPrice, maxPrice)

// Set membership (translates to SQL IN clause)
NSPredicate(format: "id IN %@", selectedIDs)

// String matching (case-insensitive, diacritic-insensitive)
NSPredicate(format: "title CONTAINS[cd] %@", searchText)

// Relationship traversal (generates SQL JOINs)
NSPredicate(format: "category.id IN %@", categoryIDs)
NSPredicate(format: "author.name CONTAINS[cd] %@", searchText)

The [cd] modifier makes string matching case and diacritic insensitive, which SQLite handles natively.

Building Dynamic Compound Predicates

Real apps rarely have static filter requirements. Users toggle filters, search, and combine criteria. Here's a pattern for building predicates dynamically:

private func buildPredicate() -> NSPredicate? {
    var predicates: [NSPredicate] = []

    // Date range filter
    if let dateRange = selectedDateRange {
        predicates.append(
            NSPredicate(
                format: "createdAt >= %@ AND createdAt <= %@",
                dateRange.start as NSDate,
                dateRange.end as NSDate
            )
        )
    }

    // Category filter
    if !selectedCategoryIDs.isEmpty {
        predicates.append(
            NSPredicate(format: "category.id IN %@", selectedCategoryIDs)
        )
    }

    // Status filter
    if let status = selectedStatus {
        predicates.append(
            NSPredicate(format: "status == %@", status)
        )
    }

    // Combine all predicates with AND
    guard !predicates.isEmpty else { return nil }
    return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}

Each active filter adds a predicate, and NSCompoundPredicate combines them. SQLite evaluates the entire compound condition in a single query.

Pagination: Two Approaches

Even with perfect predicates, loading thousands of records at once is wasteful. Core Data offers two approaches.

Option 1: fetchBatchSize (Simpler)

Core Data has built-in paging via fetchBatchSize. The fetch returns all matching objects as faults—lightweight placeholders that consume minimal memory. Core Data loads actual data in batches as you access objects.

let request = Item.fetchRequest()
request.predicate = buildPredicate()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Item.createdAt, ascending: false)]
request.fetchBatchSize = 50  // Load 50 objects at a time

let results = try viewContext.fetch(request)
// Actual data loads in batches of 50 as you iterate

Pros: Simple, works with @FetchRequest, automatic memory management Cons: Initial query still touches all matching rows

Option 2: fetchLimit + fetchOffset (Manual Control)

For true infinite scroll with explicit control:

private let pageSize = 100

private func loadItems(offset: Int = 0) {
    let request = Item.fetchRequest()
    request.predicate = buildPredicate()
    request.sortDescriptors = [NSSortDescriptor(keyPath: \Item.createdAt, ascending: false)]
    request.fetchLimit = pageSize
    request.fetchOffset = offset

    do {
        let results = try viewContext.fetch(request)
        if offset == 0 {
            self.items = results
        } else {
            self.items.append(contentsOf: results)
        }
        self.hasMoreData = results.count == pageSize
    } catch {
        // Handle error
    }
}

Pros: Full control, only queries what you need Cons: More code, loses automatic updates, large offsets can be slow

Search: The Right Way

Many developers debounce search input and hit the database on each query. But Apple recommends a smarter approach.

From WWDC 2010:

"Only the first letter typed into the search field should ever hit the disk. After the first letter you should only be refining the search results of what is already in memory."

The pattern:

  1. First keystroke → database query with predicate
  2. Subsequent keystrokes → filter the in-memory results
  3. User clears and starts over → back to database
@State private var searchText = ""
@State private var searchBaseResults: [Item] = []
@State private var isInitialSearch = true

var displayedItems: [Item] {
    if searchText.isEmpty { return items }

    // Filter in-memory for subsequent keystrokes
    return searchBaseResults.filter { item in
        item.title?.localizedCaseInsensitiveContains(searchText) == true
    }
}

private func handleSearchChange(oldValue: String, newValue: String) {
    if newValue.isEmpty {
        isInitialSearch = true
        searchBaseResults = []
    } else if isInitialSearch || !newValue.hasPrefix(oldValue) {
        // First character OR user changed direction - hit database
        performDatabaseSearch(newValue)
        isInitialSearch = false
    }
    // Otherwise: just refine in-memory
}

When typing "coffee": "c" hits the database, then "co", "cof", "coff", etc. all filter in-memory—nearly instantaneous.

@FetchRequest vs. Manual Fetching

Before reaching for manual fetching, understand the trade-offs:

Feature@FetchRequestManual Fetch
Automatic UI updatesYesNo
PaginationVia fetchBatchSizeFull control
Dynamic predicatesRequires view recreationEasy
Code complexityLowHigher

Use @FetchRequest when:

  • Dataset is small to medium (< 1000 items)
  • You need live updates as data changes
  • Filtering is relatively static

Use manual fetching when:

  • Dataset can grow very large
  • You need true infinite scroll
  • Filters are highly dynamic

My advice: embrace @FetchRequest when possible:

"Many developers manually implement fetching for SwiftUI, but this often results in more lines of code, contributing to an increased burden. Embrace SwiftUI as it was intended."

Date Boundaries: A Common Pitfall

Date filtering is tricky. Users think "last week" but databases need precise timestamps. A common bug: filtering for "today" but missing morning records due to timezone issues.

Always normalize date boundaries:

struct DateHelper {
    static var calendar: Calendar {
        var cal = Calendar.current
        cal.timeZone = TimeZone.current
        return cal
    }

    static func startOfDay(for date: Date) -> Date {
        calendar.startOfDay(for: date)
    }

    static func endOfDay(for date: Date) -> Date {
        calendar.date(bySettingHour: 23, minute: 59, second: 59, of: date) ?? date
    }

    static func lastDaysRange(_ days: Int) -> (start: Date, end: Date) {
        let now = Date()
        let start = calendar.date(byAdding: .day, value: -days, to: now) ?? now
        return (startOfDay(for: start), endOfDay(for: now))
    }
}

Key Takeaways

  1. Use NSPredicate — Let Core Data compile filters to SQL. This is Apple's official recommendation.

  2. Choose the right paginationfetchBatchSize for simplicity, fetchLimit/fetchOffset for control.

  3. Search smart — Hit database once on first keystroke, then filter in-memory.

  4. Know the trade-offs — Manual fetching gives control but loses automatic updates.

  5. Handle dates carefully — Normalize to start/end of day with timezone awareness.

The principle is simple: SQLite is fast at filtering and sorting. Let it do what it's good at. Happy coding!


References

Related Posts