This guide describes how to share an SQLite database between multiple processes, with recommendations for App Group containers, app extensions, app sandbox, and file coordination.
Overview
Sharing a database between processes (such as between your app and an app extension) creates challenges:
- Database setup may be attempted by multiple processes concurrently
- SQLite may throw
SQLITE_BUSY errors (“database is locked”)
- iOS may kill your app with a
0xDEAD10CC exception
- GRDB observation doesn’t detect changes from external processes
Preventing errors from database sharing is difficult, extremely difficult on iOS, and almost impossible to test.Always consider alternatives like sharing plain files or using other inter-process communication techniques before sharing an SQLite database.
Use the WAL Mode
To access a shared database, use a DatabasePool. It opens the database in WAL mode, which allows concurrent access from multiple processes.
You can also use DatabaseQueue with the .wal journal mode:
var config = Configuration()
config.journalMode = .wal
let dbQueue = try DatabaseQueue(path: dbPath, configuration: config)
Since multiple processes may open the database simultaneously, protect database creation with NSFileCoordinator.
Write-Capable Process
In a process that can create and write to the database:
import Foundation
import GRDB
/// Returns an initialized database pool at the shared location
func openSharedDatabase(at databaseURL: URL) throws -> DatabasePool {
let coordinator = NSFileCoordinator(filePresenter: nil)
var coordinatorError: NSError?
var dbPool: DatabasePool?
var dbError: Error?
coordinator.coordinate(writingItemAt: databaseURL, options: .forMerging, error: &coordinatorError) { url in
do {
dbPool = try openDatabase(at: url)
} catch {
dbError = error
}
}
if let error = dbError ?? coordinatorError {
throw error
}
return dbPool!
}
private func openDatabase(at databaseURL: URL) throws -> DatabasePool {
var configuration = Configuration()
configuration.prepareDatabase { db in
// Activate persistent WAL mode so that read-only processes can access the database
if db.configuration.readonly == false {
var flag: CInt = 1
let code = withUnsafeMutablePointer(to: &flag) { flagP in
sqlite3_file_control(db.sqliteConnection, nil, SQLITE_FCNTL_PERSIST_WAL, flagP)
}
guard code == SQLITE_OK else {
throw DatabaseError(resultCode: ResultCode(rawValue: code))
}
}
}
let dbPool = try DatabasePool(path: databaseURL.path, configuration: configuration)
// Perform database setup (migrations, etc.)
try migrator.migrate(dbPool)
if try dbPool.read(migrator.hasBeenSuperseded) {
throw DatabaseError(message: "Database is too recent")
}
return dbPool
}
Read-Only Process
In a process that only reads from the database:
/// Returns an initialized read-only database pool, or nil if unavailable
func openSharedReadOnlyDatabase(at databaseURL: URL) throws -> DatabasePool? {
let coordinator = NSFileCoordinator(filePresenter: nil)
var coordinatorError: NSError?
var dbPool: DatabasePool?
var dbError: Error?
coordinator.coordinate(readingItemAt: databaseURL, options: .withoutChanges, error: &coordinatorError) { url in
do {
dbPool = try openReadOnlyDatabase(at: url)
} catch {
dbError = error
}
}
if let error = dbError ?? coordinatorError {
throw error
}
return dbPool
}
private func openReadOnlyDatabase(at databaseURL: URL) throws -> DatabasePool? {
do {
var configuration = Configuration()
configuration.readonly = true
let dbPool = try DatabasePool(path: databaseURL.path, configuration: configuration)
// Check database schema version
return try dbPool.read { db in
if try migrator.hasBeenSuperseded(db) {
// Database is too recent
return nil
} else if try migrator.hasCompletedMigrations(db) == false {
// Database is too old
return nil
}
return dbPool
}
} catch {
if FileManager.default.fileExists(atPath: databaseURL.path) {
throw error
} else {
return nil
}
}
}
Persistent WAL Mode
Read-only connections require two companion files (-shm and -wal) to exist next to the database file. These files are normally deleted when connections close, which breaks read-only access.
The solution is to enable persistent WAL mode using SQLITE_FCNTL_PERSIST_WAL:
var flag: CInt = 1
let code = withUnsafeMutablePointer(to: &flag) { flagP in
sqlite3_file_control(db.sqliteConnection, nil, SQLITE_FCNTL_PERSIST_WAL, flagP)
}
guard code == SQLITE_OK else {
throw DatabaseError(resultCode: ResultCode(rawValue: code))
}
This ensures the -shm and -wal files are never deleted, guaranteeing read-only connections work reliably.
Limiting SQLITE_BUSY Errors
When multiple processes write to the database, configure a busy timeout:
var configuration = Configuration()
config.busyMode = .timeout(5.0) // Wait up to 5 seconds
let dbPool = try DatabasePool(path: dbPath, configuration: configuration)
The busy timeout makes write transactions wait instead of immediately failing with SQLITE_BUSY.
You can catch remaining busy errors:
do {
try dbPool.write { db in
// Write operations
}
} catch DatabaseError.SQLITE_BUSY {
// Another process has locked the database
print("Database is busy, try again later")
}
Preventing 0xDEAD10CC Exceptions
The 0xDEAD10CC exception (“dead lock”) occurs when iOS terminates your app for holding a database lock during suspension.
If Using SQLCipher
Use SQLCipher 4+ with the cipher_plaintext_header_size pragma:
var configuration = Configuration()
config.prepareDatabase { db in
try db.usePassphrase("secret")
try db.execute(sql: "PRAGMA cipher_plaintext_header_size = 32")
}
let dbPool = try DatabasePool(path: dbPath, configuration: configuration)
For All Databases
The technique below is EXPERIMENTAL. Use with caution.
Enable suspension observation:
var configuration = Configuration()
config.observesSuspensionNotifications = true
let dbPool = try DatabasePool(path: dbPath, configuration: configuration)
Post Database.suspendNotification when the app is about to be suspended:
class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidEnterBackground(_ application: UIApplication) {
NotificationCenter.default.post(
name: Database.suspendNotification,
object: self
)
}
}
Handle suspension errors:
do {
try dbPool.write { db in
// Database operations
}
} catch DatabaseError.SQLITE_INTERRUPT, DatabaseError.SQLITE_ABORT {
// Database is suspended
print("Database suspended, will retry when resumed")
}
Post Database.resumeNotification when resuming:
func applicationWillEnterForeground(_ application: UIApplication) {
NotificationCenter.default.post(
name: Database.resumeNotification,
object: self
)
}
App Group Containers
Share databases between your app and extensions using App Group containers:
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.example.myapp"
) {
let databaseURL = containerURL.appendingPathComponent("shared.sqlite")
let dbPool = try openSharedDatabase(at: databaseURL)
}
App Group Setup
- In Xcode, go to target Signing & Capabilities
- Add App Groups capability
- Create or select an app group (e.g.,
group.com.example.myapp)
- Add the same app group to your extension targets
Cross-Process Observation
GRDB’s observation features don’t detect changes from other processes. Use cross-process notifications:
Notify on Changes
In the process that writes:
import GRDB
// Observe all database changes
let observation = DatabaseRegionObservation(tracking: .fullDatabase)
let observer = try observation.start(in: dbPool) { db in
// Post a Darwin notification
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName("com.example.myapp.dbchange" as CFString),
nil, nil, true
)
}
Or observe specific tables:
// Observe only player and team changes
let observation = DatabaseRegionObservation(tracking: Player.all(), Team.all())
let observer = try observation.start(in: dbPool) { db in
// Notify other processes
postCrossProcessNotification()
}
Receive Notifications
In other processes:
import Foundation
let center = CFNotificationCenterGetDarwinNotifyCenter()
let observer = UnsafeRawPointer(Unmanaged.passUnretained(self).toOpaque())
CFNotificationCenterAddObserver(
center,
observer,
{ center, observer, name, object, userInfo in
// Database changed in another process
// Refresh your data
},
"com.example.myapp.dbchange" as CFString,
nil,
.deliverImmediately
)
Alternatively, use NSFileCoordinator for file-based coordination.
Best Practices
- Use WAL mode - Essential for concurrent access
- Enable persistent WAL - Required for read-only processes
- Use NSFileCoordinator - Protect database creation
- Set busy timeouts - Handle concurrent writes gracefully
- Handle suspension - Prevent 0xDEAD10CC on iOS
- Use app groups - Proper container for shared databases
- Implement cross-process notifications - Keep processes in sync
- Version your schema - Check compatibility between processes
- Consider alternatives - Sharing databases is complex
Common Patterns
Main App + Extension
// Shared code for both targets
class DatabaseManager {
static let shared = DatabaseManager()
let dbPool: DatabasePool
private init() {
let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "group.com.example.myapp"
)!
let databaseURL = containerURL.appendingPathComponent("app.sqlite")
do {
dbPool = try openSharedDatabase(at: databaseURL)
} catch {
fatalError("Database error: \(error)")
}
}
}
// Use in main app
let players = try DatabaseManager.shared.dbPool.read { db in
try Player.fetchAll(db)
}
// Use in extension
let latestPlayer = try DatabaseManager.shared.dbPool.read { db in
try Player.order(Column("createdAt").desc).fetchOne(db)
}
Multiple Read-Only Extensions
// Main app (read-write)
let dbPool = try openSharedDatabase(at: databaseURL)
// Extension 1 (read-only)
let dbPool = try openSharedReadOnlyDatabase(at: databaseURL)
// Extension 2 (read-only)
let dbPool = try openSharedReadOnlyDatabase(at: databaseURL)
Troubleshooting
”Database is locked” Errors
- Increase busy timeout
- Verify WAL mode is enabled
- Check file permissions
- Ensure proper file coordination
Read-Only Connection Fails
- Enable persistent WAL mode
- Verify
-shm and -wal files exist
- Check that write process opened first
0xDEAD10CC Crashes
- Implement suspension notifications
- Use SQLCipher 4 with plaintext header
- Release database locks before suspension
Changes Not Visible
- Implement cross-process notifications
- Verify both processes use WAL mode
- Check that changes are committed
See Also