You schedule a pipeline. It runs fine Monday. Tuesday, it retries after a timeout. Wednesday, you backfill three months. By Thursday, your dashboard shows duplicate revenue rows, and nobody knows why.
Idempotency is supposed to prevent that. But orchestration tools—Airflow, Prefect, Dagster—can quietly undo your guarantees. Retries, dynamic tasks, caching, state persistence: each feature can break idempotency if you're not looking. This isn't theory. It's what happens when your DAG reruns a task that writes to a table without cleaning up first.
Who Needs Idempotency Guarantees and What Breaks Without Them
The data engineer running hourly ETL
Picture this: you have a pipeline that ingests clickstream data every hour. The source system occasionally flakes—network timeouts, database lock contention, a Kubernetes pod that OOMs mid-stream. So you slap on a retry mechanism. Three retries, exponential backoff, standard stuff. The job reruns, picks up the same window of data, and… inserts a full duplicate hour. Not a partial overlap—exact carbon copies, identical timestamps, same event IDs. Your downstream aggregation tables now report double the traffic. The product team sees a sudden spike in conversions, celebrates, then discovers the error. Trust erodes. That's idempotency failure baked into orchestration design, not an infrastructure glitch. The retry logic did exactly what you asked: run the task again. It didn't check whether that task was safe to repeat. The catch is—most orchestrators assume your code is idempotent by default. Few pipelines actually are.
The ML engineer training models with non-deterministic seeds
Different flavor, same poison. I worked on a recommendation model where training used a random seed from the system clock. Each retry produced slightly different weights. The orchestration layer saw the task as "succeeded" on the third attempt, but the feature store now held model artifacts that could not be reproduced. Validation metrics drifted. The compliance officer later asked: which version of this model served Tuesday's predictions? We could not answer.
Non-determinism in a retried task is not a performance problem—it's a data lineage break, and it compounds silently.
— Observations from debugging a production ML pipeline that lost auditability
The orchestrator's status dashboard showed green across the board. No alerts fired. But the underlying guarantee—that retrying a task yields the same output as the first attempt—was false. That's the quiet danger: orchestration tools report task success based on exit codes and runtime metrics, not semantic correctness. You can have a perfect DAG, flawless dependency resolution, and still serve corrupted results because a single non-idempotent task ran three times.
The compliance officer auditing financial transactions
Now scale the problem. Financial reconciliation pipelines process transaction batches with deduplication keys. The retry mechanism, however, operates at the file level. A failed ingest triggers a full re-read of the same S3 object. The dedup logic lives downstream in a SQL window function—expensive and only runs once per batch. On retry, that window function sees a fresh batch boundary and misses the duplicates. The ledger now shows double entries. Not a rounding error—a compliance incident. The orchestration tool logged "retry succeeded," and the pipeline completed without any warning flags. What breaks without idempotency guarantees is not just data quality; it's legal traceability. The tricky bit is that most teams catch this only after an audit or a customer complaint. By then, the blast radius includes days of reprocessing, stakeholder trust, and engineering morale. Wrong order. Not yet fixed. That hurts.
So who needs these guarantees? Anyone whose pipeline output feeds a decision—whether that decision is a dashboard refresh, a model deployment, or a regulatory filing. And what breaks is not always obvious: silent duplicates, unreproducible artifacts, ledger imbalances, and the slow erosion of trust in your data platform. The orchestration framework itself becomes the weak link—not because it fails, but because it succeeds in running broken logic repeatedly.
Prerequisites: What You Must Know Before Trusting Retries
Deterministic task inputs and outputs
Before you trust a retry, you must know one thing cold: your task must produce the same output every time you feed it the same input. That sounds trivial until you embed a datetime.now() call in a SQL query inside a task that retries. The first run pulls rows where created_at <= now(); the retry, two seconds later, pulls a slightly larger dataset. Suddenly your downstream merge duplicates rows. I have watched teams spend three days chasing phantom duplicates that traced back to a single non-deterministic timestamp — the retry didn't re-run the same job, it ran a different one.
The fix is brutal but simple: freeze all time references to the DAG run’s logical date, not wall-clock time. Pass the execution timestamp as a parameter. Your task should behave like a pure function — given identical args, identical result. If you can't guarantee that, your idempotency guarantee is a placebo. Most orchestration tools will happily re-run a failed task without telling you the input context drifted. They assume you read the manual. The manual is this paragraph.
Stateless vs. stateful task design
Stateless tasks are boring. That's their superpower. They read input, write output, forget everything else. A stateless task doesn't care if it ran before — it has no memory, no cursor, no “last processed ID” stored in a file. The moment you introduce state — a checkpoint file on S3, a “we already sent this batch” flag in Redis — you invite a failure mode that's devilishly hard to debug. What happens when the task writes “batch X complete” to Redis but the orchestration tool thinks the task failed before that write committed? On retry, the task sees the flag and skips batch X. You just dropped data. That hurts.
Reality check: name the accommodations owner or stop.
Most teams skip this: design every task as if it will run twice, back-to-back, without warning. If the second run corrupts anything, you have a state leak. The catch is that external APIs often force state on you — a webhook that must receive events exactly once, a payment gateway that rejects duplicate transaction IDs. That's where idempotency keys enter the picture. They're the bridge between your stateless task and a stateful world.
Idempotency keys for external APIs
An idempotency key is a unique string you send with every request to an external service — usually the DAG run ID concatenated with the task instance index. The API provider stores that key and, if it sees the same key again, returns the previous response instead of processing the request a second time. Stripe does this. Twilio does this. Many SaaS APIs don't. That is where pipelines silently break.
Without an idempotency key, a retried POST to a CRM endpoint creates a duplicate contact. With one, the retry is safe — provided you generate the key deterministically from the task’s input, not from a random hash or a timestamp. Wrong key generation? You get false deduplication — the API thinks two different requests are the same and returns a cached response. I have debugged a case where a team used uuid4() as their idempotency key, then wondered why retries still created duplicates. The key changed on every invocation. The API never saw a repeat key, so it processed every retry fresh.
‘Idempotency keys are only as reliable as the source string you derive them from. Use the workflow’s logical ID, not a random number.’
— paraphrased from a production postmortem I read at 2 AM, 2023
The prerequisite, then, is a naming convention that survives retries across workers, across time zones, across cluster restarts. Your orchestration tool provides a unique run identifier — use it. Hard-code it into your task’s request headers. Test that the same key actually stops the duplicate. Then test what happens when the API crashes between receiving your request and storing the key. (Spoiler: you lose the guarantee. Build a reconciliation step for that edge case.) After these three foundations are in place — deterministic functions, stateless design, and solid key management — you're ready to design the actual idempotent workflow. Without them, every retry is a roll of the dice.
Core Workflow: Designing Idempotent Tasks for Orchestration
Step 1: Make each task purely functional—same input, same output
The foundational move is deceptively simple: your task must behave like a mathematical function. Feed it the same parameters, the same upstream data, and it must produce identical results every time—no hidden state, no reliance on wall-clock timestamps, no sneaky counters incrementing inside a class instance. I have seen teams break this within hours. They store a 'last_run_time' inside a task object, then the orchestrator retries the task three minutes later. That timestamp shifts. Score diverges. The second run produces a different partition key or a different API payload, and suddenly the database holds two partially correct records instead of one idempotent snapshot.
The discipline hurts at first. You can't call time.now() inside the task body. You can't pull a 'next sequence' from a shared Redis counter unless that counter itself is deterministic given the same input. Instead, accept all temporal context as explicit parameters: a data interval boundary, a batch identifier, an execution timestamp stamped upstream and frozen. That feels clunky. It's also the only way.
Step 2: Use idempotency keys for writes
Idempotent logic alone can't protect you from the orchestrator's parallel retries. Prefect, for example, can retry a mapped task multiple times simultaneously. Two copies land on the same INSERT statement. Without a deduplication mechanism, you get duplicate rows—or a unique constraint violation that kills the whole pipeline. The fix: generate an idempotency key from deterministic inputs (execution ID + task name + partition value) and pass it to every write operation. Databases like Postgres can enforce it via ON CONFLICT DO NOTHING. Payment APIs accept a key header and silently ignore the second request.
The catch is key collision. If two genuinely different writes share the same key, you lose data. Most teams skip this: they hash the entire input payload—too brittle, changes the key when an upstream field format shifts. Better to compose the key from semantic fields: customer_id + date_bucket + event_type. That way, a schema migration on a source column doesn't retroactively change the dedup identifier.
Worth flagging—idempotency keys don't help against partial failures within a single task. They protect the write boundary, not the internal computation. That requires a different tool.
Not every accessibility checklist earns its ink.
Step 3: Handle partial failures with atomic transactions
A task that mutates three tables—write A succeeds, write B crashes, the orchestrator retries the whole task. Now write A runs twice. If you rely solely on idempotency keys at the row level, you might still hit race conditions or duplicate side effects (sending an email, publishing a notification). The only clean solution is to wrap all mutating operations inside a single atomic unit: a database transaction, a Kafka transactional producer, or a state machine that marks the batch complete only after all sub-steps commit.
That sounds fine until your orchestrator times out. A long-running transaction holds locks; the orchestrator kills the process and retries. Now you have a zombie transaction blocking the next attempt. We fixed this by setting aggressive statement timeouts inside the task and using a distributed lock around the batch identifier—the second run waits until the first releases, then checks a ‘completed’ flag before executing. Not elegant. But it prevents the seam from blowing out.
'Atomicity is the only guarantee that makes retries safe. Without it, idempotency is just a wish wrapped in a try-catch.'
— Senior data engineer debriefing a post-mortem on a lost revenue batch
Step 4: Test with duplicate inputs
Most teams test the happy path: run once, check output. They never simulate the orchestrator resubmitting the exact same task two seconds later—or ten minutes later with stale context. The result? An outage on a Sunday because a retry flooded the production API with duplicate upserts. The fix is cheap: write a test that calls your task function twice without clearing state between calls, then assert that the final state is identical. No extra rows. No corrupted aggregations. No phantom email confirms.
What usually breaks first is a counter-based identifier inside the task—something like run_id = len(previous_runs) + 1. First call yields 1; second call sees the already-inserted record, counts it, and produces 2. Now the second run uses a different partition key. The data diverges. Test for that. Make the second call feel like a real retry: same execution context, same upstream data, no 'test mode' flag. If your task passes that gauntlet, you can trust the orchestrator to handle the rest. If it fails, you just saved yourself a Saturday war-room.
Tool Differences: Airflow, Prefect, Dagster Idempotency Pitfalls
Airflow retry context and task instance state
Airflow’s retry mechanism looks safe on paper—DAG author sets retries=3, the scheduler picks up a failed task instance, and the worker re-runs it. The problem is that Airflow doesn't clear the previous task instance's ti.xcom before the retry. I have debugged a pipeline where a retried task appended to a list stored in XCom instead of overwriting it—the downstream task suddenly saw duplicates. The root cause: Airflow’s clear_task_instances leaves state artifacts unless you explicitly call clear_xcom=True. Worse, if your task writes to an external system (say, a database upsert keyed by run_id), a retry with the same run_id can re-insert rows that the first attempt already committed. That sounds fine until you realize the first attempt partially succeeded before crashing—half your data is duplicated. The fix we landed on was writing an explicit idempotency key in every SQL statement: INSERT ... WHERE NOT EXISTS. Not pretty, but it outruns Airflow’s default behavior.
What breaks first is usually the task_instance state machine. Airflow sets the instance to running before the Python callable executes—if the worker dies mid-execution, the scheduler sees the instance as running and skips retries. I have seen teams waste a day before realizing they needed max_active_runs=1 and a proper depends_on_past to prevent overlapping runs from corrupting retry order. Worth flagging—Airflow’s retry_delay doesn't pause the scheduler’s polling; it just delays the next attempt. If your pipeline has tight SLA windows, retries often overlap with the next scheduled DAG run, causing race conditions on the same database row.
Prefect caching and result persistence quirks
Prefect markets caching as a performance feature: a @task(cache_key_fn=lambda: 'static_key') should skip re-execution if the inputs haven't changed. The catch—Prefect caches the entire Result object, including metadata like flow_run_id. If your task’s output contains a timestamp or a random seed, the cache key remains identical but the payload differs. I saw a production flow where a cached task returned stale model predictions because the upstream data had changed but the cache key didn't account for the data version hash. The fix: always parameterize cache keys with content hashes, not just static strings.
Result persistence introduces a subtler trap. Prefect stores serialized task outputs in a backend (S3, GCS, or the local filesystem). If you delete the result artifact manually—say, to force a refresh—Prefect’s deserializer can fail silently and return None instead of raising an exception. The downstream task then runs on null data, and no retry triggers because Prefect considers the upstream task successful. Most teams skip this: they never test what happens when the result store is cleared mid-pipeline. We fixed this by adding a prefect.artifacts check at the start of every critical task: if the expected result file exists but deserializes to None, raise a custom CorruptedCacheError. That forces a re-run instead of propagating garbage.
Dagster time-window partitions and asset materialization
Dagster’s partitioning model is elegant—each partition maps to a slice of time or a data partition key. The idempotency pitfall lives in how Dagster tracks materialization status. When you run a backfill over 30 daily partitions, Dagster marks each partition as materialized after the computation completes. But if the computation mutates a shared table (e.g., a daily aggregation that overwrites the same partition in BigQuery), re-running a single partition doesn't automatically invalidate downstream partitions that consumed the old data. That hurts. I have debugged a case where a data engineer fixed a bug in the Feb 15 partition, re-materialized it, but the downstream weekly aggregation still held the stale Feb 15 value because Dagster’s asset lineage didn’t propagate the invalidation.
The tool offers freshness_policies and auto_materialize policies, but these only trigger re-materialization if the upstream partition’s timestamp changes—they don't check the data content. So if your pipeline writes to a table using INSERT OVERWRITE with a WHERE clause on partition_date, a re-run with the same date produces identical output, but Dagster’s ledger still shows the partition as materialized from the first run. Not a bug—by design. The practical fix: use Dagster’s PartitionMapping to explicitly define which downstream partitions depend on which upstream partitions, and then call instance.report_run_events to reset materialization status after a selective backfill. Otherwise you end up with a data warehouse where half the partitions are logically inconsistent but the orchestrator reports green.
Reality check: name the accommodations owner or stop.
“An orchestrator that reports green while data silently diverges is worse than a failed run—it hides the rot until the weekly report bloats by 40%.”
— Data engineer at a mid-size fintech, after a three-day debugging session
Variations: Incremental, Full Refresh, and Event-Driven Pipelines
Incremental pipelines with watermark columns
The tricky bit with incremental pipelines is that idempotency hinges entirely on your watermark — that timestamp or integer column saying "everything after this is new." Most teams set the watermark, run the job, and call it done. Then a retry happens. The source system has a clock drift of two seconds. Or someone manually inserted a row with a timestamp identical to the watermark boundary. Suddenly your pipeline picks up that row twice — once in the first run, once in the retry. Duplicates flood the target table. I have seen this break a production dashboard for three days because nobody checked whether the watermark was strictly monotonic. The fix: use a deduplication window of, say, 15 minutes of overlap, then run a merge-on-keys step inside the pipeline. That adds maybe 12 seconds per run but saves you from the silent double-insert nightmare.
Full refresh with delete-and-insert
Full refresh sounds safer — delete everything, reinsert the whole dataset. Idempotent by definition, right? Wrong order. If your orchestration tool triggers two concurrent runs of the same full-refresh DAG, you get a race: one run deletes, the other inserts halfway through, and you end up with missing rows. Or worse — the delete-and-insert sequence is not wrapped in a transaction, and the run fails after the delete but before the insert. Now your table is empty. That hurts. The pattern that actually works is a shadow table: load into a staging table, then swap the production table with a single metadata operation. Airflow can do this with a PostgresHook and a CREATE TABLE … AS approach. Prefect has the concept of checkpointing the staging write. But here is the pitfall: many full-refresh pipelines skip the swap step because "it's just a small table." Small tables break exactly the same way.
Event-driven triggers and at-least-once delivery
Event-driven pipelines introduce a whole new flavor of breakage. Your webhook receiver gets a payload, triggers a run, the run succeeds, but the acknowledgement to the event source times out — so the source retries. At-least-once delivery means the same event fires your pipeline twice. If the pipeline is not idempotent, you get duplicate records. Most teams handle this by storing the event ID in a processed-events table and checking it before processing. That works. The catch is what happens when the event ID table itself gets a conflict — two concurrent runs both check, both see no record, both insert. You need a unique constraint on the event ID column and a retry-on-conflict pattern. Worth flagging: I have debugged a case where the event broker sent the same payload with different event IDs because the source system regenerated the ID on retry. That broke the dedup table entirely. The only fix was to hash the payload body and use that as the dedup key.
'An event-driven pipeline without payload deduplication is just a lottery with your data integrity.'
— senior data engineer, after a 4AM incident review
Each pattern — incremental, full refresh, event-driven — demands a different idempotency contract. The contract is not in your orchestration tool's settings panel. It's in the way you handle state, boundaries, and the edge case where "once" doesn't mean "once."
Pitfalls, Debugging, and What to Check When It Fails
Duplicate rows in target tables
The most common scream I hear is 'our counts are off by exactly 47,892 again'. That's almost always a retried task writing into a table without a proper unique constraint or an upsert—not a delete-then-insert. Airflow's default retry policy will fire the same task again, and if your SQL uses INSERT INTO without checking existence first, you get doubles. Prefect's task_retry_delay doesn't fix this; it just spaces out the damage. The fix is brutal but boring: every write must be idempotent at the row level. Use MERGE, ON CONFLICT DO UPDATE, or a transaction that deletes the partition window before writing. I have seen teams spend two months rewriting dbt models because they assumed Snowflake's transient table was enough. It's not.
Non-deterministic functions (random seeds, timestamps)
You seed a random forest model with RANDOM() in a notebook, pipeline runs fine, retry happens, and suddenly your production predictions drift by 12%. Not a model issue—a seed issue. Dagster's solid retries don't reset randomness. Neither does Airflow's retry_delay. Worth flagging—NOW() used as a partition key is another trap. Each retry writes into a different partition, data lands in two places, and your downstream dashboards lose a day reconciling. The fix: freeze timestamps once at the run level, pass them as a configuration parameter, and never call datetime.now() inside the task body. We fixed this by adding a run_time context variable that's resolved at the DAG execution trigger, not at task runtime. That one change killed half our duplication bugs.
Stateful connectors and external side effects
The API connector that posts to Slack on success—retry it, and your team gets three identical alerts. Annoying, not catastrophic. The PostgreSQL COPY FROM that calls a stored procedure incrementing a sequence—that's a disaster. Each retry advances the sequence even if the copy fails halfway. You end up with gaps and orphan keys. The pipeline orchestration tool doesn't know about that side effect. It sees a Python exception and fires again. It can't see what the database did before the error. How to test this: run the task twice manually with the same input. Compare row counts, sequence values, and external API calls. If anything changed, your task is not idempotent. Most teams skip this. They assume 'my function is deterministic' because the output looks the same in staging—until prod retries hit a rate-limited third-party API and lock your account.
'Idempotency is not a property of your orchestrator. It's a contract between your task and its own history.'
— overheard at a Prefect meetup, after someone's Airflow scheduler ate a month of log data.
How to test idempotency with replay and backfill
Stop believing your pipeline is idempotent until you have replayed the exact same date range twice and compared every row. That means: backfill a three-day window, record checksums or row counts per table, then backfill the same window again. Diff the two outputs. If they match exactly, you're safe for that window. If not, you have a debugging trail. What usually breaks first is the incremental logic—specifically, the timestamp boundary. A task processing updated_at >= last_run will pick up rows that were updated during the previous run. On replay, those rows are still within the window, so you get duplicates. The fix: use a deterministic cursor (like a monotonically increasing ID) or a watermark that's committed after the read completes. We once spent a week chasing a bug where Prefect's auto-retry was re-running a successful fetch but the API had paginated differently on retry—same endpoint, different results. Our test suite now includes a 'force retry' flag that replays the previous run's parameters without changing the upstream state. That catches 90% of the silent breaks before they hit prod. Start there.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!