10 SQLite Performance Tips for iOS & macOS Apps
SQLite
Performance
iOS
macOS

10 SQLite Performance Tips for iOS & macOS Apps

October 15, 2025
Team HarborDB

SQLite is the backbone of the Apple ecosystem. It powers Core Data, it backs typical direct database access on iOS and macOS, and it serves as the application file format for countless apps. While SQLite is famously "zero-configuration," taking it from a basic implementation to a high-performance engine capable of handling gigabytes of data and thousands of operations per second requires deep understanding and intentional tuning.

In this comprehensive guide, we will dive deep into the internals of SQLite, exploring advanced configuration (PRAGMAs), indexing strategies, and architectural patterns specifically tailored for the constraints and capabilities of iOS and macOS devices.

1. Enable Write-Ahead Logging (WAL) Mode

The single most effective change you can make to a standard SQLite database is helping it handle concurrency. By default, SQLite uses a "rollback journal" to ensure atomicity and durability. When a write transaction occurs, SQLite copies the original pages to a separate journal file before writing to the main database file. During this process, the database is locked for reading. This means readers block writers, and writers block readers.

WAL (Write-Ahead Logging) mode inverts this. Changes are written to a separate "WAL file" (often ending in -wal), and readers continue to read from the main database file (and the WAL file) without being blocked by the writer.

Why WAL Matters for iOS Apps

On iOS, the UI thread (Main Thread) often needs to read data to populate table views or collection views. If a background thread is syncing data from an API and writing to the DB in Rollback Journal mode, your Main Thread will block, causing UI stutters (dropped frames). With WAL mode, your background sync can write freely while your UI thread reads the latest committed snapshot.

How to Enable

You execute this via a PRAGMA command. This is persistent, so you only technically need to do it once, but it's good practice to run it on connection open.

PRAGMA journal_mode = WAL;

Output: wal

Deep Dive: Checkpoints

In WAL mode, the -wal file grows indefinitely until a "checkpoint" operation transfers the WAL pages back to the main .sqlite database. By default, SQLite creates an automatic checkpoint when the WAL reaches 1000 pages (approx 4MB). However, for heavy write workloads, you might want to tune wal_autocheckpoint or trigger them manually using PRAGMA wal_checkpoint(PASSIVE|FULL|RESTART|TRUNCATE).

2. Leverage Transactions Explicitly

It is a common misconception that individual SQL statements are fast enough. By default, SQLite opens a transaction for every single statement if one isn't manually started. This means INSERT INTO table... actually does:

  1. Open transaction
  2. Create journal file
  3. Write data
  4. Sync to disk (fsync)
  5. Close transaction
  6. Delete/invalidate journal

This fsync is expensive, especially on mobile flash storage.

The Batch Approach

Group your operations:

// BAD: 1000 fsyncs
for user in users {
    db.run("INSERT INTO users (name) VALUES (?)", user.name)
}

// GOOD: 1 fsync
db.transaction {
    for user in users {
        db.run("INSERT INTO users (name) VALUES (?)", user.name)
    }
}

Benchmarks typically show a 100x to 1000x speed improvement for bulk inserts when using explicit transactions.

3. Mastering Indexes: B-Trees, Covering, and Partial Indexes

Indexing is an art. Too few, and you scan the whole table (O(n)). Too many, and your creates/updates slow down (O(log n) per index).

The B-Tree Visualization

SQLite uses separate B-Trees for the table (rowid-based) and for each index.

  • A Table B-Tree stores the actual data in the leaf nodes.
  • An Index B-Tree stores the indexed key + the rowid.

Covering Indexes

To query strictly from the index without touching the main table (loading extra pages), include all retrieved columns in the index.

Scenario: You have a users table with id, name, email, last_login. Query: SELECT email FROM users WHERE name = 'Alice'

Standard Index: CREATE INDEX idx_name ON users(name);

  1. Search idx_name for 'Alice' -> get rowid.
  2. Seek rowid in Table B-Tree -> load page -> get email.

Covering Index: CREATE INDEX idx_name_email ON users(name, email);

  1. Search idx_name_email for 'Alice'. The email is arguably right there in the index leaf node. Result is returned instantly. Zero table lookups.

Partial Indexes

If you only query a subset of data (e.g., "active" users), don't index the whole table to save space.

CREATE INDEX idx_active_users_email 
ON users(email) 
WHERE status = 'active';

This index is smaller, faster to update, and faster to query.

4. Prepared Statements are Non-Negotiable

Parsing SQL is CPU-intensive. SELECT * FROM users WHERE id = 1 and SELECT * FROM users WHERE id = 2 are completely different strings to the SQLite parser.

A Prepared Statement compiles the SQL query plan into byte-code. You can then "bind" parameters to it and execute it multiple times.

Swift Example (using GRDB or SQLite.swift internally)

When you use a library effectively, it caches these statements. Use placeholders (?) instead of string interpolation:

// BAD - Compilation occurs every loop iteration
let sql = "INSERT INTO users (name) VALUES ('\(name)')"
try db.execute(sql)

// GOOD - Compile once, bind many
let stmt = try db.prepare("INSERT INTO users (name) VALUES (?)")
for name in names {
    try stmt.run(name)
}

This reduces CPU overhead significantly, which also saves battery life on mobile devices.

5. Optimize Your PRAGMA Settings

SQLite's defaults are conservative (aimed at 2005-era hardware). Modern iPhones have NVMe-class storage and gigabytes of RAM.

temp_store

By default, temporary tables and indices are stored on disk. Move them to RAM:

PRAGMA temp_store = MEMORY;

mmap_size

Memory-mapped I/O can be faster than standard read/write syscalls because it avoids copying data between kernel space and user space.

PRAGMA mmap_size = 268435456; -- 256MB

Note: Be careful with mmap if you have high memory pressure warnings on iOS, but for moderate datasets, it's a huge read booster.

synchronous

In WAL mode, the default is NORMAL. This is safe enough for most apps (you might lose the very last transaction on a power loss, but not database corruption). Avoid FULL or EXTRA unless you are writing banking software where improved durability is worth the ~20% writes speed penalty.

PRAGMA synchronous = NORMAL;

6. Understanding the Query Planner (EXPLAIN QUERY PLAN)

Never guess why a query is slow. SQLite tells you. Prefix your query with EXPLAIN QUERY PLAN.

EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id = 5 AND created_at > 1000;

Output Analysis:

  • SCAN TABLE: Bad. It's reading every row.
  • SEARCH TABLE USING INDEX: Good. It's using a B-Tree lookup.
  • USE TEMP B-TREE: Warning. It had to build a temporary index (probably for sorting).

HarborDB (our product) focuses heavily on visualizing these plans, but you can read the raw output in any CLI.

7. Handle SQLITE_BUSY Gracefully

Even in WAL mode, shared locks can occur. If a writer is finalizing a commit, a reader might be blocked briefly. By default, SQLite returns SQLITE_BUSY immediately. On iOS, you should set a timeout so SQLite retries internally before giving up.

PRAGMA busy_timeout = 3000; -- 3000 milliseconds

This simple setting prevents 99% of "Database is locked" errors in concurrent applications.

8. Denormalization for Mobile Speed

Strict normalization (3NF) is great for storage efficiency and consistency, but mobile screens often need "Joined" data instantly. Joins are expensive (O(M * log N)).

If you have a Posts list that shows the AuthorName and CommentCount, consider storing AuthorName and CommentCount directly on the Posts table. Yes, you have to update the Posts table when an author changes their name or a comment is added (Triggers can automate this!), but your READ speed (which happens 100x more often than writes) becomes O(1) per row instead of doing massive JOINs on scroll.

9. Efficient Pagination (Avoid OFFSET)

The standard web pagination LIMIT 20 OFFSET 100 is toxic in SQLite for large datasets. To get to OFFSET 10000, SQLite must compute the first 10,000 rows and throw them away. It gets linearly slower the deeper you page.

The Keysset (Seek) Method: Remember the last value seen. Query: SELECT * FROM items WHERE created_at < ? ORDER BY created_at DESC LIMIT 20

This uses the index to jump immediately to the correct spot. It is O(log N) regardless of page depth.

10. Vacuuming and Maintenance

Databases get fragmented. When you delete data, SQLite marks the pages as "free" but doesn't shrink the file size.

  • VACUUM: Rebuilds the specialized DB file from scratch. Perfectly compact. Slow. Exclusive lock.
  • PRAGMA auto_vacuum = INCREMENTAL: Allows you to reclaim space periodically without locking the world.

For iOS apps, we recommend:

  1. Use auto_vacuum = NONE (Default) for performance.
  2. Run a full VACUUM only during major migrations or rare maintenance windows (e.g., app update), as it requires disk space (2x DB size) and time.

Conclusion

Optimizing SQLite/PostgreSQL performance is a journey of understanding the hardware, the file system, and the B-Tree structures. By enabling WAL mode, using proper indexes, and understanding the query planner, almost any "slow" functionality in your iOS/macOS app can be made instant.

For developers who want to see these performance metrics in real-time and visualize their query plans, native tools like HarborDB provide a significant advantage over command-line guesswork.