Own the LangGraph Postgres Checkpointer Schema With Migrations, Not setup()
Move PostgresSaver.setup() into a migration you commit, review and apply as the schema owner before deploy. Then let the application read the schema version at boot and refuse to start when the database is behind.
LangGraph in production, a 10-part series. ← Part 1 · Part 3 →
setup() Is a Migration Runner — Just Not Yours
"A checkpointer saves a snapshot of graph state at each super-step" LangGraph docs; the store is an append log per thread, hence the index named checkpoints_thread_id_idx. setup() builds a bookkeeping table, reads the highest version applied, and runs whatever the installed version added since saver source; the package page says to "make sure to call .setup()" the first time you use the saver PyPI.
Three things the boot-time runner cannot give you. Review: a statement list that exists only inside a wheel never gets a diff. Privilege: the runner needs DDL, so the app keeps schema ownership for every other query. Start order: two replicas booting together walk the same runner through the same steps.
Copy the DDL Verbatim and Your Migration Dies on the First Index
The rendered file looks like it should be a straight paste of PostgresSaver.MIGRATIONS. It isn't.
Some index statements use CREATE INDEX CONCURRENTLY, which exists so a build does not block writes — "PostgreSQL supports building indexes without locking out writes" Postgres — and Postgres refuses to run it inside a transaction block, precisely what most migration tools wrap around every file. The indexed tables are created earlier in that same file, so there is nothing to be concurrent with. A plain CREATE INDEX leaves an identical schema, which the shape test confirms.
A Database Ahead of the Code Is Not an Error
Behind means this build is missing a migration: an order reaches packing and dies on its first checkpoint write. That refusal belongs at deploy. Ahead means someone migrated, then rolled the application back; the changes are additive, so the older code still fits. Refuse on any difference and every rollback becomes a second outage.
def render_migration(statements):
"""Run once, on a developer's machine, when the pin changes. The output is committed."""
body = [re.sub(r"\bCONCURRENTLY\s+", "", s.strip().rstrip(";")) + ";" for s in statements]
body.append("INSERT INTO checkpoint_migrations (v) VALUES "
+ ", ".join(f"({v})" for v in range(len(statements))) + " ON CONFLICT DO NOTHING;")
return "\n\n".join(body) + "\n"
def open_checkpointer(conn):
applied = conn.execute("SELECT max(v) AS v FROM checkpoint_migrations").fetchone()["v"]
expected = len(PostgresSaver.MIGRATIONS) - 1
if applied is None or applied < expected:
raise RuntimeError(f"checkpoint schema at {applied}, library expects {expected}: "
"ship a migration before this build")
if applied > expected: # an application rollback: the older code still fits
print(f"note: checkpoint schema at {applied}, ahead of this build's {expected}")
return PostgresSaver(conn)
render_migration runs when the pin changes, never at runtime. Only the file it writes ships.
The version rows are the hinge. The migration records every statement as applied; a later setup() run by the schema owner reads those rows, concludes there is nothing to do, and executes no statements. The same call under the runtime role cannot begin at all: its first statement is DDL.
setup() as the runtime role: InsufficientPrivilege
That error is the design. Once nothing at runtime creates tables, the application's role holds SELECT, INSERT, UPDATE and DELETE and nothing else.
The Shape Test Is the Last Place setup() Belongs
Build one scratch schema with setup() and a second with the committed migration, compare columns, types, nullability, defaults and indexes, and require equality. Then drop the checkpoints_thread_id_idx statement and require the test to notice.
same shape: True
planted (one index dropped), same shape: False
Pair it with a scan of the source tree that fails when setup() appears anywhere outside the test.
Practical takeaways
- A pin bump changes the installed library version.
- Render its statement list, stripping
CONCURRENTLY. - Append the version-row insert so the library's bookkeeping matches.
- Commit the file and review the diff as ordinary DDL.
- A schema-owner role applies it in the deploy pipeline.
- A shape test compares that schema against
setup()'s. - The app boots, reads the version, and serves with row privileges only.
Calling setup() on every boot still fits a laptop, a test fixture, or a single-replica service whose application role already owns the database. The committed migration plus boot check is the right choice with more than one replica, a least-privilege runtime role, or a database already governed by a migration tool.
Two replicas booting at once can still race to apply the same step, which is one more reason to run migrations as their own job.
The checkpointer is a table set you own: pin the version, render the DDL once, read the diff, and let a role that never runs mid-request apply it.
