You ask a question in English. You get rows back. One command.
export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY, GEMINI_API_KEY
schemagate select "which customers owe us money" --url "$DB" \
--answer --provider anthropic --model <model-id>
-- SQL written by Anthropic / <model-id>, from 6 tables
customer_name owed
------------- ----
Acme Corp 5100
Globex 1150
No pasting. No copying SQL around. schemagate picks the tables, the model writes the SQL, schemagate runs it and prints the rows.
Two things make it different from every other text-to-SQL wrapper: it is much cheaper per question, and it knows who is asking.
Apache-2.0. pip install schemagate.
Why it is cheap and fast
Most tools send your whole schema to the model on every single question. Real numbers from the built-in 42-table demo schema:
| Characters sent | Rough tokens | |
|---|---|---|
| Whole schema | 8,104 | ~2,000 |
| What schemagate sends | 1,722 | ~430 |
About 79% less prompt, every question. On a 400-table warehouse the gap is far bigger.
You pay for input tokens on every question, so a fifth of the prompt is a fifth of the bill. It is also faster to send and easier for the model to get right — 8 relevant tables beats 400 and "find the right ones yourself".
Picking the tables costs nothing at all:
$ time schemagate demo "which customers owe us money"
real 0m0.456s
Half a second on a laptop. No API key, no model call, no network — BM25 plus embeddings, running locally. A model is only involved once the prompt is already small.
Why it knows who is asking
This is the bug I kept meeting in production.
Something picks tables for the model. That picker does not know who the user is. It hands the model a table this user cannot read. The model writes correct SQL. The database runs it, row-level security strips every row, and the user is told:
No records found.
Nothing crashed. Nothing was logged. A wrong answer, in a confident voice.
schemagate picks tables with the caller's identity. A restricted table is not ranked low — it never enters the list, so no rewording of the question can reach it.
echo '{"restrict": {"hr_compensation": ["payroll"]}}' > catalog.json
Analyst asks:
schemagate select "what do we pay people" --url "$DB" \
--config catalog.json --principal okta:analyst
2 of 3 objects selected
main.crm_customer
main.sales_invoice
Payroll asks the same question:
schemagate select "what do we pay people" --url "$DB" \
--config catalog.json --principal okta:hr --role payroll
3 of 3 objects selected
main.hr_compensation
main.crm_customer
main.sales_invoice
Same question, same database, different person, different answer.
Install and connect
pip install schemagate
pip install "schemagate[oracle]" # or [postgres]
# Oracle
--url 'oracle+oracledb://user:pw@host:1521/?service_name=FREEPDB1'
# PostgreSQL
--url 'postgresql+psycopg://user:pw@host:5432/dbname'
Flags worth knowing: --top-k 10, --schema SALES, --exclude 'stg_*', --limit 50, --explain.
--provider takes anthropic | openai | gemini | oci | local | auto. --model is required on purpose — model ids change, and a stale default is worse than an error. Every run prints who wrote the SQL, which matters with --provider auto.
schemagate never writes to your database. It reads the catalog, and the SQL it runs is checked first: anything that is not a single SELECT or WITH is refused — writes, a second statement, a statement hidden after a -- or /* */ comment. The SQL only ever sees the selected tables, so it cannot reach a table this caller may not see.
Does this scale to Entra ID / AD groups?
Straight answer, because it matters.
What scales: roles are opaque strings and matching is a set intersection, so you pass Entra group object IDs straight through. A user in 300 groups costs the same as a user in 3.
Entra traps: past ~200 groups Entra stops putting them in the token and sends _claim_names — you must call Graph transitiveMemberOf and cache. memberOf is not transitive. And group claims are GUIDs unless you configure on-prem names.
Where it does not scale yet: the restrict map is hand-written. For 400 tables that is a second copy of an ACL you already hold in your directory and in the database's own GRANTs. Two copies drift, and drift in an ACL is exactly the bug this library exists to prevent.
The right fix is to read the grants the database already has:
-- PostgreSQL
SELECT has_table_privilege('app_user','sales.invoice','SELECT');
-- Oracle
SELECT table_name FROM all_tab_privs
WHERE grantee IN (SELECT role FROM session_roles);
Map Entra group to database role once — a dozen mappings, not four hundred. schemagate does not do this today. It is the obvious next step; if it is your blocker, say so on the issue tracker.
When the answer is wrong
It matches words. "What do we pay our doctors" fails if no table or column contains "pay" or "doctor". Describe your tables once:
schemagate describe --url "$DB" --all --provider anthropic --model <id> --config catalog.json
No key? --provider none prints a prompt to paste into any chat; save the JSON reply and apply it with --apply reply.json. Same trick works for --answer: leave --provider off and schemagate hands you a ready-made prompt, and you feed the SQL back with --sql "SELECT ...".
From an AI client
pip install "schemagate[mcp]"
schemagate studio --url "$DB" # browser UI
SCHEMAGATE_DATABASE_URL="$DB" python -m schemagate.mcp_server # Cursor, Claude Desktop
Honest limits
On a 127-object, 3-domain schema (~7M rows, live Oracle ADB 26ai), recall@6 was 47% with bare table names, 60% with Gemini 2.5 Pro descriptions, 80% with Sonnet descriptions — against 92% on a single-domain schema. Descriptions matter a lot; cross-domain schemas are still hard.
Token counts above are characters divided by four, the usual rough rule. Your tokenizer will differ a little.
Certified end to end on Oracle Autonomous Database 26ai and PostgreSQL 16.
- PyPI: https://pypi.org/project/schemagate/
- Source: https://github.com/ashishsinha1602/schemagate
- Live demo, no signup: https://ashishsinha1602.github.io/schemagate/
Fifteen years on Oracle and Postgres, and this is the bug I kept meeting. If an answer comes back wrong for you, open an issue with the question — that is the most useful bug report there is.
Top comments (0)