znotion
An async Python SDK for the Notion API, built on
httpx and pydantic v2.
- Async-first: every API call is an
async def, resources share onehttpx.AsyncClient. - Typed models: Pydantic models for pages, databases, blocks, comments, search results, and file
uploads. Unknown fields round-trip via
extra="allow"so new Notion features don't break you. - Auto-pagination: every list endpoint exposes both a single-page method (
*_page) and an async iterator that walks every result. - Typed errors: non-2xx responses raise a
NotionErrorsubclass (auth, not-found, rate-limit, …).
Requires Python 3.14+. Notion API version 2026-03-11.
Scope
In scope: Pages, Databases, Data Sources, Blocks, Comments, Search, File Uploads.
Out of scope (intentionally): the Users API and OAuth endpoints. Authenticate with a Notion internal integration token.
Notion's 2025-09-03+ releases split databases and data sources: a database is a thin container
whose property schema and query endpoint live on its data source(s). client.databases creates,
retrieves and updates the container; client.data_sources handles schema and row queries. Page
parents inside a database use {"type": "data_source_id", "data_source_id": ...} — not
"database_id" — and databases.create(properties=...) is automatically wrapped under
initial_data_source.properties.
Install
uv add znotion
Only uv is supported — pip instructions are not provided. To work against a local checkout,
clone the repo and run uv sync; the package installs in editable mode automatically.
Quickstart
Set NOTION_TOKEN in the environment or in a .env file in the working directory:
# .env
NOTION_TOKEN=ntn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Token resolution order: explicit token= argument → ./.env → os.environ["NOTION_TOKEN"]. A
missing token raises NotionConfigError.
import asyncio
from znotion import NotionClient
async def main() -> None:
notion = NotionClient()
page = await notion.pages.retrieve("PAGE_ID")
print(page.id, page.url)
asyncio.run(main())
Pass token="ntn_..." explicitly to override environment/file lookup:
notion = NotionClient(token="ntn_...")
Reusing a pooled connection
Constructing NotionClient directly as above is the simplest way to use the library: each
request opens and closes its own short-lived httpx.AsyncClient, so there is nothing to clean
up. If you are making more than a handful of requests, use async with instead — it creates a
single pooled httpx.AsyncClient that is reused across every call, which is significantly
faster:
async with NotionClient() as notion:
page = await notion.pages.retrieve("PAGE_ID")
async for row in notion.databases.query("DATABASE_ID"):
print(row.id)
Usage
All examples below assume a notion = NotionClient() bound at module or function scope (wrap
in async with NotionClient() as notion: if you want connection pooling).
Create a page in a database
Notion page creation inside a database targets the data source, not the database container.
If you already have a DatabaseObject, its data_sources[0].id is the id to use:
database = await notion.databases.retrieve("DATABASE_ID")
data_source_id = database.data_sources[0].id
page = await notion.pages.create(
parent={"type": "data_source_id", "data_source_id": data_source_id},
properties={
"Name": {"title": [{"text": {"content": "Hello from znotion"}}]},
"Status": {"select": {"name": "In progress"}},
},
children=[
{
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [{"type": "text", "text": {"content": "First paragraph."}}],
},
},
],
)
Query a data source (auto-pagination)
async for row in notion.data_sources.query(
data_source_id,
filter={"property": "Status", "select": {"equals": "In progress"}},
sorts=[{"timestamp": "created_time", "direction": "descending"}],
):
print(row.id)
Use query_page(...) instead if you want manual cursor control:
page = await notion.data_sources.query_page(data_source_id, page_size=25)
for row in page.results:
print(row.id)
if page.has_more:
next_page = await notion.data_sources.query_page(
data_source_id,
page_size=25,
start_cursor=page.next_cursor,
)
Create a database
properties describes the initial data source schema — znotion wraps it under
initial_data_source.properties for you:
database = await notion.databases.create(
parent={"type": "page_id", "page_id": "PARENT_PAGE_ID"},
title=[{"type": "text", "text": {"content": "Tasks"}}],
properties={
"Name": {"title": {}},
"Status": {"select": {"options": [{"name": "Todo"}, {"name": "Done"}]}},
},
)
# Row inserts and schema edits go through the data source:
data_source_id = database.data_sources[0].id
await notion.data_sources.update(
data_source_id,
properties={"Priority": {"number": {"format": "number"}}},
)
Append blocks
await notion.blocks.append_children(
"PAGE_OR_BLOCK_ID",
children=[
{
"object": "block",
"type": "heading_2",
"heading_2": {
"rich_text": [{"type": "text", "text": {"content": "Section"}}],
},
},
{
"object": "block",
"type": "bulleted_list_item",
"bulleted_list_item": {
"rich_text": [{"type": "text", "text": {"content": "One item"}}],
},
},
],
)
async for block in notion.blocks.children("PAGE_OR_BLOCK_ID"):
print(block.type, block.id)
Search
Notion search returns pages and data sources. The filter.value field accepts "page" or
"data_source":
async for result in notion.search.search(
query="meeting notes",
filter={"property": "object", "value": "page"},
):
print(result.object, result.id)
Create a comment
await notion.comments.create(
parent={"page_id": "PAGE_ID"},
rich_text=[{"type": "text", "text": {"content": "Looks good!"}}],
)
async for comment in notion.comments.list(block_id="PAGE_OR_BLOCK_ID"):
print(comment.id, comment.created_time)
Reply to an existing discussion by passing discussion_id="..." instead of parent=.
Upload a file
upload = await notion.file_uploads.upload_file("path/to/photo.jpg")
await notion.blocks.append_children(
"PAGE_ID",
children=[
{
"object": "block",
"type": "image",
"image": {
"type": "file_upload",
"file_upload": {"id": upload.id},
},
},
],
)
upload_file picks single-part or multi-part mode based on file size (Notion's single-part limit
is 20 MB). For manual control, use file_uploads.create(...), send(...), and complete(...)
directly.
Error handling
Every non-2xx response raises a subclass of NotionError:
from znotion import NotionClient, NotionNotFoundError, NotionRateLimitError
notion = NotionClient()
try:
await notion.pages.retrieve("missing-id")
except NotionNotFoundError as exc:
print("not found:", exc.message, exc.request_id)
except NotionRateLimitError:
...
The full set: NotionValidationError (400), NotionAuthError (401), NotionForbiddenError
(403), NotionNotFoundError (404), NotionConflictError (409), NotionRateLimitError (429),
NotionServerError (5xx), plus NotionConfigError for missing-token errors raised at
construction time.
Contributor setup
uv sync # install deps (and znotion in editable mode)
uv run pytest # unit tests
uv run ruff check znotion tests
uv run pyright
Live integration tests hit the real Notion API and are skipped unless the required env vars are set. Run them explicitly with:
uv run pytest -m live
See tests/test_live_*.py for the env vars each suite expects (at minimum NOTION_TOKEN plus a
scratch page or database id).
Release files for znotion 0.1.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| znotion-0.1.2.tar.gz | 49.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| znotion-0.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 83.7 kB
Release files / znotion-0.1.2.tar.gz
| Download URL | znotion-0.1.2.tar.gz |
|---|---|
| Size | 49.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2983d054095676cc61144f18de4e530928f832413fccab0dec0d619f5c296256
|
|
BLAKE2b-256 checksum How to use checksums |
5a7011e38652c8297fb1d00f93bcb44978790d786423025bb71f1a0896b6264e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.10.4 {"installer":{"name":"uv","version":"0.10.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / znotion-0.1.2-py3-none-any.whl
| Download URL | znotion-0.1.2-py3-none-any.whl |
|---|---|
| Size | 34.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4912ae74c113e67176a1e56fccf4801950f0c29344324fa977c5b7db2d3aec46
|
|
BLAKE2b-256 checksum How to use checksums |
43aecedd6accc2116b15c6a320f51574810241f5f54b23100852e230f34e1fe5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.10.4 {"installer":{"name":"uv","version":"0.10.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|