{"slug": "building-a-natural-language-query-interface-for-your-database-a-developer-s", "title": "Building a Natural Language Query Interface for Your Database: A Developer's Blueprint", "summary": "A developer outlines a blueprint for building a natural language query interface for databases using LLMs. The system architecture includes schema retrieval via RAG, prompt assembly, SQL generation, validation, and safe execution. The approach aims to replace one-off SQL queries with plain English questions, improving accessibility for non-technical team members.", "body_md": "Every product team eventually hits the same wall. Marketing wants to know which signups came from last week's campaign. Support wants a list of customers on the Pro plan who opened a ticket in the last 48 hours. The founder wants MRR by cohort, broken down by acquisition channel, by yesterday at 9am.\n\nAnd every time, someone pings the nearest engineer to write yet another one-off SQL query.\n\nThe dream is obvious: let people ask questions in plain English and get answers from the database. For years, that dream lived in expensive enterprise BI tools. In 2026, with capable LLMs available behind an API call, you can build a credible natural language query interface yourself — or use something like [Draxlr](https://www.draxlr.com/features/AI/) that ships with AI-powered SQL generation already built in. If you want to understand how it works under the hood, or roll your own, this post walks through what such a system actually looks like: the architecture, the SQL plumbing, the prompt design, and the production failure modes you'll want to plan for before your first user types a question.\n\nAt its simplest, a text-to-SQL system is a function:\n\n``` php\nquestion (string) -> SQL query (string) -> result (rows)\n```\n\nThe naive implementation is one prompt to GPT-style model: \"Here's my schema, here's the question, write a SQL query.\" That works for toy demos and falls apart the moment you point it at a real database with 80 tables, 14 of which are named some variation of `users`\n\n.\n\nA production-grade interface looks more like this:\n\n```\nquestion\n   |\n   v\n[ schema retrieval ]  <-- pull only relevant tables\n   |\n   v\n[ prompt assembly ]   <-- schema + examples + guardrails\n   |\n   v\n[ SQL generation ]    <-- LLM call\n   |\n   v\n[ validation ]        <-- parse, lint, dry-run\n   |\n   v\n[ safe execution ]    <-- read-only role, row limits, timeouts\n   |\n   v\nresult + the SQL it ran (always show this)\n```\n\nEach of those stages is a thing you build. Let's walk through them.\n\nThe single biggest accuracy lever is the schema context you feed the model. Dumping your entire schema into the prompt sounds tempting and is almost always wrong: it blows your context window, costs more, and — counterintuitively — makes the model *less* accurate because it has to pick the right tables out of a haystack.\n\nThe fix is retrieval. Treat your schema like a knowledge base, embed it, and pull only what's relevant to the question.\n\nStart by capturing a clean description of every table:\n\n```\nSELECT\n  c.table_name,\n  c.column_name,\n  c.data_type,\n  pgd.description AS column_comment\nFROM information_schema.columns c\nLEFT JOIN pg_catalog.pg_statio_all_tables st\n  ON st.schemaname = c.table_schema AND st.relname = c.table_name\nLEFT JOIN pg_catalog.pg_description pgd\n  ON pgd.objoid = st.relid AND pgd.objsubid = c.ordinal_position\nWHERE c.table_schema = 'public'\nORDER BY c.table_name, c.ordinal_position;\n```\n\nFor each table, build a small text document that the LLM can actually read:\n\n```\nTable: subscriptions\nDescription: One row per customer subscription. A user can have at most one\nactive subscription at a time. `status` is one of active, paused, cancelled.\nColumns:\n  - id              (uuid, PK)\n  - user_id         (uuid, FK -> users.id)\n  - plan_id         (uuid, FK -> plans.id)\n  - status          (text)\n  - mrr_cents       (integer) -- monthly recurring revenue in cents\n  - started_at      (timestamptz)\n  - cancelled_at    (timestamptz, nullable)\nSample rows:\n  id=a1.., user_id=u3.., plan_id=p_pro, status=active, mrr_cents=4900\n```\n\nEmbed each of those documents with any embedding model, store the vectors, and at query time embed the user's question and pull the top 5–10 most similar tables. This is plain RAG, applied to schema instead of documents.\n\nThe payoff is dramatic. A question like *\"how many users upgraded to Pro last month?\"* will retrieve `users`\n\n, `subscriptions`\n\n, and `plans`\n\n— and leave the 70 other tables out of the prompt entirely.\n\nOnce you have the relevant tables, the prompt itself follows a predictable shape:\n\nHere's a stripped-down version in pseudocode:\n\n``` python\ndef build_prompt(question, tables, examples):\n    return f\"\"\"You are a PostgreSQL expert. Generate a single SELECT query\nto answer the user's question. Rules:\n- Use only the tables and columns shown below.\n- Always include LIMIT 1000.\n- Use ISO date literals (e.g. '2026-05-01').\n- Never write INSERT, UPDATE, DELETE, DROP, or DDL.\n- If the question is ambiguous, return a JSON object\n  {{\"clarify\": \"...\"}} instead of SQL.\n\n## Schema\n{format_tables(tables)}\n\n## Examples\n{format_examples(examples)}\n\n## Question\n{question}\n\nReturn only the SQL, no explanation.\"\"\"\n```\n\nThe two underrated pieces here are the **examples** and the **clarification escape hatch**.\n\nA handful of question/SQL pairs from *your* domain teaches the model your conventions — that `mrr_cents`\n\nis in cents, that \"active users\" means `status = 'active' AND last_seen_at > now() - interval '30 days'`\n\n, that you always join on `tenant_id`\n\n. Three good examples often beat a thousand words of instruction.\n\nThe clarification hatch is the difference between a tool that hallucinates confidently and one that admits when a question is too vague. Ambiguous natural language is the #1 source of bad SQL, and giving the model a way to ask back is far better than letting it guess.\n\nNever run an LLM-generated query straight against your database. There are three layers worth wiring up.\n\n**Parse it.** Run the SQL through a parser like `sqlglot`\n\nor `pgsql-parser`\n\n. If it doesn't parse, you have a clean signal to either retry or report the error — no need to wait for the database to reject it.\n\n**Lint it.** Walk the parsed AST and reject anything that isn't a `SELECT`\n\n. Reject queries that reference tables outside your allowed list. Reject queries without a `LIMIT`\n\n. This is your defence against a creative model that decides `DELETE FROM users`\n\nis a reasonable answer.\n\n``` python\nfrom sqlglot import parse_one, exp\n\ndef is_safe(sql, allowed_tables):\n    tree = parse_one(sql, read=\"postgres\")\n    if not isinstance(tree, exp.Select):\n        return False, \"only SELECT allowed\"\n    for t in tree.find_all(exp.Table):\n        if t.name not in allowed_tables:\n            return False, f\"table {t.name} not allowed\"\n    if not tree.args.get(\"limit\"):\n        return False, \"missing LIMIT clause\"\n    return True, None\n```\n\n**Dry-run it.** PostgreSQL has `EXPLAIN`\n\n. Run the query under `EXPLAIN`\n\n(not `EXPLAIN ANALYZE`\n\n— that executes it) to confirm the planner accepts it. If `EXPLAIN`\n\nreturns an estimated cost above some threshold, refuse to run it or warn the user.\n\nThe query has parsed, linted, and been planned. Now run it — but not as your application's regular DB user.\n\nCreate a dedicated read-only role with the minimum privileges needed:\n\n```\nCREATE ROLE nl_query_runner LOGIN PASSWORD '...';\n\nREVOKE ALL ON ALL TABLES IN SCHEMA public FROM nl_query_runner;\nGRANT SELECT ON users, subscriptions, plans, events TO nl_query_runner;\n\nALTER ROLE nl_query_runner SET statement_timeout = '10s';\nALTER ROLE nl_query_runner SET default_transaction_read_only = on;\n```\n\nStatement timeouts catch runaway queries. `default_transaction_read_only`\n\nis belt-and-braces protection in case your lint layer ever has a bug. Granting `SELECT`\n\nonly on the specific tables you've exposed means even a perfectly-crafted injection can't touch your secrets table.\n\nFor multi-tenant apps, layer Postgres row-level security on top, so even a query like `SELECT * FROM subscriptions`\n\nonly sees the calling tenant's rows. (I wrote about this in a previous post on row-level security for embedded dashboards.)\n\nImagine a SaaS analytics app where a user types:\n\n\"What was our MRR from Pro customers in April?\"\n\nHere's what happens:\n\n`subscriptions`\n\n, `plans`\n\n, and `users`\n\n.\n\n```\n   SELECT SUM(s.mrr_cents) / 100.0 AS mrr_dollars\n   FROM subscriptions s\n   JOIN plans p ON p.id = s.plan_id\n   WHERE p.name = 'Pro'\n     AND s.status = 'active'\n     AND s.started_at <= '2026-04-30'\n     AND (s.cancelled_at IS NULL OR s.cancelled_at > '2026-04-30')\n   LIMIT 1000;\n```\n\n`LIMIT`\n\n.| mrr_dollars |\n|---|\n| 48,372.00 |\n\nThe UI shows the answer **and** the SQL that produced it. Always show the SQL. Users learn to trust the system faster when they can see its work, and your power users will start tweaking the SQL directly.\n\nA few traps you'll hit if you don't plan for them:\n\n**Trusting the model on dates.** LLMs are weirdly bad at \"last week\" vs. \"the last 7 days\" vs. \"the previous calendar week\". Resolve relative time expressions in your code before generating SQL, and inject explicit dates into the prompt.\n\n**Ambiguous column names.** If you have `users.created_at`\n\nand `subscriptions.created_at`\n\n, a question like \"how many created this month?\" is genuinely ambiguous. Detect this and fall back to the clarification hatch instead of guessing.\n\n**Pre-aggregated columns.** If you have a `daily_metrics`\n\nrollup table, the model may not know whether to query it or recompute from raw events. Document this explicitly in the table description: *\"Prefer this table for date-based aggregations; events table only for event-level analysis.\"*\n\n**Joins that explode.** A model can produce a cartesian join with a missing condition and pull back a billion rows. The `EXPLAIN`\n\ncost check and the `statement_timeout`\n\nare your safety net.\n\n**Currency, units, and time zones.** `mrr_cents`\n\nis not `mrr_dollars`\n\n. `started_at`\n\nmight be UTC; the user's \"April\" might mean Pacific Time. Encode these in your schema documentation and your few-shot examples, or expect surprising answers.\n\n**Showing only the answer, not the SQL.** This destroys trust. The first time a user gets a number that looks off and has no way to inspect it, they'll stop using your tool. Always show the query.\n\nA practical natural-language-to-SQL interface is not a single LLM call. It's a small pipeline: retrieve the relevant schema, assemble a tight prompt with examples and rules, generate the SQL, validate it before it ever touches the database, and run it under a tightly-scoped role. The LLM is the interesting part, but the boring infrastructure around it — schema retrieval, parsing, linting, read-only roles, timeouts — is what separates a demo from a product.\n\nBuild for the failure cases from day one. Add a clarification path for ambiguous questions. Always show the generated SQL. Log every question and query so you can mine them for new few-shot examples. The systems that work in production are the ones that treat the LLM as a junior analyst whose output always gets reviewed, not as an oracle.\n\nHave you built a natural language interface for your own database, or are you using one in a product? What broke first when real users started typing into it? Drop your war stories — and the tools you reached for — in the comments. I'm especially curious which retrieval strategies have worked for people with very large or very messy schemas.", "url": "https://wpnews.pro/news/building-a-natural-language-query-interface-for-your-database-a-developer-s", "canonical_source": "https://dev.to/vivekdraxlr/building-a-natural-language-query-interface-for-your-database-a-developers-blueprint-cfk", "published_at": "2026-06-16 04:23:07+00:00", "updated_at": "2026-06-16 04:47:32.818193+00:00", "lang": "en", "topics": ["large-language-models", "natural-language-processing", "developer-tools", "ai-products", "ai-infrastructure"], "entities": ["Draxlr", "GPT", "PostgreSQL", "RAG"], "alternates": {"html": "https://wpnews.pro/news/building-a-natural-language-query-interface-for-your-database-a-developer-s", "markdown": "https://wpnews.pro/news/building-a-natural-language-query-interface-for-your-database-a-developer-s.md", "text": "https://wpnews.pro/news/building-a-natural-language-query-interface-for-your-database-a-developer-s.txt", "jsonld": "https://wpnews.pro/news/building-a-natural-language-query-interface-for-your-database-a-developer-s.jsonld"}}