CLI tool to sync Notion databases to local Markdown files with YAML frontmatter.
Given a Notion database ID, notion-sync fetches all entries via the Notion API and writes them to .md files on disk. Each file gets YAML frontmatter containing the Notion ID, URL, edit timestamp, and all property values. On subsequent runs it compares last_edited_time and only re-syncs entries that changed.
curl -fsSL https://raw.githubusercontent.com/ran-codes/notion-sync/main/scripts/install.sh | bash## Install Scoop if you don't have it: https://scoop.sh
# irm get.scoop.sh | iex
scoop bucket add notion-sync https://github.com/ran-codes/notion-sync
scoop install notion-syncDownload the binary for your platform from GitHub Releases, rename it to notion-sync (or notion-sync.exe on Windows), and add it to your PATH.
Re-run the install script — it always fetches the latest release:
curl -fsSL https://raw.githubusercontent.com/ran-codes/notion-sync/main/scripts/install.sh | bashscoop update
scoop update notion-syncDownload the latest binary from GitHub Releases and replace the existing one.
# Store your API key (saved in OS keychain)
notion-sync config set apiKey <your-notion-api-key>
# Import databases into an output folder
notion-sync import <database-id-A> --output ./my-notes
notion-sync import <database-id-B> --output ./my-notes
# Refresh (incremental — only changed entries)
notion-sync refresh ./my-notes/Database\ A
# Force refresh (resync everything)
notion-sync refresh ./my-notes/Database\ A --force
# List synced databases
notion-sync list ./my-notesThe --output folder is a workspace. Each database gets a subfolder, and all databases in a workspace share a single SQLite store:
my-notes/ ← workspace (--output target)
├── _notion_sync.db ← shared SQLite store (FTS5 search, all pages)
├── Database A/
│ ├── _database.json
│ ├── Page One.md
│ └── Page Two.md
└── Database B/
├── _database.json
├── Entry Alpha.md
└── Entry Beta.md
By default both .md files and SQLite are written. Use --output-mode to control this:
notion-sync import <id> --output ./notes --output-mode markdown # .md only
notion-sync import <id> --output ./notes --output-mode sqlite # SQLite only
notion-sync import <id> --output ./notes --output-mode both # defaultYou can also set the default in config: notion-sync config set outputMode sqlite
- A Notion integration with access to the databases you want to sync
- Go to notion.so/my-integrations
- Click "New integration"
- Give it a name (e.g. "notion-sync") and select a workspace
- Copy the Internal Integration Secret (starts with
ntn_) - In Notion, open the database you want to sync
- Click the
...menu > "Connections" > add your integration
Open the database as a full page in Notion. The URL will look like:
https://www.notion.so/yourworkspace/abc123def4567890abcdef1234567890?v=...
The database ID is the 32-character hex string after your workspace name — in this example, abc123def4567890abcdef1234567890. You can pass it with or without dashes; notion-sync accepts both formats as well as the full URL.
notion-sync import <database-id> [--out <folder>] [--api-key <key>]
notion-sync refresh <folder> [--force] [--api-key <key>]
notion-sync list [<folder>]
notion-sync config set <key> <value>| Command | Description |
|---|---|
import |
First-time import of a Notion database |
refresh |
Incremental update (only changed entries) |
refresh --force |
Full resync ignoring timestamps |
list |
Show all synced databases in a folder |
config set apiKey <key> |
Store API key in OS keychain |
cmd/notion-sync/ # CLI entry point
internal/
├── notion/ # API client (rate limit, retry)
├── sync/ # Core sync logic
├── markdown/ # Block → Markdown conversion
├── frontmatter/ # YAML parse/write
└── config/ # Keyring + config file
go build ./cmd/notion-sync # Build binary# Unit + integration tests (mock client, no API needed)
go test ./...
# System tests (hit real Notion API, require API key)
/test-single-datasource-db # single data source lifecycle
/test-double-datasource-db # multi-source layout + edge cases
# Everything together (unit → single → double, sequential)
/testSee CLAUDE.md for implementation details, how to add block/property types.
- Incremental sync -- compares
last_edited_timefrom frontmatter and skips unchanged entries - Force refresh --
--forceflag bypasses timestamp checks to resync all entries (useful when database schema changes) - Soft deletes -- entries removed from a Notion database get
notion-deleted: truein their frontmatter rather than being deleted from disk - Two orchestration functions --
freshDatabaseImport()for first-time imports,refreshDatabase()for incremental updates with diff-based optimization - Database metadata file -- each synced database folder contains
_database.jsonwith metadata (database ID, title, URL, last sync time, entry count), enablingrefreshDatabase()to work from just a folder path - Manual YAML serialization -- frontmatter is written with hand-rolled code for precise formatting; the
yamlpackage is used only for parsing - Newer Notion API -- database entries are queried via
client.dataSources.query()(notdatabases.query()) to get full property data
| Package | Used for |
|---|---|
github.com/zalando/go-keyring |
OS keychain access |
gopkg.in/yaml.v3 |
YAML parsing |
modernc.org/sqlite |
Pure-Go SQLite (FTS5, no CGO) |
No third-party Notion client — uses a thin REST wrapper for full control over rate limiting.
Extracted from obsidian-notion-database-sync, an Obsidian plugin.