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)
adbworking 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:- In WhatsApp on Android, go to Settings, Chats, Chat backup, End-to-end encrypted backup. Turn it on.
- 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.
- 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.dbSanity 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 watoiStep 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.ipaNote 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_jidThe 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 asstatus = 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_typeand the modernmessage_type, so they pass straight through and watoi's placeholder logic keeps working. - I excluded
@broadcastJIDs (status updates and broadcast lists) and@lidrows (WhatsApp's newer anonymous identity addressing) since iOS would not match them to contacts anyway. messagesis 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.momdThe 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.sqliteThis 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:
- 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.
- Delete WhatsApp from the phone.
- Reinstall it from the App Store and register with the same number.
- 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.