Skip to main content
All persistent application data lives in a single SQLCipher-encrypted SQLite database named 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:
The 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 entire signal.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:
The 32-byte secret is sealed using the Android Keystore (via 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

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.
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.
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).
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.
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).
Persists Signal Protocol session records (see The Signal Protocol). Each row stores a serialized SessionRecord blob keyed by (account_id, address, device).
Other notable tables include KyberPreKeyTable, OneTimePreKeyTable, SignedPreKeyTable, SenderKeyTable, SenderKeySharedTable, IdentityTable, CallTable, CallLinkTable, PaymentTable, NotificationProfileTables, DistributionListTables, and ChatFolderTables.

Migrations

Schema changes are implemented as versioned migrations managed by SignalDatabaseMigrations. Each migration is a class in database/helpers/migration/ that implements SignalDatabaseMigration:
Migrations are named with a version prefix: 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:
When writing a migration, never reference table or column name constants from the live table classes. Those constants can change in future refactors. Copy the relevant string literals directly into the migration class so that the migration is a snapshot of the schema at a specific version.

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:
After a write, the data layer calls the appropriate notify method (e.g., 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:
The job-related tables (job_spec, constraint_spec, dependency_spec) are explicitly dropped after a restore. This prevents stale or partially-completed jobs from the backed-up state from running on the restored device.