Writing SQL Queries With LLMs in November 2026: Give the Model Your Real Schema, Check Results Against Counts You Already Know, and Wrap Every Write in a Transaction
Asking a language model to write SQL is one of the most common ways people use these tools at work. You describe what you want in plain English, and a few seconds later you have a query with joins, filters, and a GROUP BY. The problem is that a query can run without errors and still return the wrong answer. A missing join condition, a filter on the wrong date column, or a COUNT(*) that counts duplicated rows will all produce clean-looking numbers that are simply wrong.
This guide covers a practical workflow for getting correct SQL out of an LLM: what to give the model, how to check what it gives back, and how to keep a generated UPDATE or DELETE from damaging real data.
A query is only as good as your map of where the data lives: show the model the real schema. Image: Wooden Card Catalog Furniture by MarkBuckawicki, CC0, via Wikimedia Commons.
Why generated SQL fails quietly
Most broken code fails loudly. Broken SQL often does not. The common failure patterns are worth knowing because they tell you what to check:
- Invented columns and tables. Without your schema, the model guesses names like
users.createdwhen your column isaccounts.signup_at. This one at least errors out. - Join fan-out. Joining orders to order items and then summing order totals counts each order once per item. The query runs fine and the revenue figure is inflated.
- Wrong dialect.
DATE_TRUNC,LIMITversusTOP, string concatenation, andILIKEdiffer between PostgreSQL, MySQL, SQL Server, SQLite, BigQuery, and Snowflake. - NULL surprises.
WHERE status != 'cancelled'silently drops rows wherestatusis NULL.NOT INagainst a subquery that contains a NULL returns no rows at all. - Ambiguous business terms. "Active users," "last month," and "revenue" each have a definition at your company. The model does not know it and will pick one.
Step 1: Give the model your real schema
The single biggest improvement is pasting the actual table definitions for the tables involved. Not the whole database, just the relevant ones. In PostgreSQL you can get them with pg_dump --schema-only -t orders -t order_items mydb, or \d orders in psql. In MySQL, SHOW CREATE TABLE orders; works. In SQLite, .schema orders.
Include foreign keys and indexes, because they tell the model how tables relate and which columns are cheap to filter on. Then add a few lines the schema cannot express:
- The database engine and version, for example "PostgreSQL 16."
- The grain of each table: "one row per order," "one row per order line."
- Business definitions: "An active customer has at least one paid order in the last 30 days. Refunded orders have
status = 'refunded'and should be excluded from revenue." - Time zone rules: "Timestamps are stored in UTC; reports are in America/Los_Angeles."
A good request looks like this:
Engine: PostgreSQL 16. Schema below.
orders: one row per order. order_items: one row per line item.
Revenue = sum of order_items.quantity * order_items.unit_price
for orders with status = 'paid'. Timestamps are UTC; group by
calendar month in America/Los_Angeles.
Write a query for monthly revenue for 2026 so far.
Explain each join and say what one output row represents.
[CREATE TABLE statements here]
Asking the model to say what one output row represents is a cheap way to catch fan-out. If it says "one row per month" but the query groups by month and customer, you know something is off before you run it.
Step 2: Read the query before you run it
You do not need to be a SQL expert to review a generated query. Check these things in order:
- Every join has a condition, and the condition uses the actual key columns. A join without a matching
ONclause, or a comma-separatedFROM a, bwithout aWHERElink, is a cross join. - Aggregates sit at the right level. If you sum a value from the parent table after joining a child table, you have probably double counted. Aggregate the child table in a subquery or CTE first, then join.
- Filters use the right column. "Orders placed last month" should filter on the order date, not the shipping date or the row's
updated_at. - Date ranges are half-open. Prefer
created_at >= '2026-10-01' AND created_at < '2026-11-01'overBETWEEN, which includes the end boundary and gets awkward with timestamps. - NULL handling is intentional. Look at every
!=,NOT IN, andLEFT JOINand ask what happens when the value is NULL.
If something is unclear, ask the model to rewrite the query using named CTEs, one per logical step. Long nested subqueries are where mistakes hide; a CTE called paid_orders followed by one called monthly_totals is much easier to check.
Step 3: Check results against numbers you already know
A query that runs is not a query that is correct. Before you trust the output, compare it to something you can verify independently:
- Row counts. Run
SELECT COUNT(*)on the base table with the same filters. If your monthly report covers 4,200 paid orders but the base count is 3,900, the joins are multiplying rows. - A known total. Compare last month's revenue against your billing dashboard or finance report. Small differences may be timing or refunds; large ones mean the logic is wrong.
- A single hand-checked record. Pick one customer or one order and trace it through the query. Does it appear once? Is its amount right?
- Uniqueness. If a result should have one row per customer, run
SELECT customer_id, COUNT(*) FROM (your query) q GROUP BY customer_id HAVING COUNT(*) > 1. Any output means duplicates.
When the numbers do not match, paste both the query and the mismatch back to the model: "Base table has 3,900 paid orders in October, your query sums 4,200 orders. Find the cause." This gives it something concrete to reason about instead of guessing.
Step 4: Check performance before running it on production
A correct query can still lock up a busy database. Run EXPLAIN (not EXPLAIN ANALYZE, which actually executes the query) to see the plan first. Look for full scans on large tables and for a row estimate far bigger than you expect. Add a LIMIT while you are iterating, and run heavy analytical queries against a read replica or a warehouse instead of the primary database when you have one.
Be careful with indexes the model suggests. An index can speed up one query and slow down every insert on that table. Treat an index suggestion as a proposal to test, not a fix to apply.
Before you trust a generated total, reconcile it against numbers you already know. Image: Millinery Book Ledger (page 105) by William Samuel Clark (1846-1923), Public domain, via Wikimedia Commons.
Step 5: Treat every write as dangerous
Reading data wrong gives you a bad chart. Writing data wrong can take hours to undo, or may not be undoable at all. For any generated UPDATE, DELETE, or INSERT ... SELECT:
- Turn it into a SELECT first. Replace
UPDATE accounts SET plan = 'free' WHERE ...withSELECT id, plan FROM accounts WHERE ...using the exact sameWHEREclause. Check the count and spot-check the rows. - Wrap it in a transaction. In PostgreSQL:
Note that MySQL commits DDL statements likeBEGIN; UPDATE accounts SET plan = 'free' WHERE trial_ends_at < now() AND plan = 'trial'; -- the client reports e.g. "UPDATE 112"; compare to your SELECT count SELECT COUNT(*) FROM accounts WHERE plan = 'trial' AND trial_ends_at < now(); ROLLBACK; -- change to COMMIT only when the numbers matchALTER TABLEimplicitly, so a transaction will not protect you there. - Never accept a write without a WHERE clause unless you truly mean every row. Many teams enable
sql_safe_updatesin MySQL clients for exactly this reason. - Have a recent backup or a copy of the affected rows.
CREATE TABLE accounts_backup_20261111 AS SELECT * FROM accounts WHERE ...takes seconds and makes a rollback possible after commit. - Batch large changes. Updating millions of rows in one statement can hold locks for a long time. Ask the model for a batched version that works through primary key ranges.
Step 6: Keep the model away from production credentials
Tools that let an LLM run queries directly against a database are increasingly common. If you use one, give it a dedicated read-only role, not your own login. In PostgreSQL that means a role with CONNECT and SELECT on specific schemas and nothing else. Set a statement timeout for that role (for example ALTER ROLE llm_reader SET statement_timeout = '30s';) so a runaway query cannot tie up the server.
Also think about what data you paste into a chat. Schemas and aggregate numbers are usually fine to share under your company's policy; customer emails, payment details, and other personal data usually are not. Paste the shape of the data, not the data itself, when you can. A few fake sample rows that match the column types are enough for the model to understand the format.
Step 7: Save the queries that work
Once a query has passed your checks, save it with a short comment explaining what it computes, what each row means, and which business definitions it uses. Put it in version control, a shared folder, or your BI tool's saved queries. Next time someone asks for "monthly revenue," you start from a verified query instead of generating a new one and checking it all over again. These saved queries also make excellent examples to paste into future prompts, because they show the model your naming, your definitions, and your style.
A short checklist
- Paste the real
CREATE TABLEstatements, engine, and version. - State the grain of each table and your business definitions.
- Ask the model what one output row represents.
- Check every join, aggregate level, date range, and NULL case.
- Compare results to a row count, a known total, and one hand-traced record.
- Run
EXPLAINbefore heavy queries on production. - Preview writes as SELECTs, run them in a transaction, and back up affected rows.
- Use a read-only role with a timeout for any tool that executes SQL.
- Save verified queries and reuse them.
LLMs are genuinely good at the mechanical part of SQL: remembering syntax, writing window functions, and turning a messy request into a structured query. The part they cannot do is know your data. Supply the schema and definitions, verify the numbers against something real, and keep writes behind a transaction, and generated SQL becomes a reliable time saver instead of a quiet source of wrong answers.
Comments
Post a Comment