More and more SQL gets written by AI now, inside chat tools, agents and "ask your data" features. Most of it runs. That's the problem: the dangerous mistakes aren't the ones that throw an error. They're the ones that run fine and return a number that looks right.

Below are five of them. Each comes with a tiny Postgres setup you can paste into psql, the query that gets it wrong, the query that gets it right, and the thing you'd have to look at to notice.

Setup (30 seconds)

CREATE DATABASE silent_bugs; \c silent_bugs SET TIME ZONE 'UTC'; CREATE TABLE orders ( id int PRIMARY KEY, status text, amount numeric, -- list price net_amount numeric, -- after discounts: what finance calls revenue ordered_at timestamptz ); INSERT INTO orders VALUES (1001, 'shipped', 420, 400, '2026-03-15 10:00+00'), (1002, 'cancelled', 300, 300, '2026-03-20 12:00+00'), (1003, 'shipped', 480, 450, '2026-03-31 23:30 America/New_York'), (1004, 'shipped', 500, 470, '2026-04-02 09:00+00'); CREATE TABLE payments ( order_id int REFERENCES orders(id), method text, amount numeric ); INSERT INTO payments VALUES (1001, 'card', 400), (1003, 'card', 300), (1003, 'wallet', 150), (1004, 'card', 470); CREATE TABLE inventory ( sku text PRIMARY KEY, in_stock int, synced_at timestamptz ); INSERT INTO inventory VALUES ('A-102', 340, now() - interval '19 days'), ('A-117', 85, now() - interval '19 days'); 

Four orders, one of them cancelled, one paid in two parts, and an inventory table whose sync quietly stopped.

1. Cancelled orders counted as sales

Question: "What were our total sales?"

SELECT SUM(amount) AS total_sales FROM orders; -- 1700 
SELECT SUM(amount) AS total_sales FROM orders WHERE status = 'shipped'; -- 1400 

The first query is not wrong SQL. It's a wrong answer: it adds up an order that never shipped. Nothing about 1700 looks suspicious on its own.

What gives it away: the rows. Order 1002 with status cancelled is sitting in the total.

2. A join that quietly doubles a row

Question: "What's our revenue from paid orders?"

SELECT SUM(o.amount) AS revenue FROM orders o JOIN payments p ON p.order_id = o.id; -- 1880 

Order 1003 was paid in two parts (card and wallet), so the join returns it twice and its 480 is counted twice. Joining two tables is normal; the fan-out is the bug.

-- find it SELECT o.id, COUNT(*) AS rows_per_order FROM orders o JOIN payments p ON p.order_id = o.id GROUP BY o.id HAVING COUNT(*) > 1; -- 1003 | 2 -- fix it SELECT SUM(amount) AS revenue FROM orders WHERE id IN (SELECT order_id FROM payments); -- 1400 

What gives it away: the rows. The same order ID shows up twice.

3. The wrong "revenue" column

Question: "What's our revenue from shipped orders?"

SELECT SUM(amount) AS revenue FROM orders WHERE status = 'shipped'; -- 1400 
SELECT SUM(net_amount) AS revenue FROM orders WHERE status = 'shipped'; -- 1320 

Both columns are real. Both are "revenue" to somebody. Only one is the number your finance team reports. Real schemas are worse: revenue, revenue_gross, revenue_net, revenue_booked, and a view someone made in 2023.

What gives it away: the query. It names the column that was added up, so the person who knows which one is right can catch it in a second.

4. A table that stopped updating

Question: "How many A-102 do we have in stock?"

SELECT in_stock FROM inventory WHERE sku = 'A-102'; -- 340 

The sync broke 19 days ago. The table is still there and still answers, with old data.

SELECT MAX(synced_at) AS last_sync, now()::date - MAX(synced_at)::date AS days_old FROM inventory; -- days_old: 19 

What gives it away: the dates in the rows. Nothing newer than three weeks ago.

5. A time zone that moves a sale into the wrong month

Question: "Revenue by month?"

SELECT date_trunc('month', ordered_at) AS month, SUM(net_amount) AS revenue FROM orders WHERE status = 'shipped' GROUP BY 1 ORDER BY 1; -- March 400, April 920 

Order 1003 was placed at 11:30 pm on 31 March in New York. Stored in UTC, that's 03:30 on 1 April, so a UTC month boundary moves it into April.

SELECT date_trunc('month', ordered_at AT TIME ZONE 'America/New_York') AS month, SUM(net_amount) AS revenue FROM orders WHERE status = 'shipped' GROUP BY 1 ORDER BY 1; -- March 850, April 470 

Which one is "right" depends on whose month you report in, and that's exactly why it's a decision a person should see, not a default nobody noticed.

What gives it away: the timestamps in the rows, next to where the customer is.

The pattern

In all five, the query ran, the number was plausible and nothing errored. In all five, the mistake became obvious as soon as someone could see the query that ran and the rows behind the number.

That's the practical takeaway if you're building anything where AI writes SQL for users:

  • Never show a number without the query that produced it. Put it behind a "how was this calculated" toggle if you like, but keep it one click away.
  • Show (or log) the rows behind it. Duplicated IDs, cancelled statuses and old dates are easy to spot in rows and invisible in a total.
  • Let the system say "I can't answer that from this data." A clear "no" is better than a confident number from the wrong table.

How FactQL fits in

This is the part where I'm biased: I'm on the team building FactQL. Your app sends a question in plain English through one API and gets back the answer together with the query that ran, the rows behind it and a short summary of how it got there. When the data can't answer the question, it says so instead of guessing. It works on Postgres, MySQL and DuckDB, CSV and Excel files, and PDFs, and there's a free tier with no card.

But the checklist above works with any setup. If your AI feature returns a bare number today, adding the query and the rows next to it is the cheapest reliability fix you'll make this year.

Which of these five have you hit in production? I'd like to hear the ones I missed.