# Four Notion API gotchas the docs don't mention

> A Notion sync layer that read clean in the docs broke four ways in prod: UTF-16 rich_text limits, appending template enums, orphan writes, and a schema split.

**Canonical URL**: https://agentcookbooks.com/blog/notion-api-four-production-gotchas/

**Published**: 2026-05-29

**Tags**: claude-code, api

---

A sync layer that pushes generated artefacts into Notion databases read clean against the docs and broke four different ways in production over about six weeks. None of the four throw where you'd look. Each is a silent-ish failure — an empty page, the wrong enum selected, a schema that never got created, a write that reports success on nothing. Here are all four, with the fix for each, on `notion-client` 2.7.0 / API version `2022-06-28`.

## Gotcha 1: the `rich_text` 2000-char limit counts UTF-16 units, not Python characters

Notion caps a single `rich_text` value at 2000 characters — but it counts UTF-16 code units, not Python `str` length. A supplementary-plane character (most emoji, some CJK) is one Python char and two UTF-16 units. So `text[:2000]` can hand the API a 2001-unit payload when position 2000 lands on such a char, and it's rejected:

```
APIResponseError: body.children[..].rich_text[..].text.content.length
should be ≤ 2000 (validation_error)
```

It only fires on content that actually contains a supplementary-plane char, so it sails through any test suite built on ASCII fixtures and surfaces the first time a real document carries an emoji. The fix is to truncate on UTF-16 units explicitly:

```python
def _truncate_utf16(text: str, limit: int = 2000) -> str:
    units = text.encode("utf-16-le")
    if len(units) // 2 <= limit:
        return text
    return units[: limit * 2].decode("utf-16-le", errors="ignore")
```

Wire it into every block builder — `_paragraph_block`, `_heading_block`, all of them — not just the one that happened to overflow first.

## Gotcha 2: template-picker enums append, they don't replace

If you create a database from a built-in template ("Tasks Tracker", "Project Tracker"), it ships its own Status enum (Done / In progress / Not started). A later `databases.update()` PATCH that defines *your own* Status options **appends** to the existing list — it does not replace it. The grid view then shows both sets, and `pages.create()` can select the wrong "Done" variant.

The rule, if your code owns the enum schema: always create the database as a blank full-page database, never from a template. Once a template's enum is in there, you're deduping options for the life of the database.

## Gotcha 3: the two-phase write that marks empty pages "synced"

In 2.7.0, `pages.create(children=...)` doesn't work — you create the page, then `blocks.children.append()` in batches. The trap is in error handling: if the batch helper logs-and-continues on a per-batch exception, the caller sees a successful `create()` and marks the row `synced=True` even though every block append failed. The result is an empty Notion page that the sync-retry cron then skips forever, because it's already flagged synced.

Fix: collect failed batch offsets and raise at the end of the append helper, let it propagate, and set `synced=True` only on a clean exit. A partial write is a failed write.

## Gotcha 4: `additionalProperties` is mutually exclusive between Gemini and Anthropic

If the same pipeline drives tool-use on more than one model provider, one JSON schema can't serve both:

```jsonc
// Anthropic Messages API — required on every object; omitting it (or true) is rejected
{ "type": "object", "properties": { /* ... */ }, "additionalProperties": false }

// Gemini — rejects any schema that contains the key at all; omit it entirely
{ "type": "object", "properties": { /* ... */ } }
```

Keep two schemas, or keep one canonical schema and post-process to add/strip `additionalProperties` per provider right before send. There's no single value that satisfies both.

## The diagnostic that actually worked

When properties wouldn't create and the SDK gave nothing to go on, the move was to drop under `notion-client` and hit raw HTTP. In 2.7.0, `databases.retrieve()` returns no `properties` key, and `databases.query()` doesn't exist on `DatabasesEndpoint` — the SDK abstracts away the exact surface you need to debug. A bare `requests` call shows what the API actually returns:

```
GET https://api.notion.com/v1/databases/<id>
Notion-Version: 2022-06-28
Authorization: Bearer <token>
```

Each of the four cost roughly 15 minutes to pin once the symptom showed — and a day or more of "but this should work" before the symptom was even legible. The UTF-16 one is the worst of the set: invisible until a real document carries an emoji, which is exactly the input a fixture-based test never includes.