I Built My Own iMessage Wrapped (And So Can You)
My daughter was joking that someone should build a Spotify Wrapped, but for iMessage — your year in emoji and reactions. My wife said my brother-in-law should build it since he works at Apple. (Every Apple problem is his fault in our house.)
From the other room, my daughter yelled: “I bet Dad could write this!”
Challenge accepted.
The Result
Thirty minutes later, I had a working script. Here’s my 2025:
🎁 iMessage Wrapped 2025
📱 Messages
Total: 35,768
Sent: 12,191
Received: 23,577
💬 Reactions
Given: 1,974
Received: 5,014
🏆 Your Reaction Style
👍 982
❤️ 398
😂 275
‼️ 126
❓ 19
👎 16
📈 Messages by Month
Jan ███████████████ 3,052
Feb █████████████ 2,621
Mar █████████████████ 3,422
Apr ██████████████████ 3,696
May ██████████████████ 3,640
Jun ████████████████████ 3,904
Jul █████████████ 2,561
Aug ████████████ 2,448
Sep ██████████████ 2,790
Oct █████████████████ 3,466
Nov ███████████ 2,206
Dec ██████████ 1,962
Some things I learned about myself:
I’m a listener. I receive almost 2x as many messages as I send.
People love reacting to me. 5,014 reactions received vs 1,974 given.
I’m a thumbs-up guy. 
accounts for half my reactions. Apparently I’m very agreeable.
June was my chattiest month. December was quietest (holiday break mode).How It Works
Your iMessage history lives in a SQLite database at ~/Library/Messages/chat.db. It’s just sitting there, queryable.
The tricky bits:
Dates are weird. Apple stores timestamps as nanoseconds since January 1, 2001 (because of course they do).
Reactions are messages. When you tapback a

️, it’s stored as a separate message with associated_message_type set to a magic number (2000 = loved, 2001 = liked, etc.).
Custom emoji reactions landed in iOS 17 and are stored in associated_message_emoji.
Once you know the schema, it’s just SQL.
The Script
Here’s the full thing — about 100 lines of Python, no dependencies beyond the standard library:
#!/usr/bin/env python3
"""
iMessage Wrapped — Your year in emoji and reactions
Usage: python3 imessage-wrapped.py [year]
Requires: Full Disk Access for Terminal
"""
import sqlite3
import os
import sys
from datetime import datetime
from pathlib import Path
YEAR = int(sys.argv[1]) if len(sys.argv) > 1 else 2025
DB_PATH = Path.home() / "Library/Messages/chat.db"
APPLE_EPOCH_OFFSET = 978307200
TAPBACKS = {
2000: "❤️", # Loved
2001: "👍", # Liked
2002: "👎", # Disliked
2003: "😂", # Laughed
2004: "‼️", # Emphasized
2005: "❓", # Questioned
}
def get_db():
if not DB_PATH.exists():
print(f"❌ Database not found at {DB_PATH}")
sys.exit(1)
return sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
def date_filter(year):
return f"""
datetime(date/1000000000 + {APPLE_EPOCH_OFFSET}, 'unixepoch') >= '{year}-01-01'
AND datetime(date/1000000000 + {APPLE_EPOCH_OFFSET}, 'unixepoch') < '{year + 1}-01-01'
"""
def main():
print(f"\n🎁 iMessage Wrapped {YEAR}\n")
db = get_db()
cur = db.cursor()
# Message counts
cur.execute(f"""
SELECT COUNT(*),
SUM(CASE WHEN is_from_me = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN is_from_me = 0 THEN 1 ELSE 0 END)
FROM message WHERE {date_filter(YEAR)} AND associated_message_type = 0
""")
total, sent, received = cur.fetchone()
print(f"📱 Messages: {total:,} ({sent:,} sent, {received:,} received)")
# Reaction counts
cur.execute(f"""
SELECT SUM(CASE WHEN is_from_me = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN is_from_me = 0 THEN 1 ELSE 0 END)
FROM message WHERE {date_filter(YEAR)} AND associated_message_type >= 2000
""")
given, got = cur.fetchone()
print(f"💬 Reactions: {given + got:,} ({given:,} given, {got:,} received)")
# Your tapback style
print(f"\n🏆 Your Reaction Style")
cur.execute(f"""
SELECT associated_message_type, COUNT(*) FROM message
WHERE {date_filter(YEAR)} AND associated_message_type BETWEEN 2000 AND 2005 AND is_from_me = 1
GROUP BY associated_message_type ORDER BY COUNT(*) DESC
""")
for type_id, cnt in cur.fetchall():
print(f" {TAPBACKS.get(type_id, '?')} {cnt:,}")
# Custom emoji
cur.execute(f"""
SELECT associated_message_emoji, COUNT(*) FROM message
WHERE {date_filter(YEAR)} AND associated_message_emoji IS NOT NULL AND is_from_me = 1
GROUP BY associated_message_emoji ORDER BY COUNT(*) DESC LIMIT 5
""")
customs = cur.fetchall()
if customs:
print(f"\n🎯 Custom Reactions: {', '.join(f'{e} ({c})' for e, c in customs)}")
# Monthly volume
print(f"\n📈 By Month")
cur.execute(f"""
SELECT strftime('%m', datetime(date/1000000000 + {APPLE_EPOCH_OFFSET}, 'unixepoch')), COUNT(*)
FROM message WHERE {date_filter(YEAR)} AND associated_message_type = 0
GROUP BY 1 ORDER BY 1
""")
months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
data = {row[0]: row[1] for row in cur.fetchall()}
max_cnt = max(data.values()) if data else 1
for i, name in enumerate(months, 1):
cnt = data.get(f"{i:02d}", 0)
bar = "█" * int(20 * cnt / max_cnt)
print(f" {name} {bar} {cnt:,}")
db.close()
if __name__ == "__main__":
main()Running ItSave the script as imessage-wrapped.pyGrant Full Disk Access to your terminal (System Settings → Privacy & Security → Full Disk Access)Run it:python3 imessage-wrapped.py # defaults to 2025
python3 imessage-wrapped.py 2024 # or any year
That’s it. Your data never leaves your machine.
What I’d Add Next
If I turn this into a proper app:
Shareable cards — export your stats as an image
Conversation breakdown — who do you text the most?
Time of day patterns — are you a morning texter or a midnight scroller?
Streak tracking — longest daily conversation streak
But honestly? The script is fun enough. Sometimes a quick hack that makes your daughter laugh is the whole point.
The script and a slightly more polished version are on GitHub if you want to grab it.
#Apple #iMessage #Messages #NerdyStuff #Python