---
title: "Download Instagram Reels in Python — requests | FastSaverAPI"
url: https://fastsaverapi.com/guides/download-instagram-reels-python/
description: "Build an Instagram reel downloader in Python with requests: resolve the link, stream to disk, add retries and timeouts, then batch a list of URLs."
updated: 2026-08-10
api_base: https://api.fastsaver.io/v1
site_index: https://fastsaverapi.com/llms.txt
openapi: https://fastsaverapi.com/openapi.json
---

# downloading Instagram reels in Python, streamed and batched

Downloading Instagram reels in Python is two HTTP requests: resolve the link to a media URL,
then pull the bytes. This guide builds the downloader with requests — naive first,
then streamed to disk, then with retries and timeouts, then as a batch runner over a list of
links. It also covers why that media URL expires, and why that single fact decides how you
design a queue.

- **Language**: Python 3.9+, requests only

- **Endpoint**: GET /fetch · 1.5 credits per reel

- **Per reel**: 1 metered call + 1 direct CDN download

- **Ends with**: a batch runner over a list of links

## what one call gives you

The two requests do different jobs, and keeping them apart makes the rest obvious. The first
turns a page URL into a media URL. The second is an ordinary download from Instagram's CDN — it
never touches us and costs nothing.

GET
https://api.fastsaver.io/v1/fetch
resolve step · 1.5 credits



```
curl --get "https://api.fastsaver.io/v1/fetch" \
  --data-urlencode "url=https://www.instagram.com/reel/Cx7YyZgIlPq/" \
  -H "X-Api-Key: $FASTSAVER_KEY"
```



```
{
  "ok": true,
  "id": "Cx7YyZgIlPq",
  "source": "instagram.com",
  "type": "video",
  "download_url": "https://scontent-fra5-1.cdninstagram.com/o1/v/...",
  "thumbnail_url": "https://scontent-fra5-1.cdninstagram.com/v/...",
  "width": 1080,
  "height": 1920,
  "duration": 27,
  "caption": "three minutes of sourdough, compressed."
}
```


Note the --data-urlencode : reel links carry query strings like ?igsh= ,
and pasting one raw turns its parameters into yours. requests handles that when you
pass params= .

## the version that works and nothing more

Start with the smallest thing that puts an MP4 on disk.



```
import os
import requests

FETCH = "https://api.fastsaver.io/v1/fetch"
KEY = os.environ["FASTSAVER_KEY"]
LINK = "https://www.instagram.com/reel/Cx7YyZgIlPq/"

meta = requests.get(FETCH, params={"url": LINK}, headers={"X-Api-Key": KEY}).json()
open("reel.mp4", "wb").write(requests.get(meta["download_url"]).content)
```

Run it and you have the reel. Ship it and you have three problems. .content holds
the whole file in memory. Neither request has a timeout, so a stalled socket hangs the process
forever. And meta["download_url"] raises KeyError on failure, which is a
strange way to learn a post was deleted. Keep the key in the environment from the first draft.

## stream the file to disk

Replace the second request with a streamed write. stream=True defers the body,
iter_content hands you fixed-size chunks, and the with block releases the
connection even if the write throws.



```
from pathlib import Path

def save(url: str, dest: Path) -> Path:
    """Stream a media URL to dest. Writes dest.part first, then renames."""
    tmp = dest.with_suffix(dest.suffix + ".part")
    with requests.get(url, stream=True, timeout=(5, 30)) as r:
        r.raise_for_status()
        with tmp.open("wb") as f:
            for chunk in r.iter_content(chunk_size=65536):
                f.write(chunk)
    tmp.replace(dest)
    return dest
```

Two details earn their place. The timeout is a tuple — five seconds to connect, thirty between
chunks; one number would cap the whole transfer and break on slow downloads. The
.part rename means an interrupted run leaves an obviously incomplete file, not a
truncated MP4 that plays for four seconds and stops.

## timeouts, retries and the ok flag

Now the resolve step. A Session sets the key once and reuses the connection;
urllib3 handles transport retries so you never write a sleep loop.



```
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

FETCH = "https://api.fastsaver.io/v1/fetch"
BALANCE = "https://api.fastsaver.io/v1/balance"

session = requests.Session()
session.headers["X-Api-Key"] = os.environ["FASTSAVER_KEY"]
session.mount("https://", HTTPAdapter(max_retries=Retry(
    total=4,
    backoff_factor=1.5,               # 0s, 1.5s, 3s, 6s
    status_forcelist=(429, 500, 502, 503, 504),
    allowed_methods=("GET",),
    respect_retry_after_header=True,
)))


class ResolveError(RuntimeError):
    """The link cannot be resolved. Retrying will not change that."""


def resolve(link: str) -> dict:
    r = session.get(FETCH, params={"url": link}, timeout=(5, 60))
    data = r.json()                       # read the body first — failures are JSON too
    if not data.get("ok"):
        raise ResolveError(data.get("detail", f"HTTP {r.status_code}"))
    return data
```

Note what is missing: there is no raise_for_status() . Read the body before you look
at the status, because running out of credits answers a plain 400 — this API has no
402 — carrying detail: "Insufficient credits. Please top up to your
account." . A raise_for_status() would flatten that into an opaque
HTTPError and you would never see the reason. Treat ok as the success
flag instead: a private profile or a deleted post is a real answer, so record it and move on.
Automatic retries belong to 429 and 5xx, and the adapter above already owns those; the
guides index has the whole error table. Before a long run, check
what you have — /balance is free.



```
bal = session.get(BALANCE, timeout=10).json()
print(bal["plan"], "-", bal["credits"], "credits,", bal["rpm_limit"], "req/min")
# Pro - 98500 credits, 60 req/min  ->  about 65,000 reels at 1.5 each
```


## why the CDN URL expires, and what that costs you

download_url is signed by Instagram, not by us. The expiry sits in the query string,
and once it passes the CDN returns 403 to everyone. That one fact decides the shape of your
pipeline.

- Resolve and download are one unit of work. Queue the URL for a worker that picks it up twenty minutes later and a share of those jobs are dead on arrival.
- Store the id , never the URL. The shortcode makes a good key and filename; a cached download_url is a time bomb in your database.
- Re-resolving costs 1.5 credits. That is the price of splitting the stages, and it beats a job that fails at 3 a.m.
- Make 403 recoverable. The download stage should call resolve() again rather than failing an item for good.

## a batch runner over a list of links

Put the pieces together: one link per line, skip reels already on disk without spending a credit,
pace against your rate limit, log failures to a re-runnable file.



```
import re
import time
from pathlib import Path

OUT = Path("reels")
OUT.mkdir(exist_ok=True)
SHORTCODE = re.compile(r"/(?:reel|reels|p|tv)/([A-Za-z0-9_-]+)")


def shortcode(link: str) -> str:
    m = SHORTCODE.search(link)
    return m.group(1) if m else ""


def run(list_file: str, gap: float = 1.2) -> None:
    links = [l.strip() for l in Path(list_file).read_text().splitlines() if l.strip()]
    failed = []

    for i, link in enumerate(links, 1):
        code = shortcode(link)
        if code and (OUT / f"{code}.mp4").exists():
            print(f"[{i}/{len(links)}] have {code}.mp4")
            continue
        try:
            meta = resolve(link)                          # 1.5 credits
            if meta["type"] != "video":
                raise ResolveError(f"type is {meta['type']}, not video")
            dest = save(meta["download_url"], OUT / f"{meta['id']}.mp4")
            print(f"[{i}/{len(links)}] {dest.name}  {meta.get('duration')}s")
        except Exception as exc:
            print(f"[{i}/{len(links)}] FAIL {link}: {exc}")
            failed.append(f"{link}\t{exc}")
            if "Insufficient credits" in str(exc):
                failed.extend(links[i:])          # every remaining link fails the same way
                print("out of credits - stopping")
                break
        time.sleep(gap)

    if failed:
        Path("failed.txt").write_text("\n".join(failed))


run("links.txt")
```

gap is your rate budget and only covers the resolve call. Free's 10 a minute means
gap=6 ; Pro's 60 brings it to 1; on Ultra and Mega the sleep stops mattering and your
disk becomes the bottleneck — move to a thread pool and a token bucket. Skipping by shortcode
before resolving is the difference between re-running a thousand-link list for free and paying
1,500 credits for files you already had. A /p/ link can resolve to an album, and an
album has no top-level download_url at all — that is what the type guard
is for, and the Instagram downloader API reference has
that shape. Breaking out on the credits message matters too: once the balance is gone every
remaining link burns a request for the same 400.

## instaloader, yt-dlp, and when not to pay

instaloader is purpose-built for Instagram and needs no key for public posts.
yt-dlp covers it alongside a thousand other sites. Both are free, and at a few
downloads a day from a home connection either one beats an API call.

They degrade in the same place. Instagram is aggressive toward unauthenticated traffic from data
centre ranges, so the day your script moves to a VPS you collect 429s and login walls. The fixes
are all work: a session cookie from a real account, kept warm, residential proxies, rotation, and
an update whenever an extractor breaks on Instagram's schedule. A login also puts that account at
risk.

That maintenance is the whole product here. Both routes still see public content only.

## where an Instagram reel downloader breaks

- Public content only. Private profiles fail by design, with a reason rather than a file.
- Signed URLs expire — thumbnail_url too. Fetch the bytes in the same job and serve poster frames from your own storage.
- Rate limits are per plan — 10 a minute on Free up to 900 on Mega. A 429 is pacing, not a bad link.
- You get the file Instagram serves. Nothing is transcoded on the way through.
- Instagram changes. Fixes land on our side and resolve() stays as written, but nobody in this business can promise "never breaks".
- Copyright is yours. Downloading a reel is not a licence to republish it.

Log the reason for everything you skip — a batch downloader without a failure log is one you
cannot debug three weeks later.

## questions about downloading reels in Python

**Q: How do I download an Instagram reel in Python without logging in?**

A: Send the link to GET /fetch with your key in the X-Api-Key header,
read download_url from the JSON, then requests.get that URL with
stream=True and write the chunks to a file. No cookie, no headless browser.

**Q: Why does the Instagram video URL stop working after a while?**

A: It is a signed URL: Instagram's CDN puts an expiry in the query string and then answers 403
to everyone. Nothing on our side extends it. Download the bytes in the same function that
resolved the link.

**Q: Is instaloader or yt-dlp good enough instead of a paid API?**

A: Often, yes — both are free and handle public reels from a home connection. They fall over
once Instagram rate-limits your IP, which is normal for data centre traffic. After that you own a
cookie jar, proxies and extractor fixes.

**Q: How many reels can I download per minute?**

A: Your plan's per-minute ceiling covers the resolve call only; the CDN download is not metered.
Free allows 10 a minute, Pro 60, Ultra 250, Mega 900. GET /balance reports your
rpm_limit and never spends credits.

**Q: Can a Python script download reels from a private account?**

A: No. Only publicly reachable content resolves, so a private profile comes back with
ok: false and a reason instead of a file.

## Keep reading

- Instagram downloader API — the full endpoint reference
- TikTok without the watermark — sibling guide
- All guides — shared setup and the error table
- Live playground — paste a reel link, read the JSON

## Run the script against your own list

The free tier is 1,000 credits — roughly 660 resolves — enough to take this batch runner all the way to production.

Get a free API key
Endpoint reference
