Skip to content

Instantly share code, notes, and snippets.

@JoeGermuska
Last active August 30, 2026 07:08
Show Gist options
  • Select an option

  • Save JoeGermuska/5e0f91b6bee8fb2ee943b141214b9582 to your computer and use it in GitHub Desktop.

Select an option

Save JoeGermuska/5e0f91b6bee8fb2ee943b141214b9582 to your computer and use it in GitHub Desktop.
Instagram data export: why single-image posts show 'Invalid date' (and why posts.json timestamps are unreliable)
#!/usr/bin/env python3
"""
Fix dates in an Instagram data export.
Instagram writes the post-level `creation_timestamp` in posts_1.json only for
multi-image (carousel) posts. Single-image posts carry the date one level down,
in media[0], which makes many importers show "Invalid date". Captions have the
same problem: for single-image posts the caption lives in media[0].title.
This script rewrites the export's JSON so both fields are present at the post
level on every post, and repairs two further problems in posts.json:
* its top-level `timestamp` is a mid-2015 migration date, not the real post
date, for posts backfilled during that migration;
* it lists only one media entry per post, so carousel images are missing.
Usage:
python3 fix_instagram_export.py instagram-export.zip [-o patched.zip]
python3 fix_instagram_export.py path/to/unzipped/export/ # patches in place
Stdlib only. The input zip is never modified; a new one is written.
"""
import argparse
import json
import shutil
import sys
import zipfile
from pathlib import Path
POSTS_1 = "your_instagram_activity/media/posts_1.json"
POSTS = "your_instagram_activity/media/posts.json"
def post_timestamp(post):
"""The post's true date: its own field if present, else earliest media."""
if "creation_timestamp" in post:
return post["creation_timestamp"]
stamps = [m["creation_timestamp"] for m in post.get("media", []) if "creation_timestamp" in m]
return min(stamps) if stamps else None
def fix_posts_1(posts):
"""Promote date and caption from media[0] to the post level."""
fixed = 0
for post in posts:
if "creation_timestamp" in post or not post.get("media"):
continue
ts = post_timestamp(post)
if ts is None:
continue
post["creation_timestamp"] = ts
post.setdefault("title", post["media"][0].get("title", ""))
fixed += 1
return fixed
def label(post, name):
for entry in post.get("label_values", []):
if entry.get("label") == name:
return entry
return None
def fix_posts(posts, truth):
"""Correct migration timestamps and restore carousel media in posts.json.
`truth` is the parsed posts_1.json, which is index-aligned with posts.json.
"""
if len(posts) != len(truth):
print(f" ! posts.json ({len(posts)}) and posts_1.json ({len(truth)}) "
f"differ in length; skipping posts.json", file=sys.stderr)
return 0, 0, 0
dates = media = times = 0
for post, real in zip(posts, truth):
ts = post_timestamp(real)
if ts is None:
continue
# Allow 1s of slack: the album record is written just after its media.
if post.get("timestamp", 0) - ts not in (0, 1):
post["timestamp"] = ts
dates += 1
entry = label(post, "Media")
if entry is not None and len(entry.get("media", [])) < len(real.get("media", [])):
entry["media"] = json.loads(json.dumps(real["media"]))
media += 1
entry = label(post, "Creation time")
if entry is not None and entry.get("timestamp_value") == 0:
entry["timestamp_value"] = post.get("timestamp", ts)
times += 1
return dates, media, times
def patch(read, write):
"""Apply every fix. `read`/`write` map an archive path to bytes."""
raw_1 = read(POSTS_1)
if raw_1 is None:
sys.exit(f"error: {POSTS_1} not found — is this an Instagram export?")
posts_1 = json.loads(raw_1)
print(f" {POSTS_1}: dates promoted to post level on {fix_posts_1(posts_1)} posts")
# ensure_ascii=True matches Instagram's own \uXXXX escaping.
write(POSTS_1, json.dumps(posts_1, indent=2).encode())
raw = read(POSTS)
if raw is None:
print(f" {POSTS}: not present, skipping")
return
posts = json.loads(raw)
dates, media, times = fix_posts(posts, posts_1)
print(f" {POSTS}: {dates} timestamps corrected, {media} carousels restored, "
f"{times} 'Creation time' fields filled")
write(POSTS, json.dumps(posts, indent=2).encode())
def patch_zip(src, dest):
if dest.exists():
sys.exit(f"error: {dest} already exists")
patched = {}
with zipfile.ZipFile(src) as zf:
names = set(zf.namelist())
patch(lambda n: zf.read(n) if n in names else None, patched.__setitem__)
# Rebuild entry by entry so structure, order and compression survive.
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as out:
for item in zf.infolist():
data = patched.get(item.filename)
if data is None:
out.writestr(item, zf.read(item.filename))
else:
out.writestr(item, data)
print(f"wrote {dest}")
def patch_dir(root):
def read(name):
path = root / name
return path.read_bytes() if path.exists() else None
patch(read, lambda name, data: (root / name).write_bytes(data))
print(f"patched {root} in place")
def main():
ap = argparse.ArgumentParser(description="Fix dates in an Instagram data export.")
ap.add_argument("export", type=Path, help="the export .zip, or an unzipped export folder")
ap.add_argument("-o", "--output", type=Path, help="output zip (default: <name>-patched.zip)")
args = ap.parse_args()
if not args.export.exists():
sys.exit(f"error: {args.export} not found")
if args.export.is_dir():
patch_dir(args.export)
else:
dest = args.output or args.export.with_name(args.export.stem + "-patched.zip")
patch_zip(args.export, dest)
if __name__ == "__main__":
main()

"Invalid date" on single-image posts in Instagram data exports blocks grain.social import

Background

I wanted to import my Instagram posts into grain.social, the Atmosphere photo sharing app, using their tool at https://grain.social/settings/import

When I began the process, all of my single-image posts were labeled "invalid date", while the multi-image posts found the title and date correctly.

Cause

Instagram writes the post-level creation_timestamp in your_instagram_activity/media/posts_1.json only for multi-image (carousel) posts. For single-image posts the post object has no top-level creation_timestamp at all — the date lives one level down, in media[0].creation_timestamp.

In the export I looked at: 211 posts, of which 208 single-image (no top-level timestamp -> "Invalid date") and 3 carousels (timestamp present -> parsed correctly).

// single-image post — no top-level creation_timestamp or title
{ "media": [ { "uri": "...", "creation_timestamp": 1634564885, "title": "carnage" } ] }

// carousel — both present at post level
{
  "media": [ { /* ... */ }, { /* ... */ } ],
  "title": "jaunty signs of Lawrence Ave",
  "creation_timestamp": 1629985612
}

Fix

Fall back to the media entries when the post-level field is absent:

ts = post.get("creation_timestamp") or min(m["creation_timestamp"] for m in post["media"])

The same applies to captions: for single-image posts the caption is in media[0].title, not post.title, so those are likely being dropped silently today.

Related: don't trust posts.json timestamps

The sibling file posts.json (Meta's newer label_values schema) does carry a top-level timestamp on every post, which makes it look like the easier file to parse. Two problems:

  1. Its timestamps are wrong for older posts. For 117 of the 211 posts it reports the date of a mid-2015 Instagram backfill/migration rather than the original post date. True dates spanning 2010-12-19 through 2015-06-06 are all reported as June–September 2015. The per-media creation_timestamp values in posts_1.json preserve the correct dates.

    Example — same post, same media file, same caption:

    file reported date
    posts_1.json (media[0].creation_timestamp) 2014-07-20
    posts.json (timestamp) 2015-08-29
  2. It lists only one media entry per post. For carousels it points at a duplicate album-cover file named after the album's fbid (e.g. media/posts/17866952645520164.jpg, byte-identical to the first member), so the remaining images are lost. posts_1.json had 217 media entries where posts.json had 211.

Also note posts.json has a Creation time label whose timestamp_value is 0 on every post, and Original width / Original height / File size in bytes are "0" throughout — they look populated but aren't.

Recommendation

Parse posts_1.json, with the media-level fallback for both date and caption. Treat the top-level timestamp in posts.json as unreliable for anything before roughly mid-2015.

Script

fix_instagram_export.py in this gist applies all of the above to an export. Stdlib only, no dependencies. It never modifies the input zip — it writes a new one, rebuilt entry by entry so the archive structure the importer expects is preserved exactly.

python3 fix_instagram_export.py instagram-export.zip
your_instagram_activity/media/posts_1.json: dates promoted to post level on 208 posts
your_instagram_activity/media/posts.json: 117 timestamps corrected, 3 carousels restored, 211 'Creation time' fields filled
wrote instagram-export-patched.zip

One packaging note for anyone repacking an export by hand: re-zipping the folder in macOS Finder nests everything under an extra directory and adds __MACOSX/, which importers looking for your_instagram_activity/... at the archive root will read as an empty export.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment