signal.db. SignalDatabase is the central facade that owns every table object and provides the only entry points for reading and writing data.
SignalDatabase
SignalDatabase extends net.zetetic.database.sqlcipher.SQLiteOpenHelper and holds a property for each table:
SignalDatabase is a singleton initialized once in ApplicationContext. All callers access tables through the companion object properties:
readableDatabase and writableDatabase properties on the singleton are intentionally overridden to throw — callers must use signalReadableDatabase or signalWritableDatabase instead, which return a thin SignalSQLiteDatabase wrapper that adds transaction helpers.
SQLCipher encryption
The entiresignal.db file is encrypted at rest using SQLCipher. The encryption key is a 32-byte random secret.
Key generation and storage
DatabaseSecretProvider generates and persists the key on first launch:
KeyStoreHelper) and stored as a Base64 string in shared preferences. On subsequent launches, the sealed value is loaded from preferences and unsealed with the Keystore.
The secret is cached in memory as a
volatile singleton after first access to avoid repeated Keystore round-trips, which can be slow.DatabaseSecret.asString() hex-encodes the bytes. This string is passed directly to SQLiteOpenHelper as the SQLCipher passphrase, which SQLCipher uses to derive the actual AES-256 encryption key via PBKDF2.
Key tables
MessageTable
MessageTable
Stores all messages: SMS, MMS, and Signal Protocol encrypted messages. A message is associated with a thread via
thread_id and a recipient via to_recipient_id / from_recipient_id. The type column (see MessageTypes.java) encodes the message class — incoming, outgoing, missed call, key exchange, etc. — as a bitmask.Full-text search is provided by an FTS5 shadow table managed by SearchTable, kept in sync with messages via SQL triggers defined in MessageSendLogTables.CREATE_TRIGGERS.RecipientTable
RecipientTable
Stores every known contact, group, or distribution list the user has interacted with. Each row has a stable
_id (a RecipientId) that is used as a foreign key throughout the rest of the schema. Key columns include service_id (the Signal ACI or PNI), e164 (phone number), profile_key, profile_name, sealed_sender_mode, and storage_id for sync with Signal’s storage service.ThreadTable
ThreadTable
One row per conversation. Caches denormalized read/unread counts, the snippet of the most recent message, and the thread’s sort order for the conversation list. Updates to
MessageTable propagate to ThreadTable through explicit calls in the data layer (not via database triggers).AttachmentTable
AttachmentTable
Tracks every attachment associated with a message. Each row stores metadata (MIME type, size, dimensions) and file pointers. Attachment bodies are stored as encrypted files on disk, not inline in the database; the
AttachmentSecret (distinct from the DatabaseSecret) is used for file-level encryption. StickerTable uses the same AttachmentSecret.GroupTable
GroupTable
Stores group metadata: group ID, title, avatar, membership revision, and the serialized
DecryptedGroup protobuf from the group state. Group membership is stored in a separate group_membership table (created by GroupTable.CREATE_TABLES).SessionTable
SessionTable
Persists Signal Protocol session records (see The Signal Protocol). Each row stores a serialized
SessionRecord blob keyed by (account_id, address, device).KyberPreKeyTable, OneTimePreKeyTable, SignedPreKeyTable, SenderKeyTable, SenderKeySharedTable, IdentityTable, CallTable, CallLinkTable, PaymentTable, NotificationProfileTables, DistributionListTables, and ChatFolderTables.
Migrations
Schema changes are implemented as versioned migrations managed bySignalDatabaseMigrations. Each migration is a class in database/helpers/migration/ that implements SignalDatabaseMigration:
V149_LegacyMigrations, V194_KyberPreKeyMigration, V199_AddThreadActiveColumn, and so on. This naming makes it easy to trace when a schema change was introduced.
SignalDatabase.onUpgrade() disables foreign-key enforcement during the upgrade to avoid constraint violations from reordering, then re-enables it afterwards:
Adding a migration
1
Increment DATABASE_VERSION
Bump
DATABASE_VERSION in SignalDatabaseMigrations.kt by one.2
Create a migration class
Add a new file in
database/helpers/migration/ named V<version>_<DescriptiveName>.kt. Implement the SignalDatabaseMigration interface with a single migrate(context, db) method.3
Register the migration
Add your class to the
when (oldVersion) dispatch block in SignalDatabaseMigrations.migrate().4
Update CREATE_TABLE / CREATE_INDEXES
If the migration adds a column or table, update the corresponding
CREATE_TABLE constant in the table class so that fresh installs get the correct schema.Database observer pattern
DatabaseObserver provides a pub/sub mechanism so the UI layer can react to database changes without polling. It is available via AppDependencies.getDatabaseObserver().
Observers are registered per event type — conversation-level, message-level, attachment-level, and so on:
notifyConversationListeners(threadId)). All notifications are dispatched on a dedicated SerialExecutor to keep UI callbacks off the database write thread.
RxDatabaseObserver wraps DatabaseObserver as RxJava Observable streams for use in ViewModels. The coroutine-friendly ObservableContent provides a Flow-based alternative.
Backup and export
XmlBackup in the database/ directory implements Signal’s backup format (a length-prefixed protobuf stream, AES-256 encrypted with a key derived from the user’s 30-digit backup passphrase via HKDF). The backup format includes messages, threads, recipients, stickers, and attachment metadata (though not attachment bodies by default).
After restoring a backup, SignalDatabase.runPostBackupRestoreTasks() runs the full migration chain against the restored database, prunes stale data, and resets the FTS index:
