📓

型安全で高速開発!最新版Notion API対応Python SDK「notion-py-client」の紹介

に公開

はじめに

株式会社neoAIで学生AIエンジニアをしております、東 真史です。

弊社では取引や請求書などの営業データやナレッジを全てNotionに集約して管理しており、特に営業関連のデータはNotionのDatabaseをデータベースとして利用しています。

今回はNotion上のデータ処理を自動化することで、業務を効率化した際に開発したSDKであるnotion-py-clientについてご紹介いたします。

https://pypi.org/project/notion-py-client/

この記事の前提とゴール

  • 前提: Notion API を「Python」から本番運用レベルで扱いたい開発者向けの記事です。
  • 課題: TypeScript には公式 SDK がある一方、Python には型安全な SDK が存在せず、補完・型チェック・保守性に痛みがあること。
  • 解決: そのギャップを埋めるための Python 向け SDK「notion-py-client」を紹介します。
  • ゴール: 型安全なレスポンス/リクエスト、強力な補完、薄いORMヘルパーによる実装速度と保守性の両立。

こんな課題、ありませんか?

  • page["properties"]["Title"]["title"][0]["plain_text"] のような深いネストに毎回悩む
  • どのプロパティが nullable か毎回ドキュメントを開いて確認している
  • dict[str, Any] だらけで補完が効かず、typo や KeyError が埋もれる
  • フィルタやソートのJSONを書いては試し直す反復がつらい
  • ページネーションや 429(Rate Limit)のリトライ処理を毎回手書きしている
  • Relation / Multi-Select / Rollup / Formula の取り回しで実装が壊れがち
  • 日付とタイムゾーンの往復変換でバグが出やすい
  • Notion側のスキーマ変更や API アップデートの追随が後手に回る

notion-py-client は、Python から Notion API を扱う際の「型不在」「実装負荷」「運用追随」の痛みをまとめて減らし、型安全で素早く実装できる開発体験を目指して作りました。

インストール

以下のいずれかで導入できます。

  • pip
pip install notion-py-client
  • uv
uv add notion-py-client
  • Poetry
poetry add notion-py-client

SDK開発の背景(なぜHTTP直叩きではダメだったのか?)

「Notion API、PythonでもHTTP叩けば使えるよね?」
実際、私も最初は requests + JSON で十分だと思っていました。

しかし業務で本格的に使おうとすると、次のような問題にすぐ直面します:

  • properties の型定義(Title / Relation / Date / Formula…)が多く複雑
  • page["properties"]["Title"]["title"][0]["plain_text"] のようなコードが乱立
  • どのプロパティが nullable なのか毎回ドキュメントを確認しなければならない
  • APIレスポンスに型がないので補完も効かず、typoやKeyErrorに気づけない

つまり「SDKがない」こと自体が問題ではなく、
「型情報のないままNotion APIを使うことが業務開発では破綻する」
と感じたことが出発点でした。

Notion APIのSDKの現状

NotionのAPIに対応した公式のSDKは現状TypeScriptで書かれたnotion-sdk-jsしか存在せず、Pythonではnotion_clientのような非公式のものはあるものの、公式のPython SDKは存在しないというのが現状です。
本当はTypeScriptで開発を行いたかったのですが、システムを他のメンバーに引き継ぐ観点からPythonでの開発が必須となっていました。

既存のPython SDK(notion-client)の立ち位置と限界

Python向けには notion-client(パッケージ名)、notion_client(インポート名)という非公式ライブラリが存在します。
ただしこれは「HTTPラッパー」としては便利な一方で、次のような課題があります:

項目 内容
型情報 すべて dict[str, Any] として返される
補完 VS Codeなどでほぼ効かない
Property構造の知識 開発者がドキュメントを見て自力で解釈する必要がある
API追従 TypeScript版ほど迅速ではない

つまり、「HTTPの面倒を省く」ライブラリではあるが、「型安全な開発体験」を提供する SDK ではないと言えます。

型安全で最新版のAPIに対応したSDKの必要性

本来SDKとは、開発者にとって、API仕様について詳細に調べることなく必要な処理を記述するための補助を行うものであると私は考えています。
これにより、開発者は外部APIとの通信処理を簡潔に記述でき、本来集中するべき点であるアプリケーションのロジックの実装に時間を割くことができるのだと思います。
しかし、今のnotion_clientのようなSDKでは、そもそもNotionのAPI仕様について調べるだけで多大な時間を費やしてしまいます。さらに、レスポンスのパースロジックが散らばって、メンテナンス性が非常に低く、どのようなデータを受け取るのか見てもわからないコードが完成します。(これはNotionのAPI仕様をAIに与えてコーディングさせたときに顕著に現れます)

from pydantic import BaseModel
from notion_client import AsyncClient

notion = AsyncClient()

class Task(BaseModel):
    name: str
    due_date: str
    completed: bool

async def get_page(page_id: str) -> Task:
    page = await notion.pages.retrieve(page_id=page_id)
    # pageのpropertiesをパースする
    return Task(
        name=page["properties"]["Name"]["title"][0]["plain_text"],
        due_date=page["properties"]["Due Date"]["date"]["start"],
        completed=page["properties"]["Completed"]["checkbox"],
    )

AIにコーディングさせるにしても、レスポンスがどのような構造を持っているかの型情報を持っていることは非常に重要だと考えて、SDKの開発に至りました。

notion-py-clientの設計思想

今回私がこのNotion SDKを開発する上で意識した点は以下の2点です。

  1. できる限り公式のTypeScript SDKで定義されている型をカバーする
  2. NotionのデータベースをRDBのように扱うことができるORMとして使えるようにする

1点目についてはGitHub CopilotやClaudeなどのAIコーディング支援ツールを活用して、TypeScript公式SDKのソースコードを参照させることで効率的に型定義を進めました。
2点目については、SDKとしての機能を邪魔しないように、薄いヘルパー関数としてORM機能を実装することで、単にSDKとして使いたい開発者、NotionのデータベースをRDBのように扱いたい開発者どちらもが使いやすいSDKとなるように心がけました。

notion-py-clientの導入による開発体験の向上

このライブラリの導入により、システムのソースコードは簡潔になり、アプリケーションロジック実装のためのドメインモデルへのマッピングがスムーズになりました。

TypeScript互換の型補完による開発者フレンドリーな開発体験

まず、レスポンスタイプが明確に定義されることで、APIの詳細な仕様を知ることなく、必要なデータを取得するリクエストを記述して、必要なデータをパースする処理を簡潔に記述できるようになりました。

from notion_py_client import NotionAsyncClient
from notion_py_client.guards import is_full_page

notion_client = NotionAsyncClient(auth="fake_api_key")

response = await notion_client.dataSources.query(
    data_source_id="fake_datasource_id",
    page_size=1,
    filter={
        "property": "Status",
        "select": {"equals": "Active"},
    },
)
for result in response.results:
    # resultにも型がつく
    if is_full_page(result):
        for item in result.properties.values():
            if item.type == "relation":
                # Type Guardにより、RelationPropertyの型に絞り込まれる
                relation_ids = [rel.id for rel in item.relation] # relationがlist形式であることを知らなくても記述できる
            elif item.type == "people":
                # Type Guardにより、PeoplePropertyの型に絞り込まれる
                people = [person.id for person in item.people]

ヘルパー関数によるスキーマ定義とORMとしての利用

SDKとしての利用を妨げない程度の薄いヘルパー関数を実装することで、Notionのデータベースをスキーマ定義して、そのORMとしての機能を簡単に実装することができるようになりました。

マッパーの実装の詳細を見る
mapper.py
from notion_py_client.helper import NotionMapper, NotionPropertyDescriptor, Field
from notion_py_client.properties import (
    RelationProperty,
    SelectProperty,
    FormulaProperty,
    NumberProperty,
    DateProperty,
    TitleProperty,
    MultiSelectProperty,
    PeopleProperty,
    EmailProperty,
    NotionPropertyType,
)
from src.domain.entities import EnterpriseResource

# Domain Modelをgenericsで受け取ることで型補完が効いたMapperになる
class EnterpriseResourceNotionMapper(NotionMapper[EnterpriseResource]):
    assignee_field: NotionPropertyDescriptor[
        PeopleProperty, PeoplePropertyRequest, str | None
    ] = Field(
        notion_name="Assignee",
        parser=lambda p: p.people[0].id if len(p.people) > 0 else None,
    )
    date_range_field: NotionPropertyDescriptor[
        DateProperty, DatePropertyRequest, DateRange
    ] = Field(
        notion_name="DateRange",
        parser=lambda p: DateRange(
            start_date=p.get_start_date(),
            end_date=p.get_end_date(),
            time_zone=p.date.time_zone if p.date and p.date.time_zone else None,
        ),
        request_builder=lambda v: DatePropertyRequest(
            date=DateRequest(
                start=v.start_date.isoformat(),
                end=v.end_date.isoformat(),
                time_zone=v.time_zone if v.time_zone else None,
            )
        ),
    )
    project_range_field: NotionPropertyDescriptor[
        MultiSelectProperty, MultiSelectPropertyRequest, list[str]
    ] = Field(
        notion_name="ProjectRange",
        parser=lambda p: (
            [select.name for select in p.multi_select]
            if len(p.multi_select) > 0
            else []
        ),
        request_builder=lambda v: MultiSelectPropertyRequest(
            multi_select=[SelectPropertyItemRequest(name=name) for name in v]
        ),
    )

    def to_domain(self, notion_page: NotionPage) -> EnterpriseResource:
        props = notion_page.properties
        assignee_prop = props[self.assignee_field.notion_name]
        if not assignee_prop.type == "people":
            raise ValueError("Assignee is missing")
        date_range_prop = props[self.date_range_field.notion_name]
        if not date_range_prop.type == "date":
            raise ValueError("Date Range is missing")
        project_range_prop = props[self.project_range_field.notion_name]
        if not project_range_prop.type == "multi_select":
            raise ValueError("Project Range is missing")

        return EnterpriseResource(
            id=notion_page.id,
            assignee=self.assignee_field.parse(assignee_prop),
            date_range=self.date_range_field.parse(date_range_prop),
            project_range=self.project_range_field.parse(project_range_prop),
        )

    def build_create_properties(
        self, data_source_id: str, model: EnterpriseResource
    ) -> CreatePageParameters:
        ...

    def build_update_properties(
        self, model: EnterpriseResource
    ) -> UpdatePageParameters:
        ...

    def build_update_project_range_properties(
        self, id: str, project_range: list[str]
    ) -> UpdatePageParameters:
        return UpdatePageParameters(
            page_id=id,
            properties={
                self.project_range_field.notion_name: self.project_range_field.build_request(
                    project_range
                ),
            },
        )

Notionデータベースのpandasデータフレームへのスムーズな変換

既存のライブラリではそれぞれのデータベースプロパティごとにパースロジックを記述して、すべてのプロパティを網羅する必要がありましたが、この型定義やparseロジックをSDK化することで以下のように非常に簡潔なコードでpandasデータフレームへと変換できます。

pandasへの変換コード
from logging import getLogger

import pandas as pd
from notion_py_client import NotionAsyncClient
from notion_py_client.responses.page import NotionPage
from notion_py_client.utils import is_full_page
from notion_py_client.properties import NotionPropertyType

# 抽象リポジトリによる疎結合の実現
from src.core.domain.repositories import INotionDatabaseRepository


class NotionDatabaseRepository(INotionDatabaseRepository):
    def __init__(self, notion_client: NotionAsyncClient):
        self._notion_client = notion_client
        self._logger = getLogger(__name__)

    async def get_all_records(self, data_source_id: str) -> pd.DataFrame:
        self._logger.info(f"Getting all records from data source: {data_source_id}")
        next_cursor = None
        valid_records: list[NotionPage] = []
        all_records_count = 0
        while True:
            response = await self._notion_client.dataSources.query(
                data_source_id=data_source_id,
                start_cursor=next_cursor,
            )
            all_records_count += len(response.results)
            for record in response.results:
                if record.object != "page":
                    self._logger.warning(
                        f"Skipping non-page object in data source: {record.object}"
                    )
                    continue
                if is_full_page(record):
                    valid_records.append(record)
                else:
                    self._logger.warning(
                        f"Skipping partial page in data source: {record.id}"
                    )
            if not response.has_more:
                break
            next_cursor = response.next_cursor
        self._logger.info(f"Valid records: {len(valid_records)}/{all_records_count}")

        # プロパティの値を取得してDataFrameに変換
        data = []
        for record in valid_records:
            row = {}
            row["id"] = record.id
            for key, property_obj in record.properties.items():
                # get_display_valueでNotionデータベース上でのpropertyの表示値を取得できる
                row[key] = property_obj.get_display_value()
            data.append(row)

        # DataFrameに変換
        df = pd.DataFrame(data)
        self._logger.info(f"Created DataFrame with shape: {df.shape}")
        return df

これらの使い方の詳細についてはライブラリのドキュメントを参照してください。

https://higashi-masafumi.github.io/notion-py/

AI Codingの促進

ライブラリレベルで型を定義しておくことで、IDEでの型エラーをコーディングAIが検知することで、AIが正しくコーディングするように補助します。

以下はIUserRepositoryを実際に実装してもらった例です。

i_user_repository.py
from abc import ABC, abstractmethod
from domain.entities import User

class IUserRepository(ABC):
    @abstractmethod
    async def get_all_users(self) -> list[User]:
        pass

これをnotion上にuserのデータベースが存在することを想定して具体実装を行なってください。

Notion APIを使った具体実装
notion_user_repository.py
from notion_py_client import NotionAsyncClient, NotionPage
from notion_py_client.utils import is_full_page
from domain.entities import User
from logging import getLogger

class NotionUserRepository(IUserRepository):
    """
    Notion APIを使用してUserデータベースからデータを取得・作成するリポジトリ

    想定されるNotionデータベーススキーマ:
    - Name: Title型 (ユーザー名)
    - Email: Email型 (メールアドレス)
    """

    def __init__(self, notion_client: NotionAsyncClient, datasource_id: str):
        """
        Args:
            notion_client: Notion APIクライアント
            datasource_id: NotionのデータソースID
        """
        self._notion_client = notion_client
        self._datasource_id = datasource_id
        self._logger = getLogger(__name__)

    async def get_all_users(self) -> list[User]:
        """全てのユーザーを取得する

        Returns:
            ユーザーのリスト
        """
        self._logger.info(f"Getting all users from Notion data source: {self._datasource_id}")

        all_users: list[User] = []
        total_count = 0
        next_cursor = None

        while True:
            # データソースにクエリを実行
            response = await self._notion_client.dataSources.query(
                data_source_id=self._datasource_id,
                start_cursor=next_cursor,
            )

            # 各結果をパース
            for result in response.results:
                total_count += 1

                # ページオブジェクトのみを処理
                if result.object != "page":
                    self._logger.warning(f"Skipping non-page object: {result.object}")
                    continue

                # 完全なページのみを処理
                if is_full_page(result):
                    try:
                        user = self._parse_user_from_page(result)
                        all_users.append(user)
                    except Exception as error:
                        self._logger.warning(f"Failed to parse user. page_id={result.id}, error={error}")
                        continue
                else:
                    self._logger.warning(f"Skipping partial page: {result.id}")

            if not response.has_more:
                break
            next_cursor = response.next_cursor

        self._logger.info(f"Successfully fetched {len(all_users)} users out of {total_count} total records")
        return all_users

    def _parse_user_from_page(self, page: NotionPage) -> User:
        """NotionページからUserモデルをパースする

        Args:
            page: Notionページオブジェクト

        Returns:
            パースされたUserモデル

        Raises:
            ValueError: 必須プロパティが見つからない場合
        """
        props = page.properties

        # Nameプロパティ(Title型)を取得
        name_prop = props.get("Name")
        if not name_prop or name_prop.type != "title":
            raise ValueError(f"Name property not found or invalid type in page {page.id}")

        if not name_prop.title:
            raise ValueError(f"Name title is empty in page {page.id}")

        name = "".join([t.plain_text for t in name_prop.title])

        # Emailプロパティ(Email型)を取得
        email_prop = props.get("Email")
        if not email_prop or email_prop.type != "email":
            raise ValueError(f"Email property not found or invalid type in page {page.id}")

        if not email_prop.email:
            raise ValueError(f"Email is empty in page {page.id}")

        email = email_prop.email

        return User(
            id=page.id,
            name=name,
            email=email,
        )

作ってみて気づいたNotion APIのクセ

とにかくNotion APIからのデータ形式はNullableを多分に含み、深くネストしたJSON形式であり、開発者にとってこの深くネストしたJSON構造を認識してレスポンスをパースするコードを書くことは非常に無駄なコストであり、ここをSDKによってラッピングすることは効率的な開発を進めるにあたって非常に有意義であると感じました。
また、Notion APIに対してリクエストを送るときのJSON構造も非常に複雑であり、これらを1から調べて開発者がJSONを書くのではなく、SDKによってどんなパラメーターが指定できるのかをAPI仕様を調べることなく知ることができるのは非常に大きなメリットだと考えています。

レスポンスの一例
{
  "object": "data_source",
  "id": "bc1211ca-e3f1-4939-ae34-5260b16f627c",
  "created_time": "2021-07-08T23:50:00.000Z",
  "last_edited_time": "2021-07-08T23:50:00.000Z",
  "properties": {
    "+1": {
      "id": "Wp%3DC",
      "name": "+1",
      "type": "people",
      "people": {}
    },
    "In stock": {
      "id": "fk%5EY",
      "name": "In stock",
      "type": "checkbox",
      "checkbox": {}
    },
    "Price": {
      "id": "evWq",
      "name": "Price",
      "type": "number",
      "number": {
        "format": "dollar"
      }
    },
    "Description": {
      "id": "V}lX",
      "name": "Description",
      "type": "rich_text",
      "rich_text": {}
    },
    "Last ordered": {
      "id": "eVnV",
      "name": "Last ordered",
      "type": "date",
      "date": {}
    },
    "Meals": {
      "id": "%7DWA~",
      "name": "Meals",
      "type": "relation",
      "relation": {
        "database_id": "668d797c-76fa-4934-9b05-ad288df2d136",
        "synced_property_name": "Related to Grocery List (Meals)"
      }
    },
    "Number of meals": {
      "id": "Z\\Eh",
      "name": "Number of meals",
      "type": "rollup",
      "rollup": {
        "rollup_property_name": "Name",
        "relation_property_name": "Meals",
        "rollup_property_id": "title",
        "relation_property_id": "mxp^",
        "function": "count"
      }
    },
    "Store availability": {
      "id": "s}Kq",
      "name": "Store availability",
      "type": "multi_select",
      "multi_select": {
        "options": [
          {
            "id": "cb79b393-d1c1-4528-b517-c450859de766",
            "name": "Duc Loi Market",
            "color": "blue"
          },
          {
            "id": "58aae162-75d4-403b-a793-3bc7308e4cd2",
            "name": "Rainbow Grocery",
            "color": "gray"
          },
          {
            "id": "22d0f199-babc-44ff-bd80-a9eae3e3fcbf",
            "name": "Nijiya Market",
            "color": "purple"
          },
          {
            "id": "0d069987-ffb0-4347-bde2-8e4068003dbc",
            "name": "Gus's Community Market",
            "color": "yellow"
          }
        ]
      }
    },
    "Photo": {
      "id": "yfiK",
      "name": "Photo",
      "type": "files",
      "files": {}
    },
    "Food group": {
      "id": "CM%3EH",
      "name": "Food group",
      "type": "select",
      "select": {
        "options": [
          {
            "id": "6d4523fa-88cb-4ffd-9364-1e39d0f4e566",
            "name": "🥦Vegetable",
            "color": "green"
          },
          {
            "id": "268d7e75-de8f-4c4b-8b9d-de0f97021833",
            "name": "🍎Fruit",
            "color": "red"
          },
          {
            "id": "1b234a00-dc97-489c-b987-829264cfdfef",
            "name": "💪Protein",
            "color": "yellow"
          }
        ]
      }
    },
    "Name": {
      "id": "title",
      "name": "Name",
      "type": "title",
      "title": {}
    }
  },
  "parent": {
    "type": "database_id",
    "database_id": "6ee911d9-189c-4844-93e8-260c1438b6e4"
  },
  "database_parent": {
    "type": "page_id",
    "page_id": "98ad959b-2b6a-4774-80ee-00246fb0ea9b"
  },
  "archived": false,
  "is_inline": false,
  "icon": {
    "type": "emoji",
    "emoji": "🎉"
  },
  "cover": {
    "type": "external",
    "external": {
      "url": "https://website.domain/images/image.png"
    }
  },
  "url": "https://www.notion.so/bc1211cae3f14939ae34260b16f627c",
  "title": [
    {
      "type": "text",
      "text": {
        "content": "Grocery List",
        "link": null
      },
      "annotations": {
        "bold": false,
        "italic": false,
        "strikethrough": false,
        "underline": false,
        "code": false,
        "color": "default"
      },
      "plain_text": "Grocery List",
      "href": null
    }
  ]
}

今後の展望

Notion APIは日々進化しています。今年もData sourcesという新しい概念の導入により、APIの仕様は大きく変化しています。

https://developers.notion.com/docs/upgrade-guide-2025-09-03

今後はこのようなNotion APIの定期的なアップデートを監視して、SDKを継続的にアップデートしていく仕組みづくりや、コミュニティを発展させてバグなどを解消していきたいと考えています。

GitHubで編集を提案
neoAI

Discussion