Back to Blog
Instagram

Instagram Play Count API: Missing vs Zero, and How to Handle the Gaps

November 11, 2026
21 min read
S
By SociaVault Team
InstagramReelsplay countAPI errorspaginationdevelopers

Instagram Play Count API: Missing vs Zero, and How to Handle the Gaps

A Reel in your dashboard shows 0 plays. Open it in the Instagram app and it has over a million. Before you blame the data, find the line in your code that stores play counts. Odds are, a count that never arrived got saved as 0 somewhere along the way.

Missing and zero are different answers. Missing means nobody knows how many plays the video has. Zero means it has none. Treat them as the same thing and every average and ranking you build on top comes out wrong.

Our API tells you when a count is missing. GET /v1/scrape/instagram/posts and GET /v1/scrape/instagram/reels try to get a play count for every video on a page. When Instagram's public data doesn't include one for some videos, you still get the page, those videos have no play_count, and the response says so. The rest is up to your code: storing counts so unknown never turns into zero, knowing where to find the missing ones, and retrying the occasional 503 without losing your place in a paginated pull. We ran everything below live in early November 2026.

Where the play count lives

On 1 November we pulled posts and Reels from @natgeo and @nasa, and posts from @nike: 17 pages and 204 item reads, covering 56 different videos. This is where the count sits in each response.

RequestPlay count fieldNext-page cursor
instagram/posts (with or without trim=true)items[].play_countdata.next_max_id, sent back as next_max_id
instagram/reelsitems[].media.play_countdata.paging_info.max_id, sent back as max_id
instagram/reels with trim=trueitems[].play_countdata.max_id, sent back as max_id

What else we saw in those responses:

  • Every video also had ig_play_count, and it matched play_count on all 155 video reads. Neither endpoint returned view_count, video_view_count or any other play or view count field.
  • Photos (media_type 1) and carousels (media_type 8) have no play_count key at all. Videos are media_type 2, and every video in the posts responses had product_type set to clips.
  • Video slides inside a carousel don't carry a count. One @natgeo carousel had four video slides with no count on any of them, and its page still came back as a normal 200. Every other carousel with video slides looked the same.
  • items was an object with numbered keys in every response we inspected, so convert it to a list before you loop.

Here are the first two items from the @nasa posts page, cut down to the fields that matter:

{
  "0": {
    "code": "DeTUk4sDSp-",
    "media_type": 2,
    "product_type": "clips",
    "play_count": 11237731,
    "ig_play_count": 11237731,
    "like_count": 767001,
    "comment_count": 7671
  },
  "1": {
    "code": "DeJ28eTxo4s",
    "media_type": 1,
    "product_type": "feed",
    "like_count": 296981,
    "comment_count": 1065
  }
}

The photo has no play_count, and that's correct. Photos don't get plays. The full parameter lists are in the docs for instagram/posts and instagram/reels.

What goes wrong when missing becomes 0

A play count of 0 is a measurement: the video had zero plays when we asked. Our API counts 0 as present, so a page with a real zero comes back normally. A missing count, or a null, is no measurement at all. The video might have 10 plays or 10 million.

The @nasa posts page from 1 November shows what mixing them up costs. It had 12 posts, and 3 of them were videos. Here's the "average plays" that common first drafts produce:

CodeAverage plays
JS: sum + i.play_count over all 12 postsNaN
JS: sum + (i.play_count ?? 0) over all 12 posts1,523,097
Python: i.get("play_count", 0) over all 12 posts1,523,097
Videos only, known counts only6,092,388

The ?? 0 version is off by a factor of four, and nothing warns you. The NaN at least looks wrong when you print it. Over two pages of @natgeo posts (24 posts, 12 of them videos), the same mistake cut the average in half: 684,080 instead of 1,368,161.

Rankings break too. Sort with (a, b) => b.play_count - a.play_count when some items have no count, and the comparator returns NaN for those pairs, which JavaScript's sort treats as a tie. On the NASA page, the video with 4.7 million plays ended up below the one with 2.3 million, with photos scattered between them. Python won't sort at all. sorted() raises TypeError: '<' not supported between instances of 'NoneType' and 'int', and statistics.mean() raises a TypeError as well.

Coercing unknowns to 0 before you rank gets rid of the error and the scramble, and creates a quieter problem. The unknown video sorts to the bottom, so it lands in your "worst performers" list or fires an alert about a sudden drop.

The reverse bug is just as easy to write. if (!item.play_count) in JavaScript and if not count in Python both treat a real 0 as missing, because 0 is falsy in both languages.

These are the common ways bad zeros get into a database:

  • ?? 0, || 0 or .get("play_count", 0) when parsing the response
  • COALESCE(play_count, 0) in a query or a view
  • a NOT NULL DEFAULT 0 column, with an insert that leaves the field out when there's no count
  • photos and carousels stored as videos with zero plays

Store null. Don't let any default fill in a play count.

Keep unknown separate in code

These helpers return a number (0 included) or null, and never guess. Pass them videos only: from posts, keep items with media_type 2 first. Every item from reels is a video.

// Reels nest each item under `media`, unless you pass trim=true
const unwrap = (item) => item?.media ?? item;

// A number (0 included) when Instagram returned a count, null when it didn't
function playCount(item) {
  const n = unwrap(item)?.play_count;
  return Number.isFinite(n) ? n : null;
}

function viewStats(videos) {
  const counts = videos.map(playCount);
  const known = counts.filter((n) => n !== null);
  const total = known.reduce((sum, n) => sum + n, 0);
  return {
    videos: videos.length,
    unknown: counts.length - known.length, // report this next to the average
    zeros: known.filter((n) => n === 0).length, // real zeros stay in the average
    average: known.length ? Math.round(total / known.length) : null,
  };
}

// Rank what you can measure, and list the rest separately
function rankByPlays(videos) {
  const measured = videos.filter((v) => playCount(v) !== null);
  return {
    ranked: measured.sort((a, b) => playCount(b) - playCount(a)),
    unranked: videos.filter((v) => playCount(v) === null),
  };
}

The same in Python:

def unwrap(item):
    # Reels nest each item under "media", unless you pass trim=true
    return item.get("media") or item


def play_count(item):
    # A number (0 included) when Instagram returned a count, None when it didn't
    n = unwrap(item).get("play_count")
    return n if isinstance(n, (int, float)) and not isinstance(n, bool) else None


def view_stats(videos):
    counts = [play_count(v) for v in videos]
    known = [n for n in counts if n is not None]
    return {
        "videos": len(videos),
        "unknown": len(counts) - len(known),  # report this next to the average
        "zeros": known.count(0),  # real zeros stay in the average
        "average": round(sum(known) / len(known)) if known else None,
    }


def rank_by_plays(videos):
    # Rank what you can measure, and return the rest separately
    measured = [v for v in videos if play_count(v) is not None]
    unranked = [v for v in videos if play_count(v) is None]
    return sorted(measured, key=play_count, reverse=True), unranked

Report unknown next to every average. An average of 1.3 million plays across 12 of 12 videos and the same average across 7 of 12 are different claims.

On the recent pages we pulled on 1 November, unknown was 0 every time. Deeper pages are different. On 3 November, a large brand account's posts from about a year back came back with 3 play counts across 72 videos, as the next section shows. Every one of those gaps reaches your code as a missing field, and the helpers above are what keep it from turning into a zero. Hashtag search results don't include play counts at all, which we covered in our post on view counts in search results.

If the counts end up in SQL, let the column be NULL:

-- NULL means Instagram's public data had no count for this video
CREATE TABLE videos (code TEXT PRIMARY KEY, play_count INTEGER, fetched_at TEXT);

-- AVG skips NULLs, so unknown counts stay out of the average
SELECT AVG(play_count) FROM videos;

-- COALESCE turns unknown into zero plays and drags the average down
SELECT AVG(COALESCE(play_count, 0)) FROM videos;

Pages with missing counts

Since 2 November 2026, our API returns a posts or reels page even when some of its videos come back without a play count. Instagram's public data sometimes doesn't include a play count for a video, and this is how you find out:

  • Those videos have no play_count field. Everything else on the page is there as usual.
  • The response gets two extra fields: play_counts_complete, set to false, and a warning that reads "Instagram didn't return play counts for some videos on this page, so those videos have no play_count. Treat a missing count as unknown, not zero."
  • Pages where every video has a count don't include either field.
  • The page costs 1 credit, like any other.
  • These pages are slower. Our API asks for every count first, then asks again without that check, so they took 27 to 32 seconds each in our tests on 3 November.

How often it happens depends on how far back you go. On 3 November we paged a large brand account with about 3,100 posts:

EndpointVideos checkedWith a play count
instagram/posts, pages 85 to 90 (late October and November 2025)723
instagram/reels, the whole feed back to February 20232,9052,904

For older videos, the reels endpoint is where the counts are. Page through instagram/reels for the same account and match each reel to its post by code, the shortcode in the post's URL. The reels feed leaves out pinned reels, photos and carousels, so keep using posts for the full list.

Until 2 November, these pages failed with HTTP 503 and the code play_counts_unavailable. If your code waits for that code, it won't see it any more. Check play_counts_complete instead.

The 503 that's left

A posts or reels page can still fail with HTTP 503 when it comes back incomplete for some other reason. Nothing is charged, and the body looks like this:

{
  "success": false,
  "error": "Instagram didn't return complete data for this page, so the request failed and you weren't charged. Try again in a few minutes.",
  "credits_used": 0,
  "endpoint": "posts"
}

Before you write the handler:

  • It's slow. Our API retries before it gives up, so this 503 can take 40 to 50 seconds to arrive. Successful posts pages can be slow too. Ours took between 9 and 49 seconds on 1 November, against 3 to 5 seconds for reels pages. A 120-second client timeout covers both.
  • Each page is checked on its own. A 503 on page 3 tells you nothing about page 4, and a 200 on page 1 doesn't promise page 2.

Why the retry waits minutes

Most retry wrappers back off for a second or two and then double it. That's a sensible default for 5xx errors in general, and it's what our error handling guide uses. Here it only repeats work. By the time this 503 reaches you, our API has already spent 40 to 50 seconds retrying, and the message itself says to try again in a few minutes.

So the helper below waits 2 minutes, then 4, then 8, plus up to 25% random jitter so parallel workers don't all retry at once. It stops after 4 attempts. In the worst case, one page takes about 20 minutes before the helper gives up: up to 17.5 minutes of waiting plus four slow 503s.

const API = "https://api.sociavault.com/v1/scrape/instagram";
const HEADERS = { "x-api-key": process.env.SOCIAVAULT_API_KEY };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

class PageUnavailable extends Error {}

// One page of /posts or /reels. Only the uncharged 503s are retried, minutes apart.
async function getPage(
  endpoint,
  params,
  { attempts = 4, firstWaitMinutes = 2 } = {},
) {
  for (let attempt = 1; attempt <= attempts; attempt++) {
    const res = await fetch(
      `${API}/${endpoint}?${new URLSearchParams(params)}`,
      {
        headers: HEADERS,
        signal: AbortSignal.timeout(120_000), // a 503 can take 40 to 50 seconds to arrive
      },
    );
    const body = await res.json().catch(() => ({})); // don't assume every 503 body is JSON
    if (res.ok && body.success) return body.data ?? {};
    if (res.status !== 503) {
      throw new Error(
        `HTTP ${res.status}: ${body.error ?? "no error message"}`,
      );
    }
    // the page came back incomplete, and nothing was charged
    const reason = body.code ?? "no code";
    if (attempt === attempts) {
      throw new PageUnavailable(
        `${endpoint}: still 503 (${reason}) after ${attempts} tries`,
      );
    }
    const minutes = firstWaitMinutes * 2 ** (attempt - 1); // 2, 4, 8
    console.warn(
      `${endpoint}: 503 (${reason}), try ${attempt} of ${attempts}, waiting ${minutes} min`,
    );
    await sleep(minutes * 60_000 * (1 + Math.random() * 0.25)); // jitter spreads out parallel workers
  }
}

And in Python:

import os
import random
import time

import requests

API = "https://api.sociavault.com/v1/scrape/instagram"
HEADERS = {"x-api-key": os.environ["SOCIAVAULT_API_KEY"]}


class PageUnavailable(Exception):
    pass


def get_page(endpoint, params, attempts=4, first_wait_minutes=2):
    """One page of /posts or /reels. Only the uncharged 503s are retried, minutes apart."""
    for attempt in range(1, attempts + 1):
        # a 503 can take 40 to 50 seconds to arrive
        r = requests.get(f"{API}/{endpoint}", params=params, headers=HEADERS, timeout=120)
        try:
            body = r.json()
        except ValueError:  # don't assume every 503 body is JSON
            body = {}
        if r.ok and body.get("success"):
            return body.get("data") or {}
        if r.status_code != 503:
            raise RuntimeError(f"HTTP {r.status_code}: {body.get('error', 'no error message')}")
        # the page came back incomplete, and nothing was charged
        reason = body.get("code") or "no code"
        if attempt == attempts:
            raise PageUnavailable(f"{endpoint}: still 503 ({reason}) after {attempts} tries")
        minutes = first_wait_minutes * 2 ** (attempt - 1)  # 2, 4, 8
        print(f"{endpoint}: 503 ({reason}), try {attempt} of {attempts}, waiting {minutes} min")
        time.sleep(minutes * 60 * (1 + random.random() * 0.25))  # jitter spreads out workers

Anything other than a 503 throws straight away. A 400 means the request needs fixing and a 402 means you're out of credits, and waiting won't change either one. Network errors and timeouts also go straight to the caller, so wrap the call in your usual network retry if you have one.

We ran both helpers live on 1 November, and every page came back 200 on the first try. To test the retry path, we fed them 503 responses offline, including one with an HTML body. They retried each one, asked for waits of 2, 4 and 8 minutes plus jitter, and raised PageUnavailable on the fourth failure. A 400 went straight through without a retry.

Paginating when a page fails

A failed page has no cursor. The cursor for page 4 lives inside page 3's response, so when page 3 is a 503, there's nothing to move forward with. The only way on is to ask for page 3 again later, with the cursor page 2 gave you.

That gives you four rules:

  1. Save the items, then the cursor, after every page. If the process dies between the two writes, you re-fetch one page and get duplicates you can drop by code. The other order loses a page without telling you.
  2. When a page gives up, keep the old cursor and stop. You can't skip ahead, and restarting from page 1 pays again for pages you already have.
  3. In a batch, move on. Park the account, carry on with the next one, and pick it up on the next run.
  4. Cap pages per run so a bug can't page forever.

The cursors differ between the two endpoints. For posts, send next_max_id and stop when data.more_available is false. For reels, send max_id, taken from data.paging_info.max_id, and stop when data.paging_info.more_available is false. With trim=true, the reels cursor came back at data.max_id with no more_available, so the code stops when the cursor is missing. Our pagination cursor guide covers the formats on other platforms.

This builds on the helpers above:

import { appendFile, readFile, writeFile } from "node:fs/promises";

// Lists can come back as arrays or as objects with numbered keys
const asList = (x) => (Array.isArray(x) ? x : Object.values(x ?? {}));
const CURSOR_PARAM = { posts: "next_max_id", reels: "max_id" };

function nextCursor(endpoint, data) {
  if (endpoint === "posts") {
    return data.more_available === false ? null : (data.next_max_id ?? null);
  }
  const paging = data.paging_info ?? {};
  if (paging.more_available === false) return null;
  return paging.max_id ?? data.max_id ?? null; // trim=true puts it at data.max_id
}

async function collect(endpoint, params, maxPages = 10) {
  const name = `${endpoint}-${params.handle}`;
  const saved = await readFile(`${name}.checkpoint.json`, "utf8").catch(
    () => "{}",
  );
  const state = JSON.parse(saved);
  if (state.done) {
    console.log(`${name}: already complete`);
    return;
  }

  for (let page = 1; page <= maxPages; page++) {
    const query = { ...params };
    if (state.cursor) query[CURSOR_PARAM[endpoint]] = state.cursor;
    let data;
    try {
      data = await getPage(endpoint, query);
    } catch (err) {
      if (!(err instanceof PageUnavailable)) throw err;
      // No page means no new cursor. The checkpoint still points at this page.
      console.warn(`${err.message}. Run again later to resume.`);
      return;
    }
    const items = asList(data.items);
    await appendFile(
      `${name}.jsonl`,
      items.map((i) => JSON.stringify(i) + "\n").join(""),
    );
    state.cursor = nextCursor(endpoint, data);
    state.done = !state.cursor;
    await writeFile(`${name}.checkpoint.json`, JSON.stringify(state));

    const videos =
      endpoint === "reels" ? items : items.filter((i) => i.media_type === 2);
    console.log(`${name} page ${page}:`, viewStats(videos));
    if (state.done) break;
    await sleep(1000);
  }
}

Save the JavaScript blocks in one .mjs file so top-level await works, and finish it with a line like await collect("posts", { handle: "natgeo" }, 2);. The Python version:

import json
import time
from pathlib import Path


def as_list(x):
    # Lists can come back as lists or as dicts with numbered keys
    return list(x.values()) if isinstance(x, dict) else (x or [])


CURSOR_PARAM = {"posts": "next_max_id", "reels": "max_id"}


def next_cursor(endpoint, data):
    if endpoint == "posts":
        return None if data.get("more_available") is False else data.get("next_max_id")
    paging = data.get("paging_info") or {}
    if paging.get("more_available") is False:
        return None
    return paging.get("max_id") or data.get("max_id")  # trim=true puts it at data.max_id


def collect(endpoint, params, max_pages=10):
    name = f"{endpoint}-{params['handle']}"
    checkpoint = Path(f"{name}.checkpoint.json")
    state = json.loads(checkpoint.read_text()) if checkpoint.exists() else {}
    if state.get("done"):
        print(f"{name}: already complete")
        return

    for page in range(1, max_pages + 1):
        query = dict(params)
        if state.get("cursor"):
            query[CURSOR_PARAM[endpoint]] = state["cursor"]
        try:
            data = get_page(endpoint, query)
        except PageUnavailable as err:
            # No page means no new cursor. The checkpoint still points at this page.
            print(f"{err}. Run again later to resume.")
            return
        items = as_list(data.get("items"))
        with open(f"{name}.jsonl", "a") as f:
            f.writelines(json.dumps(i) + "\n" for i in items)
        state["cursor"] = next_cursor(endpoint, data)
        state["done"] = not state["cursor"]
        checkpoint.write_text(json.dumps(state))

        videos = items if endpoint == "reels" else [i for i in items if i.get("media_type") == 2]
        print(f"{name} page {page}:", view_stats(videos))
        if state["done"]:
            break
        time.sleep(1)

Call it with something like collect("reels", {"handle": "natgeo", "trim": "true"}, 2).

Here's what the four live runs returned on 1 November 2026, at 1 credit per page:

RunPagesItemsVideosAverage playsUnknown
JS, @natgeo posts224121,368,1610
JS, @nasa reels224244,865,4710
Python, @nasa posts, resumed at page 222463,591,0100
Python, @natgeo reels, trim=true224242,065,6720

The resumed run is the one to look at. Its checkpoint held a posts cursor we'd saved 16 minutes earlier, which is close to the helper's worst-case wait, and it still returned the next page. The trimmed run confirmed that data.max_id works as the reels cursor. Neither Python run returned a duplicate.

Fixing zeros you already stored

A zero in your table doesn't say where it came from. If you kept the raw JSON, check it: a video with no play_count key was unknown, so set that row to NULL. If you didn't keep it, read the video again. Recent Reels come back by paging instagram/reels. For one specific video, instagram/post-info takes the post URL and returns the count as data.data.xdt_shortcode_media.video_play_count, a different name from the list endpoints. On 1 November it returned 1,675,283 for a @natgeo Reel, for 1 credit. Then make the column nullable so the same thing can't happen again.

The honest limits

  • Some counts don't come through posts at all. On the brand account above, posts from about a year back had almost no play counts, while its reels feed had them. Asking for the same page again doesn't reliably help, so go to the reels endpoint for those.
  • Play counts are Instagram views only. Views from Reels cross-posted to Facebook aren't included, so the number can differ from what the app shows.
  • Counts move a little between reads. One @natgeo Reel read 1,675,406 plays through posts, then 1,675,283 through post-info about a minute later. If you track growth, expect small negative deltas and don't flag them as errors.
  • Carousel videos have no count of their own in these responses, so per-slide plays aren't available.
  • We didn't test how long a cursor lasts. One worked after 16 minutes. We didn't try hours or days. If a saved cursor fails, restart from page 1 and drop duplicates by code.
  • We didn't see an account with counts turned off. like_and_view_counts_disabled was false on every item we read, so we can't say what a page looks like when it's true.

Frequently Asked Questions

Why is play_count missing on some Instagram posts?

Photos and carousels never have one, because only videos get plays. On videos, Instagram's public data sometimes doesn't include a play count, especially for older posts. Our posts and reels endpoints still return the page, leave play_count out for those videos and set play_counts_complete to false. For older videos, the reels endpoint usually has the count.

Is an Instagram play count of 0 real?

Yes. Our API treats 0 as a present count, so a page with a zero on it comes back as a normal 200. If your data shows zeros on videos that clearly have views, look for code that turned missing counts into 0.

What does play_counts_complete false mean?

At least one video on that page came back without a play count. Our API returns the page anyway, at the usual 1 credit, and leaves play_count out for those videos. Pages with every count don't include the field. Until 2 November 2026, these pages failed with a 503 and the code play_counts_unavailable instead.

Do 503 responses cost credits?

No. A 503 returns credits_used: 0. Every page that comes back costs 1 credit, including pages with missing play counts.

What's the difference between play_count and ig_play_count?

In the responses we checked on 1 November, none. Both fields were on every video and always held the same number. The code above reads play_count.

Why doesn't the API play count match the Instagram app?

Play counts are Instagram views only. If a Reel was cross-posted to Facebook, those Facebook views aren't included, so the number can differ from what the app shows. Counts also move a little between reads.


Want to see how many play counts come back for your own accounts? Sign up for 50 free credits, no card needed. Failed pages are free, so you only pay for pages that come back.

Found this helpful?

Share it with others who might benefit

Ready to Try SociaVault?

Start extracting social media data with our powerful API. No credit card required.