Reducing Notion API Payloads with Server-Side Filters and Cursors
Use Notion's nested filter blocks and start_cursor pagination to fetch only the data your integration needs, reducing requests and rate-limit risk.
21 Nov 2025, 11:12 UTC

When your Notion sync pulls an entire database just to find a few overdue tasks, you waste request time, memory, and rate-limit budget. Filtering after the response arrives shifts the work to your client and increases the chance of hitting Notion's per-token limits.
Filter on the server, paginate with cursors
Notion's POST /databases/{database_id}/query endpoint accepts a JSON filter object that runs server-side. Paired with cursor-based pagination, this returns only matching pages in batches, so you do not skip or duplicate records if rows are added or deleted during the fetch.
Nested filter logic
Filters use and and or blocks containing condition objects. Each condition tests a property such as a checkbox, date, or select field. The API evaluates these conditions before sending data, so unrelated pages never leave Notion's servers.
A filter can require Status to equal In Progress and Due Date to be on or after a specific date. Nesting an or block inside an and block supports more complex queries without fetching extra rows.
Cursor pagination
Notion does not support offset-based pagination. Responses include has_more and, when true, a next_cursor string. Pass that string as start_cursor in the next request. The pointer remains valid even if the database changes while you are fetching.
Worked example: Fetching overdue tasks in batches
The following example uses Python 3 and the requests library. Run it locally. Replace {NOTION_TOKEN} and {DATABASE_ID} with your credentials. The integration token must have read access to the database.
import requests
NOTION_TOKEN = "{NOTION_TOKEN}"
DATABASE_ID = "{DATABASE_ID}"
BASE_URL = "https://api.notion.com/v1/databases/" + DATABASE_ID + "/query"
HEADERS = {
"Authorization": "Bearer " + NOTION_TOKEN,
"Notion-Version": "2022-06-30",
"Content-Type": "application/json",
}
FILTER = {
"and": [
{
"property": "Status",
"select": { "equals": "In Progress" }
},
{
"or": [
{
"property": "Due Date",
"date": { "on_or_after": "2026-11-01" }
},
{
"property": "Due Date",
"date": { "is_empty": true }
}
]
}
]
}
def fetch_all(cursor=None):
body = {"filter": FILTER, "page_size": 100}
if cursor:
body["start_cursor"] = cursor
response = requests.post(BASE_URL, headers=HEADERS, json=body)
response.raise_for_status()
data = response.json()
for page in data["results"]:
yield page
if data.get("has_more"):
yield from fetch_all(data.get("next_cursor"))
# Required permissions: read access to the target database.
# Risk: each page of results counts toward your integration's rate limit.
# Not verified against live Notion endpoints.
Expected checks: A successful response returns HTTP 200 with a results array and either next_cursor or has_more: false. Compare the returned count against the Notion UI using the same conditions. To test pagination, add more than 100 matching items and confirm the loop retrieves every batch without duplicates.
Limitations
- Notion's API is eventually consistent; recent writes may not appear immediately.
- Database schemas are dynamic. Renaming or retyping a property breaks hard-coded filter keys.
- Relation and Rollup properties return IDs only, not full linked objects. Secondary
/pages/{page_id}requests increase call volume. - Rate limits apply per integration token. High-frequency polling requires exponential backoff.
Practical verification
- Run the filter against a database with known results.
- Count returned pages and compare with the Notion UI.
- Create more than 100 matching pages and verify
next_cursorretrieves the second batch. - Monitor for HTTP 429 responses and add retry logic if they occur.
Actionable step
Wrap the query in a function that handles next_cursor automatically. Apply the narrowest filter possible before requesting data, and cache relation IDs within a single sync cycle to avoid redundant secondary fetches.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.