Backing up local-first data on Android in 2026: SAF export, Auto Backup, and a restore flow you can trust
How local-first Android apps back up user data without a server: SAF export/import, Auto Backup for App Data, and a restore that verifies before overwriting.
“No server” is the whole pitch of a local-first app. It’s also the reason a factory reset, a lost phone, or a device upgrade can erase months of budgeting history in Granyn with nothing to restore from. If there’s no cloud, backup can’t be an afterthought — it has to be a feature, built with the same care as the data model it’s protecting.
There are two mechanisms on Android that matter here, and they solve different problems. Neither replaces the other.
Auto Backup for App Data: automatic, but opaque
Android will back up your app’s files to the user’s Google Drive automatically, for free, if you opt in:
<!-- AndroidManifest.xml -->
<application
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
...>
<!-- res/xml/backup_rules.xml -->
<full-backup-content>
<include domain="database" path="granyn.db" />
<exclude domain="database" path="granyn.db-wal" />
<exclude domain="sharedpref" path="device_keys.xml" />
</full-backup-content>
This is genuinely useful — it’s the safety net that saves someone who never opens a settings screen. But it has real limits I plan around rather than fight:
- 25MB total, silently truncated if you go over. A Room database with years of transaction history plus screenshots can hit this faster than you’d expect.
- It only restores automatically during the out-of-box setup flow on a new device signed into the same Google account. A user who reinstalls the app on the same phone doesn’t get it back this way.
- It’s opaque. There’s no in-app confirmation of what got backed up or when. I can’t show the user “last backed up 3 days ago,” because the OS doesn’t tell me.
Good default, bad primary strategy. It’s the backup for people who never think about backups — not the one I’d point a worried user to.
Explicit export: the one the user actually controls
The mechanism I actually trust is a plain export/import the user triggers, writing a file to wherever they choose via the Storage Access Framework:
@Serializable
data class GranynExport(
val schemaVersion: Int = 2,
val exportedAt: Long,
val accounts: List<AccountDto>,
val transactions: List<TransactionDto>,
)
suspend fun exportTo(context: Context, uri: Uri) {
val export = GranynExport(
exportedAt = clock.nowMillis(),
accounts = accountDao.getAll().map { it.toDto() },
transactions = transactionDao.getAll().map { it.toDto() },
)
context.contentResolver.openOutputStream(uri)?.use { out ->
out.write(Json.encodeToString(export).toByteArray())
}
}
Triggered from ACTION_CREATE_DOCUMENT, so the user picks the destination — device storage, a USB drive, whatever cloud folder they already sync. No permission beyond the one-time picker, and the file is plain, inspectable JSON. That last part matters more than it sounds: a user who can open the backup in a text editor and see their own account names trusts it in a way an opaque .bak blob never earns.
schemaVersion is the field that saves future-me. Granyn’s data model has already changed once since launch — a new field means I bump the version and the importer knows which shape to expect, instead of guessing from what’s present.
Restore has to earn the right to overwrite anything
Import is where a backup feature actually gets tested, and it’s the part worth being paranoid about. The failure mode isn’t “the file is corrupt” — it’s “the file is valid but a partial write leaves the user with less data than they started with.” The rules I hold restore to:
- Parse and validate before touching the live database. Check
schemaVersion, check the file isn’t empty, check referenced IDs are internally consistent. - Show a summary before committing. “12 accounts, 340 transactions, backed up July 24” — a number the user can sanity-check against what they remember, before anything is overwritten.
- Wrap the write in a single Room transaction. Either the whole import lands or none of it does; a mid-import crash can’t leave the database in a spliced state.
suspend fun restoreFrom(export: GranynExport) = db.withTransaction {
require(export.schemaVersion <= CURRENT_SCHEMA_VERSION) {
"Backup was made with a newer app version"
}
accountDao.deleteAll()
transactionDao.deleteAll()
accountDao.insertAll(export.accounts.map { it.toEntity() })
transactionDao.insertAll(export.transactions.map { it.toEntity() })
}
db.withTransaction is doing the real safety work — if insertAll throws partway through, Room rolls the whole block back and the user’s existing data is untouched. Without it, a restore that fails halfway is worse than no restore feature at all.
Treat it like a core feature, not a settings row
It’s tempting to ship export/import as a single button buried in Settings and call it done. For a local-first app, that button is the disaster recovery plan — there’s no server-side backup quietly covering for a bug in it. I test the restore path with the same seriousness as onboarding: real exported files, a locked and rebooted phone, a fresh install. If the export button is broken, nobody finds out until they need it most, which is exactly the wrong time to discover it.
None of this is complicated engineering. It’s the boring kind that only pays off the one day a user actually needs it — which, for a local-first app, is the whole reason it exists.
// Related reading
More from the journal
Room TypeConverters in 2026: storing enums, dates, and lists without corrupting your schema
A practical guide to Room TypeConverters on Android — enums, Instant/LocalDate, and lists — plus the mistakes that turn a converter into a silent data-corruption bug.
R8 and ProGuard for Kotlin, Room, and Compose: the crash that only happens in release
Why a Room + Compose Android app can work perfectly in debug and crash in production, and the specific R8/ProGuard keep rules that catch it before a user does.
Room database indices in 2026: finding the query that's actually slow and fixing it
A practical guide to indexing a Room/SQLite database on Android — reading EXPLAIN QUERY PLAN, adding @Index without guessing, and the mistakes that silently defeat one.