pg_grammar_guard 0.4.0

This Release
pg_grammar_guard 0.4.0
Date
Status
Stable
Abstract
Compile a token-level grammar from your live catalog, and watch it for drift
Description
Everyone believes structured output stops a language model from inventing things about their database. It does not. A JSON Schema guarantees the JSON parses and that a field is a string; it cannot guarantee that the string is a column that exists, because a schema document is written once and the catalog changes on every migration. pg_grammar_guard compiles a GBNF grammar from names read out of the catalog at generation time, so a constrained model is unable to emit a table, a column or a value that does not exist -- not corrected after the fact, unable. The distinction it is built on is that a grammar constrains form, and 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. The second half is the guard: a grammar generated last month against a schema migrated last week still constrains, 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, with no error and no log line. Approve a grammar once and check_grammar reports when it drifted, keeping never_approved as its own severity rather than folding it into drift. It does not make answers correct: a model constrained to your ten real tables will still pick the wrong one of the ten. Pure SQL, no shared library and no dependencies, reading only the system catalogs.
Released By
manu15
License
PostgreSQL
Resources
Special Files
Tags

Extensions

pg_grammar_guard 0.4.0
Generate a grammar from the live catalog and detect when it drifts

README

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 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.