“Ask your database” is quickly becoming a non-negotiable feature in modern BI systems. From small teams to large enterprises, analysts expect to query data in natural language, without needing to understand SQL or underlying schemas, and get answers instantly.
The promise is simple. Making it reliable in production is not.
Table of contents
Open Table of contents
Modelling the problem
Assume we are dealing with the following data model and a user asks:

Show me the top 3 product categories by revenue for customers in the US region, who signed up after 2025, excluding orders with less than 2 units, for the last quarter
The solution can be modelled if we:
- Capture the user’s natural language query
- Inject database schema context (tables, fields, relationships, sometimes sample data)
- Prompt the LLM to translate the query into SQL
- Execute the generated SQL against the database
Looks like a simple task at first, until we start investigating the failure modes:
What can go wrong?
Answering the wrong question
For the given query, the following ambiguities may arise:
Natural Language Query:
Show me the top 3 product categories by revenue for customers in the US region, who signed up after 2025, excluding orders with less than 2 units, for the last quarter
| Requirement | Hidden ambiguity | Possible mistake |
|---|---|---|
| Revenue | Which metric? Gross, net, after discounts? | Uses the wrong revenue column |
| Product categories | Which category hierarchy? | Groups at the wrong level |
| US region | Billing, shipping, or customer location? | Filters the wrong customers |
| Signed up after 2025 | Exact date and timezone? | Applies the wrong date filter |
| Orders with ≥2 units | Order-level or item-level quantity? | Filters incorrectly |
| Last quarter | Calendar or fiscal quarter? | Uses the wrong date range |
| Top 3 | Ranked by revenue, orders, or something else? | Returns the wrong results |
The Join Graph Is a Minefield
Joins are one of the easiest ways for valid SQL to produce the wrong answer. The problem goes beyond choosing the right tables. The join itself can change the grain of the data, duplicate rows, or connect entities through the wrong relationship. Some common failure modes are:
- fan-out joins
- chasm joins
- wrong grain
- multiple customer tables
- slowly-changing dimensions
- multiple plausible join paths
Performance Issues
So for the discussed query, the following ambiguities may arise :
Natural Language Query:
Show me the top 3 product categories by revenue for customers in the US region, who signed up after 2025, excluding orders with less than 2 units, for the last quarter
Illustrative example only
This SQL was generated using GPT-5 for demonstration purposes. LLMs are non-deterministic, and different models—or even different runs of the same model—may produce entirely different SQL.
-- Example query generated by LLM
SELECT
p.category,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi
ON o.id = oi.order_id
JOIN products p
ON oi.product_id = p.id
JOIN customers c
ON o.customer_id = c.id
WHERE
YEAR(o.order_date) = 2026
AND QUARTER(o.order_date) = 2
AND c.region = 'US'
AND c.signup_date > '2025-01-01'
GROUP BY p.category
HAVING SUM(oi.quantity) >= 2
ORDER BY revenue DESC
LIMIT 3;
| Issue | What’s wrong? | Better approach |
|---|---|---|
| Non-sargable predicate | YEAR(order_date) and QUARTER(order_date) prevent index usage. | Filter using a date range (order_date >= ... AND order_date <ResponsiveTable ...). |
| Late aggregation filter | HAVING SUM(quantity) >= 2 filters after grouping every category. If the requirement is to exclude small orders, the filter should happen before aggregation. | Filter qualifying orders before computing revenue. |
| Incorrect aggregation semantics | SUM(quantity) >= 2 checks the total quantity per category, not whether each order contains at least 2 units. | Aggregate at the order level first, then roll up to categories. |
| Inefficient join order | All order_items are joined before restricting to the relevant customers and time period. | Filter orders and customers first to reduce rows flowing into the joins. |
| Missing partition pruning | Applying functions to order_date may prevent partition elimination on partitioned tables. | Use range predicates on the partition key. |
What does SARG mean?
SARG stands for Search ARGument. A predicate is sargable when the database engine can efficiently use an index to satisfy the condition. Operations such as YEAR(order_date), LOWER(email), DATE(created_at), or arithmetic expressions on indexed columns typically make predicates non-sargable, forcing the optimizer to scan significantly more data than necessary.
-- Non-sargable
WHERE YEAR(order_date) = 2025
-- Sargable
WHERE order_date >= '2025-01-01'
AND order_date < '2026-01-01'
Security Risks
Generating SQL directly from natural language introduces a new attack surface. Unlike traditional applications where queries are carefully authored and reviewed, LLM-generated SQL must defend against ambiguous requests, malicious prompts, and accidental misuse.
Illustrative example only
This SQL was generated using GPT-5 for demonstration purposes. LLMs are non-deterministic, and different models—or even different runs of the same model—may produce entirely different SQL.
-- User: "Show me all customer information for users in the US."
SELECT
c.*,
cc.card_number,
cc.cvv,
ssn.ssn,
a.api_key
FROM customers c
LEFT JOIN credit_cards cc
ON c.id = cc.customer_id
LEFT JOIN customer_ssn ssn
ON c.id = ssn.customer_id
LEFT JOIN api_keys a
ON c.id = a.customer_id
WHERE c.country = 'US';
| Issue | What’s wrong? | Better approach |
|---|---|---|
| Unauthorized data access | Uses SELECT * and joins sensitive tables containing payment information, SSNs, and API keys that were never requested. | Restrict access using role-based permissions and only expose approved columns. |
| Data exfiltration | Returns significantly more data than the user asked for, including sensitive information. | Return only the fields required to answer the user’s question. |
| Prompt injection | A malicious prompt could instruct the model to ignore previous restrictions and query sensitive tables. | Separate authorization from prompting and validate every generated query before execution. |
| Destructive queries | A model could generate UPDATE, DELETE, DROP, or ALTER statements if prompted. | Execute generated SQL using read-only credentials or allowlisted query types. |
| Denial of service | Similar queries using large cross joins or unbounded scans can consume excessive CPU, memory, and I/O. | Apply query timeouts, row limits, resource quotas, and cost-based validation before execution. |
Solutions and Trade-Offs
There’s a popular saying in engineering circles that goes something like:
Every abstraction leaks. Every optimization has a cost. Every architecture makes trade-offs.
Building a reliable text-to-SQL system is no different. The shortcomings we’ve discussed boil the problem down to three competing design goals:
- Accuracy & Reliability - Does the system consistently generate SQL that is correct, performant, and safe for production use?
- Development & Maintenance Overhead - How much engineering effort is required to build, maintain, and extend the system?
- Flexibility - How well can the system answer a wide variety of questions, including ad hoc analytical queries?
Improving one often comes at the expense of another.
In the next two articles, I’ll explore two approaches to this problem in more detail:
-
Incremental Discovery with MCP — allowing the LLM to discover schema, relationships, business logic, and other context progressively rather than injecting everything into the prompt upfront.
-
Semantic Layer + DSL — moving business logic and query semantics outside the LLM, and having the model generate a constrained representation that can be deterministically compiled into SQL.