Automations that run twice: why rows duplicate, and a tested fix
Zapier, Make, Power Automate and the apps feeding them all document ways one event can run twice. The causes, the settings and a tested dedupe script.
The short read
Microsoft's own troubleshooting page says Power Automate can duplicate a run's actions because of the at-least-once design underneath it.
- Stripe, Shopify and GitHub retry or redeliver webhooks, and Make runs webhook scenarios in parallel by default, so one event can arrive twice.
- On made-up test data, a look-up-first check stopped every duplicate with one run at a time, and none with eight at once.
What you can do with this
Give every automation a dedupe key from the sender, such as the event ID, and claim it in one atomic step: a unique column, a Make data store record or our script.
In this article
- Six documented ways one event runs twice
- Tell the causes apart from the run history
- Why “look it up first” fails: our test
- Step 1: Pick a dedupe key from the sender
- Step 2: Stop the extra runs at the source
- Step 3: Make the destination refuse a second copy
- Step 4: Put a ledger in front of code
- Step 5: Pass the key to the next system
- The gap no ledger closes
- When to skip all of this
- How we made this
When a Zap, a Make scenario or a Power Automate flow writes the same row twice, it is often doing what its documentation says it may do. Microsoft’s trigger troubleshooting page blames duplicated actions on the “at-least-once” design of Azure Logic Apps. Stripe says its webhooks “might occasionally” arrive twice. So the fix has to live in your automation, not in a support ticket.
The usual fix, looking the row up before adding it, works only while runs happen one at a time. We tested that on made-up data, and it failed completely once eight runs overlapped. This guide lists the documented causes of double runs, shows how to tell them apart, and gives the settings and the small script that stopped every duplicate in our test.
Six documented ways one event runs twice
Each cause below comes from the vendor’s own documentation, read on October 31, 2026.
| Cause | Who documents it | What you see |
|---|---|---|
| Actions repeat inside one run | Microsoft: the “at-least-once” design of Azure Logic Apps; the UI “doesn’t show this problem” | Two emails or two list items from one flow run |
| A second copy of the automation | Microsoft: copies “active in different environments”; Zapier: deduplication “only checks within the same Zap” | Two runs, one per copy, for every event |
| The sender retries | Stripe: retries for up to three days in live mode; Shopify: 8 retries over 4 hours after a 5-second timeout | Duplicates cluster when your receiver was slow or down |
| A step retries after a timeout | Logic Apps: up to 4 retries on a 408, 429 or 5xx response by default | One row created by the first try, another by the retry |
| A replay | Zapier: autoreplay up to 5 times; Make: 8 automatic attempts | A failed run comes back hours later and repeats work |
| Runs overlap | Make: webhook scenarios run “in parallel” by default; Power Automate: a trigger runs “as many runs as possible simultaneously” | A duplicate check passes twice because both runs looked before either wrote |
The timeout rows deserve a second look. A timeout means the platform got no answer, which is different from the write failing. Stripe tells receivers to return a 2xx status “before any complex logic that might cause a timeout,” because a slow answer earns a retry of an event you already handled.
Replays are narrower than they sound. Zapier’s replay reruns the steps that errored, so in its own example the email that already went out is not sent again. Make retries “starting with the module that caused the error.” Both are safe when the failed step really did nothing, and unsafe when it half-worked.
Tell the causes apart from the run history
The fix depends on which cause you have, and the history usually shows it in a minute.
- Open both duplicates and compare their run IDs. One run that produced two rows points to repeated actions or a retried step. Two runs point to everything else. Power Automate keeps a 28-day run history under All runs; Make keeps webhook logs for 3 days, or 30 on Enterprise.
- For two runs, compare the trigger data. Identical event IDs mean the event itself arrived twice: a sender retry, a replay or a second copy. GitHub sends the same
X-GitHub-Deliveryheader when a delivery is redelivered, so equal values there mean a redelivery. - Check the sender’s delivery log. Stripe’s Event deliveries tab shows each attempt with its HTTP status. A failed or timed-out first attempt followed by a success is a retry, and the cure is a faster answer plus a dedupe key.
- Search for copies. Look for the same flow in other Power Automate environments and for a second Zap or scenario on the same trigger.
- Look at the trigger’s name. A SharePoint trigger called When an item is created or modified fires on every change. A flow that updates the item it was triggered by can therefore trigger itself.
Why “look it up first” fails: our test
The standard advice is a lookup step before the create step: Zapier’s Find step, Power Automate’s Get items, Make’s Search records. We tested that pattern against a ledger and a unique column, on made-up data.
The setup: 500 invented events, delivered 678 times. 70% arrived once, 25% twice and 5% three times, with copies back to back, the way a retry storm looks. Each handler wrote to a SQLite table standing in for a spreadsheet, with a 20-millisecond pause standing in for an API call. We ran each handler one delivery at a time and then with eight worker processes at once, the overlap Make and Power Automate allow by default.
| Handler | One at a time | Eight at once | Eight at once, copies scattered |
|---|---|---|---|
| Add a row, no check | 178 duplicates | 178 duplicates | not run |
| Look up, then add if missing | 0 | 178 duplicates | 5 duplicates |
| Destination refuses a second row per key | not run | 0 | not run |
| Claim the key in a ledger first | 0 | 0 | 0 |
The lookup stopped every duplicate when runs went one at a time and none when eight ran together. Two runs each looked, each found nothing, and each wrote. Scattering the copies across the run, so they rarely overlapped, still let 5 through. No event went missing in any run, and a second full run gave the same counts.
So the rule we take from it: a duplicate check works only when the check and the write are one step that the storage enforces. A unique column does that. So does the ledger in step 4, which claims a key inside a locked transaction before doing the work. Treat these numbers as a demonstration on simulated data, not a measurement of any vendor: real platforms add their own timing.
Step 1: Pick a dedupe key from the sender
A dedupe key is a value that is the same on every copy of one event and different for every real event. The sender’s event ID is the best one, because retries reuse it.
- Stripe: the event
id. Stripe also warns that “in some cases, two separate Event objects are generated,” so for money-moving work key on the object ID indata.objectplusevent.type. - Shopify: the
X-Shopify-Webhook-Idheader, which Shopify says is for deduplicating individual deliveries. - GitHub: the
X-GitHub-Deliveryheader. - Polling triggers on records: the record ID for “new record” automations. For “updated record” ones, the ID plus the modified time, so each real edit gets its own key. Zapier’s developer docs describe the same pair,
idandupdatedAt, for its own deduplication.
Avoid timestamps on their own. Stripe says distinct events can share a created second and tells developers to “track event IDs” instead. Prefix each key with its source (stripe:evt_...), so two senders never collide. Keys are compared exactly, so trim spaces and fix the case before you store them.
Step 2: Stop the extra runs at the source
Some causes can be switched off. The settings below are from each vendor’s documentation; we had no paid accounts to try them in.
Power Automate trigger conditions. In the trigger’s Settings tab, Trigger conditions take an expression that must be true for the flow to start. A run that fails the condition shows as a skipped trigger check, not a run. To stop a SharePoint flow from triggering on its own edits, a condition along these lines compares the last editor with the flow’s account:
@not(equals(triggerOutputs()?['body/Editor/Email'], 'flows@yourcompany.com'))
triggerOutputs, equals and not are standard workflow expression functions. The field path is our assumption for a SharePoint list trigger and is untested: open a past run, read the trigger outputs, and copy the real path.
Power Automate concurrency control. Turned on in the trigger settings, it caps parallel runs; Microsoft says the default degree of parallelism is then 1. With one run at a time, a Get items lookup behaves like our one-at-a-time column above. The catch: Microsoft calls the setting “irreversible,” and removing it means building a new flow. It also slows a busy flow, and it does nothing about repeated actions inside a run.
Make: process data in order. In the scenario settings, this makes Make wait “until the previous execution is complete before starting the next one.” It fixes the overlap, but a sender retry still arrives as a second run, so pair it with a lookup or a data store key.
Zapier: one Zap per event, and know your trigger type. Zapier deduplicates polling triggers by ID within each Zap, and its help page says “instant triggers do not use deduplication.” An instant trigger that fires twice therefore runs twice. Leave autoreplay off for steps where a repeat would reach a customer, unless the step carries its own key (step 5).
Turning a flow off and on. Microsoft says a polling flow that is switched back on “processes all unprocessed or pending events,” while a webhook flow only takes new ones. If a backlog would cause a burst of duplicates, Microsoft’s advice is to delete and re-create the flow.
Step 3: Make the destination refuse a second copy
This is the unique-column row in our test, and the strongest fix wherever the destination supports it. Microsoft’s own example of an idempotent flow is “using key constraints in Dataverse to prevent duplicate records getting created.”
SharePoint lists. A column can be set to Enforce unique values. Microsoft’s developer page, an archived one written for SharePoint 2010, lists the supported column types: single line of text, choice, number, currency, date and time, single lookup, single person, and Title in a list. The column must be indexed, the setting fails while duplicates already exist, and the comparison ignores case, so “Hello World” equals “hello world.” Store the dedupe key in its own single-line text column.
The flow’s create step then fails on a duplicate, which marks the run failed. Set the next action’s Run after to include has failed so the duplicate ends quietly; Logic Apps documents the run-after setting. Without it, every duplicate becomes a failure alert.
Make data stores. The Add/Replace a Record module takes a key, and Make’s data store page says it “throws an error when you try to add the record which is already in the data store” while Overwrite an existing record is off. Put that module first, with the dedupe key, and stop the route on that error. A data store with no data structure holds only keys, which Make describes as useful when you only want to know “whether or not a specific key exists.” We did not test whether the error holds when two runs add the same key in the same instant, so keep Process data in order on as well.
Zapier. We found no uniqueness setting in Zapier’s help pages on October 31, 2026. For Zaps, the practical routes are a destination app that rejects duplicates or a call to a small receiver running the script below.
Step 4: Put a ledger in front of code
For scripts, webhook receivers and anything you host, this Python script keeps a ledger of keys in one SQLite file. It needs Python 3 and nothing else. claim takes a key inside a locked transaction (BEGIN IMMEDIATE), so two processes can never both get it. Save it as dedupe.py:
#!/usr/bin/env python3
"""Run an action at most once per key, even when the trigger fires twice.
python3 dedupe.py claim KEY exit 0: you own KEY, do the work
exit 1: KEY is done or someone else is on it
python3 dedupe.py done KEY mark KEY finished (call after the work succeeds)
python3 dedupe.py release KEY give KEY back (call when the work failed)
python3 dedupe.py prune DAYS forget finished keys older than DAYS days
Any error (empty key, unreadable file) exits 2: treat it as a failure, not a skip.
The ledger is one SQLite file (default seen.db, or --db PATH). A claim that is
older than --lease seconds (default 900) and never marked done counts as
abandoned, so a run that crashed halfway does not block the key forever.
"""
import argparse, sqlite3, sys, time
SCHEMA = """CREATE TABLE IF NOT EXISTS seen (
key TEXT PRIMARY KEY,
state TEXT NOT NULL, -- 'claimed' or 'done'
claimed_at REAL NOT NULL, -- Unix seconds, so time zones never matter
done_at REAL
)"""
def connect(path):
con = sqlite3.connect(path, timeout=30, isolation_level=None)
con.execute("PRAGMA journal_mode=WAL")
con.execute(SCHEMA)
return con
def claim(con, key, lease=900.0, now=None):
if not key or not key.strip():
raise ValueError("empty key: refuse to guess, fix the key mapping")
now = time.time() if now is None else now
con.execute("BEGIN IMMEDIATE") # one writer at a time: no race
try:
row = con.execute("SELECT state, claimed_at FROM seen WHERE key=?", (key,)).fetchone()
if row is None:
con.execute("INSERT INTO seen VALUES (?, 'claimed', ?, NULL)", (key, now))
con.execute("COMMIT"); return True
state, claimed_at = row
if state == "claimed" and now - claimed_at > lease:
con.execute("UPDATE seen SET claimed_at=? WHERE key=?", (now, key))
con.execute("COMMIT"); return True
con.execute("COMMIT"); return False
except BaseException:
con.execute("ROLLBACK"); raise
def done(con, key):
con.execute("UPDATE seen SET state='done', done_at=? WHERE key=?", (time.time(), key))
def release(con, key):
con.execute("DELETE FROM seen WHERE key=? AND state='claimed'", (key,))
def prune(con, days, now=None):
now = time.time() if now is None else now
return con.execute("DELETE FROM seen WHERE state='done' AND done_at < ?",
(now - float(days) * 86400,)).rowcount
if __name__ == "__main__":
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--db", default="seen.db")
p.add_argument("--lease", type=float, default=900.0)
p.add_argument("command", choices=["claim", "done", "release", "prune"])
p.add_argument("key", help="the dedupe key, or DAYS for prune")
a = p.parse_args()
try:
con = connect(a.db)
if a.command == "claim":
ok = claim(con, a.key, a.lease)
print("claimed" if ok else "skip: already seen")
sys.exit(0 if ok else 1)
if a.command == "prune":
print("pruned", prune(con, a.key)); sys.exit(0)
(done if a.command == "done" else release)(con, a.key)
print(a.command, a.key)
except (ValueError, sqlite3.Error) as e:
print("error:", e, file=sys.stderr); sys.exit(2)
To wrap any existing command, save this as run-once.sh next to it:
#!/usr/bin/env bash
# Usage: run-once.sh KEY COMMAND [ARGS...] runs COMMAND at most once per KEY
key="$1"; shift
python3 dedupe.py claim "$key"
rc=$?
if [ "$rc" -eq 1 ]; then exit 0; fi # already done: a duplicate, skip quietly
if [ "$rc" -ne 0 ]; then exit "$rc"; fi # error: fail loudly
if "$@"; then
python3 dedupe.py done "$key"
else
rc=$?
python3 dedupe.py release "$key"
exit "$rc"
fi
Then ./run-once.sh "stripe:evt_123" ./send-invoice.sh evt_123 runs the invoice script once, however often the event arrives. We ran exactly that with a stand-in action that failed on its first try. The first delivery failed and released the key, the second added the row, and the third printed skip: already seen. A blank key exited with code 2 instead of being skipped.
Inside a Python receiver, import it and call it directly:
import dedupe
ledger = dedupe.connect("/var/lib/myapp/seen.db") # one connection per process or thread
def on_event(event_id, payload):
key = f"shopify:{event_id}"
if not dedupe.claim(ledger, key):
return # a copy we already handled
try:
add_row(payload) # your real work
except Exception:
dedupe.release(ledger, key)
raise
dedupe.done(ledger, key)
Beyond the load test, we checked the failure cases. A run that fails and releases lets the next copy through. A run that crashes without releasing blocks copies for the lease: a retry 60 seconds later was refused, one 16 minutes later was let in. A finished key was still refused a day later. prune 3 removed nothing on the day and the finished key once the clock passed 3 days.
Five limits to know before you rely on it:
-
A failure must reach the sender. A copy that arrives while another run holds the key is skipped. If that run then fails, only a sender retry brings the event back, so let a receiver’s failure return an error status instead of a 2xx.
-
The file must survive. On a serverless platform whose disk resets between runs, the ledger forgets everything. Use a database with a unique key there instead.
-
SQLite suits one machine. Several servers sharing a network drive should use a shared database, not this file.
-
Set the lease above your slowest run. With the default 900 seconds, a 20-minute job could be taken over by a copy while it is still working.
-
Prune to match the sender’s retry window, such as
prune 3for Stripe’s three days, or the table grows with every event.
Step 5: Pass the key to the next system
Some APIs accept a dedupe key of their own, which closes the gap between your ledger and their side. Stripe’s idempotency keys go in an Idempotency-Key header of up to 255 characters on any POST. A repeat with the same key returns the saved first response instead of charging again. Stripe can remove keys once they are at least 24 hours old, and a reused key with different parameters gets an error.
The gap no ledger closes
One failure stays open, and our tests show it. If the work succeeds and the process dies before done, the key stays claimed. After the lease, a copy claims it and does the work a second time. You choose which way to fail:
- Duplicates are worse than gaps (invoices, payments, customer emails): set a lease longer than any retry window and alert on keys stuck in
claimed, so a person checks them. - Gaps are worse than duplicates (a row in a log, a status sync): keep the short lease and let copies through after a crash.
Where the work is a write to your own database, write the row and the key in one transaction and the gap disappears. That is why the unique column scored 0 in our test without a separate ledger.
When to skip all of this
Some automations are naturally safe to repeat. Setting a status to “Paid” twice leaves it “Paid,” and overwriting a row by its ID leaves one row. Our view: those need no dedupe key, and adding one only creates another file to maintain. Spend the effort on steps that add, send or charge, the ones where a second run leaves a second trace.
For scheduled scripts the same thinking applies to the schedule itself: cron and Kubernetes can start a job twice or not at all, which our guide to making scheduled scripts report their failures covers. If you are still choosing a platform, our comparison of Power Automate and Zapier for Microsoft 365 and the automation hub are the next stops.
Start with the automation that has already doubled a row. Find its cause in the run history, give it a key from the sender, and make one atomic step refuse the second copy.
How we made this
On October 31, 2026 we read the Zapier, Make, Microsoft, Stripe, Shopify and GitHub pages listed below. We wrote the dedupe script and a test harness, and ran them in a Linux sandbox with Python 3.11 and SQLite 3.45 on 500 made-up events delivered 678 times, one at a time and with eight parallel processes, twice. We also tested crashes, blank keys, pruning and the shell wrapper on invented keys. We had no Zapier, Make or Power Automate account, so every platform setting here comes from the vendors’ documentation and the trigger condition expression is untested.
Sources
- Microsoft Learn, Troubleshoot common problems with Power Automate triggers (updated August 2026)
- Microsoft Learn, Optimize Power Automate triggers (updated August 2026)
- Microsoft Learn, Handle workflow errors and exceptions in Azure Logic Apps (July 2026)
- Microsoft Learn, Workflow expression functions reference (July 2026)
- Microsoft Learn (archived), Enforcing uniqueness in column values, SharePoint 2010
- Zapier, How Zapier handles duplicate data in Zap workflows (updated May 2026)
- Zapier, What is replay? (updated May 2026)
- Zapier Platform, Polling trigger deduplication (read October 31, 2026)
- Make, Webhooks (read October 31, 2026)
- Make, Automatic retry of incomplete executions (read October 31, 2026)
- Make, Data stores (read October 31, 2026)
- Stripe, Receive Stripe events in your webhook endpoint (read October 31, 2026)
- Stripe, Idempotent requests (read October 31, 2026)
- Shopify, Verify webhook deliveries (read October 31, 2026)
- GitHub Docs, Best practices for using webhooks (read October 31, 2026)