WhatsApp Desktop
WhatsApp Desktop — Chat Database
Overview
ChatStorage.sqlite is the primary forensic artifact for WhatsApp Desktop on macOS. It is a standard, unencrypted SQLite database using Apple's Core Data persistence framework. All entity table names carry the ZWA prefix (e.g., ZWAMESSAGE, ZWACHATSESSION). The database is located at:
~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/ChatStorage.sqlite
The database is accessible to any process running as the user — no Full Disk Access, no root privileges, and no decryption keys are required.
Always collect the WAL and SHM companion files alongside the main database:
ChatStorage.sqlite
ChatStorage.sqlite-wal
ChatStorage.sqlite-shm
Timestamp Format
All timestamp columns use the Apple Core Data epoch: floating-point seconds since January 1, 2001, 00:00:00 UTC.
unix_timestamp = core_data_timestamp + 978307200
In SQLite queries:
datetime(ZMESSAGEDATE + 978307200, 'unixepoch') AS message_time
Zero values in timestamp columns indicate the timestamp was not populated. The ZPIN column on ZWACHATSESSION stores a non-zero value when the session is pinned (the value itself is a timestamp of when pinning occurred, not a boolean).
Table: ZWAMESSAGE
The central table. Every message in every conversation is stored here — text messages, media messages, system events, and missed call notifications.
| Column | Type | Description |
|---|---|---|
Z_PK | INTEGER | Core Data primary key |
Z_ENT | INTEGER | Core Data entity type ID |
Z_OPT | INTEGER | Core Data optimistic locking counter |
ZCHATSESSION | INTEGER | FK → ZWACHATSESSION.Z_PK |
ZMEDIAITEM | INTEGER | FK → ZWAMEDIAITEM.Z_PK (null for text messages) |
ZFROMJID | VARCHAR | Sender JID — <phone>@s.whatsapp.net or <id>@g.us |
ZTOJID | VARCHAR | Recipient JID (null for incoming messages) |
ZSTANZAID | VARCHAR | Unique message ID from the XMPP stanza |
ZPUSHNAME | VARCHAR | Sender's display name at time of sending |
ZTEXT | VARCHAR | Plaintext message body |
ZMESSAGETYPE | INTEGER | Message type constant (see below) |
ZISFROMME | INTEGER | 1 = outgoing (sent by account owner), 0 = incoming |
ZMESSAGESTATUS | INTEGER | Delivery/read status (see below) |
ZMESSAGEDATE | TIMESTAMP | When the message was created or received (Core Data epoch) |
ZSENTDATE | TIMESTAMP | When the message was sent — populated for outgoing messages |
ZSERVERCREATIONDATE | TIMESTAMP | Server-assigned timestamp |
ZSTARRED | INTEGER | 1 = user starred this message |
ZISFLAG | INTEGER | Internal flag status |
ZISFORWARDED | INTEGER | 1 = message was forwarded from another conversation |
ZFORWARDCOUNT | INTEGER | Number of times the message has been forwarded |
ZQUOTEDMESSAGE | INTEGER | FK → ZWAMESSAGE.Z_PK of the message being replied to |
ZGROUPEVENTTYPE | INTEGER | Group event subtype for system messages |
ZGROUPMEMBER | INTEGER | FK → ZWAGROUPMEMBER.Z_PK for group events |
ZMEDIASECTIONID | VARCHAR | Media section identifier |
ZMESSAGEACTIONTYPE | INTEGER | Action type for system-generated messages |
ZSORT | INTEGER | Sort order within the conversation |
ZFLAGS | INTEGER | Bitmask of message feature flags |
ZDATAITEMVERSION | INTEGER | Extended data schema version |
ZPARENTMESSAGE | INTEGER | FK for threaded replies |
ZCHILDMESSAGESDELIVEREDCOUNT | INTEGER | Outgoing group delivery count |
ZCHILDMESSAGESPLAYEDCOUNT | INTEGER | Outgoing group played count (voice/video) |
ZCHILDMESSAGESREADCOUNT | INTEGER | Outgoing group read count |
Schema Evolution
The ZWAMESSAGE schema has expanded significantly across WhatsApp releases. Newer versions (2024–2025) may include additional columns for message editing, view-once media, disappearing message state, and WhatsApp Channels. The macfor collector uses dynamic column detection to handle schema variations gracefully.
ZMESSAGETYPE Values
| Value | Name | Description |
|---|---|---|
| 0 | text | Plain text message |
| 1 | image | Photo or image attachment |
| 2 | video | Video attachment |
| 3 | voice | Voice message or audio note |
| 4 | contact | vCard / contact card share |
| 5 | location | GPS location or live location pin |
| 6 | system | Group event (member added/removed, name change, subject update) |
| 7 | link | URL with web preview |
| 8 | document | File attachment (PDF, DOCX, etc.) |
| 10 | missed_call | Missed voice or video call notification |
| 13 | gif | Animated GIF |
| 15 | sticker | Sticker message |
Values 9, 11, 12, and 14 have been observed but are not officially documented. The macfor collector returns unknown(N) for unrecognized values.
ZMESSAGESTATUS Values
| Value | Name | Description |
|---|---|---|
| 0 | unknown | Message not yet sent, or locally received with no status set |
| 1 | received | Message received by the server (single checkmark) |
| 2 | delivered | Delivered to recipient device (double checkmark, gray) |
| 3 | read | Read by recipient (double checkmark, blue) or voice message played |
| 4 | server_ack | Server acknowledgment |
| 5 | delivered | Alternative delivery state |
| 6 | read | Alternative read/played state |
| 8 | read_confirmed | Confirmed read receipt |
For incoming messages, ZMESSAGESTATUS typically reflects how the message was handled locally. For outgoing group messages, it reflects aggregate delivery state — ZWAMESSAGEINFO provides per-recipient detail.
Table: ZWACHATSESSION
One row per conversation thread. Covers 1:1 conversations, group chats, broadcast lists, and the status feed.
| Column | Type | Description |
|---|---|---|
Z_PK | INTEGER | Core Data primary key |
Z_ENT | INTEGER | Core Data entity type ID |
Z_OPT | INTEGER | Core Data optimistic locking counter |
ZCONTACTJID | VARCHAR | Conversation JID — <phone>@s.whatsapp.net or <id>@g.us |
ZPARTNERNAME | VARCHAR | Display name of the contact or group |
ZSESSIONTYPE | INTEGER | Session type constant (see below) |
ZMESSAGECOUNTER | INTEGER | Total messages in this conversation (historical count) |
ZUNREADCOUNT | INTEGER | Unread message count at time of collection |
ZLASTMESSAGE | INTEGER | FK → ZWAMESSAGE.Z_PK of the most recent message |
ZLASTMESSAGEDATE | TIMESTAMP | Core Data timestamp of the last message |
ZLASTMESSAGETEXT | VARCHAR | Preview text of the last message |
ZARCHIVED | INTEGER | 1 = conversation has been archived by the user |
ZMUTEEXPIRATION | TIMESTAMP | Mute-until timestamp (Core Data epoch), null if not muted |
ZPIN | TIMESTAMP | Non-zero = pinned conversation (value is the pin timestamp) |
ZGROUPINFO | INTEGER | FK → ZWAGROUPINFO.Z_PK (null for 1:1 conversations) |
ZCREATIONDATE | TIMESTAMP | When this session was first created (Core Data epoch) |
ZDISAPPEARINGMESSAGEDURATION | INTEGER | Auto-delete timer in seconds, 0 = disabled |
ZLOCATIONSHARINGCONTACT | INTEGER | FK for live location sharing |
ZSESSIONTYPE Values
| Value | Name | Description |
|---|---|---|
| 0 | private | 1:1 conversation |
| 1 | group | Group chat |
| 2 | broadcast | Broadcast list |
| 3 | status | Status updates feed |
Table: ZWAMEDIAITEM
Stores metadata for all media attachments. One row per media item, linked to its parent message via ZMESSAGE.
| Column | Type | Description |
|---|---|---|
Z_PK | INTEGER | Core Data primary key |
Z_ENT | INTEGER | Core Data entity type ID |
Z_OPT | INTEGER | Core Data optimistic locking counter |
ZMESSAGE | INTEGER | FK → ZWAMESSAGE.Z_PK |
ZMEDIALOCALPATH | VARCHAR | Relative path to the downloaded media file in the Group Container |
ZMEDIAURL | VARCHAR | Remote URL on the WhatsApp CDN for download or re-download |
ZVCARDSTRING | VARCHAR | vCard data for contact shares |
ZVCARDNAME | VARCHAR | Contact name or media file identifier |
ZMEDIAKEY | BLOB | Media decryption key (for CDN re-download) |
ZTITLE | VARCHAR | Media title or original filename |
ZFILESIZE | INTEGER | File size in bytes |
ZFILEMIMETYPE | VARCHAR | MIME type (e.g., image/jpeg, video/mp4, application/pdf) |
ZWIDTH | INTEGER | Image or video width in pixels |
ZHEIGHT | INTEGER | Image or video height in pixels |
ZASPECTRATIO | FLOAT | Aspect ratio |
ZMOVIEDURATION | INTEGER | Duration in seconds for audio and video |
ZLATITUDE | FLOAT | Latitude for location messages |
ZLONGITUDE | FLOAT | Longitude for location messages |
ZTHUMBNAILLOCALPATH | VARCHAR | Path to the local thumbnail image |
ZTHUMBNAILDATA | BLOB | Embedded thumbnail binary data |
ZDOWNLOADRETRYCOUNT | INTEGER | Number of download retry attempts |
ZMEDIAUPLOADEDFROMTARGET | INTEGER | Internal upload tracking flag |
ZMEDIALOCALPATH values are relative to the Group Container directory. To construct the full path, prepend ~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/.
Location messages (type 5) populate ZLATITUDE and ZLONGITUDE instead of file-related fields.
Table: ZWAGROUPINFO
Group-level metadata. One row per group chat, linked from ZWACHATSESSION.ZGROUPINFO.
| Column | Type | Description |
|---|---|---|
Z_PK | INTEGER | Core Data primary key |
Z_ENT | INTEGER | Core Data entity type ID |
Z_OPT | INTEGER | Core Data optimistic locking counter |
ZCHATSESSION | INTEGER | FK → ZWACHATSESSION.Z_PK |
ZCREATORJID | VARCHAR | JID of the user who created the group |
ZSUBJECT | VARCHAR | Current group name (subject) |
ZDESCRIPTION | VARCHAR | Group description text |
ZSUBJECTOWNER | VARCHAR | JID of the user who last set the subject |
ZCREATIONDATE | TIMESTAMP | Group creation timestamp (Core Data epoch) |
ZSUBJECTDATE | TIMESTAMP | When the subject was last changed (Core Data epoch) |
Table: ZWAGROUPMEMBER
Group membership. One row per member per group. Group chats are identified by ZCHATSESSION.
| Column | Type | Description |
|---|---|---|
Z_PK | INTEGER | Core Data primary key |
Z_ENT | INTEGER | Core Data entity type ID |
Z_OPT | INTEGER | Core Data optimistic locking counter |
ZCHATSESSION | INTEGER | FK → ZWACHATSESSION.Z_PK |
ZMEMBERJID | VARCHAR | Member's JID — <phone>@s.whatsapp.net |
ZISADMIN | INTEGER | Admin role: 0 = member, 1 = admin, 2 = superadmin |
ZCONTACTNAME | VARCHAR | Member's display name at time of last observation |
The ZISADMIN value 2 (superadmin) typically indicates the original group creator.
Table: ZWAMESSAGEINFO
Per-recipient delivery and read receipts for outgoing messages. Only populated for messages sent by the account owner. In group conversations, there is one row per recipient.
| Column | Type | Description |
|---|---|---|
Z_PK | INTEGER | Core Data primary key |
Z_ENT | INTEGER | Core Data entity type ID |
Z_OPT | INTEGER | Core Data optimistic locking counter |
ZMESSAGE | INTEGER | FK → ZWAMESSAGE.Z_PK |
ZRECEIPTJID | VARCHAR | Recipient JID who triggered this receipt |
ZREADDATE | TIMESTAMP | When the recipient read the message (Core Data epoch) |
ZRECEIVEDDATE | TIMESTAMP | When the recipient's device received the message (Core Data epoch) |
ZPLAYEDDATE | TIMESTAMP | When the recipient played audio or video (Core Data epoch) |
A non-null ZREADDATE is strong evidence that the recipient read the specific message on their device.
Table: ZWAPROFILEPUSHNAME
Maps JIDs to the display name the contact has set for themselves (their "push name"). This is the name the contact chose in their WhatsApp profile, distinct from the name stored in the local address book.
| Column | Type | Description |
|---|---|---|
Z_PK | INTEGER | Core Data primary key |
Z_ENT | INTEGER | Core Data entity type ID |
Z_OPT | INTEGER | Core Data optimistic locking counter |
ZJID | VARCHAR | Contact JID — <phone>@s.whatsapp.net |
ZPUSHNAME | VARCHAR | The contact's self-set display name |
This table is useful when the contact is not in the local address book — the push name provides an identity label from the contact themselves.
Table: Z_PRIMARYKEY
Core Data entity registry. The Z_MAX column stores the highest primary key value ever assigned for each entity type, providing a historical record count even after deletions.
| Column | Type | Description |
|---|---|---|
Z_ENT | INTEGER | Entity type ID (matches Z_ENT in all other tables) |
Z_NAME | VARCHAR | Entity name (e.g., WAMessage, WAChatSession) |
Z_SUPER | INTEGER | Parent entity ID (for inheritance) |
Z_MAX | INTEGER | Highest Z_PK ever assigned — a lower bound on historical record count |
Query example: SELECT Z_NAME, Z_MAX FROM Z_PRIMARYKEY ORDER BY Z_MAX DESC shows all entity types and their maximum historical counts.
Key SQL Queries
All messages in a conversation
-- List all messages in a specific conversation, newest first.
-- Replace the JID placeholder with the target phone number.
SELECT
m.Z_PK AS message_id,
datetime(m.ZMESSAGEDATE + 978307200, 'unixepoch') AS sent_at,
m.ZFROMJID AS sender_jid,
m.ZISFROMME AS is_from_me,
m.ZMESSAGETYPE AS msg_type,
m.ZMESSAGESTATUS AS status,
m.ZTEXT AS body,
m.ZSTANZAID AS stanza_id
FROM ZWAMESSAGE m
JOIN ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK
WHERE cs.ZCONTACTJID = '14155551234@s.whatsapp.net'
ORDER BY m.ZMESSAGEDATE DESC;
All conversations with last message date
-- List all conversation threads, ordered by most recent activity.
SELECT
cs.ZCONTACTJID AS chat_jid,
cs.ZPARTNERNAME AS name,
CASE cs.ZSESSIONTYPE
WHEN 0 THEN 'private'
WHEN 1 THEN 'group'
WHEN 2 THEN 'broadcast'
WHEN 3 THEN 'status'
ELSE 'unknown'
END AS session_type,
cs.ZMESSAGECOUNTER AS total_messages,
cs.ZUNREADCOUNT AS unread,
datetime(cs.ZLASTMESSAGEDATE + 978307200, 'unixepoch') AS last_message_at,
cs.ZARCHIVED AS is_archived
FROM ZWACHATSESSION cs
ORDER BY cs.ZLASTMESSAGEDATE DESC;
Outgoing messages with full delivery/read confirmation
-- Find outgoing messages where a specific recipient confirmed they read the message.
SELECT
m.Z_PK AS message_id,
datetime(m.ZMESSAGEDATE + 978307200, 'unixepoch') AS sent_at,
cs.ZCONTACTJID AS chat_jid,
m.ZTEXT AS body,
mi.ZRECEIPTJID AS read_by,
datetime(mi.ZREADDATE + 978307200, 'unixepoch') AS read_at,
datetime(mi.ZRECEIVEDDATE + 978307200, 'unixepoch') AS received_at
FROM ZWAMESSAGE m
JOIN ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK
JOIN ZWAMESSAGEINFO mi ON mi.ZMESSAGE = m.Z_PK
WHERE m.ZISFROMME = 1
AND mi.ZREADDATE IS NOT NULL
AND mi.ZREADDATE > 0
ORDER BY m.ZMESSAGEDATE DESC;
Messages with media attachments (geolocation)
-- Find all location messages with latitude/longitude coordinates.
SELECT
m.Z_PK AS message_id,
datetime(m.ZMESSAGEDATE + 978307200, 'unixepoch') AS sent_at,
cs.ZCONTACTJID AS chat_jid,
m.ZFROMJID AS sender_jid,
med.ZLATITUDE AS latitude,
med.ZLONGITUDE AS longitude,
m.ZTEXT AS location_label
FROM ZWAMESSAGE m
JOIN ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK
JOIN ZWAMEDIAITEM med ON med.ZMESSAGE = m.Z_PK
WHERE m.ZMESSAGETYPE = 5
AND med.ZLATITUDE IS NOT NULL
ORDER BY m.ZMESSAGEDATE DESC;
Forwarded messages
-- Find all forwarded messages, which may indicate information dissemination.
SELECT
m.Z_PK AS message_id,
datetime(m.ZMESSAGEDATE + 978307200, 'unixepoch') AS sent_at,
cs.ZCONTACTJID AS chat_jid,
m.ZFROMJID AS sender_jid,
m.ZISFROMME AS is_from_me,
m.ZFORWARDCOUNT AS forward_count,
m.ZTEXT AS body
FROM ZWAMESSAGE m
JOIN ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK
WHERE m.ZISFORWARDED = 1
ORDER BY m.ZMESSAGEDATE DESC;
Group membership and admin roles
-- List all members of a group chat with their admin roles.
SELECT
cs.ZPARTNERNAME AS group_name,
cs.ZCONTACTJID AS group_jid,
gi.ZCREATORJID AS creator_jid,
datetime(gi.ZCREATIONDATE + 978307200, 'unixepoch') AS group_created_at,
gm.ZMEMBERJID AS member_jid,
CASE gm.ZISADMIN
WHEN 0 THEN 'member'
WHEN 1 THEN 'admin'
WHEN 2 THEN 'superadmin'
ELSE 'unknown'
END AS role,
gm.ZCONTACTNAME AS display_name
FROM ZWAGROUPMEMBER gm
JOIN ZWACHATSESSION cs ON gm.ZCHATSESSION = cs.Z_PK
LEFT JOIN ZWAGROUPINFO gi ON cs.ZGROUPINFO = gi.Z_PK
WHERE cs.ZCONTACTJID = '120363123456789012@g.us'
ORDER BY gm.ZISADMIN DESC, gm.ZMEMBERJID;
Starred messages
-- Retrieve all starred (bookmarked) messages across all conversations.
SELECT
m.Z_PK AS message_id,
datetime(m.ZMESSAGEDATE + 978307200, 'unixepoch') AS sent_at,
cs.ZCONTACTJID AS chat_jid,
cs.ZPARTNERNAME AS chat_name,
m.ZFROMJID AS sender_jid,
m.ZMESSAGETYPE AS msg_type,
m.ZTEXT AS body
FROM ZWAMESSAGE m
JOIN ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK
WHERE m.ZSTARRED = 1
ORDER BY m.ZMESSAGEDATE DESC;
Historical record count from Core Data
-- Show entity types and their maximum historical primary key values.
-- Z_MAX is a lower bound on total records ever created.
SELECT
Z_NAME AS entity,
Z_MAX AS max_pk_ever_assigned
FROM Z_PRIMARYKEY
ORDER BY Z_MAX DESC;
Media files by MIME type
-- List all media attachments by MIME type with local file paths.
SELECT
med.ZFILEMIMETYPE AS mime_type,
count(*) AS count,
sum(med.ZFILESIZE) AS total_bytes
FROM ZWAMEDIAITEM med
WHERE med.ZFILEMIMETYPE IS NOT NULL
GROUP BY med.ZFILEMIMETYPE
ORDER BY count DESC;
macfor Collection
macfor collects the following artifacts from ChatStorage.sqlite via the messaging.whatsapp plugin:
| Record Type | Source Table | Description |
|---|---|---|
whatsapp_messages | ZWAMESSAGE + ZWACHATSESSION + ZWAMEDIAITEM | All messages with media joins |
whatsapp_conversations | ZWACHATSESSION | Conversation thread metadata |
The raw ChatStorage.sqlite, ChatStorage.sqlite-wal, and ChatStorage.sqlite-shm files are also preserved in the evidence container.
Collection command:
macfor-pro collect --plugin messaging.whatsapp --output ./evidence.zip
Skip message bodies (metadata-only mode):
macfor-pro collect --plugin messaging.whatsapp --option metadata_only=true --output ./evidence.zip