Add your description here
Project description
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).
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file znotion-0.1.2.tar.gz.
File metadata
- Download URL: znotion-0.1.2.tar.gz
- Upload date:
- Size: 49.6 kB
- Tags: Source
- Uploaded using 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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2983d054095676cc61144f18de4e530928f832413fccab0dec0d619f5c296256
|
|
| MD5 |
7bd06de032a23e6f9be76c38a293fc15
|
|
| BLAKE2b-256 |
5a7011e38652c8297fb1d00f93bcb44978790d786423025bb71f1a0896b6264e
|
File details
Details for the file znotion-0.1.2-py3-none-any.whl.
File metadata
- Download URL: znotion-0.1.2-py3-none-any.whl
- Upload date:
- Size: 34.1 kB
- Tags: Python 3
- Uploaded using 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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4912ae74c113e67176a1e56fccf4801950f0c29344324fa977c5b7db2d3aec46
|
|
| MD5 |
877f8a4c043d82e3e832a30ac3a97e90
|
|
| BLAKE2b-256 |
43aecedd6accc2116b15c6a320f51574810241f5f54b23100852e230f34e1fe5
|