Deleted records don't sync, then they come back
A sync misses deletions because polling asks which records changed since a timestamp, and a deleted record is not returned by any query, so the delete is an absence rather than a change. Even after you detect it, propagating a hard delete removes the destination row that was holding your deduplication key, so the next update from the source finds no match and re-creates the record. The durable fix is the tombstone pattern databases use: sync an active flag and a deleted-at timestamp, and never remove the row.
Your sync moves creates and updates correctly and silently ignores deletions, so a customer you removed from the CRM is still sitting in the mailing list two weeks later. The cause is not a missing connector setting. It is that a polling sync asks "what changed since 10:00" and a deleted record is not returned by that question or any other, because it is an absence rather than a change. And when you do wire up delete propagation, a second problem shows up: deleting the destination row throws away the key your sync uses to match records, so the next update re-creates the record you just removed. The fix that holds is to stop syncing deletions and start syncing a status flag.
A delete is an absence, and absence does not come back from a query
Nearly every sync in production runs on the same loop. Read a high-water mark, ask the source for records where lastModified > mark, process what comes back, advance the mark. That loop is built entirely on presence. Every row it can act on is a row the source handed it.
Now delete a contact. The row is gone from the table the query reads. It is not returned with a flag saying it used to exist. It is not returned at all. The next run gets a result set that is smaller by one and has no way to tell whether that contact was deleted, filtered out by a permission change, renamed past a filter condition, or simply not modified in that window. All four look identical: nothing.
This is why the bug survives every debugging session. There is no error, no failed step, no red execution. The workflow reports success because it did exactly what it was asked, and the destination quietly keeps a record the source no longer has. Weeks later somebody emails a customer who asked to be removed, and the investigation starts in the wrong place, usually with the email tool.
Webhook-based syncs are only better if the source actually emits a deletion event. Plenty do not. A webhook that fires on create and update tells you nothing when a row disappears, and choosing webhooks over polling does not fix this by itself.
The three ways a delete can reach you
There are only three, and they are not equally available.
| Mechanism | How it works | What it costs you | Real examples |
|---|---|---|---|
| Deletion event | The source publishes the delete as its own message, with the ID of the removed record. | You must be subscribed before the delete happens. Nothing recovers events you were not listening for. | HubSpot supports deletion subscription types on its v3 webhooks, including a separate contact.privacyDeletion type for privacy-compliant deletes, which also fires the normal delete event. |
| Queryable tombstone | The source keeps the record but marks it deleted, and lets you query the marked ones. | A retention window. Query later than that and the evidence is gone. | Salesforce's getDeleted() returns records deleted in a timespan, but only for records deleted no more than 15 days before the call, and it errors with INVALID_REPLICATION_DATE if the delete log was purged first. HubSpot keeps deleted records in its recycle bin for 90 days. |
| Full ID diff | You pull every ID from the source, compare against every ID in the destination, and treat the difference as deletions. | Real API cost and real risk. A partial read looks exactly like a mass deletion. | The only option when the source emits nothing, which includes Airtable's native automations, which have no record-deleted trigger, and a Google Sheets row that somebody removes by hand. |
Pick by what the source supports, not by what the connector's settings page implies. And if you land on the full ID diff, guard it: never act on a diff where the source returned fewer IDs than expected, never act on more than a fixed number of deletions in one run without a human looking, and log the ID list before you touch anything. A diff that runs during an outage will happily tell you every record was deleted.
Deleting the destination row deletes your match key
This is the part that turns a missing-deletes problem into a data-integrity problem, and it is where most delete-sync implementations go wrong.
Your sync matches records by something stored on the destination row. A CRM ID copied into a custom field, an external ID column, a hash of the email address. That value is how the next run knows whether to update or create. It only exists because the row exists.
So the delete lands, and the row goes away. Then an update arrives for the same customer, usually because the source system was not the only place they lived, or because a third system wrote back, or because somebody restored them from the recycle bin. The matching step looks up the key, finds nothing, and takes the create branch, exactly as designed. The record reappears.
Worse, it is not the same record. It has a new internal ID, so the activity history, the notes, the linked deals, the email engagement, and every report grouped by that ID are all attached to the old copy that is now gone. You have a duplicate person with an empty past, and the delete has to be repeated. This is the same class of failure as an automation creating duplicates, except the trigger is your own delete.
Distributed databases hit this decades ago and named the fix. Cassandra does not remove data on delete. It writes a time-stamped deletion marker called a tombstone, and keeps that marker for gc_grace_seconds, which defaults to 864000 seconds, or ten days. The marker exists so every replica learns about the delete before the evidence is discarded. Purge a tombstone before a replica has seen it and the deleted data comes back on the next repair, which the documentation calls exactly what it looks like: zombie data.
Your CRM, your spreadsheet, and your mailing list are replicas. They have no repair protocol and no shared clock. Deleting the row in one of them is the same mistake as purging a tombstone early.
Sync a status flag, not a deletion
The pattern that survives contact with real systems is small.
Give the destination record a state instead of an existence. Add two fields: an is_active boolean and a deleted_at timestamp. On a delete event, set is_active to false and stamp the time. The row stays. The match key stays. The history stays.
Make one system the owner of that flag. Deletion is a field like any other, and the same rule applies as everywhere else in sync design: exactly one system decides its value. Two systems both allowed to deactivate produces the same thrash as a two-way sync overwriting good values, just slower to notice.
Filter at the point of use, not at the point of storage. Every list, segment, report, and downstream automation reads is_active = true. This is the step people skip, and skipping it means the flag exists but the deactivated customer still gets the newsletter. If your email tool cannot filter on a custom field, move them to a suppressed list instead, which is a state change rather than a removal.
Keep the create branch honest. When an update arrives for a record where is_active is false, do not silently reactivate it. Update the fields, leave the flag alone, and log the event. If a source system genuinely wants that customer back, someone should decide that, not a mapping.
Purge on a schedule, separately. Rows marked deleted more than 90 days ago can be removed by a small job that runs on its own, reads the flag, and logs every ID it removes. That is your grace period, and it is the piece that keeps the destination from growing forever.
When you actually have to delete
Deactivation is the right default for operational syncs. It is the wrong answer for a legal erasure request, and treating them as the same path is how companies end up telling a regulator that the data was flagged rather than removed.
A CCPA or GDPR erasure runs one direction, touches every copy, and produces a receipt. That includes the copies your automations made along the way: execution logs, error payloads, CSV exports sitting in cloud storage, and any vector index or AI system that ingested the record. Those copies are the part people forget, and they are worth their own inventory before a request ever arrives. We wrote the full version of that path in deleting customer data from an AI stack.
Check whether your sync drops deletes in fifteen minutes
Pick your busiest sync and run the test on live data. Create a throwaway record in the source, let one sync cycle carry it to the destination, then delete it in the source and wait for two more cycles. Look at the destination. If the record is still there, your sync drops deletions, and it has been dropping them for as long as it has run.
Then measure the backlog. Count active records in the source and in the destination. A destination that is meaningfully larger is holding records the source no longer has, and the gap is a rough count of every delete missed since the sync went live. On a two-year-old CRM sync, that number is usually in the hundreds.
Last, open the destination and find the field your sync matches on. Ask what happens to that field when the row is removed. If the answer is that it disappears with the row, you have the resurrection bug waiting whether or not you have hit it yet.
Fixing this is usually half a day: two fields, one filter change on each downstream consumer, and a purge job. If you want a second read on where your syncs are losing records, that check is part of any workflow reliability audit we run, and you can send us the sync's field mapping and record counts and we will tell you what is missing.
Frequently Asked Questions
SOURCES & CITATIONS
- getDeleted() — Salesforce Developershttps://developer.salesforce.com/docs/atlas.en-us.api.meta/api/sforce_api_calls_getdeleted.htm
- Webhooks v3 API guide — HubSpothttps://developers.hubspot.com/docs/api-reference/legacy/webhooks/guide
- Restore deleted records — HubSpot Knowledge Basehttps://knowledge.hubspot.com/records/restore-deleted-records
- Tombstones — Apache Cassandra Documentationhttps://cassandra.apache.org/doc/stable/cassandra/managing/operating/compaction/tombstones.html
About Alexey Yushkin
Alexey is the founder of GENERAL INFORMATICS LLC. He designs and ships AI and automation systems for businesses and operators across the US.
Related reading
Want this kind of system in your business?
We build practical AI and automation systems for operators. Send us your current workflow and we will show you what to automate first.
