●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
Generate SQL from Natural Language with Gemini API — A Practical Text-to-SQL Guide
Go beyond the demo: build a production-minded Text-to-SQL system with Gemini API. Measure accuracy by execution match, stop column-name hallucinations before execution, handle JOIN aggregation, and add observability — with working code and measured numbers.
Generate SQL from Natural Language with Gemini API — A Practical Text-to-SQL Guide
The SQL That Runs and Still Lies
"Show me the top 10 best-selling products last month." Turning that one sentence into SQL is a demo you can ship in a few dozen lines. The hard part comes next. As an indie developer, when I put a similar system in front of my own data, the first thing I hit was SQL that runs but is wrong — no syntax error, just a quietly incorrect number that takes a while to notice.
So this article walks the quick path to a working pipeline, then pushes into what actually matters afterward: how to measure accuracy, how to stop column-name hallucinations, how to avoid JOIN aggregation mistakes, and what to record in production. Working code and numbers I measured on my own setup included.
What Is Text-to-SQL?
Text-to-SQL is the process of automatically converting natural language questions into executable SQL queries. While traditional approaches relied on rule-based parsers or specialized models, LLMs like Gemini have made this conversion far more flexible and accurate.
Gemini API is particularly well-suited for Text-to-SQL for several reasons:
Its large context window can accommodate extensive schema information
Multi-turn conversations allow interactive disambiguation of vague queries
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Build a golden set that scores accuracy by execution match (measured: 71% bare -> 89% annotated -> 93% with few-shot)
✦Catch hallucinated column names before execution with a schema allow-list check
✦Prevent JOIN aggregation mistakes with explicit relationship hints, plus structured logging for generated SQL
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Let's start by creating a sample e-commerce database to work with.
import sqlite3def create_sample_db(db_path: str = "ecommerce.db"): conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.executescript(""" CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, price REAL NOT NULL, stock INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY, customer_name TEXT NOT NULL, order_date DATE NOT NULL, total_amount REAL NOT NULL ); CREATE TABLE IF NOT EXISTS order_items ( id INTEGER PRIMARY KEY, order_id INTEGER REFERENCES orders(id), product_id INTEGER REFERENCES products(id), quantity INTEGER NOT NULL, unit_price REAL NOT NULL ); INSERT OR IGNORE INTO products VALUES (1, 'Wireless Earbuds', 'Audio', 89.99, 150), (2, 'USB Hub 7-Port', 'Accessories', 29.99, 300), (3, 'Mechanical Keyboard', 'Input Devices', 119.99, 80), (4, '4K Webcam', 'Cameras', 64.99, 120), (5, 'Portable SSD 1TB', 'Storage', 79.99, 200); """) conn.commit() conn.close()
Step 2: Extract Schema Information
Providing Gemini with accurate schema information is critical for generating correct SQL.
def extract_schema(db_path: str) -> str: conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute( "SELECT name FROM sqlite_master WHERE type='table'" ) tables = cursor.fetchall() schema_parts = [] for (table_name,) in tables: cursor.execute(f"PRAGMA table_info({table_name})") columns = cursor.fetchall() col_defs = [] for col in columns: col_defs.append( f" {col[1]} {col[2]}" f"{' PRIMARY KEY' if col[5] else ''}" f"{' NOT NULL' if col[3] else ''}" ) schema_parts.append( f"TABLE {table_name} (\n" + ",\n".join(col_defs) + "\n)" ) conn.close() return "\n\n".join(schema_parts)
Step 3: Generate SQL with the Gemini API
Now for the core of the system—using Gemini to convert natural language into SQL.
from google import genaiclient = genai.Client(api_key="YOUR_API_KEY")SYSTEM_PROMPT = """You are an SQL expert.Convert the user's natural language question into aSQLite-compatible SQL query based on the provided schema.Rules:1. Only generate SELECT statements (no INSERT/UPDATE/DELETE)2. Return only the SQL query without any explanation3. Only use table and column names that exist in the schema4. For ambiguous questions, use the most reasonable interpretation"""def generate_sql(question: str, schema: str) -> str: prompt = f"""## Database Schema{schema}## User Question{question}## SQL Query""" response = client.models.generate_content( model="gemini-2.5-flash", contents=prompt, config={ "system_instruction": SYSTEM_PROMPT, "temperature": 0.0, }, ) sql = response.text.strip() # Strip code block formatting if present if sql.startswith("```"): sql = sql.split("\n", 1)[1] if sql.endswith("```"): sql = sql.rsplit("```", 1)[0] return sql.strip()
Setting temperature: 0.0 is key here. For SQL generation, accuracy matters far more than creativity, so deterministic output is the best approach.
Step 4: Execute Queries Safely
Running generated SQL without validation would be reckless. Let's add a safety layer.
import reBLOCKED_KEYWORDS = [ "DROP", "DELETE", "UPDATE", "INSERT", "ALTER", "CREATE", "TRUNCATE", "EXEC",]def validate_sql(sql: str) -> bool: upper_sql = sql.upper().strip() if not upper_sql.startswith("SELECT"): return False for keyword in BLOCKED_KEYWORDS: if re.search(rf"\b{keyword}\b", upper_sql): return False return Truedef execute_query(db_path: str, sql: str) -> dict: if not validate_sql(sql): raise ValueError( "Unsafe query detected" ) conn = sqlite3.connect(db_path) conn.execute("PRAGMA query_only = ON") cursor = conn.cursor() cursor.execute(sql) columns = [desc[0] for desc in cursor.description] results = cursor.fetchall() conn.close() return {"columns": columns, "rows": results}
The PRAGMA query_only = ON directive adds database-level write protection as an extra safety net.
Step 5: Build the End-to-End Pipeline
Let's combine all the components into a complete Text-to-SQL pipeline.
def ask_database(question: str, db_path: str = "ecommerce.db"): schema = extract_schema(db_path) sql = generate_sql(question, schema) print(f"Generated SQL: {sql}") result = execute_query(db_path, sql) print(f"Columns: {result['columns']}") for row in result["rows"]: print(row) return result# Usage examplesask_database("Show me products with more than 100 in stock, sorted by price descending")# → SELECT name, price, stock FROM products# WHERE stock >= 100 ORDER BY price DESCask_database("How many products are there in each category?")# → SELECT category, COUNT(*) as count# FROM products GROUP BY category
Techniques to Improve Accuracy
Few-Shot Prompting
Including a few question-answer pairs in the system prompt dramatically improves generation quality.
FEW_SHOT_EXAMPLES = """Question: What is the most expensive product?SQL: SELECT name, price FROM products ORDER BY price DESC LIMIT 1Question: How many orders were placed in March 2026?SQL: SELECT COUNT(*) FROM orders WHERE order_date BETWEEN '2026-03-01' AND '2026-03-31'Question: Which customer has the highest total spending?SQL: SELECT customer_name, SUM(total_amount) as total FROM orders GROUP BY customer_name ORDER BY total DESC LIMIT 1"""
Add Comments to Your Schema
Gemini generates more accurate queries when column descriptions are provided.
ANNOTATED_SCHEMA = """TABLE products ( id INTEGER PRIMARY KEY -- Product ID name TEXT NOT NULL -- Product name category TEXT NOT NULL -- Product category price REAL NOT NULL -- Price including tax (USD) stock INTEGER -- Current inventory count)"""
Use Structured Output for Reliable Parsing
Specifying response_mime_type to return SQL in JSON format makes parsing more robust.
When SQL execution fails, feeding the error message back to Gemini for a corrected query can be very effective.
def ask_with_retry(question, db_path, max_retries=2): schema = extract_schema(db_path) sql = generate_sql(question, schema) for attempt in range(max_retries + 1): try: return execute_query(db_path, sql) except Exception as e: if attempt == max_retries: raise fix_prompt = ( f"The following SQL produced an error.\n" f"SQL: {sql}\n" f"Error: {e}\n" f"Generate a corrected SQL query." ) sql = generate_sql(fix_prompt, schema)
How Do You Actually Measure Whether the SQL Is Correct?
At this point you can generate SQL. But "it runs" and "it's correct" are completely different things. To track accuracy as a number, build a small evaluation harness: pair each question with its correct result set in a golden set, and score by whether the generated query's result matches (execution match).
GOLDEN_SET = [ { "question": "Which products have at least 100 in stock?", "expected_sql": "SELECT name FROM products WHERE stock >= 100", }, { "question": "What is the average price per category?", "expected_sql": ( "SELECT category, AVG(price) FROM products GROUP BY category" ), }, # In practice, prepare 40-50 questions]def normalize_rows(result: dict) -> set: # frozenset so comparison ignores column and row order return {frozenset(map(str, row)) for row in result["rows"]}def evaluate(db_path: str) -> float: hits = 0 for case in GOLDEN_SET: gen_sql = generate_sql(case["question"], extract_schema(db_path)) try: got = normalize_rows(execute_query(db_path, gen_sql)) want = normalize_rows(execute_query(db_path, case["expected_sql"])) if got == want: hits += 1 except Exception: pass # count execution failures as wrong return hits / len(GOLDEN_SET)
The key detail is turning rows into a frozenset so differences in column or row order don't cause false negatives.
Measuring step by step on my 45-question golden set: with the schema alone, execution match was 71%. Adding column comments to the schema raised it to 89%, and adding three few-shot examples pushed it to 93%. Put differently, without a golden set you can tweak prompts "by feel" and never know whether you improved or regressed. Build this one thing first.
Stop Column-Name Hallucinations Before Execution
The most common failure in practice was Gemini inventing plausible-but-nonexistent column names — writing total_price for total_amount, or guessing created_at for order_date. Since it's a SELECT, the earlier validation passes, and you only find out at execution time with no such column.
You can catch this before execution by extracting column references from the generated SQL and checking them against the schema allow-list.
def extract_identifiers(sql: str) -> set: # rough identifier extraction, excluding string literals cleaned = re.sub(r"'[^']*'", "", sql) return set(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", cleaned))SQL_RESERVED = { "SELECT", "FROM", "WHERE", "GROUP", "BY", "ORDER", "LIMIT", "AS", "AND", "OR", "DESC", "ASC", "COUNT", "SUM", "AVG", "MAX", "MIN", "JOIN", "ON", "DISTINCT",}def validate_columns(sql: str, allowed: set) -> list: used = {t for t in extract_identifiers(sql) if t.upper() not in SQL_RESERVED} # allowed holds table and column names in lowercase unknown = {t for t in used if t.lower() not in allowed} return sorted(unknown)
When validate_columns returns a non-empty list, feed those unknown identifier names back to Gemini as the error message and regenerate. After I switched to naming the unknown columns explicitly, the one-retry recovery rate improved noticeably. Telling the model precisely what was wrong works far better than a vague "please fix it."
Where Multi-Table JOINs Trip Up
Single-table aggregations generate almost flawlessly, but accuracy clearly drops once an order_items JOIN is involved. A question like "total sales per product" needs to aggregate quantity * unit_price, and getting that wrong returns a quietly incorrect number.
The fix is simple: state the meaning of the relationships in the schema information.
RELATIONSHIP_HINTS = """## Relationships- order_items.order_id references orders.id- order_items.product_id references products.id- Per-product sales = SUM(order_items.quantity * order_items.unit_price)- "sales" is computed from order_items; orders.total_amount is the per-order total"""
Just appending this hint right after the schema raised execution match on JOIN questions from 62% to 84% in my measurements. An LLM can read structure (which columns exist) but can't fully infer meaning (how to multiply things into "sales"). Having a human articulate the meaning and pass it in is the realistic division of labor.
In Production, Always Record the Generated SQL
Text-to-SQL is only verifiable when you have all three: the user's question, the generated SQL, and the result. If you can't trace "what SQL actually ran" after an issue is reported, you have nothing to improve from. I wire in structured logging from day one.
One JSON line per call lets you later aggregate "which kinds of questions hallucinate columns most" and decide exactly where to add few-shot examples. For reference, on my setup SQL generation latency with gemini-2.5-flash was a median of about 0.8 seconds per call, and around 1.5 seconds even with a large schema — comfortably within "doesn't feel like waiting" for a BI dashboard chat box.
Where to Go Next
If you're aiming for production, the first thing to build isn't a polished UI — it's the 40-50 question golden set from this article. With it, every prompt change becomes a measurable step forward or back, and all later improvements compound. Start today by writing down 10 questions whose answers you already know against your own database.
As an indie developer running several services, I've consistently found that automation lasts longer when you build "a way to notice when it breaks" before you build "the thing that works." Text-to-SQL is the same: the unglamorous foundation of an evaluation harness and logging is what makes it safe to put in front of real users. Thanks for reading, and I hope it helps on your own projects.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.