How to Pull an Entire Notion Database Safely with Cursor‑Based Pagination
Learn how to reliably fetch an entire Notion database with cursor‑based pagination. The article shows a concrete curl example, a Python loop, and tips to avoid duplicates and rate‑limit errors.
27 Sept 2025, 13:00 UTC

Why Pagination Is Mandatory
When you query a Notion database via the API you can retrieve at most 100 rows per request. Even if you set page_size to 200, the server will cap it at 100. That means any database that has more than 100 rows must be fetched in multiple calls. The API uses a cursor‑based system – has_more and next_cursor – to tell you whether another page exists and what token to send for the next request.
Getting the First Page
Use a POST to /v1/databases/{database_id}/query with a JSON body that includes page_size: 100. The response contains:
results– the array of page objectshas_more–trueif there are more rowsnext_cursor– an opaque token you will use asstart_cursorin the next request
Example using curl:
curl -X POST https://api.notion.com/v1/databases/<database_id>/query \
-H "Authorization: Bearer <integration_token>" \
-H "Notion-Version: 2024-08-15" \
-H "Content-Type: application/json" \
-d '{"page_size": 100}'
Iterating Until Completion
Once you have the first response, loop until has_more is false. Each iteration sends the next_cursor from the previous response as start_cursor in the request body.
import requests, json
API_URL = "https://api.notion.com/v1/databases/{}".format(DATABASE_ID)
HEADERS = {
"Authorization": f"Bearer {INTEGRATION_TOKEN}",
"Notion-Version": "2024-08-15",
"Content-Type": "application/json",
}
rows = []
next_cursor = None
while True:
payload = {"page_size": 100}
if next_cursor:
payload["start_cursor"] = next_cursor
resp = requests.post(API_URL, headers=HEADERS, json=payload)
data = resp.json()
rows.extend(data["results"])
if not data.get("has_more"):
break
next_cursor = data.get("next_cursor")
print(f"Fetched {len(rows)} rows")
Avoiding Skips and Duplicates
When the database is being edited concurrently, the order of rows can shift between pages. If you need a deterministic traversal, include a sorts array in every request that orders on a stable property, such as a creation timestamp or an incremental ID. Example:
{
"page_size": 100,
"sorts": [
{"property": "Created", "direction": "ascending"}
]
}
Without a sort, you may see the same row on two pages or miss a row entirely.
Common Pitfalls
- Assuming
next_cursoris a simple string you can parse – it is opaque and must be passed verbatim. - Relying on an empty
resultsarray to stop the loop – onlyhas_moreindicates whether more pages exist. - Not sharing the integration with the database – the API will return a 403 instead of partial data.
- Ignoring rate limits – tight loops over many pages can hit 429 responses. Implement exponential back‑off or a delay between requests.
- Using an incorrect
Notion-Versionheader – the integration will fail if the header is missing or points to a deprecated API version.
Limitations & Verification
Pagination is stable across recent API versions, but the shape of the response is still subject to change. Pin the Notion-Version header to a known working date (e.g., 2024-08-15) and test against a test database that contains more than 100 rows. After running the loop, compare the total row count returned with the count shown in the Notion UI to confirm no rows were missed or duplicated.
Practical Checklist
- Share the integration with the target database and grant read access.
- Set
page_sizeto 100 (the maximum). - Include a
sortsarray if concurrent edits are expected. - Loop using
has_moreandnext_cursoruntilhas_moreisfalse. - Implement back‑off for 429 responses.
- Verify the final count against the database UI.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.