Migrating Off Zoho CRM: Problems & Solutions Guide

How can users avoid data loss, attachment gaps, broken relationships, and timestamp issues when migrating off Zoho CRM?

Migrating Off Zoho CRM: The Complete Technical Guide to Avoiding Data Loss

I now have everything I need. The research confirms all core technical claims with important updates — the API has moved to a credit-based model (not the simple "15,000/25,000 calls per day" stated in the original), and the API is now at v8 (not v6 as referenced throughout). Let me now write the fully optimized, fact-checked article.


Migrating Off Zoho CRM: The Complete Technical Guide to Avoiding Data Loss

Migrating away from Zoho CRM is not a simple export-and-import exercise. It is a multi-phase data engineering project — and the organizations that treat it like one come out the other side with clean, complete data. The ones that don't discover their mistakes weeks later, when a customer calls about a missing contract or an audit surfaces a corrupted activity timeline.

This guide covers every major failure point in a Zoho CRM migration: attachments, module relationships, timestamp integrity, custom field mapping, and role permissions — with working code, validated sequences, and the specific API details you need to do it right.


Core Problem Breakdown

Before diving into solutions, here is the full landscape of what can go wrong and why:

Challenge Root Cause Impact
Broken module relationships Separate export files per module Orphaned records in target system
Missing attachments Excluded from standard data export Permanent data loss
Timestamp corruption Backdating handling differences between platforms Audit trail failures
Custom field mapping gaps Schema differences between platforms Lost data structure
Role/permission mismatches Different permission models Security gaps post-migration

Section 1: The Attachment Problem (The Most Commonly Reported Surprise)

Why It Catches Everyone Off Guard

Zoho CRM's built-in Settings → Data Administration → Export deliberately excludes file attachments. There is no checkbox to include them, no warning that they are missing, and no indication in the export confirmation that anything was left behind. Most teams discover this after the export is complete — sometimes after the Zoho subscription has already been cancelled.

This is not a bug. Attachments are treated as separate binary resources with their own API endpoints, entirely distinct from the structured record data that CSV export handles. Understanding this distinction is the foundation of a successful migration.

⚠️ Critical: Pull all attachments via API before cancelling your Zoho subscription. Once the org is deactivated, access to attachment data may be lost permanently.


Solution 1A: Zoho CRM REST API — Bulk Attachment Pull (Most Reliable)

How it works:
Use the /crm/v8/{module}/{record_id}/Attachments endpoint to programmatically fetch every attachment per record. Note that the current production API is v8 — while v6 endpoints remain functional, all new development should target v8.

Important API limits to plan around (2025):
Zoho CRM has moved from a simple "calls per day" model to a credit-based system with concurrency limits. Each attachment download consumes 1 API credit. Your daily credit budget depends on your plan:

Edition Daily Credit Formula Practical Maximum
Free 5,000 credits flat 5,000
Standard/Starter 50,000 + (users × 250) 100,000
Professional 50,000 + (users × 500) 3,000,000
Enterprise / Zoho One 50,000 + (users × 1,000) 5,000,000
Ultimate / CRM Plus 50,000 + (users × 2,000) Effectively unlimited

Concurrency limits (simultaneous requests):

Edition Max Concurrent Calls
Free 5
Standard/Starter 10
Professional 15
Enterprise / Zoho One 20
Ultimate / CRM Plus 25

Implementation:

# Step 1: Authenticate via OAuth 2.0
import requests
import os
import time

def get_access_token(client_id, client_secret, refresh_token):
url = "https://accounts.zoho.com/oauth/v2/token"
params = {
"refresh_token": refresh_token,
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "refresh_token"
}
response = requests.post(url, params=params)
return response.json()["access_token"]

# Step 2: Fetch all record IDs for a module (with rate-limit-aware pagination)
def get_all_record_ids(module, access_token):
record_ids = []
page = 1
while True:
url = f"https://www.zohoapis.com/crm/v8/{module}"
headers = {"Authorization": f"Zoho-oauthtoken {access_token}"}
params = {"page": page, "per_page": 200, "fields": "id"}
response = requests.get(url, headers=headers, params=params)

# Check remaining credits — header appears once you've used >50% of base credits
remaining = response.headers.get("X-API-CREDITS-REMAINING")
if remaining and int(remaining) < 1000:
print(f"⚠️ Low API credits remaining: {remaining}. Pausing.")
time.sleep(60)

data = response.json()
if "data" not in data:
break
record_ids.extend([r["id"] for r in data["data"]])

if not data.get("info", {}).get("more_records"):
break
page += 1
time.sleep(0.2) # Respect undocumented per-minute thresholds
return record_ids

# Step 3: Download attachments per record with retry logic
def download_attachments(module, record_id, access_token, output_dir, max_retries=3):
url = f"https://www.zohoapis.com/crm/v8/{module}/{record_id}/Attachments"
headers = {"Authorization": f"Zoho-oauthtoken {access_token}"}
response = requests.get(url, headers=headers).json()

if "data" not in response:
return []

manifest = []
for attachment in response["data"]:
att_id = attachment["id"]
file_name = attachment["File_Name"]
parent_id = attachment["Parent_Id"]["id"]

# IMPORTANT: Only file-type attachments can be downloaded.
# URL-type attachments will return an error — log them separately.
if attachment.get("$type") == "url":
manifest.append({
"module": module,
"record_id": parent_id,
"attachment_id": att_id,
"file_name": file_name,
"type": "url_link",
"url_value": attachment.get("$url"),
"local_path": None,
"status": "SKIPPED_URL_TYPE"
})
continue

download_url = f"https://www.zohoapis.com/crm/v8/{module}/{record_id}/Attachments/{att_id}"

for attempt in range(max_retries):
file_response = requests.get(download_url, headers=headers)
if file_response.status_code == 200:
break
elif file_response.status_code == 429:
wait = (attempt + 1) * 10
print(f"Rate limited. Waiting {wait}s before retry {attempt + 1}...")
time.sleep(wait)
else:
print(f"Error {file_response.status_code} for attachment {att_id}")
break

save_path = os.path.join(output_dir, module, str(record_id))
os.makedirs(save_path, exist_ok=True)
full_path = os.path.join(save_path, file_name)

with open(full_path, "wb") as f:
f.write(file_response.content)

manifest.append({
"module": module,
"record_id": parent_id,
"attachment_id": att_id,
"file_name": file_name,
"type": "file",
"local_path": full_path,
"status": "DOWNLOADED"
})
time.sleep(0.1) # Spacing between individual file downloads

return manifest

Critical Output: Always generate a manifest.csv mapping record_id → file_name → local_path → status. This is your re-linking key in the target system — and your audit trail if anything goes wrong.

Key limitations to plan around:

  • URL-type attachments cannot be downloaded via the API — only file-type attachments are retrievable. Log all URL-type attachments separately for manual handling.
  • Each attachment download = 1 API credit. For orgs with 500k+ attachments on a Standard plan, you may need multiple days or additional purchased credits.
  • Composite API does not support attachment upload/download — each file requires its own individual API call; you cannot batch them.
  • Zoho Documents-linked attachments (stored via WorkDrive integration) require a separate extraction path (see Solution 1B).

Reference: https://www.zoho.com/crm/developer/docs/api/v8/download-attachments.html


Solution 1B: Zoho WorkDrive Export (For Document-Linked Attachments)

If attachments were stored via the Zoho WorkDrive integration rather than direct upload, they exist in WorkDrive's file system and require a separate API call:

# WorkDrive API — download by file ID
GET https://workdrive.zoho.com/api/v1/files/{file_id}/download

Reference: https://workdrive.zoho.com/apidocs/v1


Solution 1C: Third-Party Migration Tools

If your team does not have developer resources, these tools handle attachment extraction without custom code:

Tool Attachment Support Relationship Preservation Cost
Trujay ✅ Yes ✅ Yes Per-record pricing
Migrate.io ✅ Yes ✅ Yes ~$500–$2,000
Skyvia ⚠️ Partial ✅ Yes Subscription
DBSync ✅ Yes ✅ Yes Enterprise

Important consideration: Third-party tools reduce engineering effort but reduce visibility into exactly what transferred. Always run a post-migration record count and attachment presence audit regardless of which tool you use. For workflow automation that connects your migration process to downstream systems, Make.com offers flexible no-code automation that can orchestrate multi-step migration pipelines without custom code.


Section 2: Preserving Module Relationships

The Core Problem Visualized

Zoho Export Reality:
├── Contacts.csv (has Zoho Contact ID)
├── Accounts.csv (has Zoho Account ID)
├── Activities.csv (has "Contact Name" text — NOT the ID)
├── Notes.csv (has "Parent Record" text — NOT always the ID)
└── Attachments (NOT EXPORTED AT ALL)

What you need:
Contacts.csv ──[Contact_ID]──► Activities.csv
Notes.csv
Attachments/

The standard CSV export does not guarantee that relationship columns contain IDs — many export as display names. If you import those into a target system, you get orphaned records with no programmatic link back to their parent.


Solution 2A: ID Preservation Strategy — Build a Relationship Map Before Export

Step 1: Export with IDs explicitly included

In Zoho CRM export settings, explicitly select "Record ID" as an export field — it is not included by default in many module exports.

# Validate your export has IDs — run this immediately after export
import pandas as pd

def validate_export_ids(filepath, module_name):
df = pd.read_csv(filepath)

required_id_columns = {
"Contacts": ["Contact ID", "Account Name", "Account ID"],
"Activities": ["Activity ID", "Contact ID", "Account ID"],
"Notes": ["Note ID", "Parent ID", "Contact ID"]
}

missing = []
for col in required_id_columns.get(module_name, []):
if col not in df.columns:
missing.append(col)

if missing:
print(f"⚠️ WARNING: {module_name} export missing columns: {missing}")
print("Re-export with these fields explicitly selected")
else:
print(f"✅ {module_name}: All relationship columns present")

return len(missing) == 0

Step 2: Build a cross-module ID mapping table

def build_relationship_map(contacts_df, activities_df, notes_df):
"""
Creates a master relationship map before import to target system.
Run this BEFORE any data moves to the target platform.
"""
relationship_map = {
"contacts": {},
"activities_by_contact": {},
"notes_by_contact": {}
}

for _, row in contacts_df.iterrows():
relationship_map["contacts"][row["Contact ID"]] = {
"name": row["Full Name"],
"account_id": row.get("Account ID"),
"email": row.get("Email")
}

for _, row in activities_df.iterrows():
contact_id = row.get("Contact ID")
if contact_id:
if contact_id not in relationship_map["activities_by_contact"]:
relationship_map["activities_by_contact"][contact_id] = []
relationship_map["activities_by_contact"][contact_id].append(
row["Activity ID"]
)

return relationship_map

Solution 2B: Use Zoho's API for Relationship-Aware Export (Preferred Over CSV)

Rather than relying on the UI export, pull data via API with relationships intact. This is the most reliable approach for any migration involving more than a few thousand records.

def export_contacts_with_relationships(access_token):
"""
Exports contacts WITH their related records in one structured output.
Output as JSON — not CSV — to preserve nested relationships.
"""
modules_to_relate = ["Activities", "Notes", "Attachments"]
contacts = get_all_records("Contacts", access_token)

enriched_contacts = []

for contact in contacts:
contact_id = contact["id"]
enriched = dict(contact)

for module in modules_to_relate:
related_url = f"https://www.zohoapis.com/crm/v8/Contacts/{contact_id}/{module}"
headers = {"Authorization": f"Zoho-oauthtoken {access_token}"}
related = requests.get(related_url, headers=headers).json()
enriched[f"related_{module}"] = related.get("data", [])
time.sleep(0.1) # Respect concurrency limits

enriched_contacts.append(enriched)

return enriched_contacts

Output this as JSON, not CSV. JSON preserves nested relationships that CSV flattens and destroys. A CSV of contacts with related notes becomes two disconnected flat files; a JSON export keeps the hierarchy intact.


Section 3: Timestamp & Backdating Issues

What Actually Happens

```
Zoho Note created: 2021-03-15 09:23:00 UTC ← Original
Standard CSV export: 2021-03-15 09:23:00 UTC