Never run model-generated SQL against real data first. Use a throwaway database on free compute to catch syntax errors, wrong shapes, and dangerous table access before the query touches anything that matters.
Why this matters
Free model access makes SQL generation cheap. It also makes bad SQL cheap: missing joins, wrong filters, accidental full scans, or queries that hit tables they should not touch.
I do not trust generated SQL on first sight. Instead, I route it through a read-only gate.
MonkeyCode's free model and free server option let me run that gate without paying for a staging database.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The gate in three parts
- Ask the model for SQL plus an expected shape.
- Validate the SQL on a throwaway SQLite database running on a free server.
- Pass only SQL that meets every check.
Part 1: Ask for SQL and expectations
I keep the model prompt small. It returns:
-
sql: the generated SQL statement -
expected_columns: list of column names -
max_rows: expected maximum row count -
intent: one sentence about what the query should do
I never ask the model to access production data or write to any permanent store.
Part 2: Validate on free compute
I upload a small Python script and a sample SQLite file to the free server. The script runs four checks in order.
import sqlite3
import sys
import json
DB_PATH = "sample.db"
TIMEOUT_MS = 2000
def validate(sql: str, expected_columns: list, max_rows: int):
conn = sqlite3.connect(DB_PATH)
conn.execute(f"PRAGMA query_only = ON")
conn.execute(f"PRAGMA busy_timeout = {TIMEOUT_MS}")
# 1. Explain plan must not hit forbidden tables
plan = conn.execute(f"EXPLAIN QUERY PLAN {sql}").fetchall()
plan_text = " ".join(str(row) for row in plan).lower()
forbidden = ["users_private", "billing", "secrets"]
if any(t in plan_text for t in forbidden):
return {"status": "reject", "reason": "forbidden table in plan"}
# 2. Run with a timeout
try:
conn.set_progress_handler(lambda: 0, TIMEOUT_MS) # not a true timeout; see note
cur = conn.execute(sql)
rows = cur.fetchmany(max_rows + 1)
except Exception as e:
return {"status": "reject", "reason": f"exec error: {e}"}
# 3. Check column names
cols = [d[0] for d in cur.description] if cur.description else []
if cols != expected_columns:
return {"status": "reject", "reason": f"columns {cols} != expected {expected_columns}"}
# 4. Check row count
if len(rows) > max_rows:
return {"status": "reject", "reason": f"row count {len(rows)} exceeds max {max_rows}"}
return {"status": "pass", "rows_returned": len(rows)}
if __name__ == "__main__":
# payload arrives as JSON from the model output
payload = json.loads(sys.stdin.read())
result = validate(payload["sql"], payload["expected_columns"], payload["max_rows"])
print(json.dumps(result))
Note: SQLite's set_progress_handler is not a hard query timeout. For a real timeout, run the query in a subprocess or use a driver that supports cancellation. On a free server, the cheapest approach is to run the whole validation script inside a shell timeout command.
Part 3: Decision table
| Check | Pass condition | Reject result |
|---|---|---|
| Explain plan table access | No forbidden tables | Block query |
| Execution | Runs without exception | Return error |
| Columns | Matches expected list | Return mismatch |
| Row count | At most max_rows
|
Return overflow |
Only a pass on all four checks moves the SQL to the next stage.
Example pass
Input SQL:
SELECT id, name FROM users WHERE active = 1
Expected columns: ["id", "name"]
Max rows: 100
Result:
{"status": "pass", "rows_returned": 42}
Example reject
Input SQL:
SELECT id, email FROM users_private WHERE active = 1
Result:
{"status": "reject", "reason": "forbidden table in plan"}
Why I don't run SQL directly against production
- A wrong
DELETEorUPDATEis irreversible. - A missing
WHEREcan return millions of rows. - A bad join can cause a full table scan that slows down other users.
The read-only gate catches these before the SQL ever reaches a real database.
Free server setup in five minutes
- Copy
sample.dbandvalidate.pyto the free server. - Run
python3 validate.py < payload.json. - Wrap the call with
timeout 10s. - Check the JSON status.
No GPU is needed. The whole check runs in under a second on small data.
What this gate catches
- Syntax errors
- Wrong column names
- Queries that touch private tables
- Result sets much larger than expected
- Obvious missing joins that produce empty or huge outputs
What it does not catch
- Subtle logic bugs that return the right shape but wrong meaning
- Performance problems on a real database engine
- Time zones, collation, or dialect differences between SQLite and your production DB
- Multi-statement transactions or side effects
Who should not use this
Skip this if:
- You need to validate SQL against a specific production engine like PostgreSQL or MySQL.
- You handle writes or transactions, not read-only queries.
- You need sub-second validation for high-volume interactive use.
Keep it read-only
The most important part is PRAGMA query_only = ON in SQLite. It blocks writes at the database level. Even if the model emits an UPDATE or DROP, the connection refuses it. This is a cheap safety layer on free compute.
After the SQL passes the gate, I review it manually if it touches anything beyond the sample schema. The gate reduces my review surface, but it does not replace it.
If you generate SQL with a free model, wire a read-only validation script into your CI. It is one small file that prevents a large class of embarrassing failures.
Top comments (0)