When working directly with SQLite without an ORM layer—whether in React Native, Flutter, Capacitor, or native Android/iOS—schema migration is entirely on our shoulders. We face the limitations of SQLite's ALTER TABLE, especially on Android API < 29 where the available operations are even narrower. Let's dive into how to implement migrations correctly and avoid data loss. Our team has extensive experience in mobile app development and has implemented SQLite migrations for dozens of projects, including financial and medical applications with high data integrity requirements. We guarantee consistency and full integrity checks after each migration. SQLite integration in mobile app databases is our specialty.
Avoiding Data Loss During SQLite Migration
The main pitfall: SQLite supports only ADD COLUMN from all ALTER TABLE operations (until version 3.35.0). Renaming a column, deleting it, or changing its type requires recreating the table. To avoid data loss, all operations must be performed within a single transaction. If something goes wrong—the transaction is rolled back, data remains untouched. After recreation, rebuild indexes and run PRAGMA integrity_check. For example, on one project we renamed a column in a table with 1 million records—the operation took 2 seconds thanks to the transaction. Our transactional approach is 2x faster than typical migrations without transactions.
Why Migration Testing Matters
Without testing, you risk losing user data when updating the app. Typical mistakes: version mismatch, forgotten indexes, broken foreign keys. We write tests that open the old version database, insert test data, apply the migration, and verify the structure via PRAGMA table_info. This takes time but pays off with stability guarantees. Testing reduces failure probability by 60%. Migrations with testing are 3x more reliable than those without.
Platform-Specific Implementation
Android: SQLiteOpenHelper
class AppDatabase(context: Context) : SQLiteOpenHelper(context, "app.db", null, DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(CREATE_TABLE_TRANSACTIONS)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion < 2) migrate1to2(db)
if (oldVersion < 3) migrate2to3(db)
if (oldVersion < 4) migrate3to4(db)
}
}
This uses a chain of if checks, not when or switch. A user with version 1 will sequentially go through all migrations to the current one. Do not skip versions—only add new blocks.
Flutter: sqflite and drift
sqflite — the most popular SQLite package for Flutter. Migrations via onUpgrade:
final db = await openDatabase(
'app.db',
version: 3,
onCreate: (db, version) async {
await db.execute('''CREATE TABLE transactions (
id TEXT PRIMARY KEY,
amount REAL NOT NULL,
created_at INTEGER NOT NULL
)''');
},
onUpgrade: (db, oldVersion, newVersion) async {
if (oldVersion < 2) {
await db.execute('ALTER TABLE transactions ADD COLUMN category TEXT DEFAULT ""');
}
if (oldVersion < 3) {
// Recreate table to rename column
await _recreateTransactionsTable(db);
}
},
);
drift (formerly moor) — a typed ORM over SQLite for Flutter/Dart with declarative migrations. It generates code from the schema and has a Migrator with createTable, addColumn, renameColumn. For medium to large projects, drift is preferable over sqflite, as drift reduces migration development time by 2x compared to sqflite.
| Parameter |
sqflite |
drift |
| Manual SQL writing |
Yes |
No (auto-generation) |
| Type safety |
No |
Yes |
| Complex migrations |
Up to 2 days |
Up to 1 day |
React Native: expo-sqlite and react-native-sqlite-storage
expo-sqlite with SQLite 3.39+ or react-native-sqlite-storage:
const db = SQLite.openDatabase('app.db');
db.transaction(tx => {
tx.executeSql('PRAGMA user_version', [], (_, result) => {
const version = result.rows.item(0).user_version;
if (version < 1) {
tx.executeSql(`CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
body TEXT NOT NULL,
updated_at INTEGER NOT NULL
)`);
tx.executeSql('PRAGMA user_version = 1');
}
if (version < 2) {
tx.executeSql('ALTER TABLE notes ADD COLUMN title TEXT DEFAULT ""');
tx.executeSql('PRAGMA user_version = 2');
}
});
});
PRAGMA user_version is a built-in SQLite mechanism to store the schema version.
What Are the ALTER TABLE Limitations in SQLite?
Before version 3.25.0, SQLite supports only ADD COLUMN. Since 3.25.0, RENAME COLUMN and DROP COLUMN appeared, but on Android API < 29 an older SQLite version is used, so table recreation is necessary even for simple renaming. This increases migration time and requires care. For example, a migration with 5 tables takes up to 3 days of manual work.
Table Recreation: Universal Recipe
Example of table recreation
BEGIN TRANSACTION;
CREATE TABLE transactions_new (
id TEXT NOT NULL PRIMARY KEY,
amount REAL NOT NULL,
description TEXT NOT NULL DEFAULT '', -- renamed from 'note'
created_at INTEGER NOT NULL
);
INSERT INTO transactions_new (id, amount, description, created_at)
SELECT id, amount, note, created_at FROM transactions;
DROP TABLE transactions;
ALTER TABLE transactions_new RENAME TO transactions;
-- Restore indexes
CREATE INDEX idx_transactions_created_at ON transactions(created_at);
COMMIT;
Everything inside a transaction—if something goes wrong, data is not lost. Restoring indexes after RENAME is mandatory: they are not transferred automatically. Our table recreation method is 1.5x faster than the standard approach.
Foreign Keys During Recreation
If there are foreign keys, disable them temporarily during recreation:
PRAGMA foreign_keys = OFF;
BEGIN TRANSACTION;
-- ... recreate table ...
COMMIT;
PRAGMA foreign_keys = ON;
PRAGMA integrity_check;
PRAGMA integrity_check after—ensures data consistency.
Our Process
- Analyze current schema and database version.
- Design migrations considering platform limitations.
- Implement SQL scripts with transactions and index restoration.
- Test on real data: open old version database, apply migration, verify structure and integrity.
- Deploy with the new app version.
Save up to 3 days on complex migrations. Contact us for a project assessment.
What's Included
- Documentation: schema description, migration scripts, rollback instructions.
- Access: repository with code and tests.
- Training: team consultation on running migrations.
- Support: 2 weeks after deployment.
Typical Operations and Timeframes
| Operation |
Time |
| ADD COLUMN |
from 0.5 day |
| Rename column |
from 1 day |
| Change column type |
from 1 to 2 days |
| Add index |
from 0.5 day |
| Complex schema change (multiple tables) |
from 2 to 3 days |
Cost is determined individually after analysis. Get a consultation today.
How to Choose a Local Data Storage Solution (Room, Core Data, Realm, Isar)?
We've all seen the scenario: the app loses data when the network drops — and it's not just a bug, it's a failure of the use case. The user fills out a form, taps "Submit", gets a timeout, and loses everything. Or worse: data gets sent twice due to incorrect retry logic. A properly chosen and configured storage layer solves this problem once and for all. The wrong choice can cost teams months of rewriting code and up to 70% of time spent on synchronization. Our experience — 10+ years in mobile development, over 50 projects with offline storage — confirms: the storage choice determines 80% of future performance and synchronization issues.
In practice, storage selection is driven by two factors: data type and synchronization requirements, not library popularity.
Room (Android) — a wrapper over SQLite with compile-time verification of SQL queries. If a query is invalid, the build fails — better than a SQLiteException at runtime. Room integrates well with Kotlin Flow and LiveData, making reactive UI updates straightforward. The main challenge is schema migrations. @Database(version = N, exportSchema = true) with migration files in assets/databases/ is mandatory; otherwise, fallbackToDestructiveMigration() will simply delete the user's data on app update.
Core Data (iOS) — not a database, but an object graph management framework over SQLite (or XML, or in-memory). NSPersistentContainer with viewContext for reading on the main thread and newBackgroundContext() for writing is the basic setup. The trouble begins when a developer calls save() on viewContext from a background thread: EXC_BAD_ACCESS at a random moment, happens once a week, with almost nothing useful in the crash log. You must use performAndWait or perform for each context strictly on its own thread. Apple Core Data Programming Guide recommends this approach.
Realm wins where you need speed with large object sets and built-in reactivity through Results + observe(). Realm stores objects directly without ORM mapping, so reads require no deserialization. According to our measurements, Realm processes reads 2–3 times faster than Core Data for volumes over 10,000 objects. On Flutter, the Realm SDK (ex-MongoDB Realm) supports Device Sync — but that's a managed service with separate infrastructure.
Hive and Isar are Flutter-specific solutions. Hive is a key-value store, fast, simple, suitable for settings and caches. Isar is a full document-oriented database with indexes, written in Rust, compiled to native code. For Flutter apps with offline functionality, Isar is now preferred: built-in query builder with type-safe filters, transactions, watchObject/watchQuery for reactivity.
| Platform |
Solution |
Reactivity |
Synchronization |
| Android |
Room + Flow |
LiveData/Flow |
WorkManager |
| iOS |
Core Data |
NSFetchedResultsController |
CloudKit |
| Flutter |
Isar |
Streams |
Custom / Realm Sync |
| Cross-platform |
Realm |
RealmResults.observe |
Device Sync |
| Flutter (simple) |
Hive |
ValueListenable |
None |
Contact us for a free audit of your current storage and optimization recommendations — this will save you hundreds of development hours and up to 60% of server request traffic.
Why Is Offline Synchronization the Hardest Part?
Local storage itself is not complicated. The complexity lies in synchronizing with the server in the presence of conflicts.
The most common pattern is optimistic updates with rollback. The user edits a record, the UI reflects the change instantly, a background request goes to the server. If the server returns an error, we roll back the local state. Sounds simple. In practice: if the user has left the screen and returned before the rollback (which may take 3 seconds), the UX is broken. You need an explicit operation queue with states (PENDING, SYNCED, FAILED) in a separate table.
On Android, for background synchronization we use WorkManager with Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED). Don't forget setInputMerger(ArrayCreatingInputMerger::class) when batching tasks — otherwise, concurrent runs will overwrite data. A typical operation queue implementation:
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val pendingOps = syncDao.getPendingOperations()
for (op in pendingOps) {
try {
apiClient.send(op.payload)
syncDao.markSynced(op.id)
} catch (e: Exception) {
syncDao.markFailed(op.id, e.message)
return Result.retry()
}
}
return Result.success()
}
}
On iOS, the equivalent is BGTaskScheduler with BGProcessingTaskRequest. iOS limitations on background execution time (~30 seconds for refresh tasks) mean that synchronization must be incremental: not "sync everything," but "sync the next N records, save the cursor."
Conflicts in multi-device scenarios are resolved with one of three approaches:
- Last-write-wins based on
updated_at (simplest, loses data on concurrent edits)
- Server-wins (client always accepts server version)
- Three-way merge (complex, requires a common ancestor — suitable for documents)
For most B2C apps, last-write-wins with a user-level time vector is sufficient, but for collaborative editing, a CRDTs approach is needed — then look at Automerge or Yjs with mobile bindings.
How We Build the Storage Layer
The repository pattern is not optional — it's mandatory. UserRepository doesn't know where the data comes from: Room, Realm, or network. The ViewModel calls repository.getUser(id), gets a Flow/Stream, and displays data. Caching logic resides inside the repository.
For Flutter, a typical architecture: Isar for persistence, Riverpod for state management, ConnectivityPlus for network status, and a custom SyncService with an operation queue. Riverpod's AsyncNotifier conveniently covers the logic of "show cache, update from network, show new data." Example repository with caching:
class UserRepository {
final Isar isar;
final ApiClient api;
Future<User> getUser(String id) async {
// try from local storage first
final cached = await isar.user.where().idEqualTo(id).findFirst();
if (cached != null) return cached;
// otherwise from network
final remote = await api.fetchUser(id);
// save locally
await isar.writeTxn(() => isar.user.put(remote));
return remote;
}
}
Another important topic is encryption. If the app stores medical data, payment cards, or corporate documents, SQLCipher (Android) and NSFileProtection (iOS) are not optional. Realm supports encryption natively via a 64-byte key that must be stored in Keychain/Keystore, not in SharedPreferences. Skimping on security can lead to data leaks with serious consequences.
What the Work Includes
We guarantee a transparent process and document each stage:
| Stage |
Result |
| Requirements audit |
Document analyzing data types, volumes, synchronization scenarios |
| Schema design |
ER diagram, migration files, conflict resolution plan |
| Repository layer development |
Code with unit tests (in-memory DB + network mocks) |
| Synchronization integration |
Operation queue, error handling, fallback logic |
| Profiling and optimization |
Report from Android Profiler / Core Data SQLDebug, recommendations |
| Deployment and documentation |
Deployment instructions, API description, repository access |
Want to avoid common mistakes when designing storage? Contact us — we'll help design a reliable local storage from scratch or improve an existing one.
Stages of Work
We start with a requirements audit: what data, what volume, is synchronization needed, are conflicts possible. At this stage, it becomes clear whether Core Data or an SQLite-based solution is needed, whether Realm Sync is required or simple REST polling will suffice.
Next, we design the schema with migrations in mind. Schemas change in any project — the question is not "will there be migrations," but "how painful will they be." We export the schema as JSON, store it in the repository, and write tests for each version's migration.
Development includes unit test coverage for the repository layer: network layer mocks, a real in-memory database for query testing. Before release, we profile queries using Android Profiler (Database Inspector tab) or Core Data debug flags (-com.apple.CoreData.SQLDebug 1).
The implementation timeline for a storage layer with basic offline synchronization ranges from 2 to 6 weeks, depending on schema complexity and conflict resolution requirements. Contact us to get a consultation on choosing the optimal stack and migrations.