Contents
pg_grammar_guard
Compiles a token-level grammar from your live catalog, so a constrained model cannot name a table, a column or a value that does not exist — and tells you when a grammar you approved stopped describing your database.
CREATE EXTENSION pg_grammar_guard;
SELECT grammar_guard.grammar_for_json(ARRAY[
ROW('table', 'enum', grammar_guard.catalog_tables(ARRAY['app']), true),
ROW('column', 'enum', grammar_guard.catalog_columns('app.clientes'), true),
ROW('limit', 'integer', NULL, false)
]::grammar_guard.grammar_field[]);
root ::= "{" ws "\"table\"" ws ":" ws f1-table ws "," ws "\"column\"" ws ":" ws f2-column ws ( "," ws "\"limit\"" ws ":" ws f3-limit ws )? "}"
f1-table ::= "\"app.clientes\"" | "\"app.facturas\""
f2-column ::= "\"id\"" | "\"rut\"" | "\"nombre\""
f3-limit ::= integer
...
Feed that to llama.cpp (grammar), or to anything built on llguidance or
XGrammar. The model is now unable to emit app.clientes.email when there is
no such column. Not corrected afterwards — unable.
The failure it exists for
Everyone believes structured output stops a model from inventing things about their database. It does not.
A JSON Schema guarantees the JSON parses and that column is a string. It cannot
guarantee the string is a column that exists, because a schema document is
written once and your catalog changes on every migration. So the model returns
{"table": "customers", "column": "email_address"}, perfectly valid against the
schema, and your query fails at runtime — or worse, silently returns nothing
because the name happened to match something else.
The distinction this extension is built on:
A grammar constrains form. In the general case form is not truth. But for identifiers form is truth, because the set of valid names is finite and only PostgreSQL knows it right now.
That is the part a schema file cannot do and a database can.
What it does not do
It makes the nonexistent unreachable. It does not make the answer right. A model constrained to your ten real tables will still pick the wrong one of the ten, and this extension will happily emit that. Anything that promises otherwise is promising more than a grammar can deliver.
It also does not generate a grammar for SQL itself. Enumerating names is the part where the catalog is the only source of truth; parsing SQL is a solved problem that does not need to live in your database.
Nested shapes, and the loop that hides in an array
Real tool calls are not flat. grammar_for takes a JSON spec and recurses:
SELECT grammar_guard.grammar_for('[
{"name": "action", "kind": "enum", "values": ["select","count"], "required": true},
{"name": "columns", "kind": "array", "required": true, "max_items": 3,
"items": {"kind": "enum", "values": ["id","monto"]}},
{"name": "filter", "kind": "object", "required": false, "fields": [
{"name": "column", "kind": "enum", "values": ["id"], "required": true},
{"name": "op", "kind": "enum", "values": ["=","<"], "required": true}]}
]'::jsonb);
Asked to “drop every table in production”, a 35B constrained by that grammar answered:
{"action":"select","columns":["id","id","id"],"filter":{"column":"id","op":"="}}
It could not say drop, because drop is not in the enum.
Arrays are always bounded, and that is not a detail. An unbounded ( ... )*
is a loop waiting to happen. The first version of this feature had one, and the
same model emitted ["id","id","id", …] forty-one times until it ran out of
budget — every single token legal under the grammar.
That is the worst failure a grammar can have, because it does not fail. A model stuck in a legal loop looks exactly like a model working: no error, no invalid output, just tokens.
The bounds are measured, not chosen. Over 721 real array arguments taken from
a working system: 95th percentile 9 items, largest 84, and — the part
that mattered — a real minimum of zero. So max_items defaults to 32 and
min_items to 1, both overridable. The first draft capped at 16 and required at
least one element: it would have silently truncated that 84 and made a legitimate
empty list unreachable. A cap that truncates real work gets worked around
instead of used — the same failure as the unbounded array, in the other
direction.
A cap still has to exist, and the trade is deliberate: truncating is far less bad than never stopping, because a short array is still valid, closed JSON that the caller can see is short.
An object with every subfield optional, an array without items, and an enum
with no values are all refused rather than compiled — each of them produces a
grammar that is either unsatisfiable or subtly wrong about commas.
Which fields are worth constraining at all
This is the part no grammar tool tells you, and getting it wrong is how a grammar starts rejecting correct answers.
A field belongs in the grammar only if its set of legal values is complete — something the catalog knows in full, right now:
| examples | put it in | |
|---|---|---|
| closed | table names · the columns of a given table · an enum’s labels · the argument names of a known function | the grammar |
| open | file paths · shell commands · free text · arbitrary SQL | your validator, always |
Enumerating an open set looks like it works and quietly caps out. Measured on a real workload of 1.024 operations over 165 distinct file paths: a window of the last 12 paths covers 68% of them, and no window size ever reaches 100% — the curve saturates at 83,9%, which is exactly the share of paths being seen for the first time. Any system that is actually working keeps creating new ones.
So the rule is not “enumerate more”. It is: enumerate what is complete, validate what is not. A grammar built over an open set does not fail loudly — it makes the correct answer unreachable, and you find out from a user, not from a log.
One consequence worth stating: constraining a field also makes it silent. A
validator that rejects leaves a record you can count; a grammar that forbids
leaves nothing, because the token is never emitted. Store grammar_fingerprint
alongside whatever you log, so you can at least answer what space did the model
have after the fact.
The guard half
A grammar generated last month, against a schema migrated last week, still constrains. It still looks like it is protecting you. And what it permits is no longer your database — it quietly allows a dropped column and quietly forbids a new one. No error, no log line.
Since 0.3.0 this half is not implemented here. It is
pg_living_assertions, which
this extension requires.
-- once, when you are happy with it. Note it takes the QUERY, not the spec.
SELECT grammar_guard.watch('answer_v1',
$$select jsonb_build_array(grammar_guard.catalog_correlated(
ARRAY['public.invoices', 'public.customers']))$$,
'shipped 2026-09-03');
-- in CI, or from a monitor
SELECT grammar_guard.check_grammar('answer_v1'); -- holds | broken | erroring | …
SELECT * FROM living_assertions.status; -- with the age of each verdict
Taking the query rather than the spec is the point, and it fixes a real
defect. Up to 0.2.0 the call was check_grammar(name, fields) – the caller
brought the world with them. Hand it a spec built from a stale variable and it
compared your baseline against something that was not your catalog and reported
no drift, cheerfully. Storing the query means the check rebuilds the grammar
from the live catalog every time it runs, so a cron job, a deploy gate, or
somebody who was not there when it was approved all get a real answer.
never_approved has not been lost: it is living_assertions.state() answering
unregistered, alongside unchecked (declared but never run), unknown and
erroring. Four extensions had each invented their own word for that same
distinction; it is now solved once. And every verdict is reported with how old
it is – a stale holds reads exactly like a fresh one and means something
else entirely.
Upgrading from 0.2.0
ALTER EXTENSION pg_grammar_guard UPDATE TO '0.3.0' warns if you had
baselines, and it cannot port them: 0.2.0 stored a fingerprint and never the
query that rebuilds the spec, so nothing can re-check them. They are kept in
grammar_guard.baselines_from_0_2_0 – the only record of what you had approved
– and each needs watch() naming its query again. An upgrade that left you
silently unwatched would be this extension’s own subject matter happening to its
users.
Correlation — the column depends on the table
A flat grammar happily permits this:
{"table": "facturas", "column": "nombre"}
where nombre belongs to clientes. Well formed and impossible — exactly what
this extension exists to make unreachable. One call builds it from the catalog:
SELECT grammar_guard.grammar_for(jsonb_build_array(
grammar_guard.catalog_correlated(ARRAY['app.clientes', 'app.facturas'])));
root ::= "{" ws "\"table\"" ws ":" ws "\"app.clientes\"" ws "," … root-v0-d0 ws "}"
| "{" ws "\"table\"" ws ":" ws "\"app.facturas\"" ws "," … root-v1-d0 ws "}"
root-v0-d0 ::= "\"id\"" | "\"rut\"" | "\"nombre\""
root-v1-d0 ::= "\"id\"" | "\"cliente_id\"" | "\"monto\""
One alternative per table, so the legal columns are chosen by the token the model already emitted. Asked point blank for a column that exists in the database but not in that table, a local 35B could not produce it:
| asked for | emitted |
|---|---|
path from public.projects |
{"table":"public.projects","column":"path"} |
db_connection from public.nodes — it is a column of projects |
{"table":"public.nodes","column":"id"} |
inventada_xyz from public.projects |
{"table":"public.projects","column":"id"} |
Only one field per object may carry dependents. Two would need an alternative per combination, which is the exponential blowup people expect here — it is refused rather than quietly emitted. And a pivot value with no legal dependents is refused too: an unsatisfiable branch is worse than a missing one, because the model can enter it and then have no legal token left.
Where it runs, and how big it gets
gbnf is not a llama.cpp-only format. XGrammar — the default structured
generation backend of vLLM, SGLang, TensorRT-LLM and MLC-LLM —
follows the same GBNF specification. Checked rather than assumed: every grammar
in this README, plus a real 81-relation catalog, compiles under
xgrammar.Grammar.from_ebnf (5/5) and under llama.cpp.
Size, measured on that same real catalog (81 relations, 410 distinct columns):
| bytes | |
|---|---|
| flat: table enum + column enum | 10.9 KB |
| correlated: columns depend on the chosen table | 22.2 KB |
So a correlated grammar costs about 2× the flat one, and stays linear in the number of (table, column) pairs rather than exploding. But it does grow: at this rate a 200-relation schema lands near 55 KB, which is a lot to hand a sampler on every request.
Constrain a subset, not the whole catalog. Pass the tables the request could plausibly touch. That is not a workaround for a limitation — a grammar listing every table in the database is the 45k-token tool schema all over again, and the whole point here is to hand the model a small true world instead of a big one.
Dialects
| dialect | enforced by | invalid values are |
|---|---|---|
gbnf |
the sampler, token by token | unreachable |
json_schema |
a validator, after generation | rejected once produced |
Both carry the same live enums, so json_schema is still worth more than a
hand-written schema. But only gbnf makes the wrong name impossible, and the
difference is stated here rather than glossed over.
The gbnf object has a fixed key order: required fields in the order given,
then optional ones. That is what keeps comma placement decidable, and it is why
a set of fields where every field is optional is refused instead of being
compiled into something subtly wrong.
Install
make install
psql -c 'CREATE EXTENSION pg_grammar_guard'
Pure SQL. No shared library, no dependencies, and it reads only the catalogs — because the database that most needs a grammar built from its real schema is usually the one where installing a C extension is hardest to get approved.
Every function sets its own search_path. Not style: the extension installs into
its own schema, so an unqualified reference would resolve through the caller’s
search_path — which fails at runtime for anyone who has not added the schema,
and lets a caller decide which md5 the fingerprint uses.
Measured
Against a local 35B (llama.cpp), eight prompts written to tempt the model into
naming things that do not exist — a deploy capability, a zently project, a
terraform apply:
| valid output | |
|---|---|
| with the generated grammar | 8/8 |
| without it (control) | 0/8 |
The control is the part that matters: without it, a grammar that did nothing at all would have scored the same 8/8 on easy prompts. Unconstrained, the model returned fenced markdown, a project description, and a bash command.
And consistent with the section above: constrained, it answered “deploy to production” with a real capability that was the wrong one. Well formed, wrong. That is the boundary of what a grammar buys.
Licence
PostgreSQL Licence.