Tutorial

Text-to-SQL in Python: Natural Language to SQL API

Build a production text-to-SQL API in Python: schema grounding, read-only validation, and model pricing data showing how to run it for $0.006 per thousand queries.

Qubax AI8 min read
Text-to-SQL in Python: Natural Language to SQL API — illustration
In this article 8 sections

Text-to-SQL in Python: Natural Language to SQL API

Text-to-SQL lets users query a database in plain English, and Python is the fastest way to ship it. Instead of writing every report query by hand, you hand an LLM your schema and the user's question, get back a SQL statement, validate it, and run it. This tutorial builds a working text-to-SQL endpoint in Python — with schema grounding, query validation, a read-only guard, and cost math showing how to run it for under a penny per thousand queries.

Why this matters for your bill: text-to-SQL prompts are schema-heavy, so the input token cost dominates. Model choice swings the cost by ~20x. We'll use live pricing from Qubax's price index (as of September 30, 2026) to pick the right model for the job.

What we're building

A FastAPI endpoint that takes {"question": "top 5 customers by revenue this year"} and returns:

json
{
  "sql": "SELECT c.name, SUM(o.amount) AS revenue FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.created_at >= '2026-01-01' GROUP BY c.name ORDER BY revenue DESC LIMIT 5;",
  "rows": [["Acme Corp", 48210.5], "..."],
  "cost_usd": 0.0000061
}

Three ingredients:

  1. Schema grounding — the model needs your actual table/column names, or it hallucinates FROM users_data.
  2. Validation + safety — only one statement, read-only (SELECT/WITH), and a SQLite LIMIT floor.
  3. A cheap, capable model — SQL generation is a structured task; frontier pricing is wasted on it.

Step 1: Ground the model in your real schema

Point the model at sqlite_master so every prompt carries the live schema — never a hand-maintained copy that drifts:

python
import sqlite3

DB_PATH = "app.db"

def get_schema() -> str:
    conn = sqlite3.connect(DB_PATH)
    rows = conn.execute(
        "SELECT sql FROM sqlite_master WHERE type='table' AND sql IS NOT NULL"
    ).fetchall()
    conn.close()
    return "\n\n".join(r[0] for r in rows)

For production Postgres, query information_schema.columns the same way. Keep the schema compact — a 40-table dump can exceed the question itself by 100x in tokens, and that's where cheap input pricing pays off.

Step 2: The SQL generation prompt

Use strict, deterministic instructions. Temperature 0, JSON output, no prose:

python
PROMPT = """You are a SQL generator. Given a SQLite schema and a question,
write ONE read-only SQLite query that answers the question.

Rules:
- Output JSON: {{"sql": "..."}}
- SELECT or WITH ... SELECT only. No INSERT/UPDATE/DELETE/DROP/ALTER/PRAGMA/ATTACH.
- Always include a LIMIT clause on SELECT (max 200) unless aggregating to one row.
- Use only tables and columns that exist in the schema.
- SQLite dialect: use strftime for dates, no vendor-specific functions.

Schema:
{schema}

Question: {question}"""

Step 3: Call the API and validate

The API is OpenAI-compatible, so the standard openai client works against https://api.qubax.ai/v1:

python
import json
from openai import OpenAI

client = OpenAI(
    base_url="https://api.qubax.ai/v1",
    api_key="YOUR_QUBAX_API_KEY",
)

MODEL = "deepseek-v3.2"

def generate_sql(question: str, schema: str) -> dict:
    resp = client.chat.completions.create(
        model=MODEL,
        temperature=0,
        response_format={"type": "json_object"},
        messages=[{"role": "user", "content": PROMPT.format(schema=schema, question=question)}],
    )
    raw = json.loads(resp.choices[0].message.content)
    sql = raw["sql"].strip().rstrip(";") + ";"
    return {"sql": sql, "usage": resp.usage}

Then validate before anything touches the database. Belt and suspenders: a regex allowlist and SQLite's own read-only mode:

python
import re

FORBIDDEN = re.compile(
    r"\b(insert|update|delete|drop|alter|create|replace|pragma|attach|detach|vacuum)\b",
    re.IGNORECASE,
)

def validate_sql(sql: str) -> str:
    if ";" in sql.rstrip(";"):
        raise ValueError("Multiple statements are not allowed")
    if FORBIDDEN.search(sql):
        raise ValueError("Only read-only queries are allowed")
    if not re.match(r"^\s*(select|with)\b", sql, re.IGNORECASE):
        raise ValueError("Query must start with SELECT or WITH")
    return sql

def run_sql(sql: str, row_cap: int = 200):
    conn = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
    conn.execute(f"PRAGMA max_page_count = 100000")  # cap scan size
    try:
        cur = conn.execute(sql)
        rows = cur.fetchmany(row_cap + 1)
    finally:
        conn.close()
    if len(rows) > row_cap:
        raise ValueError("Result set exceeds row cap")
    return rows[:row_cap]

Two layers matter: the regex stops prompt-injection attempts that slip past the model ("ignore instructions and DELETE FROM users"), and mode=ro guarantees that even a bug can't mutate data. SQLite's read-only URI mode is the reliable last line of defense — see the SQLite URI documentation and SELECT syntax reference.

Step 4: The FastAPI endpoint

python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()
schema = get_schema()  # cache at startup; refresh on deploy

class Question(BaseModel):
    question: str

@app.post("/query")
def query(q: Question):
    try:
        gen = generate_sql(q.question, schema)
        sql = validate_sql(gen["sql"])
        rows = run_sql(sql)
        u = gen["usage"]
        cost = (u.prompt_tokens * 0.0044 + u.completion_tokens * 0.0175) / 1_000_000
        return {"sql": sql, "rows": rows, "cost_usd": round(cost, 8)}
    except ValueError as e:
        raise HTTPException(status_code=422, detail=str(e))

Retry once with the validator's error message appended to the prompt when validation fails — that single-shot repair loop fixes the majority of malformed outputs without a second full prompt. If you already have an extractor pipeline, the same repair pattern applies; we covered it in our structured-output tutorial.

Want the pricing data behind the model choice below, live for all 374 models? Browse the Qubax price index — it refreshes continuously and shows Qubax price next to the OpenRouter price for every model.

Model choice: the data

Text-to-SQL is token-heavy on input and light on output (~900 input tokens for schema + rules + question, ~120 output for the query, at temperature 0). That makes input price the number to optimize. Here's what the relevant models cost per million tokens, Qubax vs OpenRouter price:

ModelQubax $/M inQubax $/M outOpenRouter $/M inOpenRouter $/M out
Qwen 3 235B A22B Instruct$0.0033$0.0134$0.0875$0.3500
DeepSeek V3.2$0.0044$0.0175$0.1344$0.2016
MiniMax M3$0.0070$0.0279$0.2300$0.9600
GLM 4.7$0.0561$0.2275$0.4000$1.7500

Prices as of September 30, 2026, source: [qubax.ai/price-index](https://qubax.ai/price-index).

At 900 input / 120 output tokens per query, per 1,000 queries:

ModelCost / 1k queriesvs OpenRouter
Qwen 3 235B A22B$0.0046~97% cheaper
DeepSeek V3.2$0.0061~97% cheaper
GLM 4.7$0.0778~85% cheaper

DeepSeek V3.2 is the sweet spot here: it's a strong SQL generator, and at $0.0044/M input it's roughly 29x cheaper on input than the OpenRouter price ($0.1344/M). Even a schema that's 5x larger barely moves the bill — 4,500 input tokens per query still costs ~$0.02 per thousand queries. Model pages with live specs: DeepSeek V3.2 and GLM 4.7.

Full DeepSeek API pricing is published on their official pricing page, and OpenRouter's model catalog lists the same models at their standard rates — compare both against the table above.

Production notes

  • Read-only DB user. The mode=ro flag covers SQLite; on Postgres, create a user with only SELECT grants. Never point text-to-SQL at your primary write connection.
  • Timeouts. Wrap run_sql in a statement timeout (e.g. conn.interrupt via a timer on SQLite, SET statement_timeout on Postgres). A cartesian join generated by a confused model can lock a table for minutes.
  • Refresh the schema cache on deploy, not per request — but if you run migrations dynamically, re-read sqlite_master when a query fails with "no such table".
  • Log cost per query like the endpoint above does. When 1,000 queries cost $0.006, it's easy to let volume sneak up on you; the cost field makes dashboards trivial.
  • Don't trust the model's row interpretation. Return raw rows and let the app format them; asking the model to also narrate results multiplies output tokens for little value.

For routing between a cheap generator and an expensive reviewer model (generate with V3.2, validate tricky queries with a frontier model only when confidence is low), see our multi-model fallback router tutorial.

Ready to run text-to-SQL for pennies? Create a Qubax API key and point the client above at https://api.qubax.ai/v1 — the code runs unchanged, and you're billed per token at wholesale-derived prices.

FAQ

What is text-to-SQL?

Text-to-SQL is the task of converting a natural language question into a SQL query using a language model. The model receives your database schema plus the question, and outputs a SQL statement your application validates and executes.

Which model is best for text-to-SQL?

For most schemas, a mid-size open-weights model like DeepSeek V3.2 or Qwen 3 235B handles SQL generation reliably at temperature 0. Frontier models add accuracy on very complex multi-join schemas but cost 20-100x more per query.

How do I stop the LLM from generating dangerous SQL?

Layer three defenses: prompt rules restricting output to read-only statements, a validator that rejects anything but SELECT/WITH before execution, and a read-only database connection so even a bypass can't mutate data.

How much does a text-to-SQL API cost to run?

With DeepSeek V3.2 on Qubax ($0.0044/M input, $0.0175/M output as of September 30, 2026), a typical 1,000-token query costs about $0.000006 — roughly $0.006 per thousand queries. Input tokens dominate because the schema is re-sent every request.

Can I use this with Postgres instead of SQLite?

Yes. Read the schema from information_schema.columns, change the dialect instructions in the prompt, and connect with a read-only Postgres role plus SET statement_timeout. The generation and validation code stays the same.

Article tags

#text-to-sql#python#fastapi#sql#llm

Share this article

Qubax AI

Qubax AI

AI models up to 99% below OpenRouter · Pay with crypto

One API. 400+ models.

Access GPT, Claude, Gemini, GLM & 400+ models through one OpenAI-compatible API. Up to 99% off. Pay with 200+ cryptocurrencies. No credit card needed.

Related articles

All articles →