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 SQLite3to a Swift file. - Open a database in the app’s Documents folder with
sqlite3_open. - Run each statement with
sqlite3_prepare_v2andsqlite3_step, then free it withsqlite3_finalize. - Bind Swift strings with
SQLITE_TRANSIENT, so SQLite copies them. PassingnilmeansSQLITE_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
| Requirement | Detail |
|---|---|
| Xcode and Swift | Swift 6.3.3 was used for the tests below |
| SQLite | Comes 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 definesSQLITE_STATICas 0 andSQLITE_TRANSIENTas -1 (SQLite constants). WithSQLITE_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. PassSQLITE_TRANSIENTinstead, 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
nilmeansSQLITE_STATIC. The string’s memory may already be gone. PassSQLITE_TRANSIENT. database is locked: a statement wasn’t finalized, or another connection is writing. Finalize every statement withdefer, and see database is locked.no such tableafter an app update: the schema in the shipped file is older than the code. Useuser_versionmigrations as in Step 3.- Undefined symbols for
sqlite3_*: linklibsqlite3.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
- SQLite vs Core Data
- SQLite CREATE TABLE
- SQLite PRAGMA
- SQLite database is locked
- React Native SQLite
- SQLite docs: C/C++ interface
Tested with Swift 6.3.3, the system SQLite 3.51.0 and SQLite.swift 0.16.0 on macOS on 7 October 2026.
