Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use Notion's REST API directly for batch database operations (querying, updating many rows, pagination, file uploads, creating databases). Auto-load when tasks mention Notion AND batch operations like "database", "update many", "iterate pages", "audit", "scan", or touch ≥3 rows. Prefer MCP (Notion search/create) for single-page CRUD only.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 59% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 42% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 67% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 85% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 129% | 0% |
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.
| 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.
Create a thin self-contained wrapper around the Notion REST API. This wrapper should:
NOTION_API_KEY or NOTION_TOKEN)start_cursor / has_more loops)urllib, json, time)python# 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
Read the token from an environment variable (e.g., NOTION_API_KEY or NOTION_TOKEN). Allow per-call override via function parameter:
pythonimport 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).
pythonfor page in query_db_all("DATABASE_ID"): # page is the full Notion page object # Access properties via page["properties"] print(page["id"])
pythondef 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", []))
pythonfrom 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).
pythonimport 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)
pythonimport 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)
pythonimport 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": []}, }])
pythonnew_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"])
paragraph.icon: null causes 400 when replaying blocksIssue: 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.
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.
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.
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.
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.
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.
| 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 |
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.
Other measured skills in the registry, with their headline benchmark lift.