Notion REST API — Direct wrapper for batch operations
When working with Notion programmatically, use the REST API (not the MCP) for any batch-shaped work. MCP implementations (like notion-search) typically have hard limitations (25-result caps, no real pagination) that make batch operations tedious. The REST API handles 100+ rows in seconds with auto-pagination.
When to use REST API vs MCP
| Operation | Use |
|---|---|
| Read 1 page, update 1 page, search for a page | MCP OK |
| Add a comment, create a single inline note | MCP OK |
| Query an entire database (any size), with filters and pagination | REST API |
| Batch update N pages (rename, retag, restructure) | REST API |
| Audit/scan/inventory a database | REST API |
| Replicate page body blocks | REST API |
| Upload files/images programmatically | REST API (MCP cannot) |
| Create a new database programmatically | REST API |
| Move pages by re-creating under a new parent | REST API |
| Any operation touching ≥3 rows | REST API |
If you find yourself calling MCP twice for the same data, switch to the REST API.
Python wrapper pattern
Create a thin self-contained wrapper around the Notion REST API. This wrapper should:
- Handle authentication via environment variable (e.g.,
NOTION_API_KEYorNOTION_TOKEN) - Provide auto-pagination for list endpoints (use
start_cursor/has_moreloops) - Include retry logic with exponential backoff for rate limits (HTTP 429) and transient errors (5xx)
- Throttle writes to respect Notion's published rate limit (~3 req/s)
- Expose CRUD functions for pages, databases, blocks, and comments
- Include property builders for clean schema construction (title, rich_text, select, date, etc.)
- Avoid external dependencies — use Python stdlib only (e.g.,
urllib,json,time)
Canonical imports (your wrapper should export)
# Core CRUD
get_page, create_page, update_page, archive_page
get_database, create_database, update_database
# Querying (query_db_all should auto-paginate)
query_database, query_db_all
# Blocks
get_block, list_block_children, list_block_children_all
append_block_children, update_block, delete_block
# Search and metadata
search, search_all, list_users_all, get_user, whoami
create_comment, list_comments_all
# File uploads (for image/attachment re-hosting)
file_upload_create, file_upload_send, file_upload_complete
# Property builders (clean payloads)
title_prop, rich_text_prop, select_prop, multi_select_prop
status_prop, number_prop, checkbox_prop, date_prop, url_prop
relation_prop, people_prop, files_prop
# Block builders
paragraph_block, heading_block, bulleted_list_item, numbered_list_item
to_do_block, code_block, divider_block, callout_block, quote_block
# Text helpers
title_text, rich_text_text
# Custom exceptions
NotionAPIError
Authentication
Read the token from an environment variable (e.g., NOTION_API_KEY or NOTION_TOKEN). Allow per-call override via function parameter:
import os
token = os.getenv("NOTION_API_KEY")
# or pass token="Bearer secret_..." to any function call
Store the token securely outside the codebase (environment, credential manager, or vault).
Canonical patterns (copy-paste-modify)
1. Iterate every page in a database
for page in query_db_all("DATABASE_ID"):
# page is the full Notion page object
# Access properties via page["properties"]
print(page["id"])
2. Read text property values cleanly
def get_plain_text(page, prop_name):
"""Extract plain text from title or rich_text property."""
p = page["properties"].get(prop_name, {})
if p.get("type") == "title":
return "".join(t.get("plain_text", "") for t in p.get("title", []))
return "".join(t.get("plain_text", "") for t in p.get("rich_text", []))
3. Batch update a property across many pages
from notion_api import query_db_all, update_page, select_prop, rich_text_prop
for page in query_db_all("DATABASE_ID"):
if get_plain_text(page, "Status") == "Stale":
update_page(page["id"], {
"Status": select_prop("Archived"),
"Notes": rich_text_prop("Auto-archived"),
})
The wrapper should throttle writes automatically (e.g., ~3 req/s).
4. Create a page with properties copied from another
import copy
def clean_property(value):
"""Strip read-only fields (id, plain_text, href) so the payload is valid."""
ptype = value["type"]
if ptype in ("title", "rich_text"):
items = []
for it in value.get(ptype, []):
it2 = copy.deepcopy(it)
it2.pop("plain_text", None)
it2.pop("href", None)
items.append(it2)
return {ptype: items}
raise ValueError(f"Handle property type {ptype!r}")
src_page = get_page("SOURCE_PAGE_ID")
props = {n: clean_property(v) for n, v in src_page["properties"].items()}
new_page = create_page(parent={"database_id": "TARGET_DATABASE_ID"}, properties=props)
5. Replicate page body (blocks)
import copy
src_blocks = list(list_block_children_all("SOURCE_PAGE_ID"))
clean = []
for b in src_blocks:
if b["type"] == "image":
# Images with signed S3 URLs must be re-uploaded (see pattern #6)
continue
btype = b["type"]
payload = {"type": btype, btype: copy.deepcopy(b[btype])}
# Strip plain_text and href from rich_text
inner = payload[btype]
if isinstance(inner, dict) and "rich_text" in inner:
for rt in inner["rich_text"]:
rt.pop("plain_text", None)
rt.pop("href", None)
clean.append(payload)
append_block_children("TARGET_PAGE_ID", clean)
6. Re-upload an image block (signed URLs expire in ~1 hour)
import urllib.request
src_url = src_blocks[i]["image"]["file"]["url"] # signed, expires soon
img_bytes = urllib.request.urlopen(
urllib.request.Request(src_url, headers={"User-Agent": "Mozilla/5.0"})
).read()
fu = file_upload_create(mode="single_part", filename="image.png", content_type="image/png")
file_upload_send(fu["id"], img_bytes, filename="image.png", content_type="image/png")
append_block_children("TARGET_PAGE_ID", [{
"type": "image",
"image": {"type": "file_upload", "file_upload": {"id": fu["id"]}, "caption": []},
}])
7. Create a new database under a page
new_db = create_database(
parent={"type": "page_id", "page_id": "PARENT_PAGE_ID"},
title=[{"type": "text", "text": {"content": "My Database"}}],
properties={
"Name": {"title": {}},
"Notes": {"rich_text": {}},
"Status": {"select": {"options": [{"name": "Open"}, {"name": "Done"}]}},
"Date": {"date": {}},
},
)
print(new_db["url"])
Known gotchas and workarounds
paragraph.icon: null causes 400 when replaying blocks
Issue: Fetching a paragraph block may return "icon": null. Replaying it via append_block_children fails:
body.children[0].paragraph.icon should be an object or undefined, not null
Fix: Strip icon and any other null-valued fields from payloads before sending.
Cannot move a database via API
Issue: PATCH /databases/{id} rejects parent updates:
Parent must be a database_id when provided
Workaround: Create a new database under the target parent, copy all pages, then archive the old database. Even 1000+ rows copy in seconds with auto-pagination.
Image blocks with signed S3 URLs expire quickly
Issue: Fetched image blocks use signed S3 URLs valid for ~1 hour. If you want to replicate the image elsewhere, the URL will expire before you embed it.
Fix: Download the image bytes immediately, then re-upload via file_upload_create and file_upload_send at the destination.
Property updates silently ignored in some MCP implementations
Issue: Some MCP update_page commands ignore the properties field depending on other parameters.
Lesson: Use the REST API directly when reliability matters. Wrappers tend to be thinner and less surprising than MCP abstractions.
Returned properties include read-only fields
Issue: get_page includes id, plain_text, and href in rich_text/title items. Echoing these back to create_page fails.
Fix: Use the helper functions (title_prop, rich_text_prop) which return clean payloads. If replicating manually, strip read-only fields as shown in pattern #4.
Rate limiting is real and should be respected
Published limit: ~3 requests/second.
Best practice: Auto-throttle writes (e.g., 0.3–0.4 seconds between calls) and implement exponential backoff for HTTP 429 and 5xx responses. For large batches (1000+ writes), expect 5–10 minutes total.
Comparison with MCP approach
| Aspect | REST API + Wrapper | MCP |
|---|---|---|
| Pagination | Auto-paginate internally | Hard-coded result cap (often 25) |
| Batch updates | Fast, single function call | Multiple calls required |
| File uploads | Supported | Usually not |
| Rate limiting | Built-in throttle + retry | User's responsibility |
| Code complexity | Slight — thin wrapper | Low — use existing API |
| Best for | Bulk/audit/migration work | Exploratory/one-off operations |
Summary
Use a REST API wrapper (custom or from a library) for all batch-shaped Notion work. It eliminates the MCP pagination bottleneck and provides reliable retry logic. Single-page CRUD and exploration are fine with MCP; everything else scales better with the REST API.