Notion to HubSpot Migration: A Technical Guide to Data Mapping & APIs
A technical guide to migrating Notion databases to HubSpot CRM — covering API constraints, property mapping, relation-to-association translation, and common failure modes.
Planning a migration?
Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.
Schedule a free call- 1,500+ migrations completed
- Zero downtime guaranteed
- Transparent, fixed pricing
- Project success responsibility
- Post-migration support included
Notion to HubSpot Migration: A Technical Guide to Data Mapping & APIs
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. If you're also evaluating other Notion migration targets, our guides on Notion to Webflow and Notion to SharePoint 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.
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.
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. When you export a relational database as a CSV file, the relation properties will export as plain text URLs. 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).
The API is different. The API provides the computed values for formulas and rollups, and the full object details for relations. 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) 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: Notion's public API enforces a hard limit of 3 requests per second per integration. Notion also enforces a secondary limit: 1,000 requests per 5 minutes per workspace. Your migration script must implement exponential backoff to handle HTTP 429 responses.
Pagination: The maximum number of results in one paginated response is 100. That applies to database queries and block children retrieval alike. You must handle the next_cursor to paginate through large databases.
Block tree traversal: The Notion API only returns first-level children for any block. 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. 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: Notion limits request payloads to 1,000 block elements and 500KB total. Any individual children array is capped at 100 elements, and nested blocks can only go two levels deep in a single request.
Rich text: The Notion API limits each rich text object to 2,000 characters.
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)
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
At ceiling throughput, paginating a 5,000-row database at 100 rows per page takes 50 requests — at least 17 seconds. 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.
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.
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 rowsHubSpot API Constraints for Data Loading
On the HubSpot side, the constraints are different but equally important.
Rate limits: HubSpot enforces burst limits (100–200 requests per 10 seconds) and daily limits (250,000–1,000,000 per day depending on tier). Search endpoints have a separate, stricter limit: 4 requests per second.
Batch endpoints: HubSpot's batch create/update endpoints let you process up to 100 records in a single API call. This is the single most important optimization for loading. From a rate limit perspective, that's 100x more efficient than individual calls.
Upsert: 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. 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)
Custom Objects: As of April 2026, Custom Objects remain Enterprise-only across every Hub. 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. 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. Discover this before you start mapping, not midway through migration.
Custom properties: Limited to 1,000 per object on Professional and Enterprise plans, and 10 total on free accounts.
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)
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)
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_idunique 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 (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:
- Create the pipeline in HubSpot via
POST /crm/v3/pipelines/dealswith all required stages. - Capture the returned
stageIdfor each stage. - Build a translation table: Notion status value → HubSpot
stageId. - Populate
dealstagein 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:
- Query
GET /v1/users/{user_id}in Notion to retrieve the user's email. - Query HubSpot's
GET /crm/v3/ownersto find the matching HubSpot owner by email. - Set
hubspot_owner_idon 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.
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:
- Download the file into memory or local storage immediately.
- Upload it to the HubSpot File Manager API (
POST /files/v3/files). - 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:
{
"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:
-
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). -
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.
-
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.
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.
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.
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)
Structure your migration in phases:
-
Phase 1: Load Companies. Read all companies from Notion. Create them in HubSpot. Store the mapping of
Notion_Company_UUID→HubSpot_Company_IDin a local database (SQLite, Redis, or even a dictionary in memory for smaller datasets). -
Phase 2: Load Contacts. Read all contacts from Notion. Create them in HubSpot. Store the mapping of
Notion_Contact_UUID→HubSpot_Contact_ID. -
Phase 3: Load Deals. Create Deals in HubSpot. Store the mapping of
Notion_Deal_UUID→HubSpot_Deal_ID. -
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.
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:
raiseWithout 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.
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)
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.
- Create an internal integration at notion.so/my-integrations
- Share each database with the integration (Connection → Add connections) — unshared databases return silently empty results
- Query each database using
POST /v1/databases/{id}/querywith pagination - Fetch page content using
GET /v1/blocks/{page_id}/childrenwith recursive traversal - Resolve Relations — the API returns related page IDs; you need to look up corresponding records to map them to HubSpot IDs later
- Download files immediately — Notion file URLs expire within ~1 hour
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
- Batch create/upsert standard objects — Contacts, Companies, Deals — using
/crm/v3/objects/{objectType}/batch/upsert(100 records per call, keyed onnotion_page_id) - Upload files to HubSpot File Manager via the Files API, capturing returned file URLs
- Create Associations between objects using the Associations v4 API batch endpoint (2,000 inputs per call); handle 207 partial-success responses
- Create engagement notes for rich page content that can't be flattened into properties
- Validate record counts — compare source row counts against HubSpot object counts
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:
raiseStep 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) |
| 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.
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)
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. The Notion API limits each rich text object to 2,000 characters. You must split longer text into multiple rich_text array elements. 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)
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)
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 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_idproperties on each HubSpot object (withhasUniqueValue=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.
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.
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.