Let SQLite Do the Work: Efficient Data Fetching in Core Data
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?
- Core Data loads all objects into memory
- Swift iterates through each one, checking conditions
- 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:
- First keystroke → database query with predicate
- Subsequent keystrokes → filter the in-memory results
- 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 | @FetchRequest | Manual Fetch |
|---|---|---|
| Automatic UI updates | Yes | No |
| Pagination | Via fetchBatchSize | Full control |
| Dynamic predicates | Requires view recreation | Easy |
| Code complexity | Low | Higher |
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
-
Use NSPredicate — Let Core Data compile filters to SQL. This is Apple's official recommendation.
-
Choose the right pagination —
fetchBatchSizefor simplicity,fetchLimit/fetchOffsetfor control. -
Search smart — Hit database once on first keystroke, then filter in-memory.
-
Know the trade-offs — Manual fetching gives control but loses automatic updates.
-
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
What is Protocol-Oriented Programming in iOS and How It Differs from Object-Oriented Programming
Protocol-Oriented Programming is a paradigm that shifts the focus from what an object is (its class) to what an object can do (its capabilities).
Why iOS Development is Perfect for 2025
Discover why iOS development stands out as the ideal stack for building focused, high-quality software in 2025.