SQLite on iOS: Swift Tutorial With the SQLite3 API and SQLite.swift

iOS includes SQLite, but Apple exposes it as a C API, and calling it from Swift has a trap: passing nil as the destructor to sqlite3_bind_text isn’t safe with Swift strings. In this article I’ll walk you through using SQLite from Swift the safe way, first with the raw SQLite3 API and then with the SQLite.swift package.

TLDR

  • iOS includes SQLite. There’s nothing to install: add import SQLite3 to a Swift file.
  • Open a database in the app’s Documents folder with sqlite3_open.
  • Run each statement with sqlite3_prepare_v2 and sqlite3_step, then free it with sqlite3_finalize.
  • Bind Swift strings with SQLITE_TRANSIENT, so SQLite copies them. Passing nil means SQLITE_STATIC.
  • Track schema changes with PRAGMA user_version, which lives inside the database file.
  • For a Swift-style API, add a wrapper such as SQLite.swift with Swift Package Manager.

What is SQLite on iOS and what will we build?

SQLite is the database engine that ships with iOS and macOS, so any app can use it without bundling a library. Swift reaches it through the SQLite3 module, a thin layer over SQLite’s C API. Apple’s Core Data framework can also store its data in SQLite. See SQLite vs Core Data for when to use which.

To show you each part of the API, I will build a small workout log around a Database class. It opens the file, keeps the schema up to date and saves workouts.

Prerequisites

RequirementDetail
Xcode and SwiftSwift 6.3.3 was used for the tests below
SQLiteComes with the OS. Check the version with sqlite3_libversion()
SQLite.swift (optional)0.16.0, added with Swift Package Manager

I ran the examples on macOS, which has the same SQLite3 module and C API as iOS. The iOS-only parts are labelled not executed: the Documents folder path, and Xcode’s linking settings.

Building the workout log

Each step adds one piece. The full program is in Step 3.

Step 1: Import SQLite3 and open the database

Add import SQLite3 to any Swift file. On iOS, put the database in the app’s Documents folder so it survives app updates (not executed here):

let url = try FileManager.default
    .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
    .appendingPathComponent("fitness.sqlite")
let db = try Database(path: url.path)

sqlite3_open creates the file if it doesn’t exist. If Xcode reports undefined symbols such as _sqlite3_open, add libsqlite3.tbd to your target’s linked libraries. My macOS build linked without it.

Step 2: Write a small Database class

This class wraps the C calls, turns errors into Swift errors and finalizes every statement with defer:

import Foundation
import SQLite3

// SQLITE_TRANSIENT tells SQLite to copy bound strings, because Swift may free them right after the call.
let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)

enum SQLiteError: Error { case message(String) }

final class Database {
    private var db: OpaquePointer?

    init(path: String) throws {
        if sqlite3_open(path, &db) != SQLITE_OK {
            throw SQLiteError.message(String(cString: sqlite3_errmsg(db)))
        }
    }

    deinit { sqlite3_close(db) }

    func execute(_ sql: String) throws {
        if sqlite3_exec(db, sql, nil, nil, nil) != SQLITE_OK {
            throw SQLiteError.message(String(cString: sqlite3_errmsg(db)))
        }
    }

    func insertWorkout(type: String, minutes: Double) throws -> Int64 {
        var stmt: OpaquePointer?
        defer { sqlite3_finalize(stmt) }
        guard sqlite3_prepare_v2(db, "INSERT INTO workouts (type, minutes) VALUES (?, ?)", -1, &stmt, nil) == SQLITE_OK else {
            throw SQLiteError.message(String(cString: sqlite3_errmsg(db)))
        }
        sqlite3_bind_text(stmt, 1, type, -1, SQLITE_TRANSIENT)
        sqlite3_bind_double(stmt, 2, minutes)
        guard sqlite3_step(stmt) == SQLITE_DONE else {
            throw SQLiteError.message(String(cString: sqlite3_errmsg(db)))
        }
        return sqlite3_last_insert_rowid(db)
    }

    func workouts(longerThan minutes: Double) throws -> [(Int64, String, Double)] {
        var stmt: OpaquePointer?
        defer { sqlite3_finalize(stmt) }
        guard sqlite3_prepare_v2(db, "SELECT id, type, minutes FROM workouts WHERE minutes > ? ORDER BY minutes DESC", -1, &stmt, nil) == SQLITE_OK else {
            throw SQLiteError.message(String(cString: sqlite3_errmsg(db)))
        }
        sqlite3_bind_double(stmt, 1, minutes)
        var rows: [(Int64, String, Double)] = []
        while sqlite3_step(stmt) == SQLITE_ROW {
            rows.append((sqlite3_column_int64(stmt, 0),
                         String(cString: sqlite3_column_text(stmt, 1)),
                         sqlite3_column_double(stmt, 2)))
        }
        return rows
    }

    var userVersion: Int32 {
        var stmt: OpaquePointer?
        defer { sqlite3_finalize(stmt) }
        sqlite3_prepare_v2(db, "PRAGMA user_version", -1, &stmt, nil)
        sqlite3_step(stmt)
        return sqlite3_column_int(stmt, 0)
    }
}

These details matter:

  • SQLITE_TRANSIENT. SQLite defines SQLITE_STATIC as 0 and SQLITE_TRANSIENT as -1 (SQLite constants). With SQLITE_STATIC, the string “must remain valid until either the prepared statement is finalized or the same SQL parameter is bound to something else” (sqlite3_bind_text). A Swift string passed to a C function isn’t guaranteed to live that long. Pass SQLITE_TRANSIENT instead, and SQLite copies it.
  • Placeholders. The ? placeholders keep values out of the SQL text, which prevents SQL injection.

Step 3: Migrate the schema with user_version

PRAGMA user_version is an integer stored in the database header. Each migration runs once, then bumps the number. This program migrates, saves three workouts in one transaction and queries them:

import Foundation
import SQLite3

// On iOS, use the app's Documents folder:
// FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("fitness.sqlite").path
let path = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "fitness.sqlite"
print("SQLite library version:", String(cString: sqlite3_libversion()))

do {
    let db = try Database(path: path)

    // Migrations keyed on PRAGMA user_version, stored inside the database file.
    if db.userVersion < 1 {
        try db.execute("CREATE TABLE workouts (id INTEGER PRIMARY KEY, type TEXT NOT NULL, minutes REAL NOT NULL)")
        try db.execute("PRAGMA user_version = 1")
        print("Migrated to version 1")
    }
    if db.userVersion < 2 {
        try db.execute("ALTER TABLE workouts ADD COLUMN calories INTEGER")
        try db.execute("PRAGMA user_version = 2")
        print("Migrated to version 2")
    }
    print("Schema version:", db.userVersion)

    try db.execute("BEGIN")
    for (type, minutes) in [("Running", 35.5), ("Yoga", 20.0), ("Cycling", 48.0)] {
        let id = try db.insertWorkout(type: type, minutes: minutes)
        print("Inserted \(type) with id \(id)")
    }
    try db.execute("COMMIT")

    for (id, type, minutes) in try db.workouts(longerThan: 30) {
        print("#\(id) \(type): \(minutes) min")
    }

    do {
        try db.execute("INSERT INTO workouts (type) VALUES ('Walk')")
    } catch {
        print("Error:", error)
    }
} catch {
    print("Failed:", error)
}

Step 4: Or use SQLite.swift instead of the C API

SQLite.swift is a Swift Package Manager library with a type-safe query builder. Add it in Xcode (File > Add Package Dependencies) or in Package.swift:

// swift-tools-version:5.9
import PackageDescription

let package = Package(
    name: "WorkoutsDemo",
    platforms: [.macOS(.v13), .iOS(.v15)],
    dependencies: [
        .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.0")
    ],
    targets: [
        .executableTarget(name: "WorkoutsDemo", dependencies: [.product(name: "SQLite", package: "SQLite.swift")])
    ]
)
import SQLite

let db = try Connection("workouts.sqlite3")
let workouts = Table("workouts")
let id = SQLite.Expression<Int64>("id")
let type = SQLite.Expression<String>("type")
let minutes = SQLite.Expression<Double>("minutes")

try db.run(workouts.create(ifNotExists: true) { t in
    t.column(id, primaryKey: true)
    t.column(type)
    t.column(minutes)
})
let rowid = try db.run(workouts.insert(type <- "Running", minutes <- 35.5))
print("Inserted row", rowid)
for row in try db.prepare(workouts.filter(minutes > 30)) {
    print(row[id], row[type], row[minutes])
}
print("Total minutes:", try db.scalar(workouts.select(minutes.sum)) ?? 0)
$ swift run -c release 2>/dev/null
Inserted row 1
1 Running 35.5
Total minutes: 35.5
SQLite.swift 0.16.0

GRDB is another wrapper, and FMDB is the Objective-C one. All three use the same SQLite engine underneath.

Step 5: Use the same API from Objective-C

Objective-C uses the same C functions after #import <sqlite3.h> (not executed here):

sqlite3 *db;
NSString *docs = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
NSString *path = [docs stringByAppendingPathComponent:@"fitness.sqlite"];
if (sqlite3_open(path.UTF8String, &db) != SQLITE_OK) {
    NSLog(@"%s", sqlite3_errmsg(db));
}

Running it

Build the two files and run the program twice against the same file:

$ swiftc -O -o fitness Database.swift main.swift && echo built
built

The first run creates the schema:

$ ./fitness fitness.sqlite
SQLite library version: 3.51.0
Migrated to version 1
Migrated to version 2
Schema version: 2
Inserted Running with id 1
Inserted Yoga with id 2
Inserted Cycling with id 3
#3 Cycling: 48.0 min
#1 Running: 35.5 min
Error: message("NOT NULL constraint failed: workouts.minutes")

The second run skips the migrations, because user_version is already 2:

$ ./fitness fitness.sqlite
SQLite library version: 3.51.0
Schema version: 2
Inserted Running with id 4
Inserted Yoga with id 5
Inserted Cycling with id 6
#3 Cycling: 48.0 min
#6 Cycling: 48.0 min
#1 Running: 35.5 min
#4 Running: 35.5 min
Error: message("NOT NULL constraint failed: workouts.minutes")

The last line of each run is the NOT NULL constraint rejecting a workout without minutes, reported as a Swift error.

Inspect the database with the sqlite3 shell

The file is an ordinary SQLite database:

$ sqlite3 -header -column fitness.sqlite 'PRAGMA user_version; SELECT * FROM workouts;'
user_version
------------
2
id  type     minutes  calories
--  -------  -------  --------
1   Running  35.5
2   Yoga     20.0
3   Cycling  48.0
4   Running  35.5
5   Yoga     20.0
6   Cycling  48.0

In the iOS Simulator, the app’s files are on your Mac. Print the Documents URL from the app, then open the file in Terminal with sqlite3 <path> (not executed here).

Common problems

These are the errors you’ll see first with the C API.

  • Wrong text stored after binding a Swift string: binding with nil means SQLITE_STATIC. The string’s memory may already be gone. Pass SQLITE_TRANSIENT.
  • database is locked: a statement wasn’t finalized, or another connection is writing. Finalize every statement with defer, and see database is locked.
  • no such table after an app update: the schema in the shipped file is older than the code. Use user_version migrations as in Step 3.
  • Undefined symbols for sqlite3_*: link libsqlite3.tbd (Step 1).

Conclusion

SQLite is already on every iOS device, so a Swift app only needs import SQLite3. Wrap the C calls in a small class, bind strings with SQLITE_TRANSIENT and keep migrations in user_version. If you’d rather not touch pointers, a wrapper like SQLite.swift gives you the same database through a Swift API.

Further reading

Tested with Swift 6.3.3, the system SQLite 3.51.0 and SQLite.swift 0.16.0 on macOS on 7 October 2026.