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.
SQLite only knows five storage classes: NULL, INTEGER, REAL, TEXT, BLOB. Almost nothing in a real app’s data model looks like that. A recurring subscription has a billing Frequency enum. A pantry item has an expiry date. A budget entry might carry a list of tags. Room’s @TypeConverter is the bridge between the two worlds, and it’s a small enough piece of code that people write it once, stop thinking about it, and end up with a converter that quietly reshapes the meaning of a column two years later.
I maintain converters across three local-first apps — Subly’s recurring billing dates, Stocky’s expiry tracking, and category enums in Granyn — and the bugs I’ve actually shipped all came from the same handful of mistakes. Here’s how to avoid them.
The basic shape
A converter is a pair of pure functions, registered on the database:
class Converters {
@TypeConverter
fun fromFrequency(value: Frequency): String = value.name
@TypeConverter
fun toFrequency(value: String): Frequency = Frequency.valueOf(value)
}
@Database(entities = [Subscription::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()
That’s the whole contract: one function per direction, per type. Room calls these automatically whenever an entity field’s type isn’t one it understands natively. The trap is in what looks like a reasonable implementation for each type.
Enums: store the name, never the ordinal
Enum.ordinal is tempting because it’s an Int and Room stores it natively with zero conversion code. It’s also a landmine: the ordinal is just the enum’s position in the source file. Reorder the Frequency cases, or insert QUARTERLY between MONTHLY and YEARLY, and every existing row silently points at the wrong value. Nothing throws. The bug is a subscription that’s billed weekly showing up as yearly, discovered by a user, not a test.
enum class Frequency { WEEKLY, MONTHLY, QUARTERLY, YEARLY }
@TypeConverter
fun fromFrequency(value: Frequency): String = value.name
@TypeConverter
fun toFrequency(value: String): Frequency = Frequency.valueOf(value)
Storing value.name as TEXT costs a few extra bytes per row and is immune to reordering. The only real hazard left is renaming a case — do that with a manual migration that rewrites the stored strings, the same way you’d handle any other column-level data change.
Dates: pick one representation and never store local time
The classic Room date bug isn’t the converter itself, it’s inconsistency: one part of the codebase converts LocalDateTime assuming device-local time, another assumes UTC, and a date that’s correct for a user in Istanbul is off by hours for the same user after a timezone-crossing flight. For anything that needs to compare or sort correctly across devices and timezones, store an instant, not a local date-time:
@TypeConverter
fun fromInstant(value: Instant?): Long? = value?.toEpochMilli()
@TypeConverter
fun toInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli)
Long epoch-millis as INTEGER sorts correctly in raw SQL (useful for ORDER BY and range queries without loading rows into Kotlin first), and it has no timezone ambiguity baked into the stored value — the timezone only matters at display time, in the UI layer, which is where it belongs. If the field is genuinely a calendar date with no time component — an expiry date on a pantry item, say — store it as an ISO-8601 TEXT string (2026-09-14) instead. It’s still sortable as a string, and it avoids the false precision of a timestamp for something that was never a moment in time.
Lists and collections: know what you’re giving up
Storing a List<String> usually means serializing to JSON in the converter:
@TypeConverter
fun fromTags(value: List<String>): String = Json.encodeToString(value)
@TypeConverter
fun toTags(value: String): List<String> = Json.decodeFromString(value)
This works, and for small, rarely-queried lists — a handful of freeform tags on a budget entry — it’s the pragmatic choice. But it’s a trade you’re making deliberately, not a free convenience: a JSON-in-a-column value is opaque to SQL. You can’t WHERE on a tag, you can’t index it, you can’t join against it. The moment a “list of things” needs to be queried, filtered, or related to other data, it isn’t a converter problem anymore — it’s a missing table. A proper @Relation with a join table costs more setup up front and pays for itself the first time a query needs “every subscription tagged work” instead of “every subscription, then filter tags in Kotlin.”
Two rules that catch most converter bugs before they ship
Converters must be pure and total. No I/O, no Clock.System.now(), no throwing on unexpected input if you can help it — a converter that throws on a value stored by an older app version turns a single corrupt row into a crash on every launch that touches that table. Prefer a safe fallback (a default enum case, a null date) over a thrown exception for anything that reads existing data.
A converter’s storage format is part of your schema, even though Room’s schema export doesn’t see it. AutoMigration diffs column types and names — it has no idea that you changed a converter from storing Instant as millis to storing it as an ISO string. That change needs the same manual migration and MigrationTestHelper treatment as any other reshaping of stored data, because from SQLite’s point of view, a TEXT column doesn’t know it used to mean something else.
Get those two right, keep enums stored as names, keep timestamps as UTC epoch-millis, and reach for a real table before reaching for JSON, and the converter layer stops being where bugs hide.
// Related reading
More from the journal
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.
Room's @Relation: querying one-to-many data on Android without N+1 queries
A practical guide to Room's @Relation annotation — modeling one-to-many data like categories and entries without N+1 queries or manual joins.
Full-text search in Room: adding instant search to a local-first Android app in 2026
A practical guide to Room's FTS4 support on Android — building a virtual search table, keeping it in sync with triggers, and why FTS5 needs a manual migration.