Idempotency: safe re-execution
Why this matters
In production, networks fail, processes crash, and tasks retry. Without idempotency, a re-executed node might charge a customer twice, insert duplicate database records, or send the same email three times. LangGraph's checkpointing and replay system only works correctly if your nodes are idempotent: otherwise recovery becomes corruption.
Explanation
Idempotency in LangGraph means that calling the same node or replaying the same graph execution with identical inputs will always produce identical outputs and side effects: running it once or ten times changes nothing after the first execution. How it works mechanically: When LangGraph uses MemorySaver checkpointing and you invoke a graph with thread_id, it can resume from a checkpoint. If a node failed mid-execution, LangGraph replays nodes to restore state. If a node isn't idempotent (e.g., it charges a payment API every time it runs), replaying it causes duplicate charges. Idempotent nodes detect "have I already done this?" and skip or return the cached result. When to use it: Always in production workflows, especially those involving external APIs (payments, email, database writes), long-running tasks with checkpointing, and multi-step processes where any step might fail and retry.
Analogy
Think of a restaurant order: an idempotent order system means that if the kitchen receives the same order ticket twice (due to a reprint), it checks "did I already cook this order?", and if yes, it just plats the existing food instead of cooking two meals. A non-idempotent kitchen cooks every ticket it receives, so a reprint means double portions.
Code
import json
from datetime import datetime
from typing import Any
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import MemorySaver
from typing_extensions import TypedDict
class State(TypedDict):
user_id: str
action: str
executed_actions: list[dict]
payment_processed: bool
def non_idempotent_payment(state: State) -> State:
"""UNSAFE: processes payment every time, even on replay."""
if state["action"] == "pay":
print(f"⚠️ CHARGING user {state['user_id']} — this runs EVERY replay!")
new_action = {
"timestamp": datetime.now().isoformat(),
"type": "charge",
"amount": 99.99
}
return {
**state,
"executed_actions": state["executed_actions"] + [new_action],
"payment_processed": True
}
return state
def idempotent_payment(state: State) -> State:
"""SAFE: checks if already processed, skips on replay."""
if state["action"] == "pay":
already_charged = any(
action["type"] == "charge"
for action in state["executed_actions"]
)
if already_charged:
print(f"✓ Payment already processed for {state['user_id']} — skipping")
return state
print(f"✓ Processing payment for {state['user_id']}")
new_action = {
"timestamp": datetime.now().isoformat(),
"type": "charge",
"amount": 99.99,
"idempotency_key": f"{state['user_id']}_pay_{datetime.now().isoformat()}"
}
return {
**state,
"executed_actions": state["executed_actions"] + [new_action],
"payment_processed": True
}
return state
def verify_idempotency_safe() -> None:
"""Demonstrate idempotent re-execution."""
print("=== IDEMPOTENT (SAFE) GRAPH ===")
builder = StateGraph(State)
builder.add_node("payment", idempotent_payment)
builder.add_edge(START, "payment")
builder.add_edge("payment", END)
graph = builder.compile(checkpointer=MemorySaver())
initial_state = {
"user_id": "user_123",
"action": "pay",
"executed_actions": [],
"payment_processed": False
}
print("\n--- First execution ---")
result1 = graph.invoke(initial_state, {"configurable": {"thread_id": "thread_1"}})
print(f"Charges: {len([a for a in result1['executed_actions'] if a['type'] == 'charge'])}")
print("\n--- Replay (same thread_id, same input) ---")
result2 = graph.invoke(initial_state, {"configurable": {"thread_id": "thread_1"}})
print(f"Charges: {len([a for a in result2['executed_actions'] if a['type'] == 'charge'])}")
print(f"\nTotal charges after 2 invocations: {len([a for a in result2['executed_actions'] if a['type'] == 'charge'])}")
print("Expected: 1 (idempotent — second run didn't charge again)")
def demonstrate_non_idempotent() -> None:
"""Show what happens without idempotency (dangerous)."""
print("\n=== NON-IDEMPOTENT (UNSAFE) GRAPH ===")
builder = StateGraph(State)
builder.add_node("payment", non_idempotent_payment)
builder.add_edge(START, "payment")
builder.add_edge("payment", END)
graph = builder.compile(checkpointer=MemorySaver())
initial_state = {
"user_id": "user_456",
"action": "pay",
"executed_actions": [],
"payment_processed": False
}
print("\n--- First execution ---")
result1 = graph.invoke(initial_state, {"configurable": {"thread_id": "thread_2"}})
print(f"Charges: {len([a for a in result1['executed_actions'] if a['type'] == 'charge'])}")
print("\n--- Replay (same thread_id, same input) ---")
result2 = graph.invoke(initial_state, {"configurable": {"thread_id": "thread_2"}})
print(f"Charges: {len([a for a in result2['executed_actions'] if a['type'] == 'charge'])}")
print(f"\nTotal charges after 2 invocations: {len([a for a in result2['executed_actions'] if a['type'] == 'charge'])}")
print("DANGER: 2 charges! This is why non-idempotent nodes break recovery.")
if __name__ == "__main__":
verify_idempotency_safe()
demonstrate_non_idempotent() === IDEMPOTENT (SAFE) GRAPH === --- First execution --- ✓ Processing payment for user_123 Charges: 1 --- Replay (same thread_id, same input) --- ✓ Payment already processed for user_123: skipping Charges: 1 Total charges after 2 invocations: 1 Expected: 1 (idempotent: second run didn't charge again) === NON-IDEMPOTENT (UNSAFE) GRAPH === --- First execution --- ⚠️ CHARGING user user_456: this runs EVERY replay! Charges: 1 --- Replay (same thread_id, same input) --- ⚠️ CHARGING user user_456: this runs EVERY replay! Charges: 2 Total charges after 2 invocations: 2 DANGER: 2 charges! This is why non-idempotent nodes break recovery.
What just happened?
Two graphs were created: one with an idempotent payment node that checks if a charge already exists before processing, and one without that check. The idempotent graph was invoked twice with the same thread_id and input; the second invocation detected the prior charge and skipped it, resulting in 1 total charge. The non-idempotent graph ran the payment logic both times, resulting in 2 total charges. This demonstrates why checkpointing + replay only works safely when nodes are idempotent.
Common gotcha
Developers assume that because LangGraph uses checkpointing, re-execution is automatically safe. Wrong. Checkpointing saves state, but doesn't prevent re-execution of side effects. A node can read the same checkpoint and still call your payment API, send an email, or insert a database row twice. You must explicitly implement the idempotency check (usually via an idempotency key or a state flag) inside the node logic.
Error recovery
DuplicateChargeErrorDuplicateDatabaseInsertErrorDuplicateEmailSentErrorExperienced dev note
Idempotency is not a 'nice to have': it's the price of admission for production LangGraph systems. Every node that touches an external system (API, database, email, file write) must be idempotent. The pattern is: (1) generate or track an idempotency key (user_id + action + timestamp), (2) check state or external system for prior execution, (3) return cached result or skip if already done. Don't rely on 'the graph will never replay': networks fail, containers crash, and ops will restart your job. Build idempotency in from day one.
Check your understanding
You have a LangGraph with three nodes: fetch_data → process → send_notification. Fetch and process are pure (read-only, no side effects). Send_notification calls a Slack webhook. Your graph crashes after send_notification runs but before returning. You restart with the same thread_id and input. What happens, and why? What must you change to make it safe?
Show answer hint
A correct answer recognizes that (1) on restart, the graph will replay all nodes from the checkpoint, including send_notification, (2) the Slack webhook will be called again, sending a duplicate message, (3) you must make send_notification idempotent by either checking if the message was already sent (via state or an idempotency record) or using Slack's thread/unique message ID feature to prevent duplicates.