X

Building an Agentic SQL Data Analyst in Python with DuckDB and LangChain

In enterprise data analytics, querying relational warehouses has traditionally required specialized SQL knowledge or static business intelligence dashboards. With modern generative AI, organizations are transitioning to autonomous Text-to-SQL data agents capable of translating ambiguous business questions into valid SQL queries, executing them against analytical data stores, and synthesizing concise insights with visualizations.

However, naïve Text-to-SQL pipelines—where an LLM simply translates natural language to SQL and executes it directly—fail catastrophically in production. The model frequently hallucinates table and column names, joins incompatible types, executes computationally expensive full-table scans, or attempts prohibited data mutations (DROP TABLE, UPDATE).

In this engineering guide, you will learn how to build an enterprise-grade Agentic SQL Data Analyst in Python using DuckDB and LangChain / LangGraph. We implement dynamic database introspection, strict read-only security sandboxing, automated SQL syntax self-correction, and streaming tabular results.

1. System Architecture: The Self-Correcting SQL Loop

To guarantee accuracy and security, our agent operates in an iterative state machine rather than a one-shot pipeline:

Self-Correcting Text-to-SQL Agent Topology
Data Architecture

Step 1: Schema Introspection & Pruning

Agent dynamically extracts table schemas, foreign keys, and sample categorical values without overloading context limits.

▼ Schema Injected into Few-Shot Prompt

Step 2: Sandboxed SQL Generation & AST Validation

Model generates DuckDB-compliant SQL. AST parser ensures only SELECT queries are permitted; DROP/ALTER/DELETE are rejected.

▼ Validated Query Dispatched to DuckDB

Step 3: Execution & Automated Error Repair Loop

DuckDB executes the query. If a syntax or column error occurs, error traces are fed back to the LLM to autonomously correct the query.

▼ Results Formatted

Step 4: Statistical Synthesis & Response Delivery

Agent outputs clean tabular summaries, statistical deductions, and recommended follow-up analytical questions.

Naive Text-to-SQL

Unvalidated One-Shot Generation

Simple prompt templates asking the LLM to generate SQL directly, executing raw strings on production databases.

  • High hallucination rate for complex joins
  • Vulnerable to prompt injection and table drops
  • Crashes on syntax errors (missing GROUP BY)
  • No column pruning for wide schemas
Agentic SQL Analyst

Self-Healing Analytical State Machine

Production agent utilizing AST parsing, DuckDB vector processing, and cyclical error recovery loops.

  • Dynamic schema inspection with sample values
  • Strict read-only AST safety validation
  • Autonomous syntax repair loops on execution failure
  • High-performance OLAP queries on DuckDB parquet

2. Environment Setup & Dependencies

Create a clean virtual environment and install the required analytical libraries:

python3 -m venv .venv
source .venv/bin/activate
pip install "duckdb>=1.0.0" "pandas>=2.2.0" "sqlglot>=25.0.0" "langchain>=0.2.0" "openai>=1.30.0"

We leverage DuckDB as our ultra-fast analytical in-process SQL OLAP engine and SQLGlot for deterministic SQL Abstract Syntax Tree (AST) parsing and security enforcement.

3. Creating the Analytical Database & Sample Data

Let us populate an in-memory DuckDB analytical database with an e-commerce transactions dataset:

import duckdb
import pandas as pd
import numpy as np

def init_mock_database() -> duckdb.DuckDBPyConnection:
    """Creates an in-memory DuckDB database populated with realistic analytical data."""
    con = duckdb.connect(database=":memory:")

    # 1. Customers Table
    con.execute("""
        CREATE TABLE customers (
            customer_id VARCHAR PRIMARY KEY,
            region VARCHAR NOT NULL,
            signup_date DATE NOT NULL,
            account_tier VARCHAR CHECK (account_tier IN ('Free', 'Pro', 'Enterprise'))
        );
    """)
    con.execute("""
        INSERT INTO customers VALUES
        ('CUST-001', 'North America', '2023-01-15', 'Enterprise'),
        ('CUST-002', 'Europe', '2023-03-22', 'Pro'),
        ('CUST-003', 'Asia-Pacific', '2023-06-10', 'Free'),
        ('CUST-004', 'North America', '2023-08-01', 'Enterprise'),
        ('CUST-005', 'Latin America', '2024-01-05', 'Pro');
    """)

    # 2. Orders Table
    con.execute("""
        CREATE TABLE orders (
            order_id VARCHAR PRIMARY KEY,
            customer_id VARCHAR REFERENCES customers(customer_id),
            order_timestamp TIMESTAMP NOT NULL,
            product_category VARCHAR NOT NULL,
            order_amount_usd DECIMAL(10,2) NOT NULL,
            order_status VARCHAR CHECK (order_status IN ('Completed', 'Refunded', 'Pending'))
        );
    """)
    con.execute("""
        INSERT INTO orders VALUES
        ('ORD-101', 'CUST-001', '2024-02-01 14:30:00', 'Hardware', 4500.00, 'Completed'),
        ('ORD-102', 'CUST-001', '2024-02-15 09:15:00', 'Cloud Compute', 12000.00, 'Completed'),
        ('ORD-103', 'CUST-002', '2024-02-18 11:45:00', 'SaaS License', 850.00, 'Completed'),
        ('ORD-104', 'CUST-003', '2024-02-20 16:00:00', 'Hardware', 250.00, 'Refunded'),
        ('ORD-105', 'CUST-004', '2024-03-01 10:20:00', 'Cloud Compute', 24000.00, 'Completed');
    """)

    return con

4. AST Security Guardrails: Enforcing Read-Only SQL

Before running any SQL query produced by an LLM, validate its Abstract Syntax Tree (AST) using SQLGlot to guarantee it contains only read-only SELECT statements:

import sqlglot
from sqlglot import exp

class SecurityViolationError(Exception):
    """Raised when an agent attempts unsafe or mutating SQL operations."""
    pass

def validate_safe_sql(sql_query: str) -> str:
    """
    Parses and sanitizes SQL query. 
    Strictly forbids DROP, ALTER, DELETE, INSERT, UPDATE, or multi-statement injections.
    """
    try:
        parsed_expressions = sqlglot.parse(sql_query, read="duckdb")
    except Exception as parse_err:
        raise ValueError(f"SQL Syntax Error during AST parsing: {str(parse_err)}")

    if len(parsed_expressions) > 1:
        raise SecurityViolationError("Multi-statement SQL queries are strictly prohibited.")

    ast = parsed_expressions[0]

    # Check expression root type
    if not isinstance(ast, exp.Select):
        raise SecurityViolationError(f"Prohibited operation: Expected SELECT query, got {type(ast).__name__}.")

    # Recursively ensure no mutating nodes are present in subqueries
    prohibited_node_types = (
        exp.Drop, exp.Delete, exp.Update, exp.Insert, exp.AlterTable, exp.Command
    )
    for node in ast.walk():
        if isinstance(node, prohibited_node_types):
            raise SecurityViolationError(f"Prohibited mutating clause found: {type(node).__name__}.")

    return ast.sql(dialect="duckdb")

5. Schema Introspection & Self-Correcting Execution Engine

Now let us build the execution coordinator. It extracts schemas with sample categorical values and implements the automated repair loop:

from typing import Dict, Any, List

class AgenticSQLAnalyst:
    def __init__(self, db_con: duckdb.DuckDBPyConnection):
        self.con = db_con
        self.schema_context = self._introspect_schema()

    def _introspect_schema(self) -> str:
        """Dynamically inspects tables and columns to produce concise model context."""
        tables = self.con.execute("SHOW TABLES").fetchall()
        schema_lines = []
        for (tbl_name,) in tables:
            cols = self.con.execute(f"DESCRIBE {tbl_name}").fetchall()
            col_desc = ", ".join([f"{col[0]} ({col[1]})" for col in cols])
            schema_lines.append(f"Table '{tbl_name}': columns = [{col_desc}]")
        return "\n".join(schema_lines)

    def execute_query(self, query: str) -> Dict[str, Any]:
        """Safely validates and executes a query, returning a clean dictionary result."""
        # 1. AST Validation
        safe_sql = validate_safe_sql(query)

        # 2. Execution
        df = self.con.execute(safe_sql).fetchdf()
        return {
            "success": True,
            "row_count": len(df),
            "columns": list(df.columns),
            "data": df.head(10).to_dict(orient="records")
        }

    def run_self_correcting_loop(self, user_question: str, generate_sql_fn, max_retries: int = 3) -> Dict[str, Any]:
        """
        Executes Text-to-SQL with autonomous error correction.
        If DuckDB throws a syntax or column error, the error is fed back to the LLM to fix.
        """
        history_context = ""
        current_attempt = 1

        while current_attempt <= max_retries:
            print(f"[*] Attempt {current_attempt}: Generating SQL for question: '{user_question}'")
            candidate_sql = generate_sql_fn(user_question, self.schema_context, history_context)
            print(f"    Proposed Query: {candidate_sql}")

            try:
                result = self.execute_query(candidate_sql)
                print(f"[+] Query executed successfully! Retrieved {result['row_count']} rows.")
                return {
                    "question": user_question,
                    "final_sql": candidate_sql,
                    "attempts": current_attempt,
                    "result": result
                }
            except Exception as err:
                error_msg = str(err)
                print(f"[!] Execution failed on attempt {current_attempt}: {error_msg}")
                history_context += (
                    f"\nAttempt {current_attempt} Failed Query: {candidate_sql}\n"
                    f"Execution Error Message: {error_msg}\n"
                    f"Please correct the query using the exact table and column names from the schema."
                )
                current_attempt += 1

        raise RuntimeError(f"Failed to generate valid SQL after {max_retries} attempts.")

6. Testing the Self-Healing Analyst in Practice

Let us simulate a run where the model initially hallucinates a column name (revenue instead of order_amount_usd):

def mock_llm_sql_generator(question: str, schema: str, error_context: str) -> str:
    """Simulates an LLM that makes a syntax mistake on attempt 1 and self-corrects on attempt 2."""
    if "Failed Query" not in error_context:
        # First attempt: Hallucinates column name 'revenue'
        return "SELECT customer_id, SUM(revenue) as total_spent FROM orders GROUP BY customer_id;"
    else:
        # Second attempt: Reads error trace and fixes column to 'order_amount_usd'
        return "SELECT customer_id, SUM(order_amount_usd) as total_spent FROM orders WHERE order_status = 'Completed' GROUP BY customer_id ORDER BY total_spent DESC;"

if __name__ == "__main__":
    db = init_mock_database()
    analyst = AgenticSQLAnalyst(db)

    output = analyst.run_self_correcting_loop(
        user_question="Who are our top customers by completed order volume?",
        generate_sql_fn=mock_llm_sql_generator
    )
    print("\n--- FINAL OUTPUT ---")
    print(f"Executed SQL: {output['final_sql']}")
    print(f"Data: {output['result']['data']}")

When run, Attempt 1 catches the DuckDB error Binder Error: Referenced column "revenue" not found in FROM clause!, reflects on the schema, and automatically produces the corrected analytical query on Attempt 2!

7. Performance & Production Best Practices

  1. Parquet Partitioning: In production, do not scan raw CSV files. Store transactional records as partition-pruned Snappy-compressed Parquet files on cloud object storage (S3 / GCS).
  2. Context Schema Pruning: For warehouses with 500+ tables, never inject the entire schema. Use a vector search or hybrid RAG index to identify the top 5 relevant tables first before prompting the model.
  3. Strict AST Guardrails: Never rely on regex to catch SQL injection. Always parse through an Abstract Syntax Tree using SQLGlot.

8. Frequently Asked Questions (FAQ)

Why choose DuckDB over SQLite for agentic analytics?

DuckDB is a columnar vectorized OLAP engine designed for analytical queries (SUM, AVG, GROUP BY, multi-million row joins), executing up to 50x faster than SQLite on analytical aggregations.

How do you prevent SQL injection in autonomous agents?

By validating every model-generated query with an AST parser before execution and strictly executing within a read-only database connection with limited row limits (LIMIT 100).

What is the ideal retry limit for self-correcting SQL agents?

Empirical testing shows that 2 to 3 retry attempts resolve over 92% of syntax and schema hallucinations. Any agent failing beyond 3 attempts should degrade gracefully to human operator review.

admin:
Related Post