instagrapi

Download Instagram stories in Python with instagrapi

Updated

Setup

Story endpoints are authenticated, and they’re observed closely — every user_stories call registers a view, exactly like opening the app. That makes login hygiene matter even more than for static media. Don’t log in cold on every run; persist the session and reuse it so Instagram sees a stable device fingerprint posting consistent traffic.

from instagrapi import Client

cl = Client()
try:
    cl.load_settings("session.json")
    cl.login("USERNAME", "PASSWORD")
except FileNotFoundError:
    cl.login("USERNAME", "PASSWORD")
    cl.dump_settings("session.json")

For the full pattern — Redis-backed sessions, multi-account rotation, surviving cookie invalidation — see /guides/instagrapi-session-persistence. Story polling especially benefits from sticky sessions because every fetch counts as a view; reissuing logins triggers extra device-verification probes that shorten how long the account stays usable. The rest of this guide assumes cl is a logged-in client.

Fetch one user’s current stories

user_stories(user_id) returns the list of Story Pydantic models currently visible on a user’s profile — anything posted in the last 24 hours. Each item carries pk (the story’s unique ID), media_type (1 for photo, 2 for video), taken_at (UTC timestamp), plus full URLs to thumbnail and full media. There is no pagination: a user’s story tray is at most a few dozen items, so a single call is the entire dataset.

The argument is the numeric user PK, not the username. Resolve the username once with user_id_from_username and cache the result if you’re polling the same account repeatedly — the resolution endpoint has its own rate limits.

from instagrapi import Client

cl = Client()
cl.load_settings("session.json")
cl.login("USERNAME", "PASSWORD")

user_id = cl.user_id_from_username("instagram")
stories = cl.user_stories(user_id)
for s in stories:
    print(s.pk, s.media_type, s.taken_at)

If the account has no active stories, the list is empty — that’s also what you get for stories older than 24 hours. There’s no public endpoint for expired stories, so if you need an archive you have to poll continuously and persist what you see.

Download a story to disk

Once you have a Story object, story_download(pk, folder=...) saves the underlying media to disk and returns the resulting pathlib.Path. instagrapi inspects media_type internally and picks the right file extension — .jpg for photos, .mp4 for videos — so you don’t branch on type yourself. The filename is derived from the story’s pk, which means re-downloading the same story is idempotent.

from pathlib import Path
out = Path("./stories")
out.mkdir(exist_ok=True)
for s in stories:
    cl.story_download(s.pk, folder=str(out))

If you only want the URL — for queueing into a separate downloader, CDN warmup, or an external storage pipeline — read s.video_url (videos) or s.thumbnail_url (photos) directly from the Story model. The download helper is just a convenience around requests.get with the session’s cookies attached.

Two practical notes. First, the CDN URLs are short-lived signed links: they expire in roughly an hour, so you can’t store them and fetch later — store the pk and re-resolve, or download immediately. Second, video stories sometimes have multiple resolutions in s.video_versions; instagrapi picks the highest by default, but if bandwidth matters you can iterate the list and grab a smaller variant yourself before writing to disk.

Batch download for many users

Once you scale past a single account, two failure modes dominate: deleted/renamed users (UserNotFound) and rate-limit walls (PleaseWaitFewMinutes). Both are recoverable, but only if you handle them explicitly — letting either bubble up will kill a long batch job halfway through.

The pattern below skips missing users, sleeps ten minutes on a rate-limit signal, and inserts a 3–7 second jitter between accounts. The jitter matters more than the magnitude: a constant time.sleep(5) between users is a fingerprint that Instagram is happy to flag, while randomized intervals look like a human flipping through a feed.

import time, random
from instagrapi.exceptions import UserNotFound, PleaseWaitFewMinutes

usernames = ["a", "b", "c"]
for u in usernames:
    try:
        uid = cl.user_id_from_username(u)
        for s in cl.user_stories(uid):
            cl.story_download(s.pk, folder="./stories")
    except UserNotFound:
        continue
    except PleaseWaitFewMinutes:
        time.sleep(60 * 10)
        continue
    time.sleep(random.uniform(3, 7))

For monitoring hundreds of accounts, you’ll want per-account proxy pinning, persistent cursors so a crash doesn’t re-view every story, and exponential backoff on PleaseWaitFewMinutes rather than a flat 10-minute sleep.

Story metadata: who’s tagged, mentions, links

Beyond the media itself, each Story carries the structured overlays Instagram exposes through the private API. story.mentions is a list of UserShort objects for every @username tag in the story — useful for tracking collab posts, brand mentions, or building a co-occurrence graph of who appears in whose stories. story.links holds swipe-up URLs (StoryLink objects with a webUri field), which historically required 10k followers and are now broader; for most non-influencer accounts this list is empty, but when present it’s the cleanest signal of a story’s commercial intent.

Other fields worth knowing: story.hashtags for tagged hashtags, story.locations for geotags, and story.stickers for the full overlay tree (polls, questions, music). The sticker tree is the noisiest field — Instagram ships dozens of sticker variants and the schema drifts — so treat unknown types as opaque dicts rather than typed models. For your own account’s stories, cl.story_seen_by(story_pk) returns the viewer list; third parties can’t read this for accounts other than their own.

Wrapping up

Stories are a steady stream of structured signal — mentions, locations, swipe-ups — that disappears in 24 hours, which means tooling has to keep up. The 24-hour window is also why polling cadence and session durability dominate cost: miss a few hours and you’ve lost data permanently. For a wider scraping toolkit (followers, posts, hashtags) see /guides/instagram-scraper-python, and for the session-management foundation that keeps long-running story pollers alive see /guides/instagrapi-session-persistence.

Related guides

Frequently asked

Can I download stories from a private account?

Only if your logged-in account follows that private account. Instagram's privacy model applies.

How long are stories available?

24 hours. After that, instagrapi's user_stories returns an empty list — there is no public 'expired stories' endpoint.

Does the target user know I downloaded their story?

Calling user_stories registers a story view, just like opening it in the app. There is no silent-fetch endpoint.

Can I get story analytics like view count?

Only for stories on your own account, via story_seen_by/story_viewers. Other accounts' view counts are not exposed.

Skip the infra?

Async CLI to download Instagram, powered by HikerAPI (no-ban backend).

Try insta-dl → Full comparison
More from the team