Skip to content
Notifications
Clear all

Step-by-step: Migrating 50k contacts from Spreadsheet Hell into Granola without losing your mind.

1 Posts
1 Users
0 Reactions
3 Views
(@finops_tracker_99)
Estimable Member
Joined: 5 months ago
Posts: 87
Topic starter   [#1647]

Alright, so after our team's "brilliant" idea to manage a massive partner contact list across 14 regional Google Sheets, we hit the breaking point. Version conflicts, stale data, zero segmentation. Our sales ops lead was ready to walk. We finally got the greenlight to centralize in Granola, but migrating 50k+ rows of messy, inconsistent spreadsheet data was its own special nightmare. Here's how we did it without blowing the budget or our sanity.

The core strategy was to avoid the Granola UI import for the initial bulk load. Their UI is fine for small batches, but for 50k records, you need the API. We used a two-step process: clean and structure first, then batch upload.

**Phase 1: The Data Triage**
We exported all sheets to CSV and used a Python script to normalize. The biggest issues were:
- Duplicate emails across regions (deduplication needed)
- Inconsistent column naming ('company', 'Company Name', 'Co.')
- Missing required fields for Granola (we mapped 'N/A' to empty strings)

Here's the basic sanitization script we ran:

```python
import pandas as pd

# Load and concatenate all regional CSVs
all_dfs = []
for region in region_files:
df = pd.read_csv(region)
df['region_source'] = region # Keep track for debugging
all_dfs.append(df)

combined_df = pd.concat(all_dfs, ignore_index=True)

# Standardize column names
column_mapping = {
'Co.': 'company',
'Company Name': 'company',
'Email Address': 'email',
'First': 'first_name'
# ... etc
}
combined_df.rename(columns=column_mapping, inplace=True)

# Deduplicate by email, keeping the most recent 'last_updated' entry
combined_df['last_updated'] = pd.to_datetime(combined_df['last_updated'])
combined_df = combined_df.sort_values('last_updated').drop_duplicates(subset=['email'], keep='last')

# Fill required Granola fields if missing
combined_df['company'] = combined_df['company'].fillna('')

# Output cleaned CSV for upload
combined_df.to_csv('granola_contacts_cleaned.csv', index=False)
```

**Phase 2: Batched API Upload**
Granola's API has rate limits. We chunked the CSV and used their batch create endpoint with error logging.

Key takeaways:
- Set `batch_size=500` to avoid timeouts.
- Log every failure to a separate file for reprocessing. We had about 2% failures (mostly weird email formatting we missed).
- Used tags in the API call to categorize contacts by original region for future segmentation.

Post-migration, we used Granola's audit log to verify counts. The whole process took about 6 hours of scripting and 45 minutes of actual runtime. The cost? Literally just the engineer's time—no third-party tools, no extra Granola fees for import. Now we have a single source of truth, and the sales team can actually use the segmentation features.



   
Quote