---
title: How to Migrate Custom Objects and Associations Between CRMs
slug: how-to-migrate-custom-objects-and-associations-between-crms
date: 2026-09-25
author: Rishabh Makhar
categories: [Migration Guide, CRM, From The Migration Trenches]
excerpt: "Learn how to migrate CRM custom objects and associations between Salesforce, HubSpot, Zoho, Pipedrive, and Attio without losing relationships or data integrity."
tldr: "Migrating custom objects between CRMs requires schema translation, strict dependency ordering, external-ID idempotency, and association-level validation — not just row counts."
canonical: https://clonepartner.com/blog/how-to-migrate-custom-objects-and-associations-between-crms
---

# How to Migrate Custom Objects and Associations Between CRMs


The best way to migrate custom objects and associations between CRMs is to treat the job as a graph migration — not a row export. You need to extract the full object graph from the source, map every relationship to the target's association model, load records in strict dependency order with external IDs for idempotent reruns, and validate that every association survived the trip. Skip any of those steps and you will orphan records, break pipeline reports, and permanently lose historical context.

Custom objects and their associations are the single most common reason DIY CRM migrations fail. Standard objects — contacts, companies, deals — move with moderate effort. But the moment your source CRM has custom objects with lookup fields, many-to-many joins, or labeled associations, complexity jumps by an order of magnitude. Every platform models relationships differently, and a direct export/import silently destroys the links that make your data useful. A CSV flattens relational data into rows and columns. The rows survive. The relationships do not.

If you remember one rule: **migrate nodes first, edges second, and prove the edges survived**. That is the difference between "records imported" and "the sales team can still trust pipeline, reporting, and history."

This guide covers the concrete differences across Salesforce, HubSpot, Zoho CRM, Pipedrive, and Attio — and the exact steps to move custom objects and associations without data loss. For a broader pre-migration framework, see [The Ultimate CRM Data Migration Checklist](https://clonepartner.com/blog/blog/the-ultimate-crm-data-migration-checklist-a-10-point-plan-for-a-zero-loss-transition).

## How Each CRM Models Custom Objects and Relationships

Before you map a single field, you need to understand what "custom object" and "association" mean in each platform. The terminology differs, the constraints differ, and the cardinality options differ. Assuming a one-to-one mapping between platforms guarantees data loss.

### Salesforce: Objects, Lookups, and Master-Detail

Salesforce custom objects are fully first-class entities with their own tabs, page layouts, validation rules, and Apex triggers. Custom objects carry the `__c` suffix in their API names.

Relationships come in three types:

- **Lookup** — a loosely coupled one-to-many link. Deleting the parent does not cascade to children. <cite index="14-7">The lookup is a loosely coupled relationship, allowing you to connect one object to another in a one-to-many fashion.</cite>
- **Master-Detail** — a tightly coupled parent-child link with cascade delete and shared security. <cite index="13-4">Each custom object can have up to two master-detail relationships and up to 40 total relationships.</cite>
- **Many-to-many** — implemented via a **junction object** with two master-detail relationships pointing to the two objects being linked. <cite index="11-8,11-9">You can use master-detail relationships to model many-to-many relationships between any two objects. A many-to-many relationship allows each record of one object to be linked to multiple records from another object and vice versa.</cite>

Key migration constraint: <cite index="10-11">you can't create a master-detail relationship if the custom object already contains data.</cite> You must create the relationship as a lookup first, populate data, then convert — or create the master-detail before loading any records. Bulk loads can resolve parents through relationship headers like `Parent__r.External_ID__c`. ([developer.salesforce.com](https://developer.salesforce.com/docs/platform/api-asynch/guide/datafiles-csv-rel-field-header-row.html?utm_source=openai))

### HubSpot: Custom Objects, Properties, and the Associations API

**HubSpot custom objects** are an Enterprise-tier feature. <cite index="47-2,47-3">HubSpot custom objects are available on the Enterprise level subscription plan. An Enterprise level subscription allows for up to ten definitions with up to 500,000 records each without any additional charges.</cite>

Relationships are managed through the **Associations API** (currently v4). <cite index="56-3,56-4,56-5">Associations represent the relationships between objects and activities in the HubSpot CRM. Record associations can exist between records of different objects as well as within the same object. The v4 Associations API includes two sets of endpoints.</cite> HubSpot does not use junction objects — any object can be associated to any other object directly, including many-to-many.

The v4 API introduced **association labels**, which let you categorize relationships (e.g., "Decision Maker", "Billing Contact"). <cite index="62-7,62-8">HubSpot allows custom association labels, which create new type IDs unique to your portal. A "Billing Contact" label you create might be type ID 28 in your portal and 47 in another.</cite> This means association type IDs are portal-specific and must be discovered at runtime — never hardcoded across migrations.

HubSpot also enforces per-record association limits. Cardinality is never "infinite." Some object pairs have lower caps than others, so check the limits before assuming your high-cardinality source data will transfer cleanly.

### Zoho CRM: Modules, Lookups, and Linking Modules

Zoho uses the term **module** where others say "object." <cite index="34-13,34-14">A custom module gets its own fields and page layouts. Lookup and related-list relationships to standard or other custom modules.</cite>

For many-to-many relationships, Zoho uses **linking modules** — a dedicated module type that holds two lookup fields from the associated modules. <cite index="32-1,32-2">Mandatory fields in a linking module are two lookup fields from the modules to be associated. A maximum of 100 custom fields are available for a linking module.</cite> Zoho also supports multi-select lookup fields, which create linking modules behind the scenes.

<cite index="73-7,73-9,73-10">Custom modules are now available in Standard and Professional editions. The limits are no longer tied to the number of users in your organization.</cite> Enterprise editions support up to 200 custom modules.

One common trap: **Zoho subforms are not modules.** A subform looks like a related list, but its rows are not independent records — they cannot be queried via API independently, cannot trigger workflows, and cannot be migrated as standalone entities. If your source system uses subforms, you need to decide whether to promote them to full modules in the target or flatten them.

For specific Zoho transition strategies, see [Zoho CRM to HubSpot Migration: The Complete Technical Guide](https://clonepartner.com/blog/blog/zoho-crm-to-hubspot-migration-the-complete-technical-guide).

### Pipedrive: No Custom Objects

**Pipedrive does not support custom objects.** <cite index="41-1">Pipedrive doesn't offer custom objects — only custom fields.</cite> You can add custom fields to built-in entities (Deals, Persons, Organizations, Products, Projects), but you cannot create new entity types.

Relationship modeling is narrow: a Person belongs to one Organization, Deals link to Persons and Organizations, and extra related records are often modeled with single-value custom fields. <cite index="39-2">All custom fields are referenced as randomly generated 40-character hashes.</cite> There are no explicit association APIs — relationships between Pipedrive entities are implicit and fixed.

Migrating **to** Pipedrive from a CRM with custom objects requires flattening your data model, moving custom object data into JSON-formatted text fields or custom fields, or accepting that Pipedrive cannot represent certain relationships. Migrating **from** Pipedrive is simpler because there is less relational complexity to preserve.

### Attio: Objects, Attributes, and Relationship Attributes

Attio uses a flexible, graph-like data model. Everything is an **object** (People, Companies, Deals, plus custom objects) connected by **relationship attributes**. <cite index="22-3,22-4,22-5">One of the most powerful aspects of objects is that you can specify their relationship types. This allows you to define how objects are connected and interact with each other. In Attio, there are three types of object relationships.</cite> Attio supports one-to-one, one-to-many, and many-to-many natively.

<cite index="24-11,24-12">These relationships are two-way. If you update the Company on a person's record, it will also update the company's Team automatically.</cite>

Attio does not force a specific hierarchy, which means you must deliberately architect the relationship chains (e.g., Company → Person → Deal) during migration. <cite index="28-15">Custom relationship fields often need manual remapping because CSV import flattens relational data.</cite> Many-to-many relationships between custom objects require API-based ingestion. See [The CTO's Guide to Salesforce to Attio Migration](https://clonepartner.com/blog/blog/the-ctos-guide-to-salesforce-to-attio-migration-2026) for implementation details.

## Why Association Direction and Cardinality Break on Import

Association **direction** and **cardinality** are the two properties that silently break during CRM migrations. They break because flat file uploads cannot natively express one-to-many or many-to-many relationships without duplicating rows or dropping references, and because each CRM expects relationship data in a different format.

### Direction mismatch

The source CRM might encode a relationship as "Contact belongs to Account" (child-to-parent), while the target CRM models it as "Account has Contact" (parent-to-child). The data is the same; the direction the API expects it is not.

- **Salesforce** lookups are stored on the child record. The `AccountId` field lives on the Contact.
- **HubSpot** associations are bidirectional but require you to specify a `from` and `to` object when creating them via API. The `associationTypeId` encodes the direction — `contact_to_company` and `company_to_contact` are different type IDs.
- **Zoho** lookup fields sit on the child module, similar to Salesforce.
- **Attio** relationship attributes are automatically bidirectional — updating one side updates the other.

### Cardinality mismatch

**Cardinality** is the numerical relationship between rows in one table and rows in another: one-to-one, one-to-many, or many-to-many.

When you export a one-to-many relationship — one Account with five Contacts — to CSV, the system either outputs five rows with identical Account data or one row with five Contacts crammed into a single cell. Standard import tools misinterpret the former as five distinct Accounts (creating duplicates) and reject the latter entirely.

Cardinality mismatch is more dangerous than direction mismatch. A Salesforce master-detail is strictly one-to-many. If you migrate to HubSpot, which allows many-to-many associations between any objects by default, you might accidentally create associations that violate your business logic. Going the other direction — from HubSpot's flexible many-to-many to Salesforce — you need to create junction objects and populate them, or you will lose half of every many-to-many link.

In Salesforce, a master-detail relationship is strictly unidirectional: the child cannot exist without the parent. If you migrate this to HubSpot's bidirectional associations, the strict dependency is lost. You must enforce it via HubSpot workflows or validation rules post-migration.

> [!WARNING]
> **The silent failure mode:** Most import tools don't error when they drop an association — they succeed at creating the record and silently skip the link. You won't know associations are missing until someone runs a report and finds 40% of deals detached from their accounts.

## The Ordering Problem: Loading Parents Before Children

CRM data forms a directed acyclic graph. Records reference other records, and those references must resolve to existing IDs in the target system. If you load Contacts before Accounts, the Contacts will be orphaned because the Account IDs they reference do not exist yet.

The correct load order follows the dependency graph:

1. **Independent objects first** — objects with no foreign-key dependencies (Users/Owners, Companies/Accounts, standalone custom objects)
2. **First-level dependents** — objects that reference only independent objects (Contacts that reference Companies)
3. **Second-level dependents** — objects that reference first-level objects (Deals/Opportunities that reference Contacts and Companies)
4. **Custom objects (dependent)** — custom objects that depend on other custom objects
5. **Activities and engagement records** — emails, calls, notes, tasks that reference multiple parent objects
6. **Association/junction records** — explicit association or junction records, loaded after all objects on both sides exist
7. **Attachments** — files tied to specific records

Salesforce explicitly recommends parent-before-child loading to avoid relationship failures. ([trailhead.salesforce.com](https://trailhead.salesforce.com/de/content/learn/modules/scalability-with-salesforce/manage-data-at-scale-with-salesforce?utm_source=openai)) The same logic applies across CRMs because a relationship cannot resolve until both endpoints exist.

Treat users, owners, stages, and reference records as prerequisites too. A migration that creates the right links but points them at the wrong owner, pipeline, or status is still broken — it just fails more quietly.

### Resolving circular references

A **circular reference** occurs when Object A has a lookup to Object B and Object B has a lookup back to Object A. Example: a `Project__c` custom object has a lookup to `Account`, and `Account` has a custom lookup back to `Primary_Project__c`.

You cannot load both simultaneously. The solution is a **multi-pass load**:

1. **Pass 1 (Insert base records):** Insert all records for both objects with the circular reference fields left null. Capture the newly generated IDs.
2. **Pass 2 (Insert dependent records):** Insert the other side's records, populating the forward-direction lookup with the IDs from Pass 1.
3. **Pass 3 (Back-fill circular references):** Run an update operation on the base records, populating the circular reference fields with the IDs from Pass 2.

```python
# Pass 1: Create projects without the circular reference
for project in source_projects:
    target_project = create_record("Project__c", {
        "Name": project["name"],
        "Account__c": id_map["Account"][project["account_id"]],
        # Primary_Contact__c intentionally omitted
    })
    id_map["Project"][project["id"]] = target_project["id"]

# Pass 2: Back-fill circular references
for account in source_accounts:
    if account.get("Primary_Project__c"):
        update_record("Account", id_map["Account"][account["id"]], {
            "Primary_Project__c": id_map["Project"][account["Primary_Project__c"]]
        })
```

This adds extra API passes, but it is the only safe way to handle circular dependencies without data loss. If both sides require a non-null value at create time, temporarily relax the validation rule until the second pass finishes.

For large Salesforce child loads, group imported child data by parent key to reduce lock contention. Salesforce explicitly recommends ordering by parent ID during bulk operations. ([developer.salesforce.com](https://developer.salesforce.com/docs/atlas.en-us.integration_patterns_and_practices.meta/integ_pat_tempate.htm?utm_source=openai))

## External IDs and Idempotent Loads

An **external ID** is a field on the target record that stores the source system's original record ID. An **idempotent load** is one you can run multiple times without creating duplicate records or unintended side effects. You need both, because production migrations are never single-run — you will iterate through test loads, partial loads, failed restarts, and delta syncs.

### Setting up external IDs per platform

Before loading any records, create a custom field on every target object to hold the source ID:

| Platform | How to Set Up External IDs |
|----------|---------------------------|
| **Salesforce** | Create a custom field marked as `External ID` and `Unique`. Salesforce natively supports `upsert` by external ID — one API call either creates or updates. |
| **HubSpot** | Create a custom property (e.g., `source_crm_id`) with `hasUniqueValue` set to true. Use the batch upsert endpoint with `idProperty` set to that field. |
| **Zoho CRM** | Create a custom field and use the `upsert` API with the `duplicate_check_fields` parameter. Zoho caps insert and upsert calls at 100 records per request. |
| **Attio** | Use a custom attribute as a matching key. Attio's API supports matching on specified attributes during record creation. |
| **Pipedrive** | Create a custom field. Pipedrive has no native upsert — you must search-then-create/update. |

When migrating custom objects, scripts will fail. APIs will time out, rate limits will be exceeded, and dirty data will trigger validation errors. If your script uses standard `POST` (insert) requests, restarting a failed script will duplicate all the records that loaded before the crash. With external IDs and `UPSERT` operations, a crashed script can be rerun immediately — the system updates existing records and inserts only the missing ones.

This strategy is also mandatory for **delta migrations**, where you sync records that changed during the cutover window.

### Why idempotency is harder for associations

Records can be upserted safely. Associations are harder.

- In **HubSpot**, creating the same association twice is a no-op (it will not duplicate).
- In **Salesforce**, inserting a duplicate junction object record creates a real duplicate unless you enforce uniqueness via a custom unique field on the junction object.
- In **Zoho**, inserting a duplicate linking module record also creates a duplicate.

For junction records and linking modules, create a deterministic key to deduplicate:

```text
edge_key = sha256(
  from_object + "|" + from_source_id + "|" +
  association_type_or_label + "|" +
  to_object + "|" + to_source_id
)
```

That edge key becomes the external ID for a Salesforce junction row, the unique row in a Zoho linking-module export, or the deduplication key in your staging layer before you call HubSpot or Attio. It also lets you rerun only failed associations instead of reloading whole objects.

The safe pattern for all association creation:

1. **Store both source IDs and target IDs in a persistent mapping table** (`source_id → target_id` for every object). Use a database, not in-memory storage — if your script crashes at record 50,000, you need those mappings to survive.
2. **Before creating an association, resolve both sides** — look up the target IDs for the `from` and `to` records using your mapping table.
3. **Use batch operations** — both HubSpot and Salesforce support batch association creation. One-by-one calls will hit rate limits fast.
4. **Log every association creation** — if a run fails midway, you need to know exactly which associations succeeded and which did not.

```python
# Idempotent association creation for HubSpot
def create_associations_batch(from_type, to_type, pairs, type_id):
    inputs = []
    for source_from_id, source_to_id in pairs:
        target_from = id_map[from_type].get(source_from_id)
        target_to = id_map[to_type].get(source_to_id)
        if target_from and target_to:
            inputs.append({
                "from": {"id": target_from},
                "to": {"id": target_to},
                "types": [{"associationCategory": "USER_DEFINED",
                           "associationTypeId": type_id}]
            })
    # Batch in groups of 100
    for chunk in chunked(inputs, 100):
        response = hubspot.crm.associations.v4.batch_api.create(
            from_type, to_type, batch_input={"inputs": chunk}
        )
```

Never match on display name if the platform allows duplicates. Salesforce upsert uses External ID fields, HubSpot can key on Record ID or unique properties, and Attio dedupes people and companies on email or domain. Names are for humans; migration keys are for machines.

> [!TIP]
> **Pro tip:** Store your ID mapping table in a persistent store (database, not just in-memory). If your script crashes at record 50,000, you don't want to re-extract and re-match all 50,000 mappings.

## How to Migrate Many-to-Many Joins and Association Labels

**Association labels** are metadata that describe the *type* of relationship between two records — "Decision Maker," "Billing Contact," "Primary Vendor." Not every CRM supports them equally:

- **Salesforce** does not have association labels as a first-class concept. Role distinctions are modeled by adding a picklist or text field on the junction object (e.g., `Role__c = 'Decision Maker'`).
- **HubSpot** supports labels natively via the v4 Associations API. <cite index="56-15,56-16">You can associate records with each other unlabeled or with labels (e.g., contact and company associated and the contact is the company's Decision maker).</cite>
- **Zoho** does not support association labels. Relationship context must be stored in fields on the linking module or the child record.
- **Attio** encodes relationship semantics through named relationship attributes on the objects themselves.

### Cross-platform many-to-many migration

The safest method is to treat each association as its own row in a staging table. Do not pack many-to-many relationships into repeated columns or comma-separated blobs.

| Source CRM | Target CRM | What You Need to Do |
|-----------|-----------|---------------------|
| Salesforce junction object | HubSpot | Create the association type (with labels if needed), then use the v4 batch API to create associations. Junction object custom fields become properties on an intermediate custom object or metadata on the association label. |
| HubSpot many-to-many | Salesforce | Create a junction custom object in Salesforce with two master-detail fields. Migrate each HubSpot association as a junction record. Map association labels to a picklist on the junction object. |
| Zoho linking module | HubSpot | Map linking module records to HubSpot associations. Any custom fields on the linking module either move to a custom object or are lost. |
| Any CRM | Pipedrive | Pipedrive has no many-to-many. You must flatten the relationship or store one side's IDs in a comma-separated custom field (lossy). |
| Any CRM | Attio | Create a relationship attribute with many-to-many cardinality. Load records on both sides first, then create the links via API. |

**The biggest trap with many-to-many migrations: data on the junction record itself.** Salesforce junction objects often carry their own fields — dates, amounts, roles. HubSpot associations are pure links and do not carry data. If your junction object has meaningful fields, you need a HubSpot custom object to hold that data, associated to both parent objects.

To migrate a Salesforce junction object to HubSpot:

1. Extract the junction object records, capturing the IDs of both parent records and any fields on the junction itself.
2. Create the parent records in HubSpot, capturing their new HubSpot IDs.
3. If the junction has custom fields, create a HubSpot custom object to hold them. Otherwise, use direct associations.
4. Use the HubSpot v4 Associations API to link the two parent records, applying an association label that describes the relationship.

```json
// Example HubSpot v4 Association Payload
{
  "inputs": [
    {
      "from": {
        "id": "10456"
      },
      "to": {
        "id": "20987"
      },
      "types": [
        {
          "associationCategory": "USER_DEFINED",
          "associationTypeId": 15
        }
      ]
    }
  ]
}
```

## How to Validate That Relationships Survived the Migration

**Counting rows is not validation.** A target system with the correct record count but broken associations is worse than one with fewer records and intact relationships — because the broken associations are invisible until a report or workflow fails. You can have 10,000 Deals in Salesforce and 10,000 Deals in HubSpot, but if the HubSpot Deals are not associated with Companies, your pipeline reports will be entirely blank.

Here is the validation protocol we run after every migration.

### 1. Association count comparison

For every object pair that has associations, compare the count of associations in the source to the count in the target.

```sql
-- Source: Count of Contact-to-Company associations in Salesforce
SELECT COUNT(*) FROM Contact WHERE AccountId IS NOT NULL;

-- Target: Count of Contact-to-Company associations in HubSpot
-- Via API: GET /crm/v4/associations/contact/company?limit=0
-- The response includes a 'total' count
```

If the counts do not match, something was dropped. Investigate the delta.

### 2. Orphan detection

Query the target system for records that should have associations but do not:

- Deals with no associated Company
- Contacts with no associated Company (if your source system enforced this)
- Custom object records with no parent associations
- Activities with no associated Contact or Deal

In Salesforce, use SOQL:

```sql
SELECT Id, Name
FROM Custom_Project__c
WHERE Account__c = NULL
```

In HubSpot, use the CRM Search API to filter for records with zero associations. If the orphan count in the target exceeds the orphan count in the source, your association logic failed.

### 3. Aggregate roll-up validation

Instead of checking individual records, check the mathematical aggregates that depend on relationships:

1. Calculate "Total Value of Closed Won Deals" grouped by Account in the source CRM.
2. Calculate the exact same metric in the target CRM.
3. Compare the outputs.

If an Account shows $500,000 in historical revenue in Salesforce but only $300,000 in Attio, you know Deal records failed to associate with that Account during migration.

### 4. Spot-check referential integrity

Pull a random sample of 50–100 records and verify that their associations resolve correctly in the target system:

```python
import random

sample = random.sample(list(id_map["Deal"].items()), min(100, len(id_map["Deal"])))
for source_id, target_id in sample:
    source_assocs = get_source_associations("Deal", source_id)
    target_assocs = get_target_associations("Deal", target_id)

    source_company_ids = {id_map["Company"][a] for a in source_assocs["companies"]}
    target_company_ids = set(target_assocs["companies"])

    if source_company_ids != target_company_ids:
        log_mismatch(source_id, target_id, source_company_ids, target_company_ids)
```

Sample records with high cardinality. Find an Account that has 50+ Contacts, 20+ Deals, and 5+ Custom Objects attached in the legacy system. Locate that Account in the target via its External ID and verify the exact same volume of child records is attached. High-volume records are most likely to hit API pagination limits during extraction, making them the best candidates for manual QA.

### 5. Label verification

If you migrated association labels, verify they transferred correctly. Pull a sample of labeled associations from the target and compare against the source. This is the step most teams skip — and it causes "Decision Maker" contacts to show up as generic, unlabeled associations in reports.

### 6. Bidirectional traversal test

For every association type, verify you can traverse the relationship from both sides. In HubSpot, confirm a Company record shows its associated Deals *and* a Deal record shows its associated Company. In Attio, verify the relationship attribute populates on both objects. A one-sided association that only appears from the parent view is a data integrity bug.

> [!NOTE]
> **Validation rule of thumb:** If you can't run your three most important CRM reports against the migrated data and get numbers within 2% of the source system's output, your associations are broken.

## Edge Cases That Silently Destroy Data

A few edge cases that catch even experienced migration engineers:

- **HubSpot upsert ignores associations.** <cite index="58-1,58-2">The issue is likely that associations require both objects to exist before the association can be created. While the upsert endpoint creates/updates the contact, the association creation appears to be failing silently.</cite> Always create associations in a separate API call after the records exist.
- **HubSpot v4 association type IDs are portal-specific.** <cite index="59-7,59-8,59-9">HubSpot wants integrations to rely on the typeId, which stays stable within a portal. The label field is what users see in HubSpot. It can be edited, translated, and is meant only for display.</cite> Discover them at runtime via the schema endpoint.
- **Salesforce master-detail won't let you reparent.** Once a child record is created with a master-detail parent, you cannot change the parent unless the relationship allows reparenting (off by default). Plan your load order accordingly.
- **Pipedrive's 40-character field hashes change per account.** <cite index="44-2">Custom field keys are not shown in our API Reference as they differ for each Pipedrive account, but they can be seen in the API requests and responses.</cite> Every migration script must discover field keys dynamically.
- **Attio CSV import flattens relationships.** <cite index="28-13">Attio's native importer handles straightforward migrations, but complex data models with custom fields and many-to-many relationships require careful mapping work before you touch the import tool.</cite>
- **API pagination silently drops associations.** When extracting relationships from HubSpot or Pipedrive, associations are often paginated separately from core record data. If you fail to write pagination logic for association endpoints, you will silently drop relationships for any record that exceeds the first page limit (often 100 or 500 associations).

> [!WARNING]
> **Warning:** Salesforce returns an error if an external ID matches multiple records. If your source data has duplicates on the legacy ID field, clean them before running the upsert — otherwise the entire batch will fail. ([developer.salesforce.com](https://developer.salesforce.com/docs/platform/api-rest/guide/dome-upsert.html?utm_source=openai))

## The Full Migration Sequence

Here is the end-to-end sequence for migrating custom objects and associations between any two CRMs:

1. **Extract full schema metadata** from the source — all objects, fields, relationships, cardinality, and labels. Include linking modules and subforms that may not appear in the standard UI.
2. **Map source objects to target objects.** Decide what becomes a custom object, what merges into a standard object, and what gets flattened. Document every decision.
3. **Create the target schema** — custom objects, custom fields, relationship definitions, association labels, pipelines, and stages in the target CRM.
4. **Create external-ID fields** on every target object. These store the source system's record IDs for mapping and idempotent reruns.
5. **Build and populate the ID mapping table.** Extract source records, create them in the target in dependency order, and store `source_id → target_id` for every record.
6. **Create associations in batch** — after all records on both sides of every relationship exist. Resolve circular references with a multi-pass approach.
7. **Run the full validation protocol** — count comparison, orphan detection, aggregate roll-ups, spot-check referential integrity, label verification, and bidirectional traversal.
8. **Run a delta sync** if your source system continued accepting data during migration. Use timestamps and external IDs to sync only what changed.

For platform-specific deep dives, see our [Salesforce to Attio migration guide](https://clonepartner.com/blog/blog/the-ctos-guide-to-salesforce-to-attio-migration-2026), [Zoho to HubSpot guide](https://clonepartner.com/blog/blog/zoho-crm-to-hubspot-migration-the-complete-technical-guide), or [Pipedrive to HubSpot guide](https://clonepartner.com/blog/blog/pipedrive-to-hubspot-migration-data-mapping-apis-rate-limits).

## When Native Import Is Not Enough

A native CRM import is usually sufficient when the data is shallow: flat objects, one-to-many lookups, no circular references, no labeled associations, and no need for rerunnable cutover. If your source CRM has fewer than 5 custom objects, no many-to-many relationships, and fewer than 50,000 records, a careful engineer with good scripts can handle the migration in a week or two.

Once you are dealing with 10+ custom objects, junction tables, labeled associations, circular references, or 100,000+ records with complex relationship graphs — the risk profile changes. A single ordering mistake can orphan thousands of records, and the debugging time to find and fix broken associations exceeds the time it would have taken to do it right.

If the CRM stores business meaning in the links between records, migrate the **links** with the same care as the records themselves. That is why custom-object migrations fail so often: teams plan the columns and ignore the graph.

> Migrating custom objects between CRMs? Let our team handle the schema translation, dependency ordering, and association validation. We've done 1,500+ migrations — your data is in safe hands.
>
> [Talk to us](https://clonepartner.com/talk-to-us?duration=30&utm_source=blog&utm_medium=button&utm_campaign=demo_bookings&utm_content=cta_click&utm_term=demo_button_click)

## Frequently asked questions

### Why do custom object associations break during CRM migration?

Associations break because each CRM models relationships differently — Salesforce uses lookup/master-detail fields on child records, HubSpot uses a separate Associations API with portal-specific type IDs, and Zoho uses linking modules. A direct CSV export flattens these into columns, silently destroying the relational links. You must recreate associations via API after loading records in dependency order.

### How do you solve circular references in a CRM data migration?

Use a multi-pass load. First, insert the base records with circular reference fields left null. Next, insert the dependent records using the newly generated IDs. Finally, update the base records with the dependent IDs. This is the only safe pattern for mutual lookups and self-hierarchies.

### What is the correct load order for CRM data migration?

Load independent objects first (Users, Companies/Accounts), then first-level dependents (Contacts), then second-level dependents (Deals/Opportunities), then junction/association records, and finally activities and attachments. For circular references, use a two-pass approach: create records with circular fields left null, then update them in a second pass.

### How do you validate CRM associations after migration?

Don't just count rows. Compare association counts per object pair between source and target, run orphan detection queries for records missing expected associations, validate aggregate roll-up metrics, spot-check referential integrity on a random sample, verify association labels transferred correctly, and test bidirectional traversal.

### Does Pipedrive support custom objects?

No. Pipedrive only supports custom fields on its built-in entities (Deals, Persons, Organizations, Products, Projects). It has no custom object or many-to-many relationship capability. Migrating to Pipedrive from a CRM with custom objects requires flattening your data model or accepting that certain relationships cannot be represented.
