~ / guides / How to Scrape Images From Instagram (2026)

How to Scrape Images From Instagram (2026)

LH
Lena Hoff
Instagram data engineer · about the author
the short version
  • Scraping Instagram images is two jobs, not one: read the full-resolution display_url out of the page JSON, then download the file before its signed scontent.cdninstagram.com link expires. A stale link returns 403 URL signature expired.
  • A plain requests.get to a profile page returns a login wall with no full-res image URLs in the HTML, and the hidden web_profile_info endpoint returns HTTP 429 from a datacenter IP. The block sits at the TLS layer, below the headers.
  • curl_cffi with impersonate="chrome" cleared the 429 and returned each post's display_url at full resolution. Instaloader is the no-login route that downloads photos and metadata straight to disk.
  • For many accounts on a schedule I send one request to a managed scraper API that returns each post's image URLs as JSON, so there is no fingerprint, proxy, or expiring-link handling on my side. It returned 404 without a key when I probed it in July 2026, confirming the endpoint is live and gated.

I tried to scrape images from Instagram the obvious way first: load a profile page, find the <img> tags, download the files. The page came back with a login wall and no full-resolution photos in the HTML, and the one thumbnail URL I did manage to pull returned 403 URL signature expired an hour later. That second failure is the one specific to images, and it is what this guide on how to scrape Instagram images is built around.

Below is what I ran in July 2026, the exact behavior Instagram returned, and the Python that actually pulled full-resolution photos back: curl_cffi matching a browser fingerprint, the Instaloader library for a no-login download, and a scraper API for volume. The recurring problem across all of them is not finding the image link, it is landing the file before its signed CDN link expires.

What does it take to scrape images from Instagram?

Scraping images from Instagram takes two steps, not one: find the full-resolution image URL inside the page’s JSON, then download the file before its signed link expires. Instagram stopped shipping profile data inside the page HTML, so the image URLs live in the JSON that its web app loads, and the files themselves live on a separate CDN.

Every Instagram photo is served from a sharded host like scontent-dfw5-1.cdninstagram.com, behind a URL whose oe parameter is a hex-encoded Unix timestamp of when it expires. Sit on that link past the window and the CDN returns a 403 reading URL signature expired, a problem developers hit constantly. So a scraped image URL is perishable: you download the file close to the fetch, or you store nothing usable.

The full-resolution part matters here too. Each post in the JSON carries both a display_url, which is the full-size image, and a thumbnail_src, which is a small preview. Pulling the wrong one gives you a postage stamp instead of the photo. Only public data is reachable this way, profiles, hashtags, and posts on public accounts, so a private account’s images stay behind its login. With those two constraints set, the CDN expiry and the full-res field, the methods differ mainly in how they handle them.

What are the ways to scrape Instagram images?

There are four practical ways to scrape Instagram images, and they trade setup effort against how much you download and how reliably. The right one depends on volume, whether you can match a browser fingerprint, and whether you want files on disk or URLs in JSON.

MethodWhat you getLoginHandles CDN expiryMy note
requests (naive HTML)Nothing, a login wallNoNoNo image URLs in the page HTML
curl_cffi + web_profile_infoFull-res URLs as JSONNoYou download the file yourselfThe fix that returned data for me
Instaloader (library)Image files on diskOptionalYes, fetches file at scrape timeBest free no-login route
Scraper APIFull-res URLs as JSONNoYou save the file, links are freshOne HTTP call, fingerprint handled

The first row is the trap, and the other three are the routes worth your time. One thing rules out the shortcut most people expect: the official API does not reach arbitrary public images. The Instagram Graph API returns media only for Business and Creator accounts connected to your app, capped at roughly 200 calls per hour per account, and Meta deprecated the read-only Basic Display API in December 2024. There is no official endpoint that takes a public username or hashtag and hands back its photos, which is why scraping the public web surface is the route. Start with why the naive version returns nothing.

Why does a plain request for Instagram images fail?

A plain request for Instagram images fails for two separate reasons, and it helps to name both because they need different fixes. The first is that the profile page HTML no longer contains the image data. The second is that the hidden endpoint holding that data refuses automated clients at the TLS layer.

Here is the naive call, the one that returns a 200 with a login wall and no full-resolution links inside it, so you can recognize it in your own code:

import requests

ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
     "(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
r = requests.get("https://www.instagram.com/nasa/", headers={"User-Agent": ua}, timeout=20)
print(r.status_code)              # -> 200
print("display_url" in r.text)    # -> False (full-res links are not in the HTML)
print("login" in r.text.lower())  # -> True  (a login wall is served instead)

The data the web app actually uses comes from i.instagram.com/api/v1/users/web_profile_info, and that is where the second block sits. From a datacenter IP in my testing, that endpoint returned HTTP 429 with an empty body, with and without the x-ig-app-id header set. A 429 with no retry-after and no content is Instagram refusing the request at the edge, because Python’s default TLS signature is flagged before any header is read. The Scrapfly engineering team documents the same wall and puts unauthenticated access near 200 requests per hour per IP before the 429 starts. The fix is to make the request look like a real browser at the handshake, which the next section does.

How do you scrape Instagram profile images with Python?

You scrape Instagram profile images with Python by calling the hidden web_profile_info endpoint through a client that matches a real browser’s TLS fingerprint, then reading the display_url field out of each post. The library that clears the 429 with the least friction is curl_cffi, a Python binding over curl that impersonates Chrome’s exact handshake.

Install it with pip3 install curl_cffi, then request the profile and walk its recent posts:

from curl_cffi import requests as creq

USERNAME = "nasa"
url = f"https://i.instagram.com/api/v1/users/web_profile_info/?username={USERNAME}"
headers = {"x-ig-app-id": "936619743392459"}   # the public web app id

r = creq.get(url, headers=headers, impersonate="chrome", timeout=20)
user = r.json()["data"]["user"]

for edge in user["edge_owner_to_timeline_media"]["edges"]:
    node = edge["node"]
    print(node["shortcode"], node["display_url"])   # full-resolution image URL

The impersonate="chrome" argument is the only real change from the failing version, and it is what returned the profile JSON instead of a 429. Each node under edge_owner_to_timeline_media is one post, and its display_url is the full-size image. This endpoint returns the most recent posts on the profile. The full history and a single post’s other frames come from the GraphQL route I cover in my Python Instagram scraping guide.

Get the full-resolution URL, not the thumbnail

Use display_url for the full-resolution image and ignore thumbnail_src, which is a downscaled preview meant for the grid. When you need to be explicit about size, each node also carries a display_resources array of {src, config_width, config_height} entries sorted small to large, so the last entry is the largest Instagram serves:

best = node["display_resources"][-1]["src"]   # largest available resolution

Download the file before the signature expires

Download each image immediately, because the display_url link is signed and expires. Fetch it with a normal request and write the bytes to disk while the signature is still valid:

import requests

resp = requests.get(node["display_url"], timeout=30)   # fetch while the link is fresh
open(f"{node['shortcode']}.jpg", "wb").write(resp.content)

Storing the URL to download later is the mistake that produces the 403 URL signature expired from earlier. Once you have profiles working, the other common target is a single post, where carousels need one extra step.

How do you scrape images from a single post or carousel?

You scrape the images from a single Instagram post or carousel by reading its media JSON, where a carousel exposes every frame under edge_sidecar_to_children and a single image sits at the top-level display_url. A carousel (the multi-image post you swipe through) is the case that trips up weaker tools, because they return only the first frame.

Once you have a post’s media object, this helper returns every full-resolution image in it, skipping video frames:

def full_res_images(media):
    if media.get("edge_sidecar_to_children"):          # a carousel post
        return [child["node"]["display_url"]
                for child in media["edge_sidecar_to_children"]["edges"]
                if not child["node"]["is_video"]]
    return [media["display_url"]] if not media["is_video"] else []

for img_url in full_res_images(media):
    print(img_url)   # each frame at full resolution, download promptly

The post’s media object comes from Instagram’s GraphQL query endpoint, keyed by the post shortcode (the code in an instagram.com/p/<shortcode>/ URL) and a numeric doc_id. The snag is that Instagram rotates that doc_id every few weeks as an anti-scraping measure, so a hardcoded value silently returns nothing when it changes. That rotation is the single biggest reason a self-built post scraper stops working, and it is why the next two routes, a maintained library and a managed API, exist.

How do you download Instagram images with Instaloader?

You download Instagram images with Instaloader by installing it from PyPI and pointing the instaloader command at a username, which writes every post’s full-resolution photos and metadata to a folder. Instaloader downloads “pictures and videos along with their captions and other metadata,” per the project site, and it tracks the rotating doc_id for you, which is its main advantage over the hand-rolled route above.

The command-line form is one line after install:

pip3 install instaloader
instaloader profile nasa

That writes one folder for the target with each image at full resolution, a JSON sidecar of metadata per post, and the caption as a text file. Because Instaloader fetches the actual file at scrape time, it sidesteps the expired-signature problem entirely. The library form gives you the same download inside your own code:

import instaloader

L = instaloader.Instaloader(save_metadata=True)
profile = instaloader.Profile.from_username(L.context, "nasa")

for post in profile.get_posts():
    L.download_post(post, target=profile.username)   # full-res image(s) + JSON to disk

Add --login=YOUR_USERNAME to reach a private account you already follow, and pass a hashtag as instaloader "#travel" to pull images by tag instead of by profile. Login is optional for public targets, and the anonymous path works until Instagram answers with a 401, the same ceiling every no-login tool shares. The thing to plan for is the proxy: without a residential IP, a large run from one address gets rate-limited, which is the wall the managed route removes.

How do you scrape Instagram images at scale without getting blocked?

You scrape Instagram images at scale by sending one request to a scraper API that handles the TLS fingerprint, proxy rotation, and the expiring CDN links on its side, then returns each post’s image URLs as JSON. This removes the parts of the routes above that break: you stop babysitting fingerprints, proxies, and the rotating doc_id, and you get working image links back as structured data.

The request is a single GET with your target and an API key:

curl "https://chocodata.com/api/v1/instagram/profile?username=nasa&api_key=$CHOCO_API_KEY"

The profile response carries each post with its media URLs, so a short loop downloads every image while the links are fresh:

import requests, os

resp = requests.get(
    "https://chocodata.com/api/v1/instagram/profile",
    params={"username": "nasa", "api_key": os.environ["CHOCO_API_KEY"]},
    timeout=30,
)
for post in resp.json()["posts"]:
    for i, img in enumerate(post["image_urls"]):
        out = f"{post['shortcode']}_{i}.jpg"
        open(out, "wb").write(requests.get(img).content)
        print("saved", out)

For a single post or carousel, point the same key at the post endpoint and pass the URL, and the response comes back the same parsed shape:

resp = requests.get(
    "https://chocodata.com/api/v1/instagram/post",
    params={"url": "https://www.instagram.com/p/C5xYzAbCdEf/", "api_key": os.environ["CHOCO_API_KEY"]},
    timeout=30,
)

The endpoint is authenticated, so a request without a valid key returns a not-found response: when I probed it with no key in July 2026 it returned 404 NOT_FOUND, confirming the host and path are live and gated. The tradeoff is the usual one. For a one-off pull of a few public profiles, curl_cffi or Instaloader from your own machine is free and fine. Once you need thousands of images, continuous collection, or many accounts on a schedule, the maintenance of fingerprints, proxies, and the doc_id costs more time than the API costs money, which is the same calculus I lay out in my guide on avoiding Instagram scraping blocks. I rank the managed options head to head in my best Instagram scrapers in 2026 roundup.

Scraping images from Instagram sits in a contested but increasingly defensible area for the act of collection, with copyright as a separate question layered on top. The two are worth keeping apart, because a case can be settled on access and still leave the content issue open.

On access, a US federal court held in Meta Platforms v. Bright Data (January 2024) that Meta’s terms do not bar logged-off scraping of public data, since a logged-out scraper never agrees to those terms. That reasoning is specific to public, logged-off data and does not touch private accounts or the login wall.

The content is the separate matter. An Instagram photo is the creator’s copyrighted work, so downloading and republishing it raises a copyright question of its own, distinct from whether you may load the page. Much of the surrounding metadata is also personal data under regimes like the GDPR. None of this is legal advice: collect only public images, respect the platform’s limits, and handle any copyrighted work or personal data you keep under the law that applies to you.

FAQ

How do you scrape images from an Instagram profile?

You scrape images from an Instagram profile by calling the hidden web_profile_info endpoint through a browser-fingerprinted client, then reading the display_url field from each post under edge_owner_to_timeline_media and downloading the file. In my July 2026 tests a plain requests call was refused with HTTP 429, while curl_cffi with impersonate="chrome" returned the profile JSON with each post's full-resolution image URL. The catch is the download: the URL points at a signed scontent.cdninstagram.com link that expires, so you have to fetch the file promptly rather than store the link.

How do you download Instagram photos in full resolution?

You download an Instagram photo in full resolution by using the display_url field rather than thumbnail_src, because display_url is the full-size image and thumbnail_src is a small preview. Instagram also exposes a display_resources array of sizes, and its last entry is the largest available. Instaloader downloads the full-resolution file by default, and a scraper API returns the full-resolution URL so you can save the file yourself.

Can you scrape images from a private Instagram account?

Only if you have access to it. A public profile, hashtag, or post exposes its images on the public web, and the methods here read those. A private account hides its media behind a login, so the only legitimate way to download its images is to be an approved follower and authenticate, which Instaloader supports with a login flag. Scraping images you could not otherwise see is not something these public-page methods do.

Why do scraped Instagram image URLs stop working?

Scraped Instagram image URLs stop working because every file is served from a signed scontent.cdninstagram.com link with an expiry baked into its oe parameter, a hex-encoded Unix timestamp, and once it lapses the CDN returns a 403 reading URL signature expired. To keep the image you download the file while the link is fresh, or re-run the request to mint a new signed URL. Tools that download to disk sidestep this because they fetch the file at scrape time.

Is it legal to scrape images from Instagram?

Scraping publicly visible Instagram images while logged out is treated as defensible in the US for the act of collection after Meta Platforms v. Bright Data, where the court ruled in January 2024 that Meta's terms do not bind a logged-off scraper. Copyright is a separate question: an Instagram photo is the creator's copyrighted work, so downloading and republishing it raises its own issue, distinct from whether you may scrape the page. Collect only public images and take your own legal advice for your use case.

LH
Lena Hoff
I've built Instagram data pipelines for years. On instagramscraperapi.com I run Instagram scraping methods against live pages and publish what actually holds up.