Design the state first: my LangGraph rule number one
My first review question on any LangGraph codebase is always the same: show me the state schema. Not the nodes, not the prompts. The schema. I earned that habit in March, on a contract-review graph, by doing everything wrong first.
The project was for the same client whose triage graph had just shipped: upload a vendor contract, extract obligations, flag deviations from their playbook, draft a summary for a lawyer. Contracts ran 40 to 80 pages. And I, wanting to keep things simple, put the full extracted text of every document straight into the graph state.
Nine days later the sqlite checkpoint file had passed 9 GB.
Here's the mechanism I hadn't respected: a checkpointer snapshots state every super-step. My state carried roughly 300 KB of contract text plus OCR fallout, each run took eight or nine steps, and every step re-serialized the lot. Multiply by a pilot group of five lawyers and you get a checkpoint store growing like a log file with ambitions. The fix was boring and correct: documents moved to object storage, state kept doc IDs and page ranges, nodes fetched what they needed. State shrank to about 4 KB and the checkpointer went back to being invisible.
the schema is the API
Everything a LangGraph app does flows through its state. Nodes are functions from state to partial state; edges are functions from state to a route. Which means the TypedDict (or Pydantic model if you want validation on every update, and since April I do) is the real interface of the system. The nodes are implementation detail behind it.
class ReviewState(TypedDict):
messages: Annotated[list, add_messages]
doc_refs: list[DocRef]
findings: Annotated[list[Finding], merge_findings]
review_round: int
The Annotated fields are reducers, and they matter more than any tutorial admits. The default is last-write-wins, which is right for scalars. add_messages appends chat history properly. And merge_findings exists because a retried node taught me that a bare operator.add reducer happily appends the same three findings twice. My custom merge dedupes on a finding id before extending. That duplicate sat in production output for four days until a lawyer asked why clause 11.2 was flagged twice.
State is working memory, not a filing cabinet: if a value doesn't steer an edge or feed a prompt, it doesn't belong in the schema.
what earns a place in state
My test for every field: does some node or edge read this to decide something? Control flags, retry counters, the message list, small accumulators like findings, references to heavy things. That's the whole list.
What stays out: raw documents, embeddings, fetched pages, anything a node can refetch from a store by id. Knowledge that outlasts a single run belongs in a proper memory layer rather than a thread's state. The state should read like the index card at the front of the case file.
when the schema changes under you
Nobody warned me about this part: schemas evolve mid-project and old checkpoints don't. In May I added review_round for a second-pass workflow, deployed, and the first resumed thread blew up with a KeyError. Its checkpoint predated the field. Nothing migrates persisted state for you. It's your job, same as any database you own.
What I do now is unglamorous. New fields are optional with defaults, nodes read them defensively through one small accessor, and genuinely breaking changes wait until in-flight threads drain (about two weeks for us, the length of our longest review). I tried versioning the whole schema with a schema_v field once; it bought me nothing over defaults and I dropped it after three weeks.
Design the state first, on paper if that helps, the way you'd design a table before writing queries. Every hour spent on the schema up front has repaid itself within the week. Every field I let sneak in casually has eventually cost me a migration, a bloated checkpointer, or a twice-flagged clause 11.2.