MSAZ-301 · Lesson 9 of 12 · 21 min

Natural language to SQL

▶ Video coming soon A video version of this lesson is on the way.

While Ask Finch answered product questions, a different request arrived from the store managers' meeting. Managers wanted to ask questions about their own numbers in plain words, such as which paint colors sold best at my store in October, or how many returns did we have for cordless drills this quarter, without waiting for the reporting team. A language model can translate such a question into a SQL query. Nikolai's first reaction was to ask who reads the result, and how much harm a wrong answer does. A manager who reorders stock based on a wrongly written query loses money; one who sees a wrong number and asks the reporting team loses only time. The design had to make the second outcome far more likely than the first.

How natural language to SQL works

The pattern, often called NL2SQL or text-to-SQL, has four parts. The model is given a description of the tables and columns it may use, a few examples of questions with correct queries, and the user's question, and is asked to return one query. The application then checks the query, runs it under a restricted identity and shows the result with the query beside it. The quality of the description matters more than anything else: a model writing SQL against 900 cryptically named tables guesses; one writing against six well-named views with described columns rarely needs to.

A flow: a manager's question goes to a box labeled prompt with schema description and examples, then to the chat model, which returns a SQL query. The query passes through a checkpoint labeled validate (single SELECT, allowed views only, compiles), then runs under a read-only user on a read replica, and the result returns to the manager with the query shown underneath.
Natural language to SQL with a restricted schema, validation and a read-only identity.

A schema made for the model

Hana and the reporting team built a reporting schema with six views: sales by store, product and day; returns with a reason code; stock snapshots; product attributes; stores; and a calendar. Each view has readable column names, such as StoreName and NetSalesAmount rather than STR_NM and AMT2, and each column has an extended property with a one-line description. The prompt is built from those properties, so it stays accurate when the views change.

Describing columns with extended properties and turning them into JSON for the prompt.
EXEC sys.sp_addextendedproperty @name = N'Description',
     @value = N'Net sales in Canadian dollars after discounts and returns, excluding sales tax',
     @level0type = N'SCHEMA', @level0name = N'rpt',
     @level1type = N'VIEW',   @level1name = N'vSalesDaily',
     @level2type = N'COLUMN', @level2name = N'NetSalesAmount';

-- Build the schema description the prompt uses
SELECT v.name AS view_name, c.name AS column_name, t.name AS data_type,
       CAST(ep.value AS nvarchar(400)) AS description
FROM sys.views AS v
JOIN sys.columns AS c ON c.object_id = v.object_id
JOIN sys.types AS t ON t.user_type_id = c.user_type_id
LEFT JOIN sys.extended_properties AS ep
       ON ep.major_id = c.object_id AND ep.minor_id = c.column_id AND ep.name = N'Description'
WHERE SCHEMA_NAME(v.schema_id) = N'rpt'
FOR JSON PATH;

The US version of the same view describes amounts in US dollars; the reporting team keeps the descriptions in the language and currency of each company's books, because a model told nothing about currency will happily add mixed amounts.

Examples in the prompt

A handful of question-and-query pairs in the prompt, called few-shot examples, teach the model the house style: which view holds what, how fiscal months are defined, and that store names come from the store view. Hana chose eight examples covering each view and the commonest traps, such as returns being negative amounts and the fiscal year starting on February 1. Adding the fiscal-year example alone fixed most wrong answers to questions about this year.

Never trust the generated query

A generated query is untrusted input written by a program that does not know the consequences. It might be wrong, slow, or, if the question contained something malicious or the model misbehaved, an attempt to change data. Defenses go in layers, so a failure in one is caught by the next.

Layers of protection around generated SQL.
LayerWhat it doesStops
Restricted identityThe query runs as a user with SELECT on rpt views only, nothing elseAny write, any access to other tables
Read-only targetThe query runs on a read replica or a reporting copyLoad on the production primary
ValidationAccept one SELECT statement referencing only allowed viewsMultiple statements, unexpected objects
Compile checksys.dm_exec_describe_first_result_set compiles the query without running itSyntax errors, unknown columns
LimitsRow limit and a statement timeout set by the applicationHuge results, runaway queries
TransparencyShow the query and its plain-language reading with the resultWrong answers passing unnoticed
A user that can read only reporting views, a compile check, and execution under that user.
CREATE USER nl2sql_reader WITHOUT LOGIN;
GRANT SELECT ON SCHEMA::rpt TO nl2sql_reader;
DENY  SELECT ON SCHEMA::catalog TO nl2sql_reader;

-- Compile without executing: returns the result columns, or an error
SELECT error_number, error_message, name, system_type_name
FROM sys.dm_exec_describe_first_result_set(@generatedSql, NULL, 0);

-- Run as the restricted user
EXECUTE AS USER = 'nl2sql_reader';
EXEC sys.sp_executesql @generatedSql;
REVERT;

The restricted identity is the layer that matters most, because it holds even if validation is fooled. A string check that looks for the word DELETE can be bypassed in many ways; a user with only SELECT on six views simply cannot delete anything. Elena also pointed the feature at the read-only replica of the reporting database, by connecting with ApplicationIntent=ReadOnly, so a badly written query can slow only the replica.

A validator in miniature

The validation layer is simple to describe and hard to make perfect, which is why it is not the main defense. Hana's checks in the prototype were: exactly one statement; it begins with SELECT or WITH; it references only names from the allowed list; and it contains no semicolons except a trailing one. Nikolai built a small model of the whole flow in Python with an in-memory database to test the checks against a list of awkward queries.

Python Example · Testing a simple validator on four generated queries, then running the accepted one on sample data.
import re, sqlite3

ALLOWED = {"vsalesdaily", "vstore"}
def validate(sql):
    s = sql.strip().rstrip(";")
    if ";" in s: return "rejected: more than one statement"
    if not re.match(r"(?is)^(select|with)\b", s): return "rejected: not a SELECT"
    names = {n.lower() for n in re.findall(r"(?i)\b(?:from|join)\s+([a-z_][a-z0-9_.]*)", s)}
    bad = {n for n in names if n.split(".")[-1] not in ALLOWED}
    return f"rejected: not allowed {sorted(bad)}" if bad else "ok"

db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE vstore (StoreId INT, StoreName TEXT);
CREATE TABLE vsalesdaily (StoreId INT, Category TEXT, SalesDate TEXT, NetSalesAmount REAL);
INSERT INTO vstore VALUES (12, 'Store 12'), (15, 'Store 15');
INSERT INTO vsalesdaily VALUES (12,'Paint','2026-10-03',1840.5),(12,'Paint','2026-10-17',2210.0),
                               (15,'Paint','2026-10-09',990.25),(12,'Tools','2026-10-09',3120.0);
""")
tests = [
    "SELECT s.StoreName, SUM(d.NetSalesAmount) FROM vsalesdaily d JOIN vstore s ON s.StoreId = d.StoreId "
    "WHERE d.Category = 'Paint' AND d.SalesDate LIKE '2026-10-%' GROUP BY s.StoreName ORDER BY 2 DESC",
    "SELECT * FROM vsalesdaily; DELETE FROM vsalesdaily",
    "SELECT * FROM customers",
    "UPDATE vstore SET StoreName = 'x'",
]
for t in tests:
    verdict = validate(t)
    print(verdict)
    if verdict == "ok":
        print("   ", db.execute(t).fetchall())
Output
ok
    [('Store 12', 4050.5), ('Store 15', 990.25)]
rejected: more than one statement
rejected: not allowed ['customers']
rejected: not a SELECT

What this code does

The program checks each candidate query against four rules: one statement only, starting with SELECT or WITH, and naming only the two allowed views after FROM or JOIN. Queries that pass are run against a small in-memory copy of the two views. The output shows the paint-sales query accepted and its result, Store 12 first with about 4,050 dollars, while the query with a hidden DELETE, the query on a customers table and the UPDATE are all rejected.

The validator caught everything in the test list, but Nikolai wrote three more queries that slipped past it, including one that read a table through a function call rather than after FROM. They failed anyway, because the restricted user had no permission on those objects. That was the point of the exercise: validation catches the common cases cheaply and gives a clear message, and permissions catch the rest.

Showing the query

Every answer shows the query that produced it, in a collapsible panel, and a one-line reading of it generated from the query, such as net paint sales by store for October 2026, largest first. Managers who do not read SQL still read the one-liner, and it is where they spot a wrong interpretation: a question about returns answered with sales, or this year read as the calendar year. In the prototype test, managers spotted 6 of the 9 wrong queries this way before acting on them.

Prompting for SQL

The prompt for query generation is stricter than the prompt for answers. It gives the schema description as JSON, the eight examples, today's date and the fiscal calendar rule, and the manager's store from the session, and it asks for a JSON reply with two fields: sql, holding a single T-SQL SELECT statement, and reading, holding a one-line description of what the query returns. It tells the model to use only the listed views and columns, to filter to the manager's store unless the question names another store the manager may see, to return at most 100 rows with TOP, and to reply with an empty sql field and an explanation in reading if the question cannot be answered from the views. Temperature is 0, because the same question should give the same query.

Generated queries are also logged with the question, the validation result, the compile result, the row count and the duration. The reporting team reviews queries that failed or ran slowly, and several became new examples in the prompt or new columns in the views. Over time, Nikolai expected the most common questions to become ordinary saved reports, which is a good outcome: natural language to SQL is often the fastest way to discover which reports people actually need.

Not every question should be answered this way, and the feature says so. Questions about individual customers, employees or prices before they are announced are outside the reporting views by design, so a model asked about them can only reply that it cannot answer. Questions that need judgment, such as whether a store should order more of a product, get a query for the underlying numbers and a note that the decision belongs to the buying team. Setting those boundaries in the views and the prompt, rather than hoping the model infers them, kept the feature useful without letting it drift into decisions it cannot make well.

Worked problem

Estimating the residual risk

In a test of 120 manager questions, 105 generated queries were correct, 9 were valid but answered the wrong question, and 6 failed validation or compilation and showed an error. Managers caught 6 of the 9 wrong answers from the reading line. How many wrong answers, out of 120, would a manager have acted on?

Show solution
  1. Queries that failed validation or compilation show an error, so no one acts on them.
  2. Of 9 valid but wrong answers, 6 were caught, leaving 3.
  3. \[\frac{3}{120} = 0.025 = 2.5 \text{ in } 100\]
  4. The reporting team judged that acceptable for questions about store sales trends, with a rule that stock reorders must still come from the standard reports.

Answer: About 3 in 120, 2.5 in 100: acceptable for exploring trends, not for decisions that move money directly.

AI-assisted tools and their security impact

Natural language to SQL also appears in developer tools. GitHub Copilot in SQL Server Management Studio and in Visual Studio Code, and Copilot in Microsoft Fabric, can write queries from descriptions, and agents in those tools can connect to databases through Model Context Protocol servers, which expose a database's schema and query tools to the model. The DP-800 outline asks candidates to interpret the security impact of these tools. The same principles apply: the tool acts with the permissions of the identity it connects as, so a developer connected as db_owner hands that power to the agent. Connect such tools with a least-privilege identity, to development or test data, and review generated changes before running them; these tools are covered in MSAZ-300.

Common mistakes

  • MistakeValidation that rejects the word DELETE makes generated SQL safe.

    InsteadString checks can be bypassed; a read-only identity with access to allowed views only is the main defense.

  • MistakeGive the model the full database schema so it can answer anything.

    InsteadA small set of well-described reporting views gives far more accurate queries.

  • MistakeRun generated queries on the production primary for current data.

    InsteadUse a read replica or reporting copy so mistakes cannot slow production.

  • MistakeHide the generated SQL to keep the interface simple.

    InsteadShow the query and a plain reading of it so users can spot wrong interpretations.

Summary and key terms

  • Natural language to SQL gives a model a schema description and examples and asks for one query.
  • Well-named reporting views with described columns, built from extended properties, give the best results.
  • Few-shot examples teach house rules such as fiscal calendars and sign conventions.
  • Treat generated SQL as untrusted: restricted identity, read-only target, validation, compile check, limits and transparency.
  • sys.dm_exec_describe_first_result_set compiles a query without running it.
  • AI-assisted tools and MCP servers act with the connecting identity's permissions; connect them with least privilege.
Natural language to SQL (NL2SQL)
Using a language model to translate a question in words into a SQL query.
Few-shot examples
Sample questions and answers included in a prompt to show the model what is wanted.
Extended property
A name and value attached to a database object, used here to describe columns.
sys.dm_exec_describe_first_result_set
A function that compiles a statement and describes its result without running it.
Read replica
A read-only copy of a database used to run queries away from the primary.
Model Context Protocol (MCP)
A protocol through which AI tools connect to external tools and data sources, such as a database.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. What is the most important defense when running generated SQL?

    Show answer

    A restricted identity with SELECT only on the allowed views.

  2. How can a query be checked for errors without running it?

    Show answer

    With sys.dm_exec_describe_first_result_set.

  3. Why did adding a fiscal-year example improve accuracy?

    Show answer

    It taught the model that Harrow & Finch's year starts on February 1, which a model would not guess.

  4. Why show a plain-language reading of the query?

    Show answer

    Users who do not read SQL can still spot a wrong interpretation before acting on it.

  5. What security point applies to Copilot agents connected through an MCP server?

    Show answer

    They act with the permissions of the identity they connect as, so use least privilege.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz