Skip to main content

LangGraph Thread IDs as Business Keys: One Idempotent Run per Period

· 5 min read
Vadim Nicolai
Senior Software Engineer

A LangGraph thread id is the key the checkpointer stores a run's state under, so derive it from what the run is about — graph, mode, period, subject — and "run this" becomes "make sure this has run": a retry resumes the existing thread instead of starting a second.

LangGraph in production, a 10-part series. ← Part 2 · Part 4 →

A random thread id cannot answer the question a scheduler is actually asking​

The identifier is "a unique ID or thread identifier assigned to each checkpoint saved by a checkpointer" LangGraph (2025). Assigned, not generated. The value is yours.

Chat examples mint a fresh UUID per call, correct there because every conversation is new. A scheduled graph is not chat. Fire the tick twice and per-call ids hand back two pick lists for one night, both consumed downstream.

Four parts in one validated formatter — and mode is not optional​

Compose the key in a frozen value object that renders the string and raises on an empty part, a part containing the separator, or an over-long id. With the Postgres saver the column is TEXT, but the real ceiling is Postgres's primary-key index, at about 2.7 KB; 255 characters is this series' own bound.

Immutability is not decoration: in a frozen dataclass, "assigning to fields will generate an exception" Python (2025).

Mode belongs in the key: off, shadow and live differ by exactly that part. Drop it and the first live run resumes whatever the shadow left behind and acts on it.

Period comes from a date the caller passes in, never from the clock at invocation. date.isoformat() returns "a string representing the date in ISO 8601 format, YYYY-MM-DD" Python (2025).

One formatter, or none.

Look before you invoke, and never test values for emptiness​

The intuitive check — read the state, treat empty values as "hasn't run" — is wrong, and wrong in the shape of a success. A finished run whose nodes wrote no state key also has empty values, so that graph restarts on every tick, indefinitely.

Branch on the snapshot's own fields:

def ensure_ran(graph, key, start):
config = {"configurable": {"thread_id": key.thread_id}}
snapshot = graph.get_state(config)
try:
if snapshot.created_at is None: # no checkpoint at all: a new run
graph.invoke(start, config)
return "began"
if snapshot.interrupts: # paused on interrupt(): a person must answer
return "waiting for an answer"
if snapshot.next: # died part-way: continue from the last step
graph.invoke(None, config)
return "picked up"
return "already done"
except Exception as exc:
return f"interrupted by {type(exc).__name__}"
interrupted by TimeoutError
picked up
already done
upstream node ran 1 time(s)
began | already done

No checkpoint, start. Non-empty interrupts: report the pause and stop — invoking with None pauses it again. Non-empty next: the run died part-way, and None continues from the last completed step, which the checkpointers page describes as the ability to "restart your graph from the last successful step" LangGraph (2025). Otherwise it is done.

The counter is the measurement that matters: upstream node ran 1 time(s) across three calls. When an answer arrives through Command(resume=...), the interrupted node re-runs from its first line, so everything above interrupt() must survive a second execution.

The key names the run; it does not serialise it​

The strongest objection is that thread_id is a persistence-layer namespace, and the guarantee is check-then-act — two processes calling get_state in the same instant both see created_at is None and both start. That is right about concurrency, and the code does not dispute it. Sequential retries, restarts and redeploys converge on one thread. Simultaneous writers do not.

A unique constraint stops an effect happening twice. It does not stop a crashed run's expensive first step being recomputed, it does not tell you a thread is paused awaiting a human, and it cannot express "died part-way, continue from the last completed step". The key makes those four states addressable.

Alternatives carry prices. A per-call UUID plus a dedup row at the effect boundary is cheaper, and wins when every effect lands in one transactional store and you never need to resume or inspect a run. A lock around the ensure step works, at the price of a held connection per run. An external orchestrator fits when the run spans many non-LLM steps, and buys a second place where "did this run?" is recorded.

The example covers restart, mode isolation, a paused thread, a run that writes nothing and key validation; concurrent writers and cross-process resume need a shared saver such as Postgres.

Practical takeaways​

  • Derive the id from graph, mode, period, subject; compute the period from a caller-supplied date.
  • Enforce the length bound and the separator rule at construction, in one formatter.
  • Branch on created_at, then interrupts, then next — never on values.

Common questions​

Can I pass my own string as thread_id? Yes — it is caller-supplied and scoped to the checkpointer LangGraph (2025).

Does reusing a thread id create a second run? Not if you look first. A plain invoke on a finished thread runs the graph again on the same thread; the look-before-invoke step is what returns "already done".

Does resuming re-execute completed nodes? Not the completed steps — execution continues from the last checkpoint. The node interrupted by interrupt() re-runs from its first line under Command(resume=...).

The thread id is the question you ask weeks later: was the pick list built, is it paused, did it finish and write nothing? Build the key so that question has an index — and build the single writer before the second process arrives.