Retries, backfills and clears all mean your task will run twice. Partition overwrite, MERGE, atomic swaps — and how to prove a task is safe.
⏱ 6 min readTopics chapter readerLevel · Medium
01 · Start here
Start here
⏱ 4 min · Topic 1 of 8
A task is idempotent when running it twice gives the same result as running it once. Retries, clears and backfills all run tasks again (chapter 12). So they are only safe on idempotent tasks.
Here is the problem in two lines. This load adds the revenue of 2026-01-10 to daily_revenue, and it ran twice:
We also made a run fail after the DELETE. The transaction rolled back, and the old row was still there.
Without the transaction, a failure after the DELETE leaves the day empty until a retry succeeds.
When a record can change later, use an upsert on its key. Order 104 changed from paid to refunded between runs. After three runs, the copy still had one row, and it was refunded:
The test copies one order, to keep the output short. Postgres and SQLite write an upsert like this, and many warehouses use MERGE. It needs a real key, such as order_id.
Watch out
Delete-insert for one day misses late changes to older days. If order 102 is refunded next week, rebuilding today never fixes 2026-01-10. For data like that, upsert on the key.
03 · Dates
Never ask the clock what day it is
⏱ 5 min · Topic 3 of 8
A task must take its day from the run, not from the computer clock. Chapter 5 showed why: a retry the next day, or a rerun next week, reads a different day.
The clock hides in many places:
Looks like
Really gives
Use instead
date.today() in Python
The day the code runs.
data_interval_start
CURRENT_DATE in SQL
The day the query runs.
A parameter from the interval.
NOW() in a WHERE line
The time the query runs.
data_interval_start and data_interval_end.
A file name from datetime.now()
The time the code runs.
The interval start, as text.
Search your DAGs and SQL for today(), now(), CURRENT_DATE and NOW(). Each hit is a bug that only shows up in a retry or a backfill.
Then test the task for an old date, and check that the output lands under that date, not today.
04 · Readers
Atomic publishing
⏱ 5 min · Topic 4 of 8
Idempotency asks: is running twice safe? Atomic publishing asks another question: what do readers see while the task runs?
TRUNCATE then INSERT empties the table first. During the load, the dashboard shows nothing. If the INSERT fails, the old data is gone too.
Instead, build the new table beside the old one, then swap the names in one transaction:
Readers see the old table or the new one, never a half table. If the build fails, the old table stays.
The time between the build and the swap is the right place for checks. Count the rows, or compare totals with the source. If a check fails, do not swap.
05 · Task size
Task size and side effects
⏱ 5 min · Topic 5 of 8
A retry runs the whole task again. So a task that does five things repeats all five, even if only the last one failed.
Suppose one task loads orders, builds revenue and then emails the report. If the email fails, a retry loads and builds again. If the build fails after the email, a retry sends the email twice.
Split along those lines:
Split where steps fail for different reasons, such as a database and an API.
Split a slow step from a fast one, so a small failure does not repeat the slow work.
Put an action that cannot be undone, such as an email, in its own task, at the end, with retries=0.
Do not split steps that always succeed or fail together. Each task adds overhead.
When another system supports an idempotency key, build the key from the run, such as shop-report-2026-01-10. Then a repeated call does nothing the second time.
06 · Proof
Proving a task is safe
⏱ 5 min · Topic 6 of 8
Reading the code is not enough. To prove a task is idempotent, run it twice for the same day and compare the table.
Here is the delete-insert load as an Airflow task:
load_revenueworked example
Python
@task
def load_revenue(data_interval_start=None, data_interval_end=None):
day = data_interval_start.strftime("%Y-%m-%d")
end = data_interval_end.strftime("%Y-%m-%d")
conn = SqliteHook(sqlite_conn_id="shop_db").get_conn()
with conn: # one transaction
conn.execute("DELETE FROM daily_revenue WHERE revenue_date = ?", (day,))
conn.execute(INSERT_SQL, (day, day, end))
print(f"loaded revenue for {day}")
INSERT_SQL is the INSERT from the write patterns section. SqliteHook comes from the SQLite provider.
A unit test can call the plain function twice. .function gives you the Python function behind a @task:
Run it twice, compareworked example
Python
def test_load_revenue_twice_gives_the_same_table(...):
start = pendulum.datetime(2026, 1, 10, tz="UTC")
end = pendulum.datetime(2026, 1, 11, tz="UTC")
query = ("SELECT count(*), sum(revenue) FROM daily_revenue "
"WHERE revenue_date = '2026-01-10'")
load_revenue.function(start, end)
first = conn.execute(query).fetchone()
load_revenue.function(start, end)
second = conn.execute(query).fetchone()
assert first == second == (1, 25.0)
pytest results
version of load_revenue
result
With the DELETE
passed
Without the DELETE
failed: assert (1, 25.0) == (2, 50.0)
The test points the hook at a small test database. Check a sum, not only a count: a task can keep the count but change the data.
You can do the same against a test Airflow. Run airflow tasks test twice for one old date, and run the same query after each run. In our run, both queries returned 1 row and 25.0.
07 · Recap
Recap
⏱ 4 min · Topic 7 of 8
Run it twice, get the same table:
Never append. Delete-insert one day, in one transaction.
Upsert on a key when records change later.
Take every date from the run, never from the clock.
Build aside and swap, so readers never see a half table.
Prove it: run twice, compare count and sum.
Check yourself: a load runs TRUNCATE, then INSERT, and the INSERT fails. What do readers see? (Answer: an empty table, and the old data is gone.)
08 · Next Chapter
Next Chapter
Your tasks are now safe to run again.
⏱ 3 min · Topic 8 of 8
Next chapter
Executors Compared: Local, Celery, Kubernetes
Your tasks are now safe to run again.
Next, you see where tasks actually run, and how the executor decides it.