← Back to Blog

The 100-Row Wall: How Notion API Pagination Quietly Truncates Your Data

You build a Notion sync. You point it at a data source, pull the rows, push them somewhere else, and watch it run. Every row you can see comes through. You mark the job done and move on. A few months later someone mentions that half their records never made it across, and you go digging for the error that broke things. There is no error. There never was one.

This is one of the most common Notion bugs I get called in to fix, and it is almost never spotted as a bug at first, because the automation did exactly what it was told. It asked Notion for the rows. Notion handed back a hundred of them. The code read those hundred and finished, happy. The other four thousand were sitting one request away, behind a flag nobody thought to check.

Notion paginates every list it returns, and the limit is low: a hundred items per request, maximum. If your workspace is small you may never reach it, which is exactly why it slides through testing and then surfaces in production once the data grows. This post is about that wall. What it is, why it stays invisible, the second place it hides where almost nobody looks, and how to write the loop that actually gets all of your data back.

The request that looks complete and is not

When you query a data source, the response that comes back is not your rows. It is a list object that contains some of your rows, plus two fields that tell you whether there are more. Those two fields are the entire story, and they are the two fields people skip straight past.

Notion API response
// POST /v1/data_sources/{data_source_id}/query
// The response is always a "list" object. This is the part of the
// shape that decides whether you get all your data or some of it:

{
  "object": "list",
  "results": [
    // ... exactly 100 page objects, never more than 100 ...
  ],
  "has_more": true,
  "next_cursor": "8a4b1c2d-9e7f-4a3b-bc21-0f9e8d7c6b5a"
}

// has_more: true   means Notion is holding back more rows.
// next_cursor      is the bookmark you send back to get the next 100.
// Read "results" and stop here, and rows 101 and beyond simply do not
// exist as far as your automation is concerned.

A quick note on naming, because it trips people up. In Notion's current API version, the thing you query is a data source, and the endpoint is /v1/data_sources/{id}/query. Older integrations called this querying a database and hit /v1/databases/{id}/query. A database can now hold more than one data source, but for pagination none of that matters. The response shape is identical, and so is the trap.

Here is the trap stated plainly. results never contains more than 100 items. If your data source has 4,200 rows, the first response holds 100 of them, sets has_more to true, and hands you a next_cursor. That cursor is a bookmark. It is Notion saying "here is where you stopped, ask me again from this point." If you never ask again, those rows do not exist for your automation. Not deleted, not errored, just never requested.

Why nobody catches it in testing

The reason this bug is so good at hiding is that the naive version of the code looks right and behaves right, for a while.

the version that drops rows
// The version almost everyone writes first.
// One request, read the results, move on.

const res = await fetch(
  "https://api.notion.com/v1/data_sources/" + dataSourceId + "/query",
  {
    method: "POST",
    headers: {
      "Authorization": "Bearer " + token,
      "Notion-Version": "2025-09-03",
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ page_size: 100 })
  }
);

const data = await res.json();
return data.results; // only the first 100. Passes every small test.

Read that and nothing jumps out. It authenticates, it queries, it returns results. In a demo it returns every row, because a demo database has twelve rows in it. In the first week of production it still works, because the table has not crossed a hundred entries yet. The failure does not arrive on a date. It arrives at a row count, and it arrives silently.

There is a second reason it stays hidden, and it is almost cruel. Notion tends to return the most recently edited rows first. So when you eyeball the output to check it, the records you look at, the ones you just created while testing, are exactly the ones that come back in the first page. Everything you check is present. The rows that get dropped are the older ones in the middle and the tail, the records nobody is actively staring at. The sync looks perfect precisely where you are looking.

This is the same shape of problem I wrote about in automation drift. The workflow keeps running, keeps showing green, and the data underneath it quietly stops being complete. A truncated query is drift that was baked in on day one, waiting for the row count to expose it.

The loop that actually gets everything

The fix is not clever. You keep asking until Notion tells you there is nothing left, and each time you pass back the cursor from the previous response so it picks up where it stopped.

paginate until has_more is false
// The version that actually returns everything.
// Keep asking until has_more is false, carrying the cursor forward.

async function queryAll(dataSourceId) {
  let results = [];
  let cursor = undefined;   // no cursor on the first request
  let hasMore = true;

  while (hasMore) {
    const res = await fetch(
      "https://api.notion.com/v1/data_sources/" + dataSourceId + "/query",
      {
        method: "POST",
        headers: {
          "Authorization": "Bearer " + token,
          "Notion-Version": "2025-09-03",
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          page_size: 100,
          start_cursor: cursor
        })
      }
    );

    const data = await res.json();
    results = results.concat(data.results);
    hasMore = data.has_more;       // false on the final page
    cursor  = data.next_cursor;    // the bookmark for the next page
  }

  return results;
}
What Your Test Saw vs. What Production Held
In testing (40 rows)
One request returns everything
has_more comes back false
Every row spot-checked
Looks completely correct
In production (4,200 rows)
First request returns 100
has_more is true, ignored
4,100 rows never fetched
No error anywhere

Two details decide whether this is correct. First, you loop on has_more, not on whether results came back empty. A full page of exactly 100 rows can still have more behind it, so "the page was not empty" is not a stop condition. When has_more is false, Notion sets next_cursor to null and you are done. Second, start_cursor is undefined on the very first request and the previous next_cursor on every request after. Treat the cursor as opaque. Do not try to read it, build it, or guess the next one. It is Notion's bookmark, not yours.

If you live in n8n rather than raw code, the same wall exists with a friendlier face. The Notion node has a Return All toggle. Leave it off and you get the first page and nothing else, which is the exact bug above wearing a checkbox. Turn it on and the node runs the cursor loop for you. If you are calling Notion through the HTTP Request node instead, you have to set its pagination options yourself: map next_cursor into the next request and stop when has_more is false. The node will not do it unless you tell it to.

The second wall: relations stop at 25

Once you have the query loop right, it is tempting to think you have beaten pagination. You have not, because there is a quieter wall a layer deeper, and it has a different number. When you retrieve a single page and read a relation property straight off it, Notion gives you at most 25 related items. Not 100. Twenty-five.

a relation, silently capped
// GET /v1/pages/{page_id}
// A relation property read straight off a page object.
// This invoice is linked to 60 line items. Watch the count.

{
  "Line Items": {
    "id": "x9f2",
    "type": "relation",
    "relation": [
      { "id": "..." }    // 25 entries come back here. Not 60.
    ],
    "has_more": true     // the tell. Notion stopped at 25.
  }
}

// People properties and @-mentions inside text behave the same way.
// The page object caps related references at 25 and sets has_more.
// No error, no warning. The other 35 are just missing.

An invoice linked to sixty line items returns twenty-five of them on the page object, with has_more set to true right there inside the property. The same cap applies to people properties and to @-mentions buried in text fields. If your automation walks a relation to total something up, or to fan out to every linked record, it is quietly working off the first twenty-five and calling it the whole set. The total is wrong. The fan-out skips records. Nothing errors.

Reading a Relation off the Page vs. Paging the Property
Off the page object
Relation capped at 25
has_more set, easy to miss
People and mentions too
Retrieve a page property item
page through the property endpoint with the same cursor loop and you get all the related rows, not the first 25

The fix is to stop reading the relation from the page object once you suspect a row can have more than twenty-five links. Instead, page through the Retrieve a page property item endpoint, which paginates exactly like a data source query: same results, has_more, and next_cursor.

getting all the related rows
// To get all 60, do not trust the relation on the page object.
// Page through the property item endpoint, same cursor pattern.

async function getAllRelated(pageId, propertyId) {
  let ids = [];
  let cursor = undefined;
  let hasMore = true;

  while (hasMore) {
    const url = "https://api.notion.com/v1/pages/" + pageId +
      "/properties/" + propertyId +
      (cursor ? "?start_cursor=" + cursor : "");

    const res = await fetch(url, {
      headers: {
        "Authorization": "Bearer " + token,
        "Notion-Version": "2025-09-03"
      }
    });

    const data = await res.json();
    ids = ids.concat(data.results.map(function (r) { return r.relation.id; }));
    hasMore = data.has_more;
    cursor  = data.next_cursor;
  }

  return ids; // all 60, not the first 25
}

This connects to something I covered in the Notion database mistakes that break automations. Relations are a safe, writable, reliable part of the model, and you should use them. But reading them at scale has its own pagination on top of the query pagination, and both have to be handled or your data is incomplete in two separate places for two separate reasons.

It is not just data source queries

The reason it pays to learn this loop properly, rather than bolting a fix onto one workflow, is that the exact same pagination governs nearly everything the Notion API hands back as a list.

Every List Endpoint Paginates the Same Way
Paginated, 100 max
Query a data source
Search
List block children
List comments
List users
What they all share
results, has_more, next_cursor. Learn the loop once and it works on every one of them.

Pulling the content of a page means listing its block children, and that is paginated, so a long document past a hundred blocks loses its tail the same way a data source loses its rows. Search is paginated, which catches people building "find every page that matches" features that quietly only find the first hundred matches. Listing users in a workspace, listing comments on a page, retrieving the items of a paginated property: all the same list shape, all the same hundred-item ceiling, all the same cursor.

Nesting makes it worse, and this is the part that bites larger jobs. If you list block children and some of those blocks are toggles or columns with their own children, each of those is another paginated list. A thorough page export is a loop inside a loop inside a loop, every level of which can hand you back a cursor. Miss the cursor at any level and you lose a branch of the tree without a trace.

Pagination runs straight into the rate limit

There is a sting in the tail. The moment you write the loop correctly and start pulling thousands of rows, you stop dropping data and start hitting the rate limit instead. Notion allows around three requests per second on average. Forty pages fetched as fast as your code can fire them will earn you a 429 partway through, and if you are not handling that, you are back to losing data, only now in a new way.

surviving the loop
// Pagination runs straight into the rate limit. Notion allows about
// three requests per second. A tight loop over thousands of rows will
// trip a 429. Handle it instead of crashing or silently dropping data.

async function notionRequest(url, options, attempt) {
  attempt = attempt || 0;
  const res = await fetch(url, options);

  if (res.status === 429 || res.status === 529) {
    // Respect the server. Retry-After is a whole number of seconds.
    const wait = Number(res.headers.get("Retry-After") || 1);
    await sleep(wait * 1000);
    if (attempt < 5) return notionRequest(url, options, attempt + 1);
  }

  return res.json();
}

function sleep(ms) {
  return new Promise(function (r) { setTimeout(r, ms); });
}

// And between pages, a small pause keeps you under the average:
//   await sleep(350);   // roughly three requests per second

A 429 from Notion comes with a Retry-After header, given as a whole number of seconds. Honour it. Wait that long, then retry the exact same request. The occasional 529 means the service is overloaded and should be treated the same way: back off and try again. The thing to never do is let a rate-limited request fall through as if it succeeded, because an empty or errored page in the middle of your loop silently truncates the result just as surely as ignoring the cursor did.

I went deep on this in how API rate limits silently kill automations. The short version for Notion specifically: put a small pause between pages so a big pull naturally stays under three requests per second, and wrap every call so a 429 waits and retries instead of vanishing. Pagination and rate limiting are the same job. You cannot solve one and ignore the other.

The fix, and a pre-flight checklist

Every problem in this post comes from the same root: a Notion list endpoint will happily give you a clean, valid, partial answer, and it is on you to notice it is partial. The fix is to treat has_more as a question you are required to answer every single time, never a field you are allowed to skip.

Before you ship anything that reads from Notion, run through this:

  • Does every list read loop until has_more is false, instead of taking the first response and moving on?
  • Are you passing the previous next_cursor back as start_cursor, and treating it as an opaque value you never construct yourself?
  • For any relation, people property, or @-mention you read at scale, are you paging the property item endpoint rather than trusting the 25 you get off the page object?
  • Did you test against a data source with more than 100 rows, not the small one you built the workflow on?
  • Does a 429 or 529 wait for Retry-After and retry, rather than slipping through as a silent gap in the middle of a paginated pull?
  • If you are in n8n, is Return All actually on, and have you confirmed it rather than assumed it?

None of this is hard. It is just easy to leave out, because leaving it out produces software that looks finished and runs green for as long as the data stays small. The whole craft of building automations that hold up is noticing the failures that do not announce themselves, and a query that stops at a hundred rows is about as quiet as they come.