I migrated all my instagram photos, here's how
On how I reclaimed my instagram posts and imported them to my personal website
Instagram provides an export feature, not easy to find but the help section provided me with the right sequence of clicks. I did that in September this year. You can choose between json and html exports, I chose json and received shortly after a zip file in my mailbox. Once deflated that amounts to 85 megabytes.
➜ instagram-jacqueminv-2025-09-04-83XcnSA4-json tree -d -L2
.
├── ads_information
├── apps_and_websites_off_of_instagram
├── connections
├── media
│ └── posts
├── personal_information
├── preferences
├── security_and_login_information
└── your_instagram_activity
└── media
./media/posts and ./your_instagram_activity/media.There was no description of the archive’s content so I browse and quickly found that all my posts were under your_instagram_activity/media/posts_1.json. I decided to ignore everything else. That means my stories, comments or likes are not migrated. What I want is all the photos and their metadata so that I can keep them on my website.
That json file contains a list of entries and each has one to many media linked to it. It’s straightforward to find the link to the media file, each one is referenced via a uri property. That’s the link to the file within the very same archive, under /media/posts.
Having sorted out the structure and what I wanted, I could start playing around on the command line. For that kind of task, jq is a good candidate.
Here’s how I proceeded to only keep the attributes I needed and prepare the payload for the next step:
jq -c '[ .[] | {title: .media[0].title, date: first(.media[].creation_timestamp), pictures: .media | del(.[].creation_timestamp, .[].title, .[].media_metadata, .[].cross_post_source), location: first(.media[].media_metadata?.photo_metadata?.exif_data[0]?)} ]' posts_1.json > preprocess.json
One unpleasant surprise was that special characters were badly encoded. As I sometimes wrote my descriptions in french, that ended in the json like “matinée” for “matinée”. I did not see customization options on the export page so what I did was going through all my entries and fix them manually. Annoying. But my export is made of less than 400 entries and not that many in french so in the end, not a huge deal either. Still surprising for a service coming from a mammoth like Meta! I did that on that preprocess.json as I had not noticed that at first.
Equipped now with that filtered json I simply needed to generate the html hugo files for each entry. To achieve that part I’ve used a bash script and m4 .
#!/usr/bin/env sh
file='preprocess.json'
length=$(cat "$file" | jq '. | length')
i_insta=1
for (( i=0; i<length; i++ ))
do
created_at=$( jq -r ".[$i] | .date | todate" "$file")
output_folder="output/photos/$(( i_insta++ ))"
out="$output_folder/index.md"
content=$( jq -r ".[$i] | .title | sub(\"\n\"; \"<br/>\")" "$file" )
latitude=$( jq -r ".[$i] | .location?.latitude" "$file" )
longitude=$( jq -r ".[$i] | .location?.longitude" "$file" )
location=""
if [ "$latitude" != "null" -a "$longitude" != "null" ]; then
location="<p class='h-geo meta'><a href='https://www.openstreetmap.org/#map=15/$latitude/$longitude'>"
location+="<data class='p-longitude' value='$longitude'>$longitude</data>, "
location+="<data class='p-latitude' value='$latitude'>$latitude</data></a></p>"
fi
mediaHtml='<ul>'
lengthMedia=$( jq -r ".[$i] | .pictures | length" "$file")
for (( m=0; m<lengthMedia; m++ ))
do
media=$(jq -r ".[$i].pictures[$m].uri" "$file")
mediaHtml+="<li><figure><a href='/photos/$media'>"
if [[ "$media" == *.mp4 ]]; then
mediaHtml+="<video autoplay loop src='/photos/$media' class='u-video'></video>"
else
mediaHtml+="<img src='/photos/$media' class='u-photo' />"
fi
mediaHtml+="</a></figure></li>"
done
mediaHtml+='</ul>'
data=$( m4 -D__CREATED_AT__="${created_at}" \
-D__CONTENT__="$content" \
-D__MEDIA__="$mediaHtml" \
-D__LOCATION__="$location" \
insta.pre)
mkdir -p "$output_folder"
echo "$data" > $out
done
With the accompanying insta.pre given to m4:
---
date: __CREATED_AT__
params:
kind: insta
---
__MEDIA__
<p>__CONTENT__</p>
__LOCATION__
<p class="meta original-link">First published on <a href="https://www.instagram.com/jacqueminv/">instagram</a> (<a href="/articles/1/">?</a>)</p>
After execution, my 359 insta posts found a new home. I just had to copy the posts folder that actually contains the photos and one video.