Add persistence, human-in-the-loop, and streaming to ordinary Python functions — without drawing a StateGraph.
Most LangGraph tutorials teach the graph-building API: you define a shared state schema, add nodes, and wire them together with edges and conditional routing. It is powerful, but for many workflows it feels like a lot of scaffolding around what is really just a sequence of function calls with a loop or two. The Functional API is LangGraph's answer to that. It lets you keep ordinary Python control flow — if statements, for loops, function calls — and still get LangGraph's headline features: durable persistence, human-in-the-loop pauses, memory, and streaming. This article covers @entrypoint and @task, how checkpointing and interrupts work in this model, and when to reach for it instead of a StateGraph.
Two decorators, that's the surface area
The Functional API is just two decorators imported from langgraph.func. @entrypoint marks the top-level function that defines a workflow; @task marks a discrete unit of work inside it — an LLM call, an API request, a bit of processing — that LangGraph should track.
from langgraph.func import entrypoint, task
from langgraph.checkpoint.memory import InMemorySaver
@task
def write_essay(topic: str) -> str:
# pretend this is a slow LLM call
return f"An essay about {topic}..."
@entrypoint(checkpointer=InMemorySaver())
def workflow(topic: str) -> str:
essay = write_essay(topic).result() # task returns a future
return essayNotice that write_essay(topic) does not return a string directly — it returns a future-like object, and you call .result() to resolve it synchronously. That indirection is the whole point: LangGraph records each task's result in the checkpoint, so on a resume it can replay completed tasks from the saved result instead of re-running them.
Invoking, and why tasks matter for determinism
You run an entrypoint like any Runnable — .invoke() or the async .ainvoke() — passing a config with a thread_id so the checkpointer knows which conversation or run this is:
config = {"configurable": {"thread_id": "essay-123"}}
result = workflow.invoke("cats", config)Anything non-deterministic — random values, timestamps, network calls, LLM requests — must live inside a @task. When a workflow resumes, the entrypoint body re-executes from the top; completed tasks are served from the checkpoint, but bare inline side effects would run again. Wrapping them in tasks is what makes resumption safe.Short-term memory with previous
An entrypoint can ask for the value it returned on its previous invocation for the same thread_id by declaring a keyword-only previous parameter. This gives you conversational or accumulating state without a state schema:
from typing import Any
@entrypoint(checkpointer=InMemorySaver())
def accumulator(number: int, *, previous: Any = None) -> int:
previous = previous or 0
return number + previous
config = {"configurable": {"thread_id": "acc-1"}}
accumulator.invoke(1, config) # -> 1
accumulator.invoke(2, config) # -> 3 (2 + previous 1)By default the value returned to the caller is also the value saved as previous. When you need those to differ — return one thing to the user but persist another — use entrypoint.final:
@entrypoint(checkpointer=InMemorySaver())
def workflow(number: int, *, previous: Any = None) -> entrypoint.final[int, int]:
previous = previous or 0
# return `previous` to the caller, but SAVE 2*number as next `previous`
return entrypoint.final(value=previous, save=2 * number)Injected parameters: config, store, writer
Beyond previous, the entrypoint can request several framework-provided values as keyword-only arguments, matched by name and type annotation:
| Parameter | Type | Purpose |
|---|---|---|
| previous | Any | The value saved from the last run on this thread_id. |
| config | RunnableConfig | Runtime configuration, including configurable values like thread_id. |
| store | BaseStore | Long-term, cross-thread memory (pass store= to the decorator). |
| writer | StreamWriter | Emit custom data to the stream (used with stream_mode="custom"). |
from langchain_core.runnables import RunnableConfig
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from langgraph.types import StreamWriter
store = InMemoryStore()
@entrypoint(checkpointer=InMemorySaver(), store=store)
def workflow(
inp: dict,
*,
previous: Any = None,
config: RunnableConfig,
store: BaseStore,
writer: StreamWriter,
) -> dict:
...
Running tasks in parallel
Because a task returns a future immediately, you can launch several before resolving any of them. Call the tasks first, then resolve their futures — LangGraph runs the outstanding tasks concurrently, which is the functional-equivalent of a fan-out/fan-in in a StateGraph, expressed as ordinary Python:
@task
def fetch(source: str) -> str:
return f"data from {source}"
@entrypoint(checkpointer=InMemorySaver())
def gather(sources: list[str]) -> list[str]:
# launch all tasks first (they don't block here)...
futures = [fetch(s) for s in sources]
# ...then resolve them; the tasks ran concurrently
return [f.result() for f in futures]This is where the future-based design pays off beyond just checkpointing: the same mechanism that lets LangGraph replay a completed task on resume also lets it schedule independent tasks in parallel without you touching threads or asyncio directly.
Human-in-the-loop with interrupt
Because entrypoints are durable, you can pause one mid-flight to collect human input and resume later — even in a different process or after a restart. Call interrupt() with a payload describing what you need; it raises out of the workflow, the checkpoint is saved, and .invoke() returns the interrupt to the caller. You resume by invoking again with a Command(resume=...):
from langgraph.types import interrupt, Command
@entrypoint(checkpointer=InMemorySaver())
def review_flow(topic: str) -> dict:
essay = write_essay(topic).result()
# pause here and surface the essay for approval
decision = interrupt({"essay": essay, "action": "approve or reject?"})
return {"essay": essay, "approved": decision}
config = {"configurable": {"thread_id": "review-1"}}
review_flow.invoke("cats", config) # returns the interrupt payload
# ... later, after a human decides ...
review_flow.invoke(Command(resume=True), config) # resumes, returns the dictOn resume, the essay is not regenerated — write_essay ran before the interrupt, so its result is replayed from the checkpoint. That is exactly why the expensive step is a @task and the pause is inline.Streaming
Entrypoints support the same .stream()/.astream() interface as graphs. Use stream_mode="updates" to see each task's output as it completes, and stream_mode="custom" together with the injected writer to emit your own progress events:
@entrypoint(checkpointer=InMemorySaver())
def workflow(topic: str, *, writer: StreamWriter) -> str:
writer({"status": "starting"})
essay = write_essay(topic).result()
writer({"status": "done"})
return essay
for mode, chunk in workflow.stream(
"cats", config, stream_mode=["updates", "custom"]
):
print(mode, chunk)Resuming after an error
If a workflow raises partway through, the checkpoint retains the tasks that already succeeded. After you fix the underlying issue, resume by invoking with None and the same thread_id; completed tasks are replayed from the checkpoint and execution continues from where it failed:
workflow.invoke(None, config) # replays finished tasks, retries the restFunctional API vs StateGraph
Both APIs share the same runtime — the same checkpointers, the same interrupt mechanism, the same stores. The choice is about how you want to express control flow.
| Choose the Functional API when | Choose StateGraph when |
|---|---|
| The flow is essentially sequential with normal Python branching and loops. | You need explicit branching, fan-out/fan-in, or cyclic routing between named nodes. |
| You want to add persistence/HITL to existing code with minimal restructuring. | You want a visualizable graph and a shared, typed state channel. |
| State is naturally the function's return value plus previous. | Many steps read and write overlapping slices of a large shared state. |
| You value reading the workflow top-to-bottom as ordinary code. | You need conditional edges, subgraphs, or a router deciding the next node. |
A useful rule of thumb: if you can describe the workflow as "call this, then maybe loop, then call that," the Functional API will read more naturally. If you find yourself drawing arrows on a whiteboard with several possible next steps from each box, that diagram is a StateGraph.
You don't have to choose globally, either. Because both APIs compile to the same runtime, a common pattern is to use the Functional API for the high-level orchestration — the readable, mostly-sequential outer flow — and drop into a compiled StateGraph for a genuinely branchy sub-problem, invoking that graph from inside a @task. The checkpointer, interrupts, and streaming all continue to work across the boundary, so mixing the two is a supported design choice rather than a hack.
Wrapping up
The Functional API removes the ceremony from LangGraph without giving up what makes it valuable. You write plain functions, wrap the risky or expensive parts in @task, mark the top with @entrypoint, and attach a checkpointer — and in return you get durable execution, resumable human-in-the-loop pauses, cross-thread memory, and streaming. Reach for it when your logic is more sequence than graph, and keep StateGraph for the genuinely branchy, cyclic workflows it was designed for.