Skip to main content

Complete Guide: How to Scrape Instagram Posts and Profiles Using Apify

Turn public Instagram posts and profiles into typed rows of captions, likes, comments, followers and post counts from Python.

Updated 13 min readMarkdown
Complete Guide: How to Scrape Instagram Posts and Profiles Using Apify

This guide covers how to scrape public Instagram data from Python with two Apify Actors that are built to be used together. Instagram Posts & Engagement Scraper (reventadata/instagram-scraper) turns post links and usernames into one row per post: caption, like and comment counts, image link, author and date. Instagram Profile & Followers Scraper (reventadata/instagram-profile-scraper) turns usernames into one row per profile: full name, biography, avatar and follower, following and post counts.

The use case is any table that tracks public accounts over time: creator shortlists, brand monitoring, a content calendar that compares engagement across accounts. Instead of opening profiles one by one and copying numbers, you call two Apify Actors and get JSON.

By the end you will have run both from Python, read their rows, and know where each number comes from and how precise it is. Prerequisites are minimal: Python 3.11+ and an Apify account. Estimated time: one sitting, mostly reading.

The two sample rows in this guide are a sample from a capture dated 2026-10-22 (a post) and a sample from a capture dated 2026-10-17 (a profile), parsed with each Actor's own parser. No run figures are quoted.

TL;DR

In this tutorial, you'll learn to:

  1. Install apify-client and set your Apify token.
  2. Call Instagram Posts & Engagement Scraper with usernames or directUrls and maxItems.
  3. Call Instagram Profile & Followers Scraper with usernames, optionally minFollowers.
  4. Read both datasets from Python and export them as JSON, CSV or Excel.

Expected outcome: post rows with caption, likesCount, commentsCount, imageUrl, author and timestamp, and profile rows with followersCount, followingCount and postsCount, all from public pages read logged out.

Prerequisites and setup

You need:

  1. An Apify account. Apify Console is Apify's web interface.
  2. An API token. You can find it on the API & Integrations page in Apify Console. Apify's docs advise giving each token a description and never using one token for several services.
  3. Python 3.11 or newer. apify-client 3.3.0 requires it.
  4. A list of public accounts or post links to read. Per both READMEs, private accounts are not read.

Install the client:

pip install "apify-client~=3.0"

The code reads the token from an environment variable:

export APIFY_API_TOKEN="your_apify_token_here"

Reference documentation for the client lives at docs.apify.com/api/client/python.

Understanding the two Instagram Actors

What they do and how they work

Both Actors read the public pages a logged-out browser sees and send no login. They are unofficial and not affiliated with Instagram or Meta.

The posts Actor takes two kinds of target. A link with /p/ or /reel/ is fetched as a post page; any other link is read as a profile page. For a username, it opens the profile page, takes the post codes listed there, then opens each post page. Each post page gives the caption, counts, image link, author and date.

The profile Actor reads one profile page per username and saves one row with the counts and bio, parsing count labels in English, Romanian, Spanish and French.

By default both fetch through Apify Unblocker (proxy group UNBLOCKER).

Limitations

  • Newest posts only. A username yields the posts its profile page lists. Per the README, that was 12 per profile when checked on 2026-10-22; older posts are not reached.
  • Rounded counts. Likes, comments and followers come as Instagram rounds them on the page (12K, 687M).
  • No comments text, follower lists or private accounts.
  • Date only. timestamp is the date as Instagram writes it.

Step-by-step walkthrough

Step 1: Accessing the Actors

Open the posts Actor and the profile Actor on Apify Store. Each README documents every input and output field. Everything after this step runs from Python.

Step 2: Configuring input parameters

The posts Actor's inputs:

Field Type Purpose
directUrls array Post, reel or profile links, e.g. https://www.instagram.com/p/DeuOR-CjKE9/.
usernames array Public usernames; a leading @ is stripped.
maxItems integer Rows saved at most, 1 to 5,000, default 20.
proxyConfiguration object Apify proxy settings; Unblocker when unset.

The profile Actor's inputs:

Field Type Purpose
usernames array Handles, @handles or profile URLs.
maxItems integer Rows saved at most, 1 to 5,000, default 20.
minFollowers integer Drop profiles with fewer followers.
proxyConfiguration object Apify proxy settings; Unblocker when unset.

Three example configurations:

  • Newest posts of one account: {"usernames": ["natgeo"], "maxItems": 5}.
  • Specific posts: {"directUrls": ["https://www.instagram.com/p/DeuOR-CjKE9/"]}.
  • Large accounts only: {"usernames": ["instagram", "natgeo"], "minFollowers": 1000000}.

A maxItems outside 1 to 5,000 is refused before the run starts: Apify validates input against the schema and, when it does not conform, "the caller receives an error and the Actor is not started".

Step 3: Advanced configuration

minFollowers is the profile Actor's only filter. A profile below it is dropped before it is saved, and so is a profile whose follower count could not be read. maxItems is the main volume control on both Actors: each run stops once that many rows are saved.

Leave proxyConfiguration unset unless you have a reason; the default is Apify Unblocker.

Step 4: Optional enrichment

Neither Actor has enrichment options. The enrichment is the pairing itself: profile rows add account-level counts to the post rows, joined on the username you passed in.

Step 5: Running the Actors

First, a helper that runs an Actor, waits, and returns its rows:

import os
from apify_client import ApifyClient

# 1. Initialize ApifyClient with the token from the environment
client = ApifyClient(os.environ["APIFY_API_TOKEN"])

# 2. Run an Actor, wait for it, and return its dataset rows
def run_actor(actor_id, run_input):
    run = client.actor(actor_id).call(run_input=run_input)
    if run is None or run.status != "SUCCEEDED":
        status = run.status if run else "unknown"
        raise SystemExit(f"{actor_id} did not succeed (status: {status}).")
    return client.dataset(run.default_dataset_id).list_items().items

call() starts the Actor and waits for it to finish before returning the run, or None. To stop a run early, use the Abort button in Apify Console or the Abort Actor run API endpoint.

Step 6: Examining the results

Now call both Actors and print a summary:

# 3. Newest posts of one profile, at most 5 rows
posts = run_actor(
    "reventadata/instagram-scraper",
    {"usernames": ["natgeo"], "maxItems": 5},
)

# 4. Profile stats, one row per username
profiles = run_actor(
    "reventadata/instagram-profile-scraper",
    {"usernames": ["instagram", "natgeo"], "maxItems": 5},
)

for p in profiles:
    print(f"@{p['username']}: {p['followersCount']} followers, {p['postsCount']} posts")
for post in posts:
    print(f"{post['timestamp']} | {post['likesCount']} likes | {post['url']}")

Here is one post row, a sample from a capture dated 2026-10-22, parsed with the posts Actor's own parser (image link shortened). A saved row also carries scrapedAt, added at save time:

{
    "id": "DeuOR-CjKE9",
    "url": "https://www.instagram.com/p/DeuOR-CjKE9/",
    "caption": "Some of the best reasons to travel in 2027 are far from the usual destinations. 🌎\n\nNational Geographic’s picks for Best of the World 2027 are here and features 25 destinations across six continents. \n\nWhether you're drawn to places steeped in history or the thrill of exploring the wild, there's no shortage of experiences worth planning a trip around. \n\nFind your next adventure and see our full #BestOfTheWorld 2027 guide at the link in bio. \n\nPhotograph by Thomas Ekström",
    "likesCount": 12000,
    "commentsCount": 147,
    "imageUrl": "https://scontent.cdninstagram.com/v/t51.82787-15/858921974_18710195065019133_277441153414192961_n.jpg?stp=c216.0.648.648a_dst-jpg_e35_s640x640_tt6&...",
    "author": "National Geographic",
    "timestamp": "October 20, 2026"
}

And one profile row, a sample from a capture dated 2026-10-17, parsed with the profile Actor's own parser (the avatar link is a stand-in from the test capture):

{
    "id": "instagram",
    "username": "instagram",
    "fullName": "Instagram",
    "biography": "Discover what's new on Instagram 🔎✨",
    "followersCount": 687000000,
    "followingCount": 308,
    "postsCount": 8620,
    "avatarUrl": "https://scontent.cdninstagram.com/v/avatar.jpg",
    "url": "https://www.instagram.com/instagram/"
}

Step 7: Downloading and exporting data

Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL. From code, list_items() returns one page of rows in a single API call; iterate_items() pages through all of them.

Working with the API

The helper above is the whole integration: client.actor(<id>).call(run_input=...) starts a run and blocks until it ends, and client.dataset(run.default_dataset_id) reads its rows. If you prefer not to block, start() starts the Actor and immediately returns the run.

Authentication. The client takes the token as its first argument. Keep it in an environment variable, as here, and out of source control.

Error handling. Check run.status before reading rows, as the helper does. Both Actors end a run FAILED when it saves no rows because a page was blocked or could not be read, so a wrong username can surface as FAILED; a search that loads normally but matches nothing ends SUCCEEDED with a status message saying so. An empty input differs by Actor: with neither directUrls nor usernames, the posts Actor ends FAILED with a message asking for one; the profiles Actor, with no usernames, logs a warning and ends without rows. To see what happened, read the run log with client.log(run.id).get(), run being what call() returned; a summary line near the end is Run finished: pushed N records to dataset.

Webhooks. To react when a run finishes, register a webhook on ACTOR.RUN.SUCCEEDED. Its payload's resource is the response of the Get Actor run endpoint, which carries defaultDatasetId, so the receiver can fetch the rows without polling.

Scheduling. Save each input as a task and attach an Apify schedule. Schedules use cron expressions; 0 8 * * * is every day at 8 AM.

Limits. maxItems bounds each run's size. Start with 5, the value both input forms prefill, and raise it once your code handles the fields.

Advanced usage patterns

Batch processing multiple accounts

Both Actors take a list. Put every account in one usernames array rather than one run per account: the posts Actor walks them in order and stops at maxItems, and the profile Actor saves one row each. Within a run, a post or profile already saved is not saved twice.

Profile first, posts second

Run the profile Actor with minFollowers on a long candidate list, keep the usernames that came back, then pass only those to the posts Actor. The post rows then cover only accounts that cleared the threshold.

Scheduled runs and history

Counts move. A schedule that runs both tasks daily, with each dataset stored by date, turns two snapshots into a change log: follower growth from the profile rows, engagement on new posts from the post rows.

Monitoring

Watch for FAILED runs: both Actors fail a run that saves nothing because of a block or an unreadable page, and the posts Actor also fails on an empty input, so a failure is the signal to read that run's log. A webhook on ACTOR.RUN.SUCCEEDED plus a status check in your receiver covers the happy path; read the log for the rest.

Volume and cost control

maxItems is the lever on both Actors. On the posts side, a username yields at most the posts its profile page lists, so many accounts with a modest maxItems reads differently from one account with a large one.

Understanding your data

  • likesCount, commentsCount: integers, rounded the way Instagram displays them. The captured post's page said 12K likes, 147 comments; the row holds 12000 and 147. Values in the thousands are approximate.
  • followersCount, followingCount, postsCount: same rule. 687M became 687000000; 8,620 became 8620.
  • timestamp: text such as October 20, 2026. Convert with datetime.strptime(value, "%B %d, %Y").
  • author: the display name (National Geographic), not the handle.
  • caption: the full caption, line breaks as \n.
  • url: posts always as /p/<shortcode>/, reels included; profiles as https://www.instagram.com/<username>/.
  • imageUrl, avatarUrl: Instagram CDN links that expire.
  • fullName: equals the username when the page shows no name.

A common pattern: divide likesCount by the profile's followersCount to compare engagement across accounts, keeping in mind that both numbers are rounded. Another: count posts per timestamp date to see how often an account publishes. Both work on the rows as they come, with no extra requests.

Troubleshooting guide

  • Run FAILED, zero rows. Both Actors fail a run that saves nothing when a page was blocked or could not be read. Check usernames for typos and renamed or private accounts. If profiles loaded but minFollowers dropped them all, the run ends SUCCEEDED with a status message; loosen minFollowers.
  • Run SUCCEEDED, empty dataset. Either the pages loaded but nothing matched (the status message says so), or no usernames reached the profiles Actor, which logged a warning and ended.
  • Fewer posts than maxItems. A username yields only the posts its profile page lists. A profile page without a post list is skipped with a warning; check the log.
  • Input rejected at start. maxItems is outside 1 to 5,000.
  • Name equals handle. fullName falls back to the username when none is shown.
  • Dead image links. CDN links expire; download images soon after the run.
  • Reading logs. client.log(run.id).get() returns the run log as text; a summary line near the end gives the number of rows pushed. Warnings in the log name the page that failed, so you can retry that one account alone.

For help, use the Actor's Store page.

Best practices

  1. Always set maxItems. It bounds each run on both Actors.
  2. Start small. maxItems: 5 until your code handles the fields.
  3. Treat counts as rounded. Do not compare 12000 likes to 12400 as if both were exact.
  4. Store scrapedAt with every row. Counts change; the timestamp tells you when a number was true.
  5. Mind freshness. Images expire and counts move; schedule reruns instead of reusing old datasets.
  6. Handle failure explicitly. Check run.status before reading rows.
  7. Be polite with volume. Read the accounts you need, not every account you can find; a smaller usernames list keeps each run short and its output easy to check.
  8. Leave the proxy default alone unless you have a reason to change it.

Pricing

For current pricing and details, see the Store page.

Conclusion and next steps

You ran Instagram Posts & Engagement Scraper and Instagram Profile & Followers Scraper from Python, read typed post and profile rows, and saw how rounded counts and date text come through. Next: schedule both tasks, attach a webhook, or join the two datasets on username for an engagement table.

Try Instagram Posts & Engagement Scraper yourself and let me know what you build!

About the author: reventadata builds and maintains marketplace and social-data scrapers on the Apify Store.

Resources

About the author

  • Hi, I’m the engineer behind ReventaData. I build automated web scrapers, reverse-engineer undocumented APIs, and design data pipelines on Apify that turn complex, unstructured websites into clean, reliable feeds. Here on Hashnode, I share practical tutorials on modern web scraping with Python, bypassing aggressive anti-bot protections (Cloudflare, Akamai, PerimeterX), and data pipeline automation, reverse-engineering hidden endpoints, and automating data extraction. Explore our turnkey scrapers: https://apify.com/reventadata

No comments yet. Be the first to comment.