Major

Natural Language to SQL: Building a Guarded Postgres Agent

Natural-language-to-SQL tools are accurate enough to be useful and wrong often enough to be dangerous. Here is how to build a Postgres query agent with read-only access, blocked writes, and row caps.

Jose GironUpdated
Major x PostgreSQL lockup for a guarded natural language to SQL agent

How to run natural language to SQL against Postgres safely

Natural language to SQL turns a plain-English question into a query a model writes for your database. It is useful and sometimes wrong, so the model should never be the thing that executes it. The safe pattern is an agent that drafts a SELECT and a deterministic, permission-checked app that decides whether it runs. That is the split Major is built around: the agent reasons once about the question, and an app it built enforces the rules on every run after.

This guide is for data, ops and platform teams who want non-developers to get answers from production Postgres without handing an LLM a write connection. Below: what the technique is, the guardrails that matter, a worked Slack example, and how to build it on Major.

What natural language to SQL actually does

A model reads a question like "how many pro-plan customers signed up in May" and returns text that happens to be SQL. It does not query your data. Whether that text runs, and what it can touch, is a separate decision that belongs to your application.

Quality depends on context. A model with no schema guesses column names and invents joins. A model given a curated list of allowed views, a few sample rows and some known-good queries writes far better SQL. Accuracy still drops on complex joins and ratios, so treat every generated query as a candidate to be checked, not a result to be trusted.

Where a Postgres query agent pays off

Every good use case is a read.

  • A support lead asks in Slack how many tickets stayed open more than 48 hours last week.
  • A growth PM segments signups by month and plan tier without waiting on a dashboard change.
  • An on-call engineer checks whether error rates by region spiked in the last hour, using read-only views.
  • A finance analyst pulls gross revenue for a date range without opening a SQL client.
  • A daily metrics digest runs a fixed set of questions every morning and posts to a channel.

Nobody on that list should be issuing an UPDATE through a model. That observation drives the whole design.

The guardrails that matter

Start at the database. Create a dedicated role that can log in and read, nothing more. Grant SELECT on a small set of curated views, never base tables, so the agent cannot see columns it should not. Set default_transaction_read_only on the role so even a stray statement cannot write. The role, not the prompt, is your hard boundary.

Then put a deterministic layer between the agent and the role. It applies these checks in order and rejects the query if any one fails:

  1. Exactly one statement, and it is a SELECT.
  2. No DML or DDL keywords anywhere (INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, GRANT).
  3. Only the allowed views are referenced.
  4. A LIMIT is present; if not, the layer injects one capped at max_rows.
  5. EXPLAIN runs first, and the query aborts if estimated cost passes a ceiling.
  6. The query, the allow-or-reject decision and the result size are logged.

A keyword filter alone is a backstop, not a boundary. Clever formatting can slip past it, so keep the read-only role even if your code blocks writes. The honest limit of this pattern: it will not mask a salary or email column inside an allowed view. Sensitive schemas need column-level masking or a review step before results leave the system.

Worked example: the guarded Slack query agent

A teammate posts "how many pro-plan customers signed up in May" in a Slack channel. The flow:

  1. Trigger. The Slack message starts the run. A scheduled digest can start the same flow each morning.
  2. Context read. The agent pulls the allowed-view schema and a short list of example queries from a Notion page, so it writes against real column names.
  3. Generate. The agent writes one candidate SELECT against the curated views.
  4. Guarded execution. The agent hands the SQL to a "Run Read-Only Query" app with typed inputs (sql, max_rows). Only that app holds the Postgres connection.
  5. Deliver. Rows post back in Slack, with an option to export the full result to Google Sheets.

The agent never sees a connection string. If it hallucinates DROP TABLE, the app rejects it at check 1 and logs the attempt. If it forgets a LIMIT, the app adds one. Failure modes shrink to "the query was rejected" or "the query was slightly wrong," and neither damages data.

Build the guarded query agent on Major

Major is the enterprise platform where agents build the software they run on, and this workflow is a clean fit for that idea. The repeatable part (validate, limit, execute, log) is code. The judgment part (turn a question into SQL) is the model.

  • Connectors: PostgreSQL (read-only role), Slack, Notion and Google Sheets, each connected once with scoped credentials.
  • The app: "Run Read-Only Query" is a typed app the agent builds once. It holds the Postgres connection, runs the six checks above as deterministic code, and stores a query log table in its managed database.
  • The agent: reads schema context from Notion, drafts the SELECT, and can only execute through the app. It cannot reach Postgres any other way.
  • The trigger: a Slack workflow trigger for questions, plus an optional schedule for the daily metrics digest.
  • Governance: the database credential sits behind the credential proxy, RBAC controls who can invoke the app, and every call is audited with the invoking user's identity, so their own data permissions apply.
  • Cost shape: the model spends tokens on drafting SQL, not on re-deriving guardrails each run. The checks cost nothing per run because they are code.

Build this agent on Major and the next team that wants answers from a different database reuses the same app pattern instead of starting over.

How the alternatives compare

  • Raw LLM with a write-capable connection: one hallucinated statement runs against live data. No audit trail.
  • IDE or MCP query tools: fine for one developer on their own data, weak for shared production because governance and audit live nowhere central.
  • Hosted NL-to-SQL SaaS: fast to start, but safety varies by vendor and your schema leaves your boundary.
  • Hand-written SQL by the data team: safest per query, but it becomes a request queue.
  • Major: the agent drafts, a governed app executes reads only, and every query is logged. You own the guardrail logic as code you can inspect.

Start building on Major

Build the guarded Postgres query agent with Slack, Notion and Google Sheets, and keep every query read-only, limited and logged.

Related articles

Frequently asked questions

What is natural language to SQL?
Natural language to SQL is a technique where a language model reads a question in ordinary English and writes a SQL query that answers it. The model does not query your data. It writes text, and a separate system decides whether that text runs. Context quality matters most: a curated schema, sample rows and known-good queries beat a bare prompt.
How accurate is natural language to SQL?
Accuracy runs roughly 85 to 95 percent on clean, standard queries and falls on realistic databases. On the BIRD benchmark, GPT-4 with curated knowledge reached 54.89 percent execution accuracy against 92.96 percent for humans. Treat published accuracy as a ceiling, and expect worse on complex joins and ratios. No accuracy number makes unguarded execution safe.
Is natural language to SQL safe for production?
Only with guardrails. The model writes SQL, but a deterministic layer must enforce a read-only Postgres role, block every non-SELECT statement, force row limits, check EXPLAIN cost and log each query. With those controls it is safe for reads. Without them, one hallucinated query can corrupt or expose production data.
How do you stop an AI from running destructive SQL?
Use three layers and rely on the model for none of them. Connect through a read-only Postgres role so writes are impossible at the database level. Put a deterministic layer in front that rejects anything that is not a single SELECT. Then log every query and its allow-or-reject decision so bad patterns get caught.
Do you still need a read-only role if your code already blocks writes?
Yes. A keyword filter in application code is a backstop, since clever formatting can bypass it and it does nothing about a bug in your own executor. The read-only role and view-only grants are the boundary the database itself enforces. Keep both, and add column masking for sensitive fields.