SQLite empowers iOS developers with remarkable local database capabilities – combining powerful querying, transaction support, and excellent performance without server dependencies. This compact yet mighty solution handles everything from simple data persistence to complex relational operations while maintaining a tiny footprint.
Working with SQLite across programming languages
- SQLite with NodeJS
- SQLite with Next.JS
- SQLite3 with Python
- SQLite with Python Flask
- SQLite with Python Django
- SQLite with Python AioSQLite
- SQLite with Python SQLAlchemy
- SQLite with Golang
- SQLite with Prisma
- SQLite with FastAPI
- SQLite with PHP
- SQLite for Expo Mobile Apps
- SQLite with React Native
- SQLite with PhoneGap
- OP-SQLite with React Native
- SQLite with C#
- SQLite with Javascript
- SQLite with R
- SQLite with Rust
- SQLite with iOS
I was building a fitness tracking app last year when I discovered how dramatically SQLite improved my app’s performance. Let’s skip the basics and jump straight into making SQLite work brilliantly in your iOS projects.
Swift-Friendly Setup with SQLite.swift
Setting up SQLite directly with C APIs feels like trying to assemble IKEA furniture without instructions. Let’s use SQLite.swift instead:
// In Package.swift
dependencies: [
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.3")
]
Import it and create your database:
import SQLite
// Create database connection
let dbPath = try FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false
)
.appendingPathComponent("fitness.db").path
let db = try Connection(dbPath)
The code above creates your database file in the app’s documents directory – perfect for persisting data between app launches.
Table Creation – Your Data’s Home
We’ll now create the most important part of the application which is the database and I’ll be using SQLite for this.
// Define workout table
let workouts = Table("workouts")
let id = Expression<Int64>("id")
let type = Expression<String>("type")
let duration = Expression<Double>("duration")
let calories = Expression<Int>("calories")
let completed = Expression<Bool>("completed")
// Create the table
try db.run(workouts.create { t in
t.column(id, primaryKey: .autoincrement)
t.column(type)
t.column(duration)
t.column(calories)
t.column(completed, defaultValue: false)
})
This creates a table tracking workout activities with columns for type, duration, calories burned, and completion status. The primary key automatically increments, saving you from managing unique IDs manually.
CRUD Operations – The Data Lifecycle
Creating Records
Adding workouts becomes surprisingly readable with SQLite.swift:
// Add a new workout
try db.run(workouts.insert(
type <- "Running",
duration <- 35.5,
calories <- 320,
completed <- true
))
// Output:
// Inserted workout with ID: 1
SQLite.swift transforms your insert operations into clean, expressive code using the <-
operator for assignments.
Reading Records
Querying data feels like writing plain English:
// Get all completed workouts
let completedWorkouts = workouts.filter(completed == true)
for workout in try db.prepare(completedWorkouts) {
print("Workout: \(workout[type]), Duration: \(workout[duration]) minutes")
}
// Output:
// Workout: Running, Duration: 35.5 minutes
The .filter()
method works similar to Swift arrays but operates directly on the database, keeping memory usage low even with thousands of records.
Updating Records
Need to correct a workout duration? Simple:
// Update the running workout duration
let runningWorkout = workouts.filter(type == "Running" && id == 1)
try db.run(runningWorkout.update(duration <- 42.0))
// Output:
// Updated workout duration
The update syntax mirrors inserts, maintaining a consistent mental model across operations.
Deleting Records
Removing data follows the same pattern:
// Delete short workouts
let shortWorkouts = workouts.filter(duration < 10)
try db.run(shortWorkouts.delete())
// Output:
// Deleted 3 short workouts
Advanced Query Techniques
Aggregation – Getting Valuable Insights
Transform raw workout data into actionable insights:
// Calculate total calories burned this week
let mondayDate = Calendar.current.date(from: DateComponents(year: 2025, month: 4, day: 22))!
let weeklyWorkouts = workouts.filter(timestamp >= mondayDate)
let totalCalories = try db.scalar(weeklyWorkouts.select(calories.sum))
print("Weekly calories burned: \(totalCalories ?? 0)")
// Output:
// Weekly calories burned: 2150
The .sum
function is just one of many aggregations including .count
, .avg
, .min
, and .max
– perfect for dashboard metrics.
Transactions – Ensuring Data Integrity
Transactions guarantee all-or-nothing operations:
try db.transaction {
// Record completion of a workout program
try db.run(workouts.insert(type <- "Warmup", duration <- 5.0, calories <- 30))
try db.run(workouts.insert(type <- "Strength", duration <- 25.0, calories <- 210))
try db.run(workouts.insert(type <- "Cardio", duration <- 20.0, calories <- 180))
try db.run(workouts.insert(type <- "Cooldown", duration <- 5.0, calories <- 20))
// Update user statistics
try db.run(userStats.update(lastWorkoutDate <- Date(),
totalWorkouts <- totalWorkouts + 1))
return .commit
}
If any part fails, the entire transaction rolls back – your data stays consistent even if the app crashes mid-update.
Real-World Application: Offline-First Sync System
Let’s build a workout tracking system with server synchronization:
// Create pending changes table
let pendingSync = Table("pending_sync")
let changeId = Expression<Int64>("id")
// "insert", "update", "delete"
let changeType = Expression<String>("change_type")
let recordId = Expression<Int64?>("record_id")
let recordData = Expression<String>("data_json")
let syncAttempts = Expression<Int>("attempts")
try db.run(
pendingSync.create { t in
t.column(changeId, primaryKey: .autoincrement)
t.column(changeType)
t.column(recordId)
t.column(recordData)
t.column(syncAttempts, defaultValue: 0)
})
// Track a new workout and queue for sync
func saveWorkout(type: String, duration: Double, calories: Int) {
try db.transaction {
// 1. Save to workouts table
let id = try db.run(
workouts.insert(
type <- type,
duration <- duration,
calories <- calories,
completed <- true
))
// 2. Add to sync queue
let workoutData = """
{"type":"\(type)","duration":\(duration),"calories":\(calories)}
"""
try db.run(
pendingSync.insert(
changeType <- "insert",
recordId <- id,
recordData <- workoutData
))
return .commit
}
}
// Process pending syncs
func syncWithServer() {
// Get unsynced items (limit 20 per batch)
let pendingItems = pendingSync.filter(syncAttempts < 3).limit(20)
for item in try db.prepare(pendingItems) {
apiService.sync(
type: item[changeType],
recordId: item[recordId],
data: item[recordData]
) { success in
if success {
try db.run(pendingSync.filter(changeId == item[changeId]).delete())
} else {
try db.run(
pendingSync.filter(changeId == item[changeId])
.update(syncAttempts <- item[syncAttempts] + 1))
}
}
}
}
This system ensures data persists locally first, then syncs when connectivity allows – creating a smooth experience regardless of network conditions.
Performance Optimization
Keep your database lightning fast:
// Create strategic indexes
try db.run(workouts.createIndex(type))
try db.run(workouts.createIndex([timestamp, type])) // Compound index
// Use explain query plan to diagnose performance
for row in try db.prepare("EXPLAIN QUERY PLAN SELECT * FROM workouts WHERE type = 'Running'") {
print(row)
}
Indexes dramatically speed up filtering operations, much like a book’s index helps you find content without reading every page.
Database Migrations
Handle schema evolution gracefully:
// Version tracking table
let versions = Table("schema_version")
let versionNumber = Expression<Int>("version")
// Current app schema version
let currentVersion = 2
// Run migrations
func migrateIfNeeded() {
try db.transaction {
// Create version table if needed
try db.run(versions.create(ifNotExists: true) { t in
t.column(versionNumber)
})
// Get current database version
let dbVersion = try db.scalar(versions.select(versionNumber).limit(1)) ?? 0
// Run needed migrations
if dbVersion < 1 {
try db.run("ALTER TABLE workouts ADD COLUMN difficulty TEXT")
}
if dbVersion < 2 {
try db.run("ALTER TABLE workouts ADD COLUMN notes TEXT")
try db.run(workouts.createIndex(difficulty))
}
// Update version tracking
if dbVersion == 0 {
try db.run(versions.insert(versionNumber <- currentVersion))
} else {
try db.run(versions.update(versionNumber <- currentVersion))
}
return .commit
}
}
This migration system runs only the necessary updates based on the current database version, ensuring smooth upgrades without data loss.
Security Considerations
For sensitive information, consider encryption:
// Using SQLCipher (requires additional dependencies)
let encryptedDB = try Connection(dbPath)
try encryptedDB.key("user_specific_strong_key")
// Alternative: Sensitive data encryption
func encryptSensitiveData(_ data: String) -> String {
// Implement AES encryption using CryptoKit
// Store encrypted values rather than plaintext
return encryptedValue
}
SQLCipher provides full database encryption, while field-level encryption offers targeted protection for specific sensitive values.
Conclusion
SQLite delivers remarkable performance for iOS apps with zero configuration servers. The combination of a tiny footprint, SQL querying power, and transaction support makes it perfect for practically any data persistence need.
The built-in support in iOS means you’re not adding external dependencies to your app, and SQLite.swift provides an elegant Swift-native interface that feels natural alongside your other code.
Whether tracking workouts, caching network data, or building a complete offline-first experience, SQLite handles the complexity while letting you focus on creating a fantastic user experience.