Guide · Python

Search Instagram accounts, hashtags and posts by keyword with Python

One keyword in, three lists out: the accounts worth a look, the hashtags sized by how many posts they hold, and the reels that already do well for it.

ScrapingBot 10 min read
Endpoints
/search/global, /search/posts
Cost
5 credits per call
Language
Python 3.9+
Needs
requests
On this page

Meta's Instagram API can't search. It has no account or keyword search, its Business Discovery feature needs the exact username of a professional account, and its Hashtag Search allows 30 unique hashtags per account in any 7 days. So "find the accounts, hashtags and posts for my niche" (the first step of creator outreach, competitor research or a content plan) needs another route.

This guide uses ScrapingBot's Instagram search endpoints to turn one keyword into three CSV files: accounts with followers, type and category, hashtags with their post counts, and the top reels with plays and likes. It also covers what the results leave out and where they mislead. Every response and output below is real, collected for "latte art" on October 20, 2026 at about 11:50 AM ET.

Before you start

  • A ScrapingBot API key. Create a free account for 100 credits: enough for the default run below (90 credits at most). No card needed.
  • Python 3.9 or newer and pip install requests.

Five search endpoints, one keyword

Every Instagram call is a POST to https://scrapingbot.io/api/v1/instagram with the endpoint and its parameters in the body, and costs 5 credits. Here is what each search endpoint returned for "latte art":

EndpointReturns"latte art"
/search/users_by_keywordAccounts: username, display name, ID, verified badge, latest story time47 accounts
/search/hashtags_by_keywordHashtags with their post counts55 hashtags
/search/places_by_keywordPlaces (Instagram locations) with an address line15 places
/search/globalAll three lists in one call47 + 55 + 15
/search/postsTop posts, with plays, likes, comments and the author8 reels

/search/global returned exactly the same three lists as the three separate calls, so it saves 10 credits whenever you want more than one of them. None of the five has a next page: one call is the whole answer.

curl -X POST https://scrapingbot.io/api/v1/instagram \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "/search/global", "params": {"keyword": "latte art"}}'
{
  "success": true,
  "data": {
    "status": "ok",
    "query": "latte art",
    "users": [
      { "position": 0, "user": { "id": "23778095", "username": "coffeestation_latteart", "full_name": "Asaf Rauch - Barista Swag Latte Art", "is_verified": false, "latest_reel_media": 1792486993, "profile_pic_url": "https://scontent-dus1-1.cdninstagram.com/…", … } },
      …
    ],
    "hashtags": [
      { "position": 0, "hashtag": { "id": "17841593500078274", "name": "lattéart", "media_count": 25854 } },
      { "position": 1, "hashtag": { "id": "17843719849050484", "name": "lattearttulip", "media_count": 32030 } },
      …
    ],
    "places": [
      { "position": 1, "place": { "title": "Latte Art", "subtitle": "Misgav Am St 12, Tel Aviv-Yafo", "location": { "pk": "102245109084938", "name": "Latte Art", … } } },
      …
    ]
  },
  "duration": "0.92",
  "statusCode": 200,
  "creditsUsed": 5
}

What an account result tells you, and what it doesn't

An account result is short. It is enough to decide whom to look up, not to rank anyone:

FieldIn the 47 "latte art" results
username, full_name, idAlways present. Key your records on id: it survives a rename.
is_verified3 of 47 had the badge.
latest_reel_mediaA Unix timestamp of the account's latest story, or 0 when it has none up. 16 of 47 had posted a story in the previous 24 hours, a cheap sign of who is active right now. (The stories endpoint returned the same timestamp for the first account.)
Follower count, bio, categoryNot in search results. Look each account up with /user/by_username (5 credits).

Two things in the lookups matter for a shortlist. account_type says what kind of account it is: 1 personal, 2 business, 3 creator. Among the 47 it was 23 creators, 19 businesses and 5 personal accounts. is_business, which sounds like the same thing, was false on all 47, cafés included, so don't use it. And category, the label under the name, was set on 29 of 47: "Coffee shop", "Cafe", "Digital creator", "Personal blog" and so on.

Search matches names, not topics

Instagram's account search looks at usernames and display names. It doesn't know what an account posts, so a keyword brings in whatever is named after it. Among the 47 "latte art" accounts:

  • A nail salon. @latte_.nailart is a salon in Kaohsiung. Its bio is written in decorative Unicode letters (𝙣𝙖𝙞𝙡 𝙨𝙖𝙡𝙤𝙣), which a plain "nail" in bio check does not match. Normalizing with unicodedata.normalize("NFKC", …) turns them back into ordinary letters first.
  • Cafés that are called Latte Art. Three accounts, in Lebanon, Iraq and a French-speaking market, all say they have served coffee, tea and desserts "since 2003". They are cafés with that name, not latte art accounts.
  • Competition and school accounts, such as the Latte Art Grading System's national chapters: on topic, but small, and organizations rather than creators.

Search order isn't size order either. The largest account among the 47, @latteartcity with 1,049,629 followers, was the 21st suggestion. And the biggest names in a niche may not be in the account results at all, which is where post search helps.

Hashtags: choose by size

Hashtag search returns tags and their post counts, in Instagram's order rather than by size: the biggest, #latteart, came eighth. The 55 tags ranged from 1,264 posts to 12,184,668:

SizeTagsExamples (posts)
Over 1 million1#latteart 12,184,668
100K to 1M3#latteartist 508,836, #latteartvideo 240,158
10K to 100K31#latteartpractice 72,530, #latteartcompetition 23,156, #latteartfail 13,904
Under 10K20#latteartbeginner 8,281, #latteartgradingsystem 6,870

The size tells you what a tag is for. A post in a 12-million-post tag is gone from its recent feed in minutes; a 10K to 100K tag like #latteartpractice is a community you can actually show up in, and the small ones name exact sub-niches (beginners, competitions, a country). Expect near-duplicates: #latteart☕️ (7,150 posts) and #latteart☕ (1,264) differ only in an invisible emoji variation selector, and #lattéart has its own 25,854.

Hashtag search gives you the tags, not their posts. To see what posts well for a keyword, use post search.

Top posts: what already works for the keyword

/search/posts takes a query and returns one page of top posts. For "latte art" that was 8 reels, each with the full post object (177 keys) and its author:

{
  "success": true,
  "data": {
    "data": {
      "count": 8,
      "items": [
        {
          "id": "3903666952097665853",
          "code": "DYsnGkRNpM9",
          "media_type": 2,
          "product_type": "clips",
          "taken_at": 1779613783,
          "like_count": 127943,
          "comment_count": 332,
          "play_count": 2411638,
          "caption": { "text": "You need 🌹☕️ follow @djiibarista …", … },
          "user": { "id": "56027267367", "username": "djiibarista", "is_verified": false, "follower_count": 16556, … },
          …
        },
        …
      ]
    },
    "pagination_token": "GU1VCxopAAdzLB4TBl5bFgRKMEIy…"
  },
  "duration": "7.34",
  "statusCode": 200,
  "creditsUsed": 5
}
  • These are top posts, not recent ones. The 8 were posted between August 31, 2024 and September 7, 2026. Use post search to learn what performs, not to monitor what's new.
  • Plays were filled in. play_count was set on all 8 (195,954 to 2,411,638), and every item carries the author's follower_count. On profile feeds reel play counts are often empty, so don't count on them elsewhere.
  • Small accounts win here. The most-played reel, 2.4 million plays, came from @djiibarista with 16,556 followers.
  • It finds people account search misses. Only 1 of the 8 authors was among the 47 account results. The others included @tannercolsoncoffee, with 1,936,170 followers, the largest account found for the keyword.
  • There is no next page. The response includes a pagination_token, but the endpoint doesn't accept one: 8 posts is the answer.

The script

Save this as ig_search.py. It makes two search calls, looks up the authors of the top posts and then Instagram's suggested accounts up to a budget you set, within your plan's concurrency, sets aside accounts matching words you exclude, and writes three CSV files.

import csv
import sys
import threading
import time
import unicodedata
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone

import requests

API = "https://scrapingbot.io/api/v1/instagram"
HEADERS = {"x-api-key": "YOUR_API_KEY"}
CONCURRENCY = 1  # your plan's limit: Free 1, Starter 10, Startup 50
TYPES = {1: "personal", 2: "business", 3: "creator"}
credits = {"used": 0}
lock = threading.Lock()


class OutOfCredits(Exception):
    pass


def call(endpoint, params, tries=4):
    """One Instagram call: 5 credits on success. Failed calls are refunded."""
    for attempt in range(tries):
        res = requests.post(API, headers=HEADERS, timeout=60,
                            json={"endpoint": endpoint, "params": params})
        body = res.json()
        if body.get("success"):
            with lock:
                credits["used"] += body.get("creditsUsed", 0)
            return body["data"]
        if res.status_code in (401, 402):  # bad key or out of credits
            raise OutOfCredits(body.get("error"))
        if res.status_code in (400, 404):  # bad input or no such account
            raise LookupError(body.get("error"))
        time.sleep(2 ** attempt)  # 408, 429 or 5xx: wait and try again
    raise LookupError(f"kept failing: {body.get('error')}")


def plain(text):
    """Fold fancy Unicode letters (𝙣𝙖𝙞𝙡 -> nail) and case, so word checks work."""
    return unicodedata.normalize("NFKC", text or "").casefold()


def profile(candidate):
    try:
        p = call("/user/by_username", {"username": candidate["username"]})
    except (LookupError, OutOfCredits) as err:  # keep going: what's paid for is saved
        return {**candidate, "status": str(err)}
    return {**candidate, "status": "ok", "name": p.get("full_name") or "",
            "followers": p.get("follower_count"), "following": p.get("following_count"),
            "type": TYPES.get(p.get("account_type"), ""), "category": p.get("category") or "",
            "verified": p.get("is_verified"), "private": p.get("is_private"),
            "website": p.get("external_url") or "",
            "bio": " ".join((p.get("biography") or "").split())}


def save(path, rows, fields):
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
        writer.writeheader()
        writer.writerows(rows)


def research(keyword, profiles=16, exclude=()):
    found = call("/search/global", {"keyword": keyword})  # accounts, hashtags, places
    reels = call("/search/posts", {"query": keyword})["data"]["items"]

    # Accounts: the authors of the top posts first, then Instagram's suggestions,
    # up to `profiles` lookups (5 credits each).
    now = datetime.now(timezone.utc).timestamp()
    candidates = {}
    for item in reels:
        name = item["user"]["username"]
        candidates.setdefault(name, {"username": name, "source": "top post", "story_24h": ""})
    for r in found.get("users", []):
        u = r["user"]
        story = u.get("latest_reel_media") or 0  # time of the latest story, 0 if none
        c = candidates.setdefault(u["username"], {"username": u["username"], "source": ""})
        c["source"] = (c["source"] + " + " if c["source"] else "") + "account search"
        c["story_24h"] = now - story < 86400

    with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
        looked_up = list(pool.map(profile, list(candidates.values())[:profiles]))
    accounts = [a for a in looked_up if a["status"] == "ok"]
    failed = [a for a in looked_up if a["status"] != "ok"]

    words = plain(keyword).split()
    for a in accounts:
        text = plain(" ".join([a["username"], a["name"], a["bio"], a["category"]]))
        a["off_topic"] = any(w in text for w in exclude)
        a["keyword_in_bio"] = all(w in plain(a["bio"]) for w in words)
    keep = sorted((a for a in accounts if not a["off_topic"]),
                  key=lambda a: a["followers"] or 0, reverse=True)

    hashtags = sorted(({"hashtag": r["hashtag"]["name"], "posts": r["hashtag"]["media_count"]}
                       for r in found.get("hashtags", [])), key=lambda h: -h["posts"])
    posts = [{"url": f"https://www.instagram.com/p/{i['code']}/",
              "author": i["user"]["username"], "author_followers": i["user"].get("follower_count"),
              "plays": i.get("play_count"), "likes": i.get("like_count"),
              "comments": i.get("comment_count"),
              "date": datetime.fromtimestamp(i["taken_at"], timezone.utc).date().isoformat(),
              "caption": " ".join(((i.get("caption") or {}).get("text") or "").split())[:80]}
             for i in reels]
    return keep, [a for a in accounts if a["off_topic"]], failed, hashtags, posts


if __name__ == "__main__":
    keyword = sys.argv[1]
    profiles = int(sys.argv[2]) if len(sys.argv) > 2 else 16  # 10 + 16 x 5 = 90 credits at most
    exclude = [plain(w) for w in sys.argv[3].split(",")] if len(sys.argv) > 3 else []
    try:
        accounts, dropped, failed, hashtags, posts = research(keyword, profiles, exclude)
    except OutOfCredits as err:  # the searches themselves failed: nothing to save
        sys.exit(err)
    slug = keyword.replace(" ", "_")

    print(f"Accounts for '{keyword}' ({len(accounts)} kept, {len(dropped)} off topic):")
    for a in accounts[:12]:
        story = "story today" if a["story_24h"] is True else ""
        print(f"  @{a['username']:<26} {a['followers'] or 0:>9,}  {a['type']:<9} "
              f"{a['category'][:18]:<18} {story}")
    for a in dropped:
        print(f"  dropped @{a['username']} ({a['category'] or a['bio'][:30]})")
    for a in failed:
        print(f"  not looked up @{a['username']}: {a['status']}")
    bands = [("1M+", 1_000_000, float("inf")), ("100K-1M", 100_000, 1_000_000),
             ("10K-100K", 10_000, 100_000), ("under 10K", 0, 10_000)]
    print(f"Hashtags ({len(hashtags)}):")
    for label, low, high in bands:
        band = [h for h in hashtags if low <= h["posts"] < high]
        print(f"  {label:<9} {len(band):>2}  " + ", ".join(f"#{h['hashtag']}" for h in band[:4]))
    print(f"Top posts ({len(posts)}):")
    for p in sorted(posts, key=lambda p: -(p["plays"] or 0)):
        print(f"  {p['plays'] or 0:>10,} plays {p['likes'] or 0:>8,} likes  @{p['author']:<20} {p['date']}")

    save(f"{slug}_accounts.csv", accounts + dropped, ["username", "name", "followers", "following",
         "type", "category", "verified", "private", "website", "story_24h", "keyword_in_bio",
         "source", "off_topic", "bio"])
    save(f"{slug}_hashtags.csv", hashtags, ["hashtag", "posts"])
    save(f"{slug}_top_posts.csv", posts, list(posts[0]) if posts else ["url"])
    print(f"{credits['used']} credits; wrote {slug}_accounts.csv, {slug}_hashtags.csv, {slug}_top_posts.csv")
  • Two calls cover the search. /search/global brings accounts, hashtags and places for 5 credits, and /search/posts the top reels for 5 more.
  • Lookups are capped. Top-post authors come first, then Instagram's suggestions, up to profiles lookups (16 by default, so a run costs at most 90 credits). The number of top posts varies (8 here, 9 in our docs example), so the cap, not the post count, sets the cost.
  • "story today" comes from the search result (latest_reel_media within the last 24 hours), so it costs nothing extra. Top-post authors that weren't in the account results show it blank.
  • plain() normalizes before matching. NFKC folds decorative letters into ordinary ones, and casefold() handles case, for both the exclude list and the keyword_in_bio column.
  • Failures are handled once, in call(). A missing account (404) is skipped, a timeout (408) or a busy plan (429) is retried with a growing pause, and a bad key or an empty balance stops the lookups without losing what you paid for: the CSV files are still written, and the accounts not looked up are listed. Failed calls are refunded.

Run it

python ig_search.py "latte art"
Accounts for 'latte art' (16 kept, 0 off topic):
  @tannercolsoncoffee         1,936,170  creator                      
  @latte_art_tutorials          471,247  creator                      story today
  @coffeestation_latteart       302,131  business  Shopping & retail  story today
  @latteartmania_tokyo          156,266  business  Cafe               story today
  @frenchpress.latteart         100,833  creator   Art                
  @baristabrian                  73,436  creator   Artist             
  @worldoflatteart               48,488  creator                      
  @shawn__1023                   34,019  creator   Digital creator    
  @latteart.iraq                 22,962  business                     story today
  @barista_chow                  22,886  business  Art                
  @latteartlebanon               18,816  creator   Coffee shop        story today
  @latteartmania_ginza           17,812  business  Cafe               story today
Hashtags (55):
  1M+        1  #latteart
  100K-1M    3  #latteartist, #latteartporn, #latteartvideo
  10K-100K  31  #latteartpractice, #latteartsg, #latteartlicious, #latteartphilippines
  under 10K 20  #latteartbeginner, #latteartcowboy, #latteartworkshop, #latteartchampionship
Top posts (8):
   2,411,638 plays  127,943 likes  @djiibarista          2026-05-24
   1,477,194 plays   80,575 likes  @tannercolsoncoffee   2026-06-04
   1,068,349 plays   35,742 likes  @worldoflatteart      2026-01-26
     937,279 plays   25,377 likes  @shawn__1023          2024-08-31
     614,874 plays   26,318 likes  @barista_chow         2026-09-07
     338,069 plays    6,300 likes  @latte_art_tutorials  2025-12-23
     241,922 plays    4,779 likes  @baristabrian         2025-11-10
     195,954 plays   13,528 likes  @b_o_n_p_s_y          2026-03-21
90 credits; wrote latte_art_accounts.csv, latte_art_hashtags.csv, latte_art_top_posts.csv

That is 2 searches and 16 lookups (the 8 top-post authors, then 8 suggestions; one author was also a suggestion): 90 credits, inside the free tier. To profile every account and drop the off-topic ones, raise the budget and pass the words to exclude:

python ig_search.py "latte art" 60 nail,salon
Accounts for 'latte art' (53 kept, 1 off topic):
  @tannercolsoncoffee         1,936,170  creator                      
  @latteartcity               1,049,629  creator   Digital creator    story today
  @latte_art_tutorials          471,247  creator                      story today
  @coffeestation_latteart       302,131  business  Shopping & retail  story today
  @latteartmania_tokyo          156,266  business  Cafe               story today
  @frenchpress.latteart         100,833  creator   Art                
  @baristabrian                  73,436  creator   Artist             
  @worldoflatteart               48,488  creator                      
  @shawn__1023                   34,019  creator   Digital creator    
  @latteartseruveni              27,434  creator   Digital creator    story today
  @latteart.iraq                 22,962  business                     story today
  @barista_chow                  22,886  business  Art                
  dropped @latte_.nailart (𝙆𝙖𝙤𝙝𝙨𝙞𝙪𝙣𝙜 𝙣𝙖𝙞𝙡 𝙨𝙖𝙡𝙤𝙣𐙚 ᯓ近三民、左營 )
…
280 credits; wrote latte_art_accounts.csv, latte_art_hashtags.csv, latte_art_top_posts.csv

All 54 accounts cost 280 credits, about $0.05 on the Starter plan. @latteartcity, the 21st suggestion, is now second, and the nail salon is set aside even though its bio uses decorative letters. Accounts set aside are still written to the CSV with off_topic set, so you can check them.

What to do with the three files

latte_art_accounts.csv has one row per account:

username,name,followers,following,type,category,verified,private,website,story_24h,keyword_in_bio,source,off_topic,bio
tannercolsoncoffee,Tanner Colson,1936170,491,creator,,False,False,http://beacons.ai/tannercolsoncoffee/,,False,top post,False,Helping you make better coffee
latteartcity,Goran Dujic,1049629,1760,creator,Digital creator,True,False,http://latteartcity.com/app,True,True,account search,False,☕ The Home of Latte Art 🎓 Learn • Practice • Get AI Feedback …
latte_art_tutorials,Asaf Rauch | EVO + Vortex System,471247,64,creator,,False,False,http://www.baristaswag.com/complete-set,True,True,top post + account search,False,Latte art education by Asaf Rauch …
  • Outreach to creators: keep type = creator, sort by followers, and prefer rows with story_24h or a top post (source): they are active and their content travels. In the full run, 28 of 54 accounts were creators and 27 mention latte art in their bio.
  • Local businesses: keep type = business with a category like "Cafe" or "Coffee shop", and use the website column.
  • Content plan: latte_art_hashtags.csv is sorted by post count, so pick a few from the 10K to 100K band, and read the captions in latte_art_top_posts.csv for formats that already reach millions of plays.

Then go deeper on the shortlist. Followers alone don't say whether an audience is engaged: calculate the engagement rate for a list of creators from their recent posts, or track their follower counts over time.

Places

/search/places_by_keyword searches Instagram locations by name. "latte art" returned 15, mostly places named Latte Art (in Tel Aviv, Amchit and Bushey, for example) and a few training venues, each with an address line in subtitle. "coffee tokyo" returned 13, such as Inoda Coffee Tokyo and Bicycle Coffee Tokyo. "blue bottle coffee" returned none at all, so search places by the words in their names, and treat an empty list as "no match", not an error.

Cost and limits

  • 5 credits per call, whatever the number of results. A keyword is 10 credits for the two searches plus 5 per account you look up. Failed calls are refunded.
  • No paging. About 50 accounts, 55 hashtags, 15 places and 8 posts per keyword is what you get. For more, search related keywords ("latte art tutorial", "home barista") and de-duplicate on the account ID.
  • Results change. Search reflects Instagram's suggestions at that moment, and story times move every day. Save the date with each run if you compare runs.
  • Optional fields are optional. media_count was null on every profile we looked up, is_business was always false, and category was empty on 18 of the 47 suggestions.
  • Concurrency follows your plan: Free allows 1 request at a time, Starter 10 and Startup 50 (every plan's limit). Set CONCURRENCY to match.

Where to go from here

Common questions

Is there an Instagram search API?

Not from Meta for accounts or keywords. Its Instagram API has no account search, Business Discovery needs the exact username of a professional account, and Hashtag Search is limited to 30 unique hashtags per account in any 7 days. ScrapingBot has five search endpoints (accounts, hashtags, places, all three at once, and posts) at 5 credits a call, with no Instagram login.

How do I search Instagram users by keyword with Python?

POST {"endpoint": "/search/users_by_keyword", "params": {"keyword": "latte art"}} to https://scrapingbot.io/api/v1/instagram with your key in the x-api-key header. On October 20, 2026 it returned 47 accounts with their username, display name, numeric ID, verified badge and the time of their latest story, but no follower counts: look each account up with /user/by_username for those.

Can I get the posts for an Instagram hashtag?

Hashtag search returns hashtags and how many posts each has (55 hashtags for "latte art", from 1,264 to 12,184,668 posts), not the posts themselves. For posts, /search/posts returns one page of top posts for a keyword: 8 reels for "latte art", with play, like and comment counts and the author's follower count. It has no next page.

Why does Instagram search return accounts that are off topic?

Account search matches names and usernames, not what an account posts. "latte art" returned a nail salon called latte_.nailart and three cafés named Latte Art. Look the accounts up and filter on their bio and category; normalize the text first, because bios written in decorative Unicode letters do not match plain words.

How much does an Instagram keyword search cost?

5 credits per call. The script in this guide makes two search calls (10 credits) plus 5 credits per account it looks up: at most 90 credits for the default run (16 lookups), which fits in the 100 free credits, or 280 credits to profile all 54 accounts, about $0.05 on the Starter plan ($49.99 for 275,000 credits).

Do I need an Instagram account or login?

No. Search and profile lookups read public data; you only need a ScrapingBot API key.

Start scraping in the next five minutes.

100 free credits, no credit card. One API key works for websites, TikTok, Instagram, Google, Amazon and ChatGPT.