n4nAI

Building an AI agent that writes SQL from natural language

A hands-on tutorial for building an AI agent text-to-SQL system that converts questions to verified SQL over a real Postgres database.

n4n Team3 min read629 words

Audio narration

Coming soon — every post will get a voice note here.

Most text-to-SQL demos collapse when they meet a real schema with joins, nullable columns, and ambiguous entity names. This tutorial builds an AI agent text-to-SQL pipeline that introspects a live PostgreSQL database, generates parameterized queries, and validates them with EXPLAIN before any data leaves the server. You will leave with runnable code, not a slideware architecture.

Prerequisites

You need a working Python 3.11 environment and a PostgreSQL database you can write to. Install the dependencies below. We use SQLAlchemy for introspection, psycopg2 for the driver, and the official OpenAI SDK because we will hit an OpenAI-compatible HTTP interface.

pip install sqlalchemy psycopg2-binary openai python-dotenv

Create a .env file with your database URL and an API key for an OpenAI-compatible gateway. We point the client at n4n.ai’s endpoint later to get automatic fallback across 240+ models when a provider is degraded.

DB_URL=postgresql://user:password@localhost:5432/analytics
N4N_API_KEY=sk-...

You should be comfortable reading psql output and basic Python async isn’t required; everything here is synchronous for clarity.

Step 1: Create a representative schema

Synthetic schemas with a single table hide the real problem: relational joins. Stand up a small e-commerce model and seed it.

CREATE TABLE customers (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    signup_date DATE NOT NULL
);

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(id),
    total_cents INTEGER NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO customers (name, signup_date) VALUES
    ('Alice', '2023-01-04'),
    ('Bob', '2023-02-15');
INSERT INTO orders (customer_id, total_cents) VALUES
    (1, 5000), (1, 3200), (2, 8000);

Run that in psql or via a migration tool. The orders.customer_id foreign key is what makes the agent’s job non-trivial.

Step 2: Introspect the schema at runtime

Hardcoding the DDL in your prompt guarantees drift. Pull the live schema on startup so the model always sees reality.

from sqlalchemy import create_engine, inspect

def get_schema_context(db_url: str) -> str:
    engine = create_engine(db_url)
    inspector = inspect(engine)
    lines = []
    for table in inspector.get_table_names():
        cols = inspector.get_columns(table)
        col_defs = ", ".join(f"{c['name']} {c['type']}" for c in cols)
        lines.append(f"Table {table}: {col_defs}")
    return "\n".join(lines)

# Expected output:
# Table customers: id INTEGER, name TEXT, signup_date DATE
# Table orders: id INTEGER, customer_id INTEGER, total_cents INTEGER, created_at TIMESTAMPTZ

The inspect call is cheap and caches well. In production, refresh it on migration events, not every request.

Step 3: Construct the prompt

The model needs a strict output contract. We forbid markdown fences and demand PostgreSQL syntax. We also instruct it to use $1 placeholders when a value is missing, though this tutorial executes only literal queries.

SYSTEM_PROMPT = """You are a SQL generator. Given a PostgreSQL schema and a natural language question,
return a single SQL statement. Use parameterized placeholders ($1, $2) for any user-supplied values.
Do not wrap the SQL in code fences. Only output the SQL text."""

def build_messages(schema_ctx: str, question: str, history: list = None):
    messages = [{"role": "system", "content": SYSTEM_PROMPT}]
    if history:
        messages.extend(history)
    messages.append({
        "role": "user",
        "content": f"Schema:\n{schema_ctx}\n\nQuestion: {question}"
    })
    return messages

Keeping the system prompt lean reduces token waste. The schema context is the only variable payload that matters.

Step 4: Call the model

Configure the OpenAI client against the n4n.ai OpenAI-compatible endpoint. It forwards provider cache-control hints and routes to models like gpt-4o-mini without changing your code when a provider is rate-limited.

import os
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(
    base_url="https://api.n4n.ai/v1",
    api_key=os.environ["N4N_API_KEY"]
)

def generate_sql(question: str, schema_ctx: str) -> str:
    msgs = build_messages(schema_ctx, question)
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=msgs,
        temperature=0
    )
    return resp.choices[0].message.content.strip()

# Checkpoint:
# >>> schema_ctx = get_schema_context(os.environ["DB_URL"])
# >>> generate_sql("What is the total revenue per customer?", schema_ctx)
# SELECT c.name, SUM(o.total_cents) FROM customers c JOIN orders o ON o.customer_id = c.id GROUP BY c.name

Temperature zero is non-negotiable for SQL generation. Creativity here is a bug.

Step 5: Validate before executing

Model output is untrusted. Wrap it in EXPLAIN to force the planner to parse it. If the syntax is broken or a column doesn’t exist, Postgres throws before any data moves.

def validate_sql(engine, sql: str):
    with engine.connect() as conn:
        # EXPLAIN parses and plans but does not execute
        conn.execution_options(autocommit=True).execute(f"EXPLAIN {sql}")

Note: EXPLAIN with $1 placeholders fails because there is no prepared statement. For this tutorial we only run literal queries; the agent loop below asks for clarification if parameters are present. In a fuller system you would prepare the statement separately.

Step 6: Execute and return rows

Once validated, run the query under a read-only role. Return mappings so the caller gets dicts.

def run_query(engine, sql: str):
    with engine.connect() as conn:
        result = conn.execute(sql)
        return [dict(row) for row in result.mappings()]

# Checkpoint:
# >>> sql = generate_sql("List customers who spent more than 3000 cents", schema_ctx)
# >>> validate_sql(engine, sql)
# >>> run_query(engine, sql)
# [{'name': 'Alice'}, {'name': 'Bob'}]

The separation of validate_sql and run_query is what keeps a typo from becoming a DROP TABLE.

Step 7: Turn it into an agent loop

A real AI agent text-to-SQL system handles failure instead of crashing. The loop below feeds validation errors back to the model for one retry.

def agent_loop(question: str, max_turns: int = 3):
    schema_ctx = get_schema_context(os.environ["DB_URL"])
    engine = create_engine(os.environ["DB_URL"])
    history = []
    for _ in range(max_turns):
        sql = generate_sql(question, schema_ctx)
        try:
            validate_sql(engine, sql)
        except Exception as e:
            history.append({"role": "assistant", "content": sql})
            history.append({"role": "user", "content": f"That SQL failed: {e}. Rewrite it correctly."})
            continue
        return run_query(engine, sql)
    raise RuntimeError("Agent could not produce valid SQL")

# Running:
# >>> agent_loop("How many orders did Alice place?")
# [{'count': 2}]

This is a minimal agent, but the shape is correct: observe, act, validate, correct. You can extend history with tool results or schema snippets.

Production considerations

Schema drift is the top failure mode. Re-introspect on a timer or via migration hooks. Second, models hallucinate column names; the EXPLAIN guard cuts those errors to near zero. Third, always separate generation from execution—your agent should never hold raw credentials to write paths.

If you route through n4n.ai, per-token usage metering lets you attribute costs per agent session, which matters when ten internal teams hit the same AI agent text-to-SQL endpoint. Combine that with a read-only Postgres role and query timeouts, and you have a system safe enough for a self-serve analytics portal.

The pattern scales to BigQuery or Snowflake by swapping the SQLAlchemy dialect and adjusting the EXPLAIN equivalent (VALIDATE or EXPLAIN WITHOUT_EXECUTE). That is the foundation for analytics copilots engineers can actually trust.

Tagstext-to-sqltutorialdata-engineeringnl-to-sql

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All ai agents in data engineering & analytics posts →