Write a Row to the Notion API with Only Python's Standard Library
"I just want to record a single row in a Notion database once the process finishes." Setting up a virtual environment (venv) and running pip install requests just for that feels a bit excessive. Moreover, in automated scripts, it introduces a potential point of failure if the script runs on a machine where the library isn't installed.
I built a script using only the standard library's urllib to log a review task to Notion whenever an automated run fails. In this article, I'll explain how to set it up, using a household chore example (a shopping list item).
Why I Chose the Standard Library
The official Python documentation recommends the requests package as a more feature-rich HTTP client. However, all we need to send to the Notion API is a single JSON POST request. Because urllib.request is more than enough for this, I chose the standard library for the following reasons:
- No installation required. As long as Python is installed, it runs out of the box on any machine.
- Resilient in unattended execution. The fewer dependencies you have, the less likely it will fail one day due to a missing library.
- Doesn't invoke an AI on every call. For routines that run frequently, such as failure notifications, keeping them as structured API calls keeps things lightweight and avoids extra costs.
Workflow for Writing to Notion
- 1. IntegrationCreate it and obtain the token
- 2. ShareUse "Add connections" on the target
- 3. Store the tokenIn an environment variable or .env, not in code
- 4. POSTCreate a page with urllib
Preparing the Token
The official Notion documentation explains how to create an internal integration: create a new connection in the developer portal, then retrieve the API token from the "Configuration" tab (workspace owner permissions are required). Additionally, Notion officially provides "personal access tokens" that operate under your own user permissions.
Next, open the target page or database and select the connection you created from "Add connections" in the top-right menu. The official documentation notes that without this sharing step, the connection cannot interact with the page.
This is where I once got stuck. When I reused an existing token for another purpose, the newly targeted page hadn't been shared yet, causing it to fail silently with a 404 error. I have detailed this in Notion API object_not_found (404): What to Check First.
Regarding token management, the official documentation recommends reading it from an environment variable rather than storing it in source code or version control. In my setup, I write a single line like NOTION_TOKEN=… in a .env file located in the same directory as the script, which the script then reads. Note that Python's os.environ does not read .env files automatically. The code below falls back to reading .env with a few lines if the environment variable isn't set. If you use a synced folder or Git, don't forget to exclude .env.
Minimal Code to Create a Single Page
The endpoint for creating a page is POST /v1/pages. The request requires an Authorization: Bearer token and a Notion-Version header. According to the official documentation, Notion-Version is required on all requests, with the latest version as of October 2026 being 2026-03-11.
The following example adds a single shopping item row to a database with two properties: "Name" and "Date" (property names are examples; adjust them to match your own database schema).
import json
import os
import urllib.error
import urllib.request
from pathlib import Path
NOTION_VERSION = "2026-03-11"
DATA_SOURCE_ID = "YOUR_DATA_SOURCE_ID"
def load_token():
"""Read NOTION_TOKEN from the environment, or from .env next to this script"""
token = os.environ.get("NOTION_TOKEN")
if token:
return token
env_file = Path(__file__).with_name(".env")
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
if line.startswith("NOTION_TOKEN="):
return line.split("=", 1)[1].strip()
raise SystemExit("NOTION_TOKEN not found")
TOKEN = load_token()
payload = {
"parent": {"type": "data_source_id", "data_source_id": DATA_SOURCE_ID},
"properties": {
"Name": {"title": [{"type": "text", "text": {"content": "Buy milk"}}]},
"Date": {"date": {"start": "2026-10-20"}},
},
}
req = urllib.request.Request(
"https://api.notion.com/v1/pages",
data=json.dumps(payload).encode("utf-8"),
method="POST",
headers={
"Authorization": f"Bearer {TOKEN}",
"Notion-Version": NOTION_VERSION,
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
print("Created successfully:", resp.status)
except urllib.error.HTTPError as e:
print("Failed: HTTP", e.code, e.read().decode("utf-8", errors="replace"))
raise SystemExit(1)
except OSError as e:
print("Connection failed:", e)
raise SystemExit(1)
There are three key points:
- Pass the body as bytes. According to the official Python documentation,
Requestdata must be a byte sequence. We encode it usingjson.dumps(...).encode("utf-8"). - Specify the method explicitly. While omitting it defaults to POST when data is provided, being explicit improves readability.
- Set a timeout. In unattended execution, this prevents the script from hanging indefinitely if the connection stalls.
Specifying the "Parent" When Adding a Row to a Database
Notion's official upgrade guide explains that API version 2025-09-03 introduced data sources, and tells you to switch the parent to data_source_id when adding a row to a database. The page creation reference still lists database_id as a parent type, but following the guide and using data_source_id is the safe choice. In the older version (2022-06-28), you used database_id.
| Notion-Version | Parent when adding a row to a database |
|---|---|
| 2022-06-28 | {"database_id": "…"} |
| 2025-09-03 and later | {"data_source_id": "…"} (per the official guide) |
You can retrieve the data source ID from the data_sources list returned by the retrieve database API, or via "Manage data sources" > "Copy data source ID" in the database settings within Notion. Do not confuse this with the ID extracted from the URL, which is the database ID. To differentiate IDs from URLs, you can use the Notion ID Finder.
If you are creating a page as a child of a standard page, the parent is specified with page_id, and the only property you can specify is title (as noted in the official reference).
Reading Error Details
When a request fails, an HTTPError is raised. The official Python documentation notes that this exception can also function as a response instance, allowing you to read the body with read(). In the official Notion documentation, the error response body contains code and message. The HTTP status code (such as 404) can be retrieved via e.code.
except urllib.error.HTTPError as e:
body = json.loads(e.read().decode("utf-8", errors="replace"))
print(e.code, body.get("code"), body.get("message"))
| Status | code | Official description |
|---|---|---|
| 400 | validation_error | The request body does not match the expected schema. Check the message. |
| 401 | unauthorized | The token is invalid. |
| 404 | object_not_found | The resource does not exist for this token, or it has not been shared. |
| 429 | rate_limited | Too many requests. Retry after a backoff period. |
The example above exits with code 1 on failure. My actual script, though, is an add-on that reports failures of other automated runs, so it doesn't stop when the write fails; it just prints the status and response body to standard error (sys.stderr). However, letting write failures pass silently means you might not notice them for days. I discuss mechanisms for catching automated execution failures in Catch Task Scheduler Failures: Check Your Automated Runs Every Morning.
Summary
- Writing to the Notion API is just a single JSON POST request.
urllib.requestis all you need—no pip or venv required. - Create an integration first, then share the target using "Add connections". Keep your token in an environment variable or
.envfile, never hardcoded. - The required headers are
Authorization: BearerandNotion-Version. When adding a row to a database in API version 2025-09-03 or later, specifydata_source_id, as the official guide instructs. - When a request fails, reading the
HTTPErrorbody (codeandmessage) reveals the cause.