---
title: "Notion to HubSpot Migration: A Technical Guide to Data Mapping & APIs"
slug: notion-to-hubspot-migration-a-technical-guide-to-data-mapping-apis
date: 2026-08-14
author: Rishabh
categories: [Notion, HubSpot, Migration Guide]
excerpt: "A technical guide to migrating Notion databases to HubSpot CRM — covering API constraints, property mapping, relation-to-association translation, and common failure modes."
tldr: "Notion to HubSpot migration requires API-led extraction to preserve relations, files, and page content. CSV only works for single flat databases with no history."
canonical: https://clonepartner.com/blog/notion-to-hubspot-migration-a-technical-guide-to-data-mapping-apis/
---

# Notion to HubSpot Migration: A Technical Guide to Data Mapping & APIs


# Notion to HubSpot Migration: A Technical Guide to Data Mapping & APIs

> [!NOTE]
> **TL;DR:** Migrating from Notion to HubSpot requires translating a flexible, block-based workspace into a strict relational CRM schema. CSV export works for a single flat database. For anything with cross-database relations, page content, or file attachments, you need an API-led migration. Key constraints: Notion enforces 3 requests per second (1,000 per 5 minutes), file URLs expire in ~1 hour, and HubSpot Custom Objects require Enterprise. Plan for a phased load — parent objects first, associations second, notes and files last — with an identity map linking Notion UUIDs to HubSpot record IDs.

Migrating from Notion to HubSpot is a data-model translation problem. Notion is a block-based workspace where databases are flexible collections of pages with arbitrary property schemas and freeform content. HubSpot is a relational CRM with fixed standard objects (Contacts, Companies, Deals, Tickets) connected through an explicit Associations API — and Custom Objects gated behind an Enterprise subscription.

Every Notion database you use as a CRM, pipeline tracker, or customer directory needs to be deconstructed into HubSpot's object schema. The connective tissue between databases — Relations and Rollups — needs to be rebuilt as Associations and Calculated Properties. A naive CSV export from Notion will flatten your relational databases, strip out all the rich text content inside your pages, and silently drop file attachments.

If your Notion setup is a single flat database with contact info, a CSV export and HubSpot's native import wizard can work. If you're running multiple related databases (e.g., Companies → Contacts → Deals → Projects) with file attachments and rich page content, you need an API-led migration or a managed service.

This guide covers the exact constraints on both sides, a concrete property-mapping strategy, migration approach comparison, and the edge cases that cause silent data loss.

For background on how Notion exports behave and what they drop, see [The Complete Guide to Exporting Notion Data](https://clonepartner.com/blog/blog/definitive-guide-notion-data-export-api-pdf-html/). If you're also evaluating other Notion migration targets, our guides on [Notion to Webflow](https://clonepartner.com/blog/blog/notion-to-webflow-migration-api-limits-data-mapping/) and [Notion to SharePoint](https://clonepartner.com/blog/blog/notion-to-sharepoint-migration/) cover parallel sets of extraction challenges.

## Notion's Data Model vs. HubSpot's CRM Schema

**Notion** organizes data as Workspaces → Pages → Databases → Rows (Pages) → Blocks. Each database has a user-defined property schema — you can add any combination of Text, Number, Select, Multi-select, Status, Date, Person, Checkbox, URL, Email, Phone, Relation, Rollup, Formula, File, Created Time, and more. Every row in a database is itself a page that can contain arbitrary block content: paragraphs, headings, images, embeds, code blocks, toggle lists.

**HubSpot** organizes data as Objects → Records → Properties → Associations. Standard objects are Contacts, Companies, Deals, and Tickets. Each object has built-in properties and supports custom properties (up to 1,000 per object on Professional and Enterprise plans). Records across objects are linked through the Associations API. Custom Objects — entirely new record types with their own properties, pipelines, and associations — require an Enterprise subscription on at least one Hub.

The fundamental mismatch: Notion lets you build _any_ schema with _any_ relationships. HubSpot enforces a predefined object hierarchy. Your Notion "Clients" database might map to HubSpot Contacts _or_ Companies. Your "Projects" database might become Deals, Tickets, or a Custom Object. There is no automatic translation — you have to make explicit architectural decisions for every database.

When moving from Notion to HubSpot, you are fundamentally disassembling Notion Pages and distributing their components across multiple HubSpot entities: properties become CRM fields, page content becomes Note engagements, file attachments go to the File Manager, and Relations become Associations.

### What Notion Has That HubSpot Doesn't (Natively)

| Notion Concept | HubSpot Equivalent | Gap |
|---|---|---|
| Page body content (blocks) | No direct equivalent | Must serialize to Notes, rich text properties, or link back to Notion |
| Relation property | Association (v4 API) | Requires object-to-object association via API; no CSV equivalent |
| Rollup property | Calculated property (limited) | HubSpot calculated properties only work within the same object; cross-object rollups require workflows or custom code |
| Formula property | Calculation property | HubSpot's formula syntax is different; complex Notion formulas need manual rewrite |
| File & Media property | File Manager + attachment | Notion file URLs expire; must download and re-upload |
| Database views (Board, Calendar, Gallery, Timeline) | Saved views, dashboards | Must be rebuilt manually in HubSpot |
| Self-referencing Relations | Not natively supported | Requires workaround with Custom Objects or flat properties |

## Notion Export Limitations

Before choosing a migration method, understand what Notion's native export actually preserves — and what it silently drops.

> [!WARNING]
> **Notion CSV export strips the connective tissue of your workspace.** Relations export as plain-text URLs (page IDs, not titles). Rollups export as static snapshot values. Formulas, views, filters, and sorts are not included. If you re-import that CSV elsewhere, you get flat rows with no relationships.

<cite index="43-5">Notion's Markdown, CSV, and HTML exports save your page text and flat rows — but silently drop the relations, rollups, formulas, and views you'd need to actually rebuild the workspace.</cite> <cite index="42-3">When you export a relational database as a CSV file, the relation properties will export as plain text URLs.</cite> <cite index="46-8,46-9,46-10">In a CSV export, relations give you the ID of the linked page (not the title), rollups give you the definition of the calculation (not the number), and user fields give you a user ID (not the person's name).</cite>

The API is different. <cite index="46-14">The API provides the computed values for formulas and rollups, and the full object details for relations.</cite> This is why an API-led extraction is almost always the right choice for anything beyond a single flat table.

Workspace-level exports can take up to 30 hours, and download links expire after 7 days. ([notion.com](https://www.notion.com/help/export-your-content?slug=export-your-content)) If you need a defensible archive before cutover, take one — but do not treat it as your migration source for anything relational.

## Notion API Constraints for Data Extraction

Every migration from Notion starts with extraction, and the API's constraints define your throughput ceiling.

**Rate limits:** <cite index="5-1">Notion's public API enforces a hard limit of 3 requests per second per integration.</cite> <cite index="5-13">Notion also enforces a secondary limit: 1,000 requests per 5 minutes per workspace.</cite> Your migration script must implement exponential backoff to handle HTTP 429 responses.

**Pagination:** <cite index="25-2">The maximum number of results in one paginated response is 100.</cite> That applies to database queries and block children retrieval alike. You must handle the `next_cursor` to paginate through large databases.

**Block tree traversal:** <cite index="25-14">The Notion API only returns first-level children for any block.</cite> <cite index="25-15">You must check each block's `has_children` property and recursively call the retrieve block children endpoint to reconstruct a full page, handling cursor-based pagination at every level.</cite> Querying a database only returns page properties — to get the content _inside_ a page, you must make a separate `GET /v1/blocks/{block_id}/children` request for every single page.

**Payload limits:** <cite index="25-17">Notion limits request payloads to 1,000 block elements and 500KB total.</cite> <cite index="25-18">Any individual children array is capped at 100 elements, and nested blocks can only go two levels deep in a single request.</cite>

**Rich text:** <cite index="44-18">The Notion API limits each rich text object to 2,000 characters.</cite>

**Query result limits:** A single database query can return at most 10,000 results via pagination. If your database exceeds this, you'll need filtered queries to segment the extraction. ([developers.notion.com](https://developers.notion.com/reference/query-a-data-source?utm_source=openai))

**Integration token scope:** A critical pre-migration check: your Notion integration token only accesses pages and databases that have been explicitly shared with the integration via Connections. Pages or databases that haven't been connected will silently return no results — no error, just empty responses. Before extraction, walk every database in your migration scope and confirm the integration connection is active. Workspace-level admin tokens avoid this problem but require admin access.

### Extraction Throughput Math

<cite index="24-1">At ceiling throughput, paginating a 5,000-row database at 100 rows per page takes 50 requests — at least 17 seconds.</cite> <cite index="24-9,24-10">Fetching the full block content for each of those 5,000 rows adds 5,000 more requests — over 28 minutes at the rate limit ceiling.</cite>

For a migration with 10,000 database rows across 3 databases, plus page content for each row:

- ~300 requests for database pagination (100 rows/page × 3 databases)
- ~10,000 requests for page content (1 per row minimum, more for nested blocks)
- **Total: ~10,300 requests → ~57 minutes at 3 req/s**

That's the _optimistic_ case. Rows with deeply nested content (toggles inside toggles, embedded databases) multiply the request count. Plan for 2–4× the theoretical minimum.

```python
import time
import random
from notion_client import Client
from notion_client.errors import APIResponseError

notion = Client(auth="your-integration-token")

def query_all_rows(database_id, max_retries=5):
    """Paginate through all rows in a Notion database with exponential backoff."""
    rows = []
    cursor = None
    while True:
        for attempt in range(max_retries):
            try:
                response = notion.databases.query(
                    database_id=database_id,
                    start_cursor=cursor,
                    page_size=100
                )
                break
            except APIResponseError as e:
                if e.status == 429:
                    wait = (2 ** attempt) + random.uniform(0, 1)
                    print(f"Rate limited. Retrying in {wait:.1f}s (attempt {attempt+1}/{max_retries})")
                    time.sleep(wait)
                else:
                    raise
        rows.extend(response["results"])
        if not response["has_more"]:
            break
        cursor = response["next_cursor"]
        time.sleep(0.35)  # Stay under 3 req/s
    return rows
```

## HubSpot API Constraints for Data Loading

On the HubSpot side, the constraints are different but equally important.

**Rate limits:** <cite index="11-1">HubSpot enforces burst limits (100–200 requests per 10 seconds) and daily limits (250,000–1,000,000 per day depending on tier).</cite> <cite index="11-2">Search endpoints have a separate, stricter limit: 4 requests per second.</cite>

**Batch endpoints:** <cite index="11-15">HubSpot's batch create/update endpoints let you process up to 100 records in a single API call.</cite> This is the single most important optimization for loading. <cite index="11-16">From a rate limit perspective, that's 100x more efficient than individual calls.</cite>

**Upsert:** <cite index="39-1,39-2">If records already exist, they'll be updated; if they don't exist, they'll be created. To upsert records, make a POST request to `/crm/v3/objects/{objectTypeId}/batch/upsert`.</cite> This is critical for idempotent migration scripts that might need to be re-run.

**Batch associations:** Batch association create calls accept up to 2,000 inputs per request. ([developers.hubspot.com](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/guide?utm_source=openai))

**Custom Objects:** <cite index="54-3">As of April 2026, Custom Objects remain Enterprise-only across every Hub.</cite> <cite index="50-8">Custom objects require an Enterprise-tier subscription across Sales Hub, Service Hub, Marketing Hub, or Operations Hub, and support up to 10 custom objects with up to 1,000,000 records per object.</cite> <cite index="54-5,54-6,54-7">One Enterprise seat on any Hub unlocks Custom Objects across the whole portal. You do not need Enterprise on every Hub — just one. What does not qualify: any Professional, Starter, or Free tier on any Hub.</cite> Discover this _before_ you start mapping, not midway through migration.

**Custom properties:** <cite index="50-1">Limited to 1,000 per object on Professional and Enterprise plans, and 10 total on free accounts.</cite>

**Note body limit:** HubSpot's `hs_note_body` field is capped at 65,536 characters. If a Notion page exceeds this, you'll need to split it across multiple Note engagements or attach the full content as an uploaded file. ([developers.hubspot.com](https://developers.hubspot.com/docs/api-reference/legacy/crm/activities/notes/guide))

**Import file limits:** HubSpot's import tool accepts `.csv`, `.xlsx`, and `.xls` files with fewer than 1,000 columns. On paid CRM tiers, import files can be up to 512 MB and 1,048,576 rows per file. ([knowledge.hubspot.com](https://knowledge.hubspot.com/import-and-export/set-up-your-import-file?r44b=no))

**Property history timestamps:** HubSpot tracks change history for every property. Bulk API imports timestamp all values at the moment of migration — not at the original creation time. This affects reporting (e.g., "deals created in Q3 2023" may show incorrectly in historical reports) and activity timelines. To preserve original timestamps, use a custom `notion_created_time` property and populate HubSpot's `createdate` equivalent where the API allows it. Note: `createdate` is read-only on standard objects via the API; you can set it only during the initial record creation request via the `properties` payload for contacts and companies, but not via batch update after the fact.

**Deduplication keys by object type:** HubSpot deduplicates differently by object:
- **Contacts:** deduplicated on `email`. Two rows with the same email merge or reject depending on import method.
- **Companies:** deduplicated on `domain`. Two companies with the same domain (`hubspot.com`) are treated as duplicates. If your Notion company records lack domain values, deduplication won't fire — but you'll also get no automatic Company-Contact association from HubSpot's email domain matching.
- **Deals and Tickets:** no built-in deduplication key. The API will create duplicate records. Use your `notion_page_id` unique property as the upsert key.

Deduplicate _before_ loading. The batch upsert endpoint with `idProperty: "notion_page_id"` handles re-runs cleanly.

## Property-Type Mapping: Notion → HubSpot

Every Notion property type needs to map to a HubSpot property type, and the translation isn't always clean.

| Notion Property Type | HubSpot Property Type | Notes |
|---|---|---|
| **Title** (Name) | Single-line text (Name/label) | Direct map |
| **Text** (Rich text) | Single-line or multi-line text | HubSpot multi-line text has no rich formatting |
| **Number** | Number | Match format (integer, decimal, currency, percentage) |
| **Select** | Dropdown (enumeration) | Pre-create all option values in HubSpot before import |
| **Multi-select** | Multiple checkboxes | Semicolon-separated values on CSV import (not commas) |
| **Status** | Dropdown or Pipeline stage | If mapping to a pipeline, create matching stages first |
| **Date** | Date or Datetime | Notion stores ISO 8601; HubSpot accepts `YYYY-MM-DD` or Unix timestamps (midnight UTC) |
| **Person** | HubSpot Owner | Only works if the Notion person is also a HubSpot user; map by email; unmatched Notion users should fall back to a custom text property |
| **Checkbox** | Checkbox (boolean) | Direct map; `true`/`false` |
| **URL** | URL | Direct map |
| **Email** | Email (on Contact) or single-line text | Direct map for Contact's `email` property |
| **Phone** | Phone number | Direct map |
| **Files & Media** | File (via File Manager API) | Upload files to HubSpot first, then attach URLs to records; Notion file URLs expire |
| **Relation** | Association | Requires Associations v4 API; no CSV equivalent |
| **Rollup** | Calculated property or workflow | Cannot be directly migrated; must be reconstructed or snapshotted |
| **Formula** | Calculation property | Must be rewritten in HubSpot's formula syntax |
| **Created Time** | `createdate` (settable on creation only) | Cannot be updated via API after record creation; use a custom `notion_created_time` property to preserve original timestamps for reporting |
| **Created By** | Custom text property | No native equivalent for original creator; store as `notion_created_by_email` text field |

### Multi-select Gotcha

Notion allows users to create new multi-select tags on the fly. HubSpot requires all dropdown and Multiple Checkbox options to be predefined in the property schema before a record can use them.

Before loading data into HubSpot, your script must parse all unique values from Notion's multi-select properties and make a schema update request (`PATCH /crm/v3/properties/{objectType}/{propertyName}`) to append the new options. If you attempt to push a value that doesn't exist in the HubSpot schema, the entire record creation will fail. When importing via CSV, HubSpot expects semicolons (`;`) between values — _not_ commas. Get this delimiter wrong and you'll create one garbled option instead of multiple clean ones.

### Deal Pipeline Stage Mapping

If your Notion database has a Status or Select property that represents deal stages (e.g., "Prospecting," "Proposal Sent," "Closed Won"), migrating it to a HubSpot Deal requires pre-creating the pipeline and its stages in HubSpot first.

Each HubSpot Deal must reference a valid `pipeline` ID and `dealstage` ID — not the stage label. The mapping process:

1. Create the pipeline in HubSpot via `POST /crm/v3/pipelines/deals` with all required stages.
2. Capture the returned `stageId` for each stage.
3. Build a translation table: Notion status value → HubSpot `stageId`.
4. Populate `dealstage` in your batch create payload using the translated ID, not the label string.

Sending a label string (e.g., `"Closed Won"`) instead of a stage ID will result in a validation error or silently null out the deal stage depending on API version. Pre-create pipelines before you load any Deal records.

### Date and Timezone Drift

Notion dates can include start time, end time, and timezone. HubSpot Date properties store only a date (midnight UTC). If you need to preserve time precision, use a **Datetime** property in HubSpot.

HubSpot internally stores all dates as Unix timestamps in UTC. Timezone offsets from Notion need explicit conversion, or they'll silently shift by hours. A meeting noted as "March 15, 3:00 PM PST" in Notion could cross a date boundary when stored as a date-only property in HubSpot. Always test date conversion with edge-case times near midnight.

### Handling Notion Person Properties With No HubSpot Match

Notion's Person property returns a Notion User ID. The ideal outcome is mapping that ID to a HubSpot Owner using the email address as the join key:

1. Query `GET /v1/users/{user_id}` in Notion to retrieve the user's email.
2. Query HubSpot's `GET /crm/v3/owners` to find the matching HubSpot owner by email.
3. Set `hubspot_owner_id` on the record.

When the Notion user has no HubSpot equivalent (a contractor, a former employee, a Notion guest with limited access), do not silently drop the assignment. Store the original email in a custom `notion_assigned_to` text property so the assignment isn't lost. Flag these records in a migration report for manual review.

## The 1-Hour File Attachment Expiration

One of the most dangerous edge cases in a Notion migration involves file attachments and images.

Notion hosts files on AWS S3. When you query the Notion API for a page or block containing a file, the API returns a temporary, signed S3 URL. **This URL expires in approximately 1 hour.**

> [!CAUTION]
> **Notion file URLs expire.** If your extraction script caches raw API responses and processes them hours later, every file URL will be dead. This is the most common source of silent data loss in Notion migrations.

If your migration script extracts records, saves the JSON to a local file, and attempts to upload those URLs to HubSpot hours later, every file upload will fail with a `403 Forbidden` error.

**The fix:** Your extraction script must process files synchronously or via a high-priority queue. The moment the Notion API returns a file URL:

1. Download the file into memory or local storage immediately.
2. Upload it to the HubSpot File Manager API (`POST /files/v3/files`).
3. Store the new, permanent HubSpot File URL in your migration payload.

Do not defer this step. File URL expiration is not recoverable without re-querying the Notion API to get a fresh signed URL — and re-querying at scale means replaying your entire extraction for affected pages.

## Notion Page Content: Blocks to HubSpot Notes

This is the question most migration guides skip. Notion databases are unique because each row is also a page with rich block content — notes, embedded images, toggle lists, sub-pages. HubSpot records are flat property containers with optional engagement objects (notes, emails, calls).

### The JSON-to-HTML Parsing Problem

Notion page content does not export as clean text. It exports as an array of JSON objects representing distinct blocks.

A simple paragraph in Notion looks like this in the API response:

```json
{
  "type": "paragraph",
  "paragraph": {
    "rich_text": [
      {
        "type": "text",
        "text": {
          "content": "Discussed the Q3 pipeline.",
          "link": null
        }
      }
    ]
  }
}
```

HubSpot's Engagements API expects Notes formatted as HTML. You cannot push Notion's raw JSON into HubSpot.

You must build a parsing function that translates Notion block types (`paragraph`, `heading_1`, `bulleted_list_item`, `to_do`) into their HTML equivalents (`<p>`, `<h1>`, `<ul><li>`, `<input type="checkbox">`). Once converted to an HTML string, you create a HubSpot Note Engagement and associate it with the parent CRM record.

Notion pages often contain `@mentions` of workspace users. These extract as Notion User IDs. To preserve readability, your script must query the Notion Users API, map the Notion User ID to their email address, and replace the ID in the text block with the user's actual name before sending the HTML to HubSpot.

### Three Options for Page Content

You have three realistic approaches:

1. **Serialize to a HubSpot Note engagement.** Convert block content to HTML, then create a Note engagement associated with the HubSpot record. You lose nested structure but preserve searchable text. If the content exceeds 65,536 characters, split it across multiple Notes (e.g., append `[Part 1 of 3]` in the note title for traceability).

2. **Store a link back to Notion.** Add a URL property on the HubSpot record pointing to the original Notion page. Simplest approach, but creates a permanent dependency on Notion.

3. **Migrate to HubSpot Knowledge Base.** If the content is customer-facing documentation, HubSpot Knowledge Base (Service Hub Professional+) can receive it. You'll need to convert Notion blocks to HTML and handle images separately.

<cite index="20-6,20-7">One common approach is to sync specific content blocks from a Notion page into a rich text field in HubSpot, or more commonly, sync a direct link to the Notion page itself, attaching it to the HubSpot record for easy access.</cite>

Most teams use a combination: Note engagements for customer-facing context (call notes, implementation summaries, handoff notes) and links back to Notion for internal documentation that doesn't belong on a CRM timeline.

## Rebuilding Relationships: The Identity Map

If you have a `Companies` database and a `Contacts` database in Notion connected by a Relation property, migrating them requires an **identity map**.

Notion identifies records by UUID. HubSpot identifies records by integer ID. When you extract a Contact from Notion, the API tells you it's related to Company `a1b2c3d4...`. HubSpot has no idea what that UUID means.

> [!TIP]
> **Create a unique source ID property in HubSpot.** Add one custom property per target object (e.g., `notion_page_id`) with `hasUniqueValue=true`. This single design choice makes deduplication, delta loads, retries, and validation far easier because you stop depending on names or emails as the only identifiers. ([developers.hubspot.com](https://developers.hubspot.com/docs/api-reference/latest/crm/properties/create-property?utm_source=openai))

Structure your migration in phases:

1. **Phase 1: Load Companies.** Read all companies from Notion. Create them in HubSpot. Store the mapping of `Notion_Company_UUID` → `HubSpot_Company_ID` in a local database (SQLite, Redis, or even a dictionary in memory for smaller datasets).

2. **Phase 2: Load Contacts.** Read all contacts from Notion. Create them in HubSpot. Store the mapping of `Notion_Contact_UUID` → `HubSpot_Contact_ID`.

3. **Phase 3: Load Deals.** Create Deals in HubSpot. Store the mapping of `Notion_Deal_UUID` → `HubSpot_Deal_ID`.

4. **Phase 4: Execute Associations.** Iterate through the Notion relation data. For every relation, look up the corresponding HubSpot IDs in your identity map. Send a batch request to the HubSpot Associations v4 API (up to 2,000 inputs per call) linking the relevant objects. Handle 207 partial-success responses — these indicate some associations succeeded and others failed within the same batch.

```python
import hubspot
from hubspot.crm.associations.v4 import BatchInputPublicAssociationMultiArchiveInputRequest

client = hubspot.Client.create(access_token="your-hubspot-token")

def batch_create_associations(pairs, from_type, to_type, association_type_id, max_retries=5):
    """
    Create associations in batches of 2000 with retry on rate limit.
    pairs: list of (from_id, to_id) tuples
    """
    for i in range(0, len(pairs), 2000):
        batch = pairs[i:i+2000]
        inputs = [
            {
                "from": {"id": str(from_id)},
                "to": {"id": str(to_id)},
                "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": association_type_id}]
            }
            for from_id, to_id in batch
        ]
        for attempt in range(max_retries):
            try:
                response = client.crm.associations.v4.batch_api.create(
                    from_object_type=from_type,
                    to_object_type=to_type,
                    batch_input_public_association_multi_post={"inputs": inputs}
                )
                # Check for partial failures in 207 responses
                if hasattr(response, 'errors') and response.errors:
                    print(f"Partial association failures: {response.errors}")
                break
            except Exception as e:
                if "429" in str(e):
                    import time, random
                    wait = (2 ** attempt) + random.uniform(0, 1)
                    time.sleep(wait)
                else:
                    raise
```

Without an identity map, your records will migrate successfully but they will be entirely orphaned — no links between Contacts and Companies, no Deal-to-Contact connections.

> [!CAUTION]
> **Do not join on titles or company names.** HubSpot's import only supports Record ID, email, company domain, or a custom unique-value property as reliable identifiers. Names are a weak migration key — duplicates and normalization will break your associations. ([developers.hubspot.com](https://developers.hubspot.com/docs/api-reference/latest/crm/imports/guide))

## Step-by-Step Migration Process

### Step 1: Audit Your Notion Workspace

Before touching any code, map every database you plan to migrate:

- List all databases and their property schemas
- Identify which databases have Relations to each other
- Count total rows per database
- Note which rows have significant page body content (blocks)
- Identify file attachments (images, PDFs, documents)
- Document any Rollup or Formula properties you need to preserve functionally
- Separate true CRM data from internal documentation that should stay in Notion
- Confirm integration token access: verify every database appears in an API test query before assuming it's accessible

### Step 2: Design Your HubSpot Schema

Decide how each Notion database maps to HubSpot:

- **Contacts database** → HubSpot Contacts
- **Companies database** → HubSpot Companies
- **Deals/Opportunities database** → HubSpot Deals (pre-create pipeline and stages first)
- **Projects/Tasks database** → HubSpot Deals (if revenue-linked), Tickets (if support-related), or Custom Objects (Enterprise only)
- **Knowledge/Wiki database** → HubSpot Knowledge Base articles (Service Hub Professional+) or Notes

Create all custom properties in HubSpot _before_ importing data. For dropdown/enumeration properties, pre-populate every option value that exists in your Notion Select and Multi-select columns. HubSpot's import will reject values that don't match existing options unless you explicitly allow new option creation.

For Deal migrations specifically: create the pipeline first, capture stage IDs, and build the Notion status → HubSpot stage ID translation table before loading any records.

### Step 3: Extract Data via the Notion API

Use the Notion API — not CSV export — for any migration involving relations, files, or page content.

1. **Create an internal integration** at [notion.so/my-integrations](https://notion.so/my-integrations)
2. **Share each database** with the integration (Connection → Add connections) — unshared databases return silently empty results
3. **Query each database** using `POST /v1/databases/{id}/query` with pagination
4. **Fetch page content** using `GET /v1/blocks/{page_id}/children` with recursive traversal
5. **Resolve Relations** — the API returns related page IDs; you need to look up corresponding records to map them to HubSpot IDs later
6. **Download files immediately** — Notion file URLs expire within ~1 hour

> [!TIP]
> **Use `last_edited_time` for incremental extraction.** If your migration takes multiple passes, filter database queries by `last_edited_time` to only re-extract changed records. This is also essential for delta syncs after the initial migration.

### Step 4: Transform and Load into HubSpot

1. **Batch create/upsert standard objects** — Contacts, Companies, Deals — using `/crm/v3/objects/{objectType}/batch/upsert` (100 records per call, keyed on `notion_page_id`)
2. **Upload files** to HubSpot File Manager via the Files API, capturing returned file URLs
3. **Create Associations** between objects using the Associations v4 API batch endpoint (2,000 inputs per call); handle 207 partial-success responses
4. **Create engagement notes** for rich page content that can't be flattened into properties
5. **Validate record counts** — compare source row counts against HubSpot object counts

```python
import hubspot
from hubspot.crm.contacts import BatchInputSimplePublicObjectInputForCreate

client = hubspot.Client.create(access_token="your-hubspot-token")

def batch_create_contacts(records, max_retries=5):
    """Create contacts in batches of 100 with exponential backoff."""
    import time, random
    for i in range(0, len(records), 100):
        batch = records[i:i+100]
        inputs = [
            {"properties": {
                "email": r["email"],
                "firstname": r["first_name"],
                "lastname": r["last_name"],
                "company": r["company"],
                "notion_page_id": r["notion_id"],  # Unique source ID for upsert/dedup
                # Map additional custom properties here
            }}
            for r in batch
        ]
        batch_input = BatchInputSimplePublicObjectInputForCreate(inputs=inputs)
        for attempt in range(max_retries):
            try:
                client.crm.contacts.batch_api.create(batch_input)
                break
            except Exception as e:
                if "429" in str(e):
                    wait = (2 ** attempt) + random.uniform(0, 1)
                    time.sleep(wait)
                else:
                    raise
```

### Step 5: Validate and Rebuild

Some Notion functionality has no HubSpot data equivalent and must be manually reconstructed:

- **Database views** (Kanban boards, calendars, timelines) → HubSpot saved views and dashboards
- **Formulas and Rollups** → HubSpot calculated properties or workflow-computed values
- **Automations** → HubSpot workflows
- **Permissions** → HubSpot team and user-level access controls

Compare source and target record counts, spot-check 20+ individual records for property accuracy, and verify all associations are intact. Use batch reads by record ID (not search) for validation — HubSpot search has eventual consistency lag, is capped at 200 records per page, and stops at 10,000 results per query.

## Migration Approaches Compared

| Approach | Best For | Limitations |
|---|---|---|
| **CSV Export + HubSpot Import** | Single flat database, <5K rows, no relations | Loses relations, rollups, formulas, files; no page content; no associations; always creates new records (no upsert) |
| **HubSpot Data Sync** | Ongoing sync between Notion and HubSpot | Live sync only — not a backfill tool; checks every 5 minutes; custom field mappings require Data Hub ([knowledge.hubspot.com](https://knowledge.hubspot.com/integrations/connect-and-use-hubspot-data-sync?_hsmi=2)) |
| **Zapier / Make (iPaaS)** | Small ongoing syncs, <1K records | Per-record API calls hit Notion rate limits fast; no batch support; expensive at scale; struggles with JSON-to-HTML block parsing |
| **Custom API Script** | Full migration with relations, files, page content | Requires engineering time; must handle rate limiting, pagination, error recovery |
| **Managed Migration Service** | Complex multi-database workspaces, >10K records, tight timelines | Cost; less control over implementation details |

### When CSV Is Enough

CSV works when you have a single Notion database with no Relations, you don't need page body content in HubSpot, you have fewer than ~5,000 rows, and you can manually remap Select/Multi-select values.

CSV fails when you have inter-database Relations that must become HubSpot Associations, you have file attachments (they're not in CSV exports), you need page content as HubSpot notes, or you need idempotent re-runs (HubSpot CSV import always creates new records — it does not update).

### When You Need the API

The API path is required when multiple Notion databases map to different HubSpot objects with Associations, files and images must be transferred, page body content needs conversion to HubSpot notes, you need upsert behavior for safe re-runs, or record counts exceed what's practical with manual CSV mapping.

> [!WARNING]
> **HubSpot Data Sync is not a backfill tool.** HubSpot's native Notion integration asks you to select a source table, choose sync direction, and map fields. That is a live sync workflow. It does not replace the extraction, cleaning, ID design, and history remapping needed for a one-time CRM migration. ([knowledge.hubspot.com](https://knowledge.hubspot.com/integrations/connect-and-use-hubspot-data-sync?_hsmi=2))

## Common Failure Modes

**1. Orphaned associations.** If you create Contacts before Companies, or Deals before Contacts, association creation will fail on missing target records. Always load parent objects first: Companies → Contacts → Deals → Tickets/Custom Objects. Build an ID mapping table and resolve references during the association phase.

**2. Duplicate contacts.** HubSpot deduplicates Contacts on email address. If your Notion database has rows with the same email, HubSpot will merge or reject them depending on your import method. Deduplicate _before_ loading. The API's upsert endpoint with `idProperty: "email"` handles this cleanly.

**3. Duplicate companies not caught.** HubSpot deduplicates Companies on `domain` — not on company name. If your Notion company records lack domain values, HubSpot's deduplication won't fire, and you'll create duplicates for every company that appears in multiple Notion relations. Populate `domain` before loading, or accept that manual deduplication will be needed post-migration.

**4. Expired Notion file URLs.** The single most common silent data loss vector. If your extraction script caches raw API responses and processes them hours later, every file link will 403. Download immediately upon extraction. Recovery requires re-querying Notion for fresh signed URLs.

**5. Using names as join keys.** It works in a sample file and explodes when duplicates appear in production data.

**6. Rich text truncation.** <cite index="44-17,44-18,44-19">The Notion API limits each rich text object to 2,000 characters. You must split longer text into multiple `rich_text` array elements.</cite> On the HubSpot side, `hs_note_body` is capped at 65,536 characters.

**7. Timezone drift on dates.** Notion dates with explicit timezones will shift when loaded into HubSpot's UTC-only date properties. Always test date conversion with edge-case times near midnight.

**8. Missing Custom Object access.** If your Notion workspace has databases that don't map to Contacts, Companies, Deals, or Tickets, you need Custom Objects — which require HubSpot Enterprise. Discover this before you start mapping, not midway through migration.

**9. Deal stage ID vs. label.** HubSpot Deal `dealstage` requires a stage ID (a string like `"appointmentscheduled"`), not a label string. Sending a label will silently null out the stage on some API versions or return a validation error on others. Pre-create pipelines, capture stage IDs, and translate Notion status values before loading.

**10. Blank cells don't clear values.** Blank cells in HubSpot import files do not clear existing property values. If you need to null out fields during delta imports, that requires a separate API pass. ([knowledge.hubspot.com](https://knowledge.hubspot.com/import-and-export/set-up-your-import-file?bbpage=1&utm_source=openai))

**11. HubSpot search lag.** Do not rely on search to validate freshly created records. Search has eventual consistency lag, is capped at 200 records per page, and stops at 10,000 results per query. Validate by batching reads by ID instead. ([developers.hubspot.com](https://developers.hubspot.com/docs/api-reference/latest/crm/search-the-crm?utm_source=openai))

**12. Outdated Notion scripts.** Notion's API versioning has changed significantly. Since version `2025-09-03`, the platform split databases from data sources, so many older community scripts are outdated. Pin your `Notion-Version` header and check the [upgrade guide](https://developers.notion.com/guides/get-started/upgrade-guide-2025-09-03) before reusing existing code.

**13. Unmatched Notion users silently dropped.** If a Notion Person property maps to a user who doesn't exist in HubSpot, the owner assignment will be null with no error. Store the original Notion user email in a custom text property (`notion_assigned_to`) and generate a migration report of all records needing manual owner reassignment.

**14. Property history timestamps reflect migration date, not original date.** All HubSpot property history entries will be timestamped at migration time. Historical reports (e.g., "leads created in Q1 2023") will show incorrect date distributions. Mitigate by preserving original timestamps in custom properties and rebuilding date-based reports after migration.

## Pre-Migration Checklist

- [ ] Audit all Notion databases: row counts, property schemas, Relations map
- [ ] Decide HubSpot object mapping for every database (Contact, Company, Deal, Ticket, Custom Object)
- [ ] Confirm HubSpot subscription tier supports your requirements (Custom Objects = Enterprise)
- [ ] Create all custom properties and dropdown options in HubSpot before loading
- [ ] Pre-create Deal pipelines and capture stage IDs before loading any Deal records
- [ ] Create unique `notion_page_id` properties on each HubSpot object (with `hasUniqueValue=true`)
- [ ] Create a Notion internal integration and share all databases with it; verify via test query
- [ ] Build an ID mapping table structure (Notion page UUID → HubSpot record ID)
- [ ] Test with a sample migration of 50–100 records per database
- [ ] Validate association integrity: every linked record in Notion has a corresponding association in HubSpot
- [ ] Compare source/target row counts after full load
- [ ] Spot-check 20+ individual records for property accuracy
- [ ] Generate a report of unmatched Notion users (Person properties with no HubSpot owner equivalent)
- [ ] Confirm date conversion behavior with edge-case timestamps near midnight UTC

For a broader migration planning framework, see our [CRM Data Migration Checklist](https://clonepartner.com/blog/blog/the-ultimate-crm-data-migration-checklist-a-10-point-plan-for-a-zero-loss-transition/).

## When to Consider a Managed Migration

Self-serve makes sense when you have engineering capacity, a simple database structure (1–2 databases, no Relations, <5K rows), and a flexible timeline.

A managed migration makes sense when:

- You have 3+ related Notion databases that must map to HubSpot objects with Associations
- Total row counts exceed 10,000
- You need file and page content migration (not just properties)
- Your team doesn't have bandwidth to build and debug custom extraction/loading scripts
- You need the migration done in days, not weeks of iteration

If your move can honestly be described as "one database, one object, no history," self-serve CSV may be enough. If you have multiple related databases, page-body notes, files, or a tight cutover window, this is engineering work, not admin work.

> Need help migrating from Notion to HubSpot? Book a free 30-minute call with our migration engineering team. We'll map your Notion database schema to HubSpot's object model and give you an honest assessment of complexity, timeline, and whether you need us at all.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Can I migrate from Notion to HubSpot using CSV export?

CSV works for a single flat database with no Relations, no files, and fewer than ~5,000 rows. But CSV export strips Relations (they become plain-text page IDs, not titles), drops Rollups and Formulas, and excludes page content and file attachments. For anything with cross-database relationships, use the Notion API.

### What are the Notion API rate limits for data extraction?

Notion enforces a hard limit of 3 requests per second per integration, with a secondary limit of 1,000 requests per 5 minutes per workspace. Database queries return a maximum of 100 results per page, and block children retrieval requires recursive calls. Plan for 2–4× theoretical minimum extraction time.

### Do I need HubSpot Enterprise to migrate from Notion?

Only if your Notion databases don't map cleanly to HubSpot's standard objects (Contacts, Companies, Deals, Tickets). Custom Objects — required for data that needs its own record type — are Enterprise-only. One Enterprise seat on any Hub unlocks Custom Objects across the whole portal.

### How do Notion Relations map to HubSpot?

Notion Relations become HubSpot Associations. You create parent objects first (e.g., Companies), then child objects (e.g., Contacts), then use the Associations v4 API to link them. CSV imports cannot create associations — you must use the API or a managed migration tool.

### What happens to Notion page content during migration to HubSpot?

Each Notion database row is a page with rich block content that has no direct HubSpot equivalent. You can serialize it to a HubSpot Note engagement (converting blocks to HTML), store a URL back to the original Notion page, or convert it to HubSpot Knowledge Base articles. Most teams use a combination of notes and links.
