<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sane Devs</title>
    <description>The latest articles on DEV Community by Sane Devs (@sanedevs).</description>
    <link>https://dev.to/sanedevs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4228086%2F8f22a0ec-8ca8-48fd-9140-c5d05b836745.png</url>
      <title>DEV Community: Sane Devs</title>
      <link>https://dev.to/sanedevs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sanedevs"/>
    <language>en</language>
    <item>
      <title>Your CRM Import Needs a Dry Run: How to Stop Duplicates Before They Happen</title>
      <dc:creator>Sane Devs</dc:creator>
      <pubDate>Fri, 06 Nov 2026 16:10:10 +0000</pubDate>
      <link>https://dev.to/sanedevs/your-crm-import-needs-a-dry-run-how-to-stop-duplicates-before-they-happen-1o6k</link>
      <guid>https://dev.to/sanedevs/your-crm-import-needs-a-dry-run-how-to-stop-duplicates-before-they-happen-1o6k</guid>
      <description>&lt;p&gt;Most CRM data problems are not caused by bad data. They are caused by a bad match key and an import that wrote straight to production.&lt;/p&gt;

&lt;p&gt;This post describes one pattern that prevents most of it: &lt;strong&gt;plan first, write second.&lt;/strong&gt; The import script produces a plan of what it would do, a human checks the counts, and only then does it write. It works with HubSpot, Pipedrive, Salesforce, or a Postgres table. The concepts are the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;You have a CSV of 500 leads from an enrichment tool. You push it into the CRM. A week later someone notices three records for the same person and two companies called "Acme" with different domains.&lt;/p&gt;

&lt;p&gt;What went wrong was upstream of the CRM. The import never decided what "the same person" means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Define the match key and normalize it
&lt;/h2&gt;

&lt;p&gt;Pick one unique key per object:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Contacts:&lt;/strong&gt; email address. In HubSpot this is the default dedupe property for contacts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Companies:&lt;/strong&gt; domain name. Also HubSpot's default company dedupe property.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then normalize before comparing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lowercase and trim emails. &lt;code&gt;Sarah.Chen@Example.com&lt;/code&gt; and &lt;code&gt;sarah.chen@example.com&lt;/code&gt; are the same person.&lt;/li&gt;
&lt;li&gt;For domains, strip the protocol, &lt;code&gt;www.&lt;/code&gt;, paths and trailing slashes. &lt;code&gt;https://www.nexusflow.com/about&lt;/code&gt; becomes &lt;code&gt;nexusflow.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Never use free-mail domains (gmail.com, outlook.com, yahoo.com) as a company key. Otherwise every Gmail user becomes an employee of one giant company called "gmail.com".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A decision worth making deliberately: plus-addressing. &lt;code&gt;ali+test@example.com&lt;/code&gt; may or may not be the same person as &lt;code&gt;ali@example.com&lt;/code&gt;. Stripping the &lt;code&gt;+tag&lt;/code&gt; merges more but can merge distinct people on shared inboxes. Most teams leave it alone and flag it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Dedupe inside the file first
&lt;/h2&gt;

&lt;p&gt;If your file has the same email twice, a naive loop will check the CRM twice, find nothing both times, and create two records. Deduplicate the file before touching the CRM. When two rows collide, choose a rule (most complete row wins, or last row wins) and log what was skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Look up each key
&lt;/h2&gt;

&lt;p&gt;For every remaining row, ask the CRM whether the key exists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exists: &lt;strong&gt;update&lt;/strong&gt; (only fill empty fields, or define which fields the import is allowed to overwrite)&lt;/li&gt;
&lt;li&gt;does not exist: &lt;strong&gt;create&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use the batch endpoints. HubSpot's batch APIs accept up to 100 records per request, which keeps you well away from rate limits compared with one call per row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Print the plan
&lt;/h2&gt;

&lt;p&gt;The plan is the product. It should look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Contacts   create: 37   update: 6   skip (dup in file): 1
Companies  create: 36   update: 6
Associations: 42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Print the first ten rows of each bucket too. A person scanning the output catches things no validation rule would.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Sanity-check the counts
&lt;/h2&gt;

&lt;p&gt;This is the step that earns its keep. Compare the plan to what you expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You expected mostly updates (re-enriching existing leads) and the plan says mostly creates? Your match key is not matching. Check normalization.&lt;/li&gt;
&lt;li&gt;You expected mostly new records and the plan says mostly updates? You may be overwriting real data with a stale file.&lt;/li&gt;
&lt;li&gt;The skip count is high? The source has heavy duplication; fix it upstream.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of these look wrong, you stop. Nothing has been written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Write, then log
&lt;/h2&gt;

&lt;p&gt;Only after approval, run the same code path with &lt;code&gt;dry_run=False&lt;/code&gt;. This matters: the dry run and the real run should share one function, so the plan is a true preview and not a separate guess. Write in batches, log every record ID created or updated, and keep that log. If something is off later, you can undo precisely instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it idempotent
&lt;/h2&gt;

&lt;p&gt;A good import can be run twice and the second run creates nothing. Once you are matching on a normalized key, that mostly comes for free: the second run sees every record as an update. If your second run creates records, that is a bug, and the dry run would have told you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas people hit
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Normalizing only on one side.&lt;/strong&gt; If the CRM stores mixed-case emails and you only lowercase your file, matches fail. Compare lowercase to lowercase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Company domain from the contact's email.&lt;/strong&gt; Fine for work addresses, wrong for free-mail. Skip those.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overwriting richer data.&lt;/strong&gt; Decide per field whether the import may overwrite. A common safe default is fill-empty-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No record of what changed.&lt;/strong&gt; Always keep a log of IDs and before/after values for updates.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Choose one match key per object and normalize it.&lt;/li&gt;
&lt;li&gt;Dedupe the file before dedupe against the CRM.&lt;/li&gt;
&lt;li&gt;Print create / update / skip counts and have a human approve them.&lt;/li&gt;
&lt;li&gt;Share one code path between dry run and real run.&lt;/li&gt;
&lt;li&gt;Log everything you write.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost of this pattern is about an hour of work. The cost of skipping it is a duplicate-merge project later, and those are never fun.&lt;/p&gt;

&lt;p&gt;What do you match contacts on in your CRM, and has it ever failed you?&lt;/p&gt;

</description>
      <category>automation</category>
      <category>data</category>
      <category>softwaredevelopment</category>
    </item>
  </channel>
</rss>
