Back to Blog

How I Transferred WhatsApp Chat History from Android to iPhone When Every Tool Failed

I got a new iPhone 17 recently. Moving everything from my Android, a POCO phone running Android 16, was easy, except for one thing: WhatsApp. Years of chat history, 325,647 messages, and the official transfer refused to move any of it.
WhatsApp does have an official Android to iPhone transfer. It scans a QR code, the Android creates a temporary hotspot named something like WHATSAPP-90, and the iPhone joins it to pull your chats over. In my case the iPhone 17 would see the network created by the POCO, ask to join, and fail instantly. Every single time. I tried all the usual fixes: mobile data off, VPN off, network settings reset, 2.4GHz hotspot band, rebooting both phones. Nothing worked.
So I searched the internet, and the results were grim. Every article and YouTube video pushed the same handful of paid "phone transfer" tools, mostly through affiliate links, all closed source, all requiring you to hand your entire chat history to an app you cannot inspect. Some of the videos were suggesting software so shady I would not install it on a throwaway machine, let alone give it years of private conversations. That was not going to happen. So I did what any reasonable person would do at 1 AM: I decided to do the migration myself with open source tools.
Fair warning before you start: this method moves messages only. Media comes across as placeholders like <image> and <video>, though captions are preserved. Also, this involves modifying an iPhone backup and restoring it, so read everything first and keep safety copies at every step.
Here is the full recipe.

Prerequisites

  • A Mac with Xcode installed (the full app, not just command line tools)
  • adb working with your Android phone
  • Full Disk Access granted to your terminal (System Settings, Privacy & Security)
  • WhatsApp activated on the iPhone with your number, with one or two test messages sent
  • ipatool-rs (Kosthi) and wa-crypt-tools

Step 1: Get the Decrypted Android Database

The old tricks for extracting msgstore.db involved rooting your phone or downgrading to an ancient APK. There is a much cleaner way now, but the order of the steps matters:
  1. In WhatsApp on Android, go to Settings, Chats, Chat backup, End-to-end encrypted backup. Turn it on.
  2. Choose "Use 64-digit encryption key instead" and save that key somewhere safe. This is what decrypts the database later, so do not skip it.
  3. Now go back to the Chat backup screen and tap Back Up to trigger a fresh local backup.
That last step is not optional. The msgstore.db.crypt15 file only exists, encrypted with your new key, after you run a backup following the key setup. If you pull an old backup file, or forget to trigger a new one, the decryption will fail.
The backup lands at a path that is readable over adb without root:
bash
mkdir ~/wa-migration && cd ~/wa-migration
 
adb pull /sdcard/Android/media/com.whatsapp/WhatsApp/Databases/msgstore.db.crypt15 .
 
pip install wa-crypt-tools
wadecrypt <your-64-digit-key> msgstore.db.crypt15 msgstore.db
Sanity check:
bash
sqlite3 msgstore.db "select count(*) from message;"
Mine printed 325647. The hard part was already done, or so I thought.

Step 2: Build watoi

watoi (WhatsApp Android To iOS Importer) is the core tool here. It reads the Android database and injects the messages into the iOS ChatStorage.sqlite using WhatsApp's own CoreData model. I used the kwvg fork since it supports a newer schema than the original.
bash
git clone https://github.com/kwvg/watoi
cd watoi
xcodebuild -project watoi.xcodeproj -target watoi

Step 3: Back Up the iPhone and Extract ChatStorage.sqlite

Connect the iPhone, open Finder, select the phone, choose "Back up all of the data on your iPhone to this Mac", and make sure "Encrypt local backup" is unchecked. The scripts cannot work with encrypted backups.
bash
scripts/bedit.sh list-backups
export BACKUP_ID="<your backup id>"
 
# safety copy, do not skip this
cp -R ~/Library/Application\ Support/MobileSync/Backup/${BACKUP_ID} ~/wa-migration/backup-original
 
export ORIGINALS="originals/$(date +%s)"
mkdir -p $ORIGINALS
scripts/bedit.sh extract-chats $BACKUP_ID $ORIGINALS/ChatStorage.sqlite
scripts/bedit.sh extract-blob $BACKUP_ID Manifest.db $ORIGINALS/Manifest.db
cp $ORIGINALS/ChatStorage.sqlite ./ChatStorage.sqlite
 
sqlite3 ./ChatStorage.sqlite "select count(*) from ZWAMESSAGE;"
If that last command prints a small number (my fresh install had 118 rows), you are good.

Step 4: Get the WhatsApp IPA

watoi needs the CoreData model files from the WhatsApp iOS app bundle. Apple Configurator is the commonly suggested way to get the IPA, but it kept failing for me with AMSErrorDomain error 100.
ipatool is a much nicer way. It downloads the IPA directly from the App Store using your Apple ID. The original majd/ipatool from homebrew did not work for me (the upstream project has gone stale and auth kept failing), so I used ipatool-rs, the actively maintained Rust rewrite by Kosthi:
bash
brew install Kosthi/tap/ipatool
 
# Interactive login (prompts for a 2FA code if Apple requires one)
ipatool auth login --email [email protected] --password 'password'
 
# Or pass the 2FA code up front
ipatool auth login --email [email protected] --password 'password' --auth-code 123456
 
ipatool download -b net.whatsapp.WhatsApp -o ~/wa-migration/WhatsApp.ipa
Note the login flag is --email now, not the old -e.
One thing to keep in mind: ipatool downloads the latest App Store version, so make sure the WhatsApp on your iPhone is updated to the same version before you make the backup. The database schema must match the model.
bash
cd ~/wa-migration/watoi
unzip ~/wa-migration/WhatsApp.ipa -d app
find app -name "*.momd"
In current versions the model lives at app/Payload/WhatsApp.app/Frameworks/SharedModules.framework/WhatsAppChat.momd. Older guides point to Core.framework, which no longer exists.

Step 5: The Part Where Everything Broke

I ran the import and it crashed immediately:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException',
reason: '*** -[__NSDictionaryM setObject:forKey:]: key cannot be nil'
The crash was in importChats. watoi reads chats with SELECT * FROM chat_view and keys everything on a column called raw_string_jid. I checked my database:
Error: in prepare, no such column: raw_string_jid
The column does not exist anymore. WhatsApp migrated the Android database to a fully normalized schema years ago, and by 2026 the legacy compatibility structures that watoi depends on are gone completely. No raw_string_jid in chat_view, no messages table with key_remote_jid, no group_participants table. Every JID lives in a central jid table, and everything else references it by row id.
So watoi was querying tables from another era. Instead of rewriting the Objective-C, I did something lazier and, I think, nicer: I rebuilt the legacy schema as a compatibility layer inside the database itself.

Step 6: The Legacy Schema Compatibility Layer

watoi needs three things: chat_view with raw_string_jid, a messages table with the old column names, and group_participants. All three can be reconstructed from the modern schema with SQL.
Make a safety copy of msgstore.db first, then run this:
sql
-- 1. chat_view with raw_string_jid
DROP VIEW IF EXISTS chat_view;
CREATE VIEW chat_view AS
SELECT
    j.raw_string                     AS raw_string_jid,
    NULLIF(c.subject, '')            AS subject,
    c.archived                       AS archived,
    COALESCE(c.created_timestamp, 0) AS created_timestamp
FROM chat c
JOIN jid j ON c.jid_row_id = j._id
WHERE j.raw_string IS NOT NULL
  AND j.raw_string NOT LIKE '%@broadcast'
  AND j.raw_string NOT LIKE '%@lid'
  AND (c.hidden IS NULL OR c.hidden = 0);
 
-- 2. legacy shaped messages table, materialized for speed
DROP VIEW  IF EXISTS messages;
DROP TABLE IF EXISTS messages;
CREATE TABLE messages AS
SELECT
    m._id                                            AS _id,
    cj.raw_string                                    AS key_remote_jid,
    m.from_me                                        AS key_from_me,
    m.key_id                                         AS key_id,
    CASE WHEN m.message_type = 7 THEN 6 ELSE 0 END   AS status,
    m.text_data                                      AS data,
    m.timestamp                                      AS timestamp,
    COALESCE(sj.raw_string, '')                      AS remote_resource,
    m.message_type                                   AS media_wa_type,
    mm.media_caption                                 AS media_caption
FROM message m
JOIN chat c        ON m.chat_row_id = c._id
JOIN jid  cj       ON c.jid_row_id = cj._id
LEFT JOIN jid sj   ON m.sender_jid_row_id = sj._id
LEFT JOIN message_media mm ON mm.message_row_id = m._id
WHERE cj.raw_string NOT LIKE '%@broadcast'
  AND cj.raw_string NOT LIKE '%@lid';
 
CREATE INDEX idx_watoi_messages ON messages(key_remote_jid, timestamp);
 
-- 3. group_participants
DROP VIEW  IF EXISTS group_participants;
DROP TABLE IF EXISTS group_participants;
CREATE VIEW group_participants AS
SELECT
    gj.raw_string                                AS gjid,
    COALESCE(uj.raw_string, '')                  AS jid,
    CASE WHEN gpu.rank > 0 THEN 1 ELSE 0 END     AS admin
FROM group_participant_user gpu
JOIN jid gj      ON gpu.group_jid_row_id = gj._id
LEFT JOIN jid uj ON gpu.user_jid_row_id  = uj._id;
A few notes on what is going on here:
  • Modern system messages are message_type = 7. The legacy schema marked them as status = 6, which watoi filters out. The CASE expression translates one convention to the other.
  • The media type numbers are mostly identical between the legacy media_wa_type and the modern message_type, so they pass straight through and watoi's placeholder logic keeps working.
  • I excluded @broadcast JIDs (status updates and broadcast lists) and @lid rows (WhatsApp's newer anonymous identity addressing) since iOS would not match them to contacts anyway.
  • messages is a real indexed table rather than a view. watoi queries it once per chat, and running that against a view joining 325K rows a hundred plus times would take forever.
I also added a small guard in main.m inside importChats to skip any chat rows with a nil JID, right after chatJID is read:
objc
if (chatJID == nil || (id)chatJID == null || ![chatJID isKindOfClass:[NSString class]]) {
    NSLog(@"Skipping chat with NULL jid: %@", achat);
    continue;
}
Rebuild watoi after that change.

Step 7: Run the Import

bash
build/Release/watoi \
  ~/wa-migration/msgstore.db \
  ./ChatStorage.sqlite \
  app/Payload/WhatsApp.app/Frameworks/SharedModules.framework/WhatsAppChat.momd
The log fills up with null text detected lines. These are message types the old enum does not know about: reactions, polls, view-once notices, business template messages. They get skipped or imported empty. Actual text messages all come through.
The whole import of 323,782 messages across 139 chats took about 9 seconds on my machine. The index on the compatibility table does a lot of heavy lifting there.
Verify before going further:
bash
sqlite3 ./ChatStorage.sqlite "select count(*) from ZWAMESSAGE;"
sqlite3 ./ChatStorage.sqlite "select count(*) from ZWACHATSESSION;"

Step 8: Patch the Backup and Restore

bash
scripts/bedit.sh replace-chats $BACKUP_ID ./ChatStorage.sqlite
This swaps the modified database into the backup and updates the file hash in Manifest.db, which is why you cannot just copy the file in by hand.
Then in Finder: select the iPhone, Restore Backup, pick the backup you just modified, and keep the cable connected until the phone is fully restored. You may need to turn off Find My iPhone temporarily.

Step 9: The iCloud Round Trip

Here is the part that almost gave me a heart attack. After the restore, I opened WhatsApp and the chats were not there. The messages exist in the database at this point, but WhatsApp's UI does not pick them up directly. It needs to rebuild its indexes from a proper restore, and the trick is to route the data through iCloud:
  1. Open WhatsApp, go to Settings, Chats, Chat Backup, and run a backup to iCloud. This backs up the database we just injected, including all the imported history.
  2. Delete WhatsApp from the phone.
  3. Reinstall it from the App Store and register with the same number.
  4. When it detects the iCloud backup, tap Restore Chat History.
After the restore finished, everything was there. All 139 chats with their full history, going back years, correctly ordered with correct timestamps. Media shows as <image> and <video> placeholders with captions preserved.
Since the history now lives in a normal iCloud backup, it also survives any future reinstall like a native migration would.

Conclusion

The official WhatsApp transfer is a black box, and when it fails there is no error message, no log, nothing to debug. The open source route is the opposite: every step is inspectable, your data never leaves your machines, and when something breaks you can actually fix it. In my case the fix was teaching a 2017-era tool to read a 2026 database by rebuilding the schema it expected as SQL views.
Messages only, no media, and a full evening of work. Still worth it for years of history that would otherwise be gone.

References

  1. watoi and the kwvg fork
  2. wa-crypt-tools
  3. ipatool-rs (Kosthi)
  4. Matt Olan's watoi writeup