name: notion-import
description: Bulk-import rows from CSV / Google Sheets / markdown / pasted data into an existing Notion database, with auto-inferred column mapping and a dry-run report before any write. Use when the user says "import this into Notion", "load these rows", "add this CSV to the database", "bulk-add to <tracker>". Never writes without showing the mapping + sample first.
/notion-import
Bulk-load rows into an existing Notion DB without manual one-at-a-time entry. Always non-destructive — never modifies existing rows, only creates new ones (unless explicitly told to upsert).
- Target database (required). URL or ID.
- Source data (required). One of:
- File path to a
.csv / .tsv / .md / .json on disk - Google Sheets URL (read via Composio per memory
composio_default_mcp.md) - Pasted block of text in the conversation (parse as CSV/TSV/markdown table)
- Mapping override (optional). If the user wants
Vendor Name → Tool, they can specify upfront; otherwise infer. - Upsert behaviour (optional). Default: create-only. If user passes "upsert by <property>", match existing rows on that property and update instead of duplicating.
Steps
- Read target.
notion-fetch the DB → get the schema (property names + types + select options). - Read source. Parse the CSV / pasted text / Sheet. Detect header row + types per column.
- Auto-map columns. For each source column, find best-match target property by:
- Exact case-insensitive name match (highest priority)
- Substring / synonym match (e.g. "Cost" → "Monthly Cost $")
- Type compatibility (don't map text-only column to a number property)
- Leave unmapped columns as a "drop these" list — surface them.
- Coerce values.
- Numbers: strip "$", "%", "," — parse to float.
- Select: match value to existing option (case-insensitive). If no match, add to the "new options to create" list — show user before creating.
- Multi-select: split on
, | or ;. - Dates: parse ISO 8601 /
MM/DD/YYYY / YYYY-MM-DD / Mon DD YYYY. Surface ambiguous values. - Title: take the first non-empty mapped source column if title not explicit.
- Dry-run report. Output BEFORE any write:
- N rows to create (M rows skipped + reasons)
- Column mapping table (source → target, type, sample value)
- New select options that'll be created
- Any rows that failed parsing (with reasons)
- First 3 fully-mapped rows as a sample.
- Confirm gate. Wait for user "go" / "yes" / explicit confirmation.
- Write. Batch via
notion-create-pages with data_source_id parent. Notion API caps at 100 pages/call — chunk if needed. - Confirm. Print: rows created (with URLs of first 3 + last 3), any failures.
Decision rules
- If the user pastes data without a clear header row, ask whether row 1 is headers or data before parsing.
- If a source column maps to a number property but contains "$1,234.56", strip + parse, do NOT skip the row.
- If a source column maps to a select property but the value isn't an existing option, surface in the dry-run as "new option to create" — do not silently create.
- If a source row has no value for the title property, skip it AND list it in the failures section. Don't make up a title.
Anti-patterns
- Don't auto-create new select options without surfacing them first.
- Don't upsert by default — duplicates are recoverable, overwrites aren't.
- Don't import without a dry-run, even if the source is small.
- Don't trust the source's column order — match by name.
Output
- Dry-run report (in conversation).
- After confirm: count of rows created + URLs of a few samples.
- If failures: a list with reasons.
Hard-won gotchas
- Google Sheets via Composio: the
composio_sheets_gotchas.md memory has read+write gotchas. Use proxy_execute for anything advanced. - Notion API pagination: chunk creates into batches of 100.
- Rate limits: Notion is generous but if writing 500+ rows, space the batches.