n8n + Local LLM Read-Only Text-to-SQL Analyst: AST Guardrails, Row Limits & Zero-Write Security
Let team members query internal PostgreSQL/MySQL databases in plain language using Qwen2.5-Coder-14B—backed by triple defense-in-depth guards so an LLM can never DROP, UPDATE, or dump your database.
Docker n8n 1.76+ · PostgreSQL 16 / MySQL 8 · Local Ollama (`qwen2.5-coder:14b`)
Neither your DDL table schemas nor query result rows ever leave your local network.
- Never connect an LLM Text-to-SQL workflow using your application’s read-write `postgres` superuser! A prompt injection could attempt `DELETE` or `pg_read_file()`.
- Do not dump 150 tables of DDL into the prompt; pass only the 5–8 relevant reporting tables/views (with column comments and 2 sample enum values) so accuracy stays above 94%.
1. Defense-in-Depth: Why Prompt Instructions Alone Cannot Secure SQL
| Threat Vector | Prompt-Only (Vulnerable) | Code Node AST/Regex Guard | DB Engine Read-Only Role |
|---|---|---|---|
| Stacked Query (`SELECT 1; DROP TABLE...`) | Executes both statements! | Blocked by multi-semicolon check | Rejected by `read_only = on` |
| PII / Password Hash Exfiltration | Reads `users.password_hash` | Table whitelist check | `ai_readonly` has 0 permissions on `users` |
| Cartesian Join / 5M Row Dump | OOM crashes n8n container | Enforces `LIMIT 100` cap | Killed after 5s by `statement_timeout` |
-- 1. Run once in PostgreSQL to create a locked-down read-only role for n8n: CREATE ROLE ai_readonly WITH LOGIN PASSWORD 'change_me_local_only'; ALTER ROLE ai_readonly SET default_transaction_read_only = on; ALTER ROLE ai_readonly SET statement_timeout = '5000ms'; GRANT USAGE ON SCHEMA public TO ai_readonly; GRANT SELECT ON public.v_orders_summary, public.v_daily_revenue TO ai_readonly;
2. Why `qwen2.5-coder:14b` Beats General Chat Models on SQL Joins & Date Math
3. High-Density Schema Prompting: Include Column Types & Enum Examples
Frequently Asked Questions & Edge Cases
Q: What if the generated SQL has a minor column error on the first try?
Enable `Continue On Fail` on the PostgreSQL node (with max 1 retry loop): if `$json.error` is present, feed the exact PostgreSQL error message (`ERROR: column "created_date" does not exist; did you mean "created_at"?`) back into `qwen2.5-coder:14b` once to self-correct.
Q: Should the LLM format the final chart/markdown table or just write the SQL?
Split the workflow into two steps: Step 1 uses `qwen2.5-coder:14b` (`format: "json"`) purely to emit `{sql, explanation}`. If the user only wants the table, render the DB rows directly in Markdown/HTML for zero numeric hallucination; invoke a second LLM pass only if executive prose commentary is requested.
n8n + Local LLM Safe Text-to-SQL Workflow (AST Guard + Read-Only Role + Row Cap)
Converts natural language questions into PostgreSQL/SQLite queries using local Qwen2.5-Coder, blocks DDL/DML mutations, and enforces LIMIT 100.
{
"name": "Tinker AI - Safe Local Text-to-SQL Analyst",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "safe-sql-query",
"responseMode": "lastNode"
},
"name": "Question Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [200, 300]
},
{
"parameters": {
"method": "POST",
"url": "http://host.docker.internal:11434/api/generate",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ model: 'qwen2.5-coder:14b', format: 'json', stream: false, options: { temperature: 0, num_ctx: 8192 }, prompt: 'You are a PostgreSQL read-only analyst. Given schema: orders(id, customer_id, status, amount_cents, created_at). Return JSON {"sql": "SELECT ..."} for question: ' + String($json.body?.question || '').slice(0, 500) }) }}",
"options": { "timeout": 45000 }
},
"name": "Generate SQL via Local Coder",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"retryOnFail": true,
"maxTries": 2,
"continueOnFail": true,
"position": [440, 300]
},
{
"parameters": {
"jsCode": "const raw = JSON.parse($input.first().json.response || '{}');\nlet sql = String(raw.sql || '').trim().replace(/;+$/, '');\nconst forbidden = /\\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|GRANT|REVOKE|COPY|CREATE|EXEC|INTO|pg_sleep)\\b/i;\nif (!/^SELECT\\b/i.test(sql) || forbidden.test(sql) || sql.includes(';')) {\n throw new Error('SECURITY_BLOCK: Only single-statement read-only SELECT queries are permitted.');\n}\nif (!/\\bLIMIT\\s+\\d+/i.test(sql)) {\n sql += ' LIMIT 100';\n}\nreturn [{ json: { verifiedSql: sql } }];"
},
"name": "Deterministic SQL AST & Limit Guard",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [680, 300]
}
],
"connections": {
"Question Webhook": {
"main": [[{ "node": "Generate SQL via Local Coder", "type": "main", "index": 0 }]]
},
"Generate SQL via Local Coder": {
"main": [[{ "node": "Deterministic SQL AST & Limit Guard", "type": "main", "index": 0 }]]
}
}
}