Notion is built for managing information, not analyzing it. Notion Analytics pulls your Notion databases into a local SQLite file on a schedule, enabling dashboards in Power BI or Grafana — with a full field-level change history so you can see exactly how your data evolved over time.
flowchart LR
N[("Notion\nDatabases")] -->|Notion API| S["sync.py<br>Windows->Task Scheduler<br>Linux->cron"]
S --> DB[("notion_analytics.db<br>SQLite")]
DB -->|ODBC| PB["Power BI<br>(Windows only)"]
DB -->|SQLite plugin| G["Grafana"]
- Scheduled sync — runs via Task Scheduler (Windows) or cron / systemd (Linux)
- Field-level change tracking — every property change recorded with timestamps; full history of how your data evolved
- Auto-schema evolution — new Notion columns appear automatically on the next sync, no manual migration
- Content and comment sync — page body and comments captured as plain text
- Property rename support — declare a rename in config, change history migrated automatically
- CSV export — optional alongside SQLite
- Cross-platform — Windows and Linux both supported
- Quick Start
- Setup
- Guide
- Reference
- Updating
- Troubleshooting
- Known Limitations
- Planned Features
- Dependencies & Related Projects
- License
- Credits
git clone https://github.com/DreamShark-Bytes/Notion_Analytics.git
cd Notion_Analytics
python -m venv venv
# Linux / macOS
venv/bin/pip install -r requirements.txt
# Windows
venv\Scripts\pip install -r requirements.txtCopy and configure:
cp config_example.toml config.toml
# Edit config.toml: add your Notion token and at least one database IDRun:
venv/bin/python sync.py # Linux / macOS
venv\Scripts\python sync.py # WindowsYour data is now in notion_analytics.db. Open it in DB Browser for SQLite to verify, then connect Power BI or Grafana.
- Go to notion.so/my-integrations
- Click New integration, give it a name, select your workspace
- Copy the Internal Integration Secret
- For each database you want to sync: open the database in Notion →
...menu → Connect to → select your integration
cp config_example.toml config.tomlFind each database ID in its Notion URL: https://notion.so/workspace/**{database-id}**?v=...
token = "ntn_your_token_here"
[[databases]]
id = "your-database-id-here"
name = "tasks" # table prefix in SQLite — no spaces, lowercase
include_content = true # include page body as plain text
include_comments = true # include page comments
track_changes = true # record field-level change historySee config_example.toml for all available options.
# One-shot sync
venv/bin/python sync.py # Linux
venv\Scripts\python sync.py # Windows
# Use a different config file
venv/bin/python sync.py --config config_test.toml
# Full refresh — re-fetches all pages (useful after config changes)
venv/bin/python sync.py --fullOutput: notion_analytics.db (SQLite), optionally exports/ (CSV), log at notion_analytics.log.
- Open Task Scheduler → Create Task (not "Create Basic Task")
- General tab: Name:
Notion Analytics Sync· Description:Syncs Notion databases to a local SQLite file for use with Power BI and Grafana. - Triggers tab: New → On a schedule → Daily → check Repeat task every: 1 hour for duration Indefinitely
- Actions tab: New → Start a program
- Program/script:
C:\Users\YourName\Documents\Notion_Analytics\venv\Scripts\python.exe - Add arguments:
sync.py - Start in:
C:\Users\YourName\Documents\Notion_Analytics
- Program/script:
- Conditions tab: uncheck "Start the task only if the computer is on AC power" if on battery
- Settings tab: check "If the task is already running, do not start a new instance"
- Click OK → right-click the task → Run once → check
notion_analytics.log
crontab -eAdd (runs every hour):
0 * * * * cd /home/vince/Documents/Notion_Analytics && /home/vince/Documents/Notion_Analytics/venv/bin/python sync.py >> notion_analytics.log 2>&1
Create /etc/systemd/system/notion-analytics.service:
[Unit]
Description=Notion Analytics sync
[Service]
Type=oneshot
WorkingDirectory=/home/vince/Documents/Notion_Analytics
ExecStart=/home/vince/Documents/Notion_Analytics/venv/bin/python sync.py
User=vinceCreate /etc/systemd/system/notion-analytics.timer:
[Unit]
Description=Run Notion Analytics sync hourly
[Timer]
OnBootSec=1min
OnUnitActiveSec=1h
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload
sudo systemctl enable --now notion-analytics.timerDownload and install the SQLite ODBC driver (sqliteodbc_w64.exe for 64-bit Windows).
- Open ODBC Data Sources (64-bit) — use the 64-bit version (search "ODBC" in Start)
- System DSN tab → Add → select SQLite3 ODBC Driver
- Name the DSN (e.g.
NotionAnalytics) and set the database path to yournotion_analytics.dbfile - In Power BI Desktop → Get Data → ODBC → select your DSN → load the tables
Tip: Set the Data Category of the
urlcolumn to Web URL in Power BI to make Notion page links clickable in reports.
Alternative — CSV export (no driver needed)
Set export_csv = true under [output] in config.toml. After each sync, CSV files appear in exports/. In Power BI Desktop → Get Data → Text/CSV.
Option A — Remote Desktop (RDP): Enable Remote Desktop on your Windows machine and connect via Microsoft Remote Desktop (iOS/Android). Tailscale (free personal tier) makes the machine reachable from anywhere without port forwarding.
Option B — Power BI Service: Publish reports to the cloud and view them in Power BI Mobile. Requires a work or school Microsoft account — personal accounts are not supported. If you have one, sign in to Power BI Desktop → File → Publish → Publish to Power BI. For automatic cloud refresh without manually republishing, install the On-Premises Data Gateway (Personal mode, free) on the same machine.
Grafana (free, open-source) is a cross-platform alternative to Power BI that connects directly to SQLite.
- Install Grafana: grafana.com/docs/grafana/latest/setup-grafana/installation/
- Install the SQLite plugin: Administration → Plugins → search
SQLite→ install - Add a data source: Connections → Data sources → Add → SQLite → set the path to your
notion_analytics.db - Build dashboards with SQL queries against
_pages,_changes, and_commentstables
Grafana runs as a service and is accessible from any browser — reachable remotely via Tailscale without port forwarding.
After the first sync:
# Linux / macOS
sqlite3 notion_analytics.db ".tables"
sqlite3 notion_analytics.db "SELECT COUNT(*) FROM tasks_pages;"
sqlite3 notion_analytics.db "SELECT COUNT(*) FROM tasks_changes;"Windows: DB Browser for SQLite (free) lets you browse tables and run queries visually.
Rows in _changes with old_value = NULL are initial state records from the first sync — that's expected.
When track_changes = true is set for a database, every property change is recorded in the {name}_changes table with a timestamp.
Initial state: On the first sync of any page, all tracked fields are written as initial records — old_value = NULL, new_value = current value, valid_from = the page's created_time. This gives you a baseline for every field from the moment Notion created the page, not just from when you started syncing.
Subsequent syncs: Only fields whose values have changed since the last sync are recorded. Unchanged fields produce no entry.
Fields always excluded from tracking (too noisy or redundant):
| Field | Reason |
|---|---|
last_edited_time |
Changes on every sync |
content_text |
Large and noisy |
url |
Never changes |
Controlling which fields are tracked:
[[databases]]
name = "tasks"
track_changes = true
# Track only these fields (allowlist):
change_fields = ["Status", "Due Date", "Assignee"]
# Or track everything except these (denylist):
exclude_change_fields = ["Notes", "Description"]change_fields and exclude_change_fields use original Notion property names (not sanitized column names). If both are set, change_fields takes precedence.
When include_content = true is set for a database, the full body of each Notion page is captured as plain text in the content_text column.
Performance note: Content requires a separate API call per page. For large databases (hundreds of pages), this significantly increases sync time. Set
include_content = falsefor databases where the body text is not needed.
Block types are converted to plain text as follows:
| Block type | Stored as |
|---|---|
| Paragraph, headings, toggles | Plain text |
| Bullet / numbered list | Plain text (prefix stripped) |
| To-do | [x] or [ ] prefix |
| Quote | > text |
| Callout | | text |
| Code | [code:language] text |
| Table | Pipe-separated rows: cell1 | cell2 | cell3 |
| Child page / inline database | [child_page: Title] or [child_database: Title] — not recursed into |
| Images, video, audio, PDF | [image: caption], [video], [audio], [pdf] |
| File attachments | [file: filename] |
| Bookmark, embed, link preview | [bookmark: url], [embed: url], [link_preview: url] |
| Unsupported | [unsupported] |
Use separate configs for testing
Keep a config_test.toml with a different db_path so test runs never touch production data:
# config_test.toml
[output]
db_path = "notion_analytics_test.db"venv/bin/python sync.py --config config_test.tomlLimit which columns are synced
Use include_columns to sync only the fields you need. Reduces table width and sync time for wide databases:
[[databases]]
name = "tasks"
include_columns = ["Name", "Status", "Due Date", "Assignee"]Apply a property rename before the next sync
Add column_renames to config.toml before the next scheduled sync runs. If the sync runs first without it, the old column stops updating and the new column starts fresh — you lose the connection between them. See Handling Schema Changes.
CSV export for simple consumers
[output]
export_csv = true
csv_dir = "exports" # defaultCSV files are written alongside the SQLite file after each sync. Useful for sharing snapshots or loading into tools that don't support SQLite directly.
For each configured database (example: tasks):
| Table | Description |
|---|---|
tasks_pages |
Current state — one row per Notion page |
tasks_changes |
Field-level change history |
tasks_comments |
Page comments (if enabled) |
| Column | Type | Notes |
|---|---|---|
page_id |
TEXT (PK) | Notion page ID |
created_time |
TEXT | ISO 8601 |
last_edited_time |
TEXT | ISO 8601 |
url |
TEXT | Notion page URL |
content_text |
TEXT | Page body as plain text (if enabled) |
| (property columns) | varies | One column per Notion property, sanitized to lowercase_with_underscores |
| Column | Notes |
|---|---|
page_id |
References tasks_pages.page_id |
field |
Sanitized column name |
old_value |
Previous value (NULL for initial record) |
new_value |
New value |
valid_from |
When the value took effect (page created_time for initial records, detection time for changes) |
detected_at |
When this sync run detected the change |
| Column | Notes |
|---|---|
comment_id |
Notion comment ID (PK) |
page_id |
References tasks_pages.page_id |
created_time |
ISO 8601 |
last_edited_time |
ISO 8601 |
text |
Comment body as plain text |
| Notion type | Stored as |
|---|---|
| Title, Rich text | Plain text string |
| Number | REAL |
| Select, Status | Option name string |
| Multi-select | Comma-separated names |
| Date | ISO string; date ranges as start/end |
| Checkbox | INTEGER (1 / 0) |
| Formula | Computed result (API returns the evaluated value, not the formula) |
| Relation | Comma-separated related page IDs |
| Rollup | Number (count), date start, or number |
| People | Comma-separated names |
| Files / Images | True/False by default; controlled by files_handling in extract_page_row |
| Created by / Last edited by | Excluded |
- Add the rename to
config.tomlunder the affected database before the next sync runs:
column_renames = {"Old Property Name" = "New Property Name"}- Run the sync — data and change history are migrated automatically.
- Remove the entry once done (safe to leave, but cleaner without).
No action needed — new columns are added automatically on the next sync.
The column remains in SQLite with its historical data but stops being updated.
rm notion_analytics.db # Linux
del notion_analytics.db # Windows
venv/bin/python sync.py # Linux
venv\Scripts\python sync.py # WindowsThe next run recreates all tables. Change history starts fresh from that point.
Windows — Task Scheduler:
Right-click the Notion Analytics Sync task → Disable. If it is currently running, also click End before disabling.
Linux — systemd timer:
sudo systemctl stop notion-analytics.timerLinux — cron:
crontab -e # comment out the sync line with #git pullReinstall dependencies in case requirements.txt changed:
venv/bin/pip install -r requirements.txt # Linux
venv\Scripts\pip install -r requirements.txt # WindowsCheck the commit history for breaking changes or migration steps before continuing.
venv/bin/python sync.py # Linux
venv\Scripts\python sync.py # WindowsSchema changes are applied automatically. Verify the log looks clean before re-enabling the scheduler.
Windows — Task Scheduler: Right-click the task → Enable.
Linux — systemd timer:
sudo systemctl start notion-analytics.timerLinux — cron:
crontab -e # remove the # from the sync lineFailed to fetch database schema
Integration token is wrong, or the integration hasn't been connected to that database in Notion (... menu → Connect to).
No databases configured
config.toml is missing [[databases]] entries, or the wrong config file is being used.
Power BI can't find the SQLite file
Verify the file path in your ODBC DSN matches the actual .db file location. If the file is on a network share, confirm the drive is mapped before opening Power BI.
Columns missing in Power BI
New Notion properties don't appear until the next sync after they were added. If include_columns is set, check that list.
Change history looks wrong after a rename
Add the old and new property names to column_renames and run the sync once. See Handling Schema Changes.
Full error details
Always check notion_analytics.log — the terminal only shows a summary.
- No incremental sync yet — every run fetches all pages (planned)
- Deleted pages are not detected — stale rows accumulate until a database reset (planned)
- Power BI Service publishing requires a work or school Microsoft account
See PLANNED.md for design details.
- Incremental sync (filter by
last_edited_time) - Deleted page cleanup (soft delete with configurable lifespan)
- Date field start/end split (
due_date→due_date_start/due_date_end) - Select/status option ID tracking for rename detection
- Change tracking backup
See requirements.txt for the full dependency list.
- Notion_API — shared Notion API client used by this project (pinned via
requirements.txt) - Notion_Automator — companion daemon that writes automation logic back to Notion; this project reads what that one writes
MIT License. See LICENSE.
Developed in collaboration with Claude Code by Anthropic. All architectural decisions, data model design, requirements definition, and production deployment are owned by the human author. Claude assisted with implementation, documentation, and code review under directed oversight — a design-led workflow where nothing ships without human review and approval.