LLM-Powered SQL Query Generator
Build an AI app that converts plain English questions into SQL queries.
Introduction
30 Second Summary
Every day, people sit on mountains of data trapped in spreadsheets. They know the answers are in there, but getting them out requires writing SQL queries by hand.
In this project, you will build a web app that lets anyone ask questions about their data in plain English. You upload a file, type a question, and an AI generates the SQL query, runs it, and visualizes the results.
What You'll Build
A Streamlit web application that uses Claude to convert natural language questions into SQL queries, execute them against your data, and render interactive charts.
By the end of this project, you'll have:
- A database layer that auto-loads any CSV or Excel file into an in-memory SQLite database and extracts schema DDL for prompt context.
- A schema-aware prompt engineering system that injects DDL and sample data into Claude for accurate SQL generation.
- An interactive chat interface with a smart visualization panel that suggests and renders appropriate Plotly charts based on query results.
- Secret Mission: Add multi-file support so users can query across tables with JOINs and export their query history as a downloadable SQL notebook.
Are there any prerequisites?
You need basic Python knowledge and familiarity with SQL syntax. You also need an Anthropic API key, which you can get from the Anthropic Console.
Before We Start
You are about to build a web app that lets anyone query their data using plain English instead of writing SQL by hand. The app sends your question to Claude, which generates the SQL query for you.
Before we write any application code, we need to set up our project environment. This means checking our tools, creating a virtual environment, installing the right packages, and configuring API access.
In this step, get ready to:
- Verify that Python and VS Code are installed on your machine.
- Create the project folder, virtual environment, and install all dependencies.
- Configure the Anthropic API key and verify the connection works.
Check that Python and VS Code are installed
This project requires Python 3.10 or higher and VS Code as your code editor.
- Check your Python version by running this command:
python3 --version
✔️ I see a version number
You should see something like Python 3.10.x or higher. You are good to continue.
ⓧ Command not found
You need to install Python 3.10 or higher before continuing.
- Download Python from python.org/downloads.
- Run the installer and check the box that says Add Python to PATH.
- Close and reopen your terminal, then run python3 --version again to confirm.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
✔️ VS Code opens
VS Code is ready. You can minimize it for now.
ⓧ It's not installed yet
- Download VS Code from code.visualstudio.com.
- Run the installer and follow the prompts.
- Once installed, open VS Code using the search bar instructions above.
Create the project folder and install dependencies
A virtual environment keeps this project's packages isolated from the rest of your system. It prevents version conflicts between projects.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows), type Terminal, and press Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir sql-query-generator
cd sql-query-generator
- Create a virtual environment by running this command:
python3 -m venv venv
- Activate the virtual environment by running this command:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
On Windows, you may need to enable script execution first. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell, then try the activation command again.
On macOS, make sure you ran source venv/bin/activate (not just venv/bin/activate).
help me activate my Python virtual environment
Now you need to define the packages this project depends on. You will create a requirements.txt file that lists them all with version pins.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the sql-query-generator folder on your Desktop, then click Open.
- Create a new file by pressing Cmd+N (macOS) or Ctrl+N (Windows).
- Save the file immediately as requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Paste the following content into requirements.txt:
streamlit>=1.44.0
anthropic>=0.97.0
pandas>=2.1.0
openpyxl>=3.1.0
plotly>=5.18.0
What are these packages?
- streamlit is the web framework that powers the chat interface and file uploader.
- anthropic is the official Python SDK for calling the Claude API.
- pandas handles reading CSV and Excel files into structured data.
- openpyxl is required by pandas to read .xlsx Excel files.
- plotly renders interactive charts from your query results.
- Save requirements.txt.
- Install all the dependencies by running this command in your terminal:
pip install -r requirements.txt
You should see pip downloading and installing each package. The final line says Successfully installed followed by a list of package names.
Seeing permission errors or pip not found?
Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If not, re-run the activation command from above.
If you see network errors, check your internet connection and try again.
help me install Python packages with pip
✔️ Awesome, I've got everything!
Great. Double check you have saved requirements.txt and all packages installed without errors.
ⓧ I'd like to double check the full code
Here is the complete requirements.txt file:
streamlit>=1.44.0
anthropic>=0.97.0
pandas>=2.1.0
openpyxl>=3.1.0
plotly>=5.18.0
Configure your Anthropic API key
Claude is the LLM that will generate SQL queries from your plain English questions. To call Claude from your code, you need an API key from Anthropic.
- Go to console.anthropic.com.
- Sign up for an account if you do not have one, or sign in.
- Click API Keys in the left sidebar.
- Click Create Key.
- Give your key a name like sql-query-generator and click Create Key.
- Copy the key. You will not be able to see it again after closing the dialog.
How much does the API cost?
Anthropic gives new accounts free credits to get started. This project generates short SQL queries, so you will spend only a few cents total during testing.
The anthropic SDK automatically reads your API key from an environment variable called ANTHROPIC_API_KEY. You need to set this in your terminal session.
- Set the environment variable by running this command (replace the placeholder with your actual key):
macOS
export ANTHROPIC_API_KEY=your-api-key-here
Windows
set ANTHROPIC_API_KEY=your-api-key-here
This only lasts for your current session
The environment variable resets when you close your terminal. Each time you start a new terminal session to work on this project, you will need to activate your virtual environment and set this variable again.
- Verify the SDK can find your key by running this command:
python3 -c "import anthropic; client = anthropic.Anthropic(); print('Anthropic SDK ready!')"
You should see Anthropic SDK ready! printed in your terminal. This confirms the SDK is installed and can read your API key.
Seeing an AuthenticationError or missing key error?
Double check that you ran the export (or set on Windows) command in the same terminal window where you are running the Python command.
Make sure you replaced your-api-key-here with the actual key you copied from the Anthropic console. There should be no spaces around the = sign.
help me set up my Anthropic API key
Your environment is fully set up. Next up, you will build the database layer that loads CSV and Excel files into SQLite for querying.
Build the Database Layer
Your environment is ready and your dependencies are installed. Now it's time to build the foundation that makes everything else possible.
Before Claude can generate SQL for your data, your app needs a way to load any file into a queryable database and describe its structure. That description (the schema DDL) is what makes Claude's SQL generation accurate.
In this step, get ready to:
- Create database.py with a function that loads CSV or Excel files into SQLite.
- Add functions to extract the schema DDL and sample data rows.
- Add utility functions for executing queries and listing columns.
Create the file loading function
Your app needs to turn any uploaded CSV or Excel file into something it can query with SQL. The load_file_to_db function handles this by reading the file with Pandas, cleaning column names, and loading everything into an in-memory SQLite database.
- Create a new file called database.py in your sql-query-generator folder and add the following code:
import sqlite3
import pandas as pd
def load_file_to_db(uploaded_file):
"""Load a CSV or Excel file into an in-memory SQLite database."""
file_name = uploaded_file.name.lower()
if file_name.endswith(".csv"):
df = pd.read_csv(uploaded_file)
elif file_name.endswith((".xlsx", ".xls")):
df = pd.read_excel(uploaded_file)
else:
raise ValueError(f"Unsupported file type: {file_name}")
# Clean column names: remove spaces and special characters
df.columns = [
col.strip().replace(" ", "_").replace("(", "").replace(")", "")
for col in df.columns
]
# Create in-memory SQLite database
conn = sqlite3.connect(":memory:")
table_name = file_name.rsplit(".", 1)[0].replace(" ", "_").replace("-", "_")
df.to_sql(table_name, conn, index=False, if_exists="replace")
return conn, table_name
What does this code do?
- uploaded_file.name.lower() checks the filename extension to decide whether to use pd.read_csv() or pd.read_excel().
- The column cleaning replaces spaces and parentheses with underscores so SQL queries work without special quoting.
- sqlite3.connect(":memory:") creates a database that lives only in RAM. It's fast but disappears when the app stops.
- df.to_sql() writes the entire DataFrame into SQLite as a table named after the file.
- Save database.py.
- Confirm there are no syntax errors by running this command in your terminal:
python -c "import database; print('database.py loaded successfully')"
You should see database.py loaded successfully printed in your terminal.
Seeing a ModuleNotFoundError or SyntaxError?
- Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- Check that you're running the command from inside your sql-query-generator folder where database.py lives.
- Double check for any missing colons, mismatched parentheses, or incorrect indentation in your code.
Help me debug my database.py import error.
Extract the schema and sample data
Claude needs to know the exact structure of the database to generate accurate SQL. The most effective way to provide this is by injecting the actual CREATE TABLE DDL statement into the prompt.
- In database.py, add the following two functions below the load_file_to_db function:
def get_schema_ddl(conn, table_name):
"""Extract CREATE TABLE DDL from the database."""
cursor = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
result = cursor.fetchone()
return result[0] if result else ""
def get_sample_data(conn, table_name, limit=3):
"""Get sample rows to include in the prompt for better context."""
df = pd.read_sql_query(f"SELECT * FROM {table_name} LIMIT {limit}", conn)
return df.to_string(index=False)
What does this code do?
- get_schema_ddl queries SQLite's internal sqlite_master table to retrieve the exact CREATE TABLE statement. This gives Claude the column names, types, and constraints in one compact string.
- get_sample_data fetches a few real rows from the table. These sample values help Claude understand data formats like date patterns, string casing, and ID structures.
Why DDL instead of describing the table in words?
Research on text-to-SQL accuracy shows that injecting actual CREATE TABLE DDL into the prompt is the single biggest factor in getting correct queries back from an LLM. Descriptions, bullet lists, or just column names all produce worse results.
DDL gives the model the exact column names, their data types, and any constraints in a format it was trained to understand. Combined with sample rows, Claude can infer value patterns and generate precise WHERE clauses.
- Save database.py.
- Verify the module still loads without errors by running:
python -c "import database; print('All functions loaded')"
You should see All functions loaded with no errors.
Seeing an IndentationError?
- Make sure there is a blank line between the end of load_file_to_db and the def get_schema_ddl line.
- Confirm each function starts at the leftmost indentation level (no extra spaces before def).
Help me fix my indentation error in database.py.
Add query execution helpers
The final two functions handle running the generated SQL and listing column names for validation. These are small utilities that the main app calls after Claude produces a query.
- Add the following two functions at the bottom of database.py, below get_sample_data:
def get_column_names(conn, table_name):
"""Get list of column names for validation."""
cursor = conn.execute(f"PRAGMA table_info({table_name})")
return [row[1] for row in cursor.fetchall()]
def execute_query(conn, query):
"""Execute a SQL query and return results as a DataFrame."""
return pd.read_sql_query(query, conn)
What does this code do?
- get_column_names uses SQLite's PRAGMA table_info command to get a list of all column names. The app uses this to display available columns in the sidebar.
- execute_query runs any SQL string against the database and returns the results as a Pandas DataFrame. This is what powers the results table in the chat interface.
- Save database.py.
- Run this final import check to confirm the full module is working:
python -c "from database import load_file_to_db, get_schema_ddl, get_sample_data, get_column_names, execute_query; print('All 5 functions ready')"
You should see All 5 functions ready printed with no errors.
Seeing an ImportError for one of the functions?
- Check that each function name in your file matches exactly: load_file_to_db, get_schema_ddl, get_sample_data, get_column_names, execute_query.
- Make sure all five functions are defined at the top level (no indentation before def) and not accidentally nested inside another function.
Help me fix my database.py import issue.
✔️ Awesome, I've got everything!
Great. Double check you've saved database.py and you're ready to move on.
ⓧ I'd like to double check the full code
Here's the complete database.py file for comparison:
import sqlite3
import pandas as pd
def load_file_to_db(uploaded_file):
"""Load a CSV or Excel file into an in-memory SQLite database."""
file_name = uploaded_file.name.lower()
if file_name.endswith(".csv"):
df = pd.read_csv(uploaded_file)
elif file_name.endswith((".xlsx", ".xls")):
df = pd.read_excel(uploaded_file)
else:
raise ValueError(f"Unsupported file type: {file_name}")
# Clean column names: remove spaces and special characters
df.columns = [
col.strip().replace(" ", "_").replace("(", "").replace(")", "")
for col in df.columns
]
# Create in-memory SQLite database
conn = sqlite3.connect(":memory:")
table_name = file_name.rsplit(".", 1)[0].replace(" ", "_").replace("-", "_")
df.to_sql(table_name, conn, index=False, if_exists="replace")
return conn, table_name
def get_schema_ddl(conn, table_name):
"""Extract CREATE TABLE DDL from the database."""
cursor = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
result = cursor.fetchone()
return result[0] if result else ""
def get_sample_data(conn, table_name, limit=3):
"""Get sample rows to include in the prompt for better context."""
df = pd.read_sql_query(f"SELECT * FROM {table_name} LIMIT {limit}", conn)
return df.to_string(index=False)
def get_column_names(conn, table_name):
"""Get list of column names for validation."""
cursor = conn.execute(f"PRAGMA table_info({table_name})")
return [row[1] for row in cursor.fetchall()]
def execute_query(conn, query):
"""Execute a SQL query and return results as a DataFrame."""
return pd.read_sql_query(query, conn)
Your database layer is complete. It can load any CSV or Excel file, describe its structure as DDL, and execute SQL queries against it. Next up, you'll build the LLM module that takes this schema and turns natural language questions into SQL.
Build the SQL Generator with Claude
Your database layer can load files into SQLite and extract schema information. Now you need to use that schema to make Claude generate accurate SQL queries.
The single biggest factor in text-to-SQL accuracy is how you structure the schema context in your prompt. In this step, you will build the llm.py module that constructs a schema-aware system prompt, sends questions to the Anthropic Messages API, and parses Claude's response into clean SQL.
In this step, get ready to:
- Build a schema-aware system prompt that injects DDL and sample data for Claude.
- Write a response parser to extract clean SQL from Claude's output.
- Connect to the Anthropic Messages API to generate SQL from natural language questions.
Create the system prompt builder
The prompt you send to Claude determines whether it generates valid SQL or hallucinates column names. Injecting the actual CREATE TABLE DDL gives Claude the exact table structure. Including a few sample rows helps it understand value formats like date strings or ID patterns.
- Create a new file called llm.py in your sql-query-generator folder.
- Add the imports and the system prompt builder function by pasting this code:
import anthropic
import re
def build_system_prompt(schema_ddl, sample_data, table_name):
"""Build the system prompt with schema context for accurate SQL generation."""
return f"""You are a SQL query generator. Your job is to convert natural language questions into valid SQLite SQL queries.
<schema>
{schema_ddl}
</schema>
<sample_data>
Here are a few sample rows from the table '{table_name}':
{sample_data}
</sample_data>
Rules:
1. Generate ONLY valid SQLite SQL queries.
2. Use ONLY the table and column names shown in the schema above.
3. Always reference the table as '{table_name}'.
4. Return ONLY the SQL query inside a ```sql code block.
5. If the question cannot be answered with the available data, explain why briefly.
6. For aggregations, always include meaningful column aliases using AS.
7. Limit results to 100 rows unless the user specifies otherwise."""
What does this code do?
This function assembles the system prompt that shapes Claude's behavior for the entire conversation.
- The <schema> XML tags wrap the DDL so Claude can clearly distinguish the table structure from the instructions. Claude was trained with XML tag conventions.
- The <sample_data> section shows Claude real values from the table so it understands formatting (like whether dates use dashes or slashes).
- The numbered rules constrain output format. Rule 4 tells Claude to wrap SQL in a code block, which makes parsing predictable.
Why inject DDL instead of describing the table?
You could describe your table in plain English ("there's a column called revenue that holds dollar amounts"). But research shows that injecting the actual CREATE TABLE statement produces significantly more accurate SQL. The DDL gives Claude exact column names, data types, and constraints in a format it already understands from training.
- Save llm.py.
You will not see a change yet. The next chunk adds the response parser that uses the output format defined in Rule 4.
Add the SQL response parser
Claude returns SQL inside a markdown code block (like ```sql ... ```). You need a parser that reliably extracts just the raw SQL statement, regardless of whether Claude wraps it in a named or generic code block.
- In llm.py, add the extract_sql function below the build_system_prompt function:
def extract_sql(response_text):
"""Extract SQL query from Claude's response."""
# Try to find SQL in a sql code block
pattern = r"```sql\s*(.*?)\s*```"
match = re.search(pattern, response_text, re.DOTALL)
if match:
return match.group(1).strip()
# Try generic code block
pattern = r"```\s*(.*?)\s*```"
match = re.search(pattern, response_text, re.DOTALL)
if match:
return match.group(1).strip()
# If no code block, check if response looks like SQL
if response_text.strip().upper().startswith(("SELECT", "WITH")):
return response_text.strip()
return ""
What does this code do?
This function tries three strategies in order to find the SQL in Claude's response:
- First, it looks for a ```sql code block. This is the expected case because Rule 4 in the system prompt asks Claude to use this format.
- If that fails, it tries a generic code block (triple backticks without a language tag).
- As a last resort, it checks if the raw response starts with SELECT or WITH, catching cases where Claude skips the code block entirely.
- If none of these match, it returns an empty string so the app knows no valid SQL was generated.
- Save llm.py.
You will not see a change yet. The next chunk wires everything together with the API call.
Write the generate_sql function
This is the function that the app will call. It takes a natural language question, builds the schema-aware prompt, sends it to Claude via the Anthropic Messages API, and returns the parsed SQL.
- In llm.py, add the generate_sql function below extract_sql:
def generate_sql(question, schema_ddl, sample_data, table_name):
"""Generate SQL from a natural language question using Claude."""
client = anthropic.Anthropic()
system_prompt = build_system_prompt(schema_ddl, sample_data, table_name)
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
system=system_prompt,
messages=[
{"role": "user", "content": question}
],
)
response_text = message.content[0].text
sql_query = extract_sql(response_text)
return sql_query, response_text
What does this code do?
- anthropic.Anthropic() creates a client that automatically reads your ANTHROPIC_API_KEY environment variable. No need to pass the key manually.
- The system parameter is a top-level argument on messages.create(). It sets Claude's behavior for the entire conversation, separate from the user message.
- model="claude-sonnet-4-5" selects Claude Sonnet 4.5, which balances speed and accuracy well for SQL generation tasks.
- message.content[0].text extracts the text response from Claude's message object.
- The function returns both the extracted SQL and the full response text. This lets the app show Claude's explanation if no valid SQL was found.
- Save llm.py.
- Verify that the Anthropic SDK can connect by running this in your terminal:
python -c "import anthropic; client = anthropic.Anthropic(); print('Anthropic client ready!')"
You should see Anthropic client ready! printed in the terminal. This confirms the SDK is installed and your API key is configured.
Seeing an AuthenticationError?
- Make sure your ANTHROPIC_API_KEY environment variable is set in the same terminal session where you ran the command.
- If you set it in a .env file, remember that you need to source it or restart your terminal.
- Double check that your key starts with sk-ant- and has no trailing spaces.
Help me fix my Anthropic API key authentication error.
✔️ Awesome, I've got everything!
Double check you have saved llm.py with all three functions.
ⓧ I'd like to double check the full code
Here is the complete llm.py file for comparison:
import anthropic
import re
def build_system_prompt(schema_ddl, sample_data, table_name):
"""Build the system prompt with schema context for accurate SQL generation."""
return f"""You are a SQL query generator. Your job is to convert natural language questions into valid SQLite SQL queries.
<schema>
{schema_ddl}
</schema>
<sample_data>
Here are a few sample rows from the table '{table_name}':
{sample_data}
</sample_data>
Rules:
1. Generate ONLY valid SQLite SQL queries.
2. Use ONLY the table and column names shown in the schema above.
3. Always reference the table as '{table_name}'.
4. Return ONLY the SQL query inside a ```sql code block.
5. If the question cannot be answered with the available data, explain why briefly.
6. For aggregations, always include meaningful column aliases using AS.
7. Limit results to 100 rows unless the user specifies otherwise."""
def extract_sql(response_text):
"""Extract SQL query from Claude's response."""
# Try to find SQL in a sql code block
pattern = r"```sql\s*(.*?)\s*```"
match = re.search(pattern, response_text, re.DOTALL)
if match:
return match.group(1).strip()
# Try generic code block
pattern = r"```\s*(.*?)\s*```"
match = re.search(pattern, response_text, re.DOTALL)
if match:
return match.group(1).strip()
# If no code block, check if response looks like SQL
if response_text.strip().upper().startswith(("SELECT", "WITH")):
return response_text.strip()
return ""
def generate_sql(question, schema_ddl, sample_data, table_name):
"""Generate SQL from a natural language question using Claude."""
client = anthropic.Anthropic()
system_prompt = build_system_prompt(schema_ddl, sample_data, table_name)
message = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
system=system_prompt,
messages=[
{"role": "user", "content": question}
],
)
response_text = message.content[0].text
sql_query = extract_sql(response_text)
return sql_query, response_text
Your LLM integration is complete. Next up, you will build the Streamlit chat interface that connects your database layer and SQL generator into an interactive web app.
Build the Streamlit Chat App
You now have two powerful modules: one that loads data into a queryable database, and another that generates SQL from plain English using Claude. The missing piece is a way for users to actually interact with these tools.
In this step, you will build the user-facing web app using Streamlit. Streamlit handles file uploads, chat interfaces, and data display all from a single Python file.
In this step, get ready to:
- Create app.py with page layout, session state, and a sidebar file uploader.
- Build the chat interface with message history using Streamlit's chat components.
- Wire up the chat to generate SQL, execute queries, and display results.
Create app.py with page layout and session state
Streamlit reruns your entire script on every user interaction. Session state is how you persist data (like the database connection and chat history) across those reruns.
- Create a new file called app.py in your sql-query-generator folder.
- Add the imports, page configuration, and session state initialization by copying this code:
import streamlit as st
from database import load_file_to_db, get_schema_ddl, get_sample_data, get_column_names, execute_query
from llm import generate_sql
from visualize import suggest_chart_types, create_chart
st.set_page_config(page_title="SQL Query Generator", page_icon="\ud83d\udd0d", layout="wide")
st.title("\ud83d\udd0d LLM-Powered SQL Query Generator")
st.caption("Upload a data file, ask questions in plain English, and get SQL queries + visualizations.")
# Initialize session state
if "messages" not in st.session_state:
st.session_state.messages = []
if "db_conn" not in st.session_state:
st.session_state.db_conn = None
if "table_name" not in st.session_state:
st.session_state.table_name = None
if "schema_ddl" not in st.session_state:
st.session_state.schema_ddl = None
if "last_result" not in st.session_state:
st.session_state.last_result = None
if "loaded_file_name" not in st.session_state:
st.session_state.loaded_file_name = None
What does this code do?
The imports pull in your database.py and llm.py modules, plus a visualize module you will create in the next step.
The six session_state keys store the database connection, table name, schema, chat messages, last query result, and the loaded file name. Without session state, Streamlit would lose all of this data every time the user types a message.
- Now add the sidebar section directly below the session state code. The sidebar handles file uploads and displays the detected schema:
# Sidebar: File upload and schema display
with st.sidebar:
st.header("\ud83d\udcc1 Data Source")
uploaded_file = st.file_uploader(
"Upload a CSV or Excel file",
type=["csv", "xlsx", "xls"],
help="Your data will be loaded into an in-memory database for querying.",
)
if uploaded_file and st.session_state.loaded_file_name != uploaded_file.name:
try:
conn, table_name = load_file_to_db(uploaded_file)
st.session_state.db_conn = conn
st.session_state.table_name = table_name
st.session_state.schema_ddl = get_schema_ddl(conn, table_name)
st.session_state.loaded_file_name = uploaded_file.name
st.session_state.messages = []
st.session_state.last_result = None
except Exception as e:
st.error(f"Error loading file: {e}")
if st.session_state.schema_ddl:
st.success(f"Table: `{st.session_state.table_name}`")
st.code(st.session_state.schema_ddl, language="sql")
columns = get_column_names(st.session_state.db_conn, st.session_state.table_name)
st.caption(f"Columns: {', '.join(columns)}")
What does this code do?
The file uploader accepts CSV and Excel files. When a new file is uploaded, it calls your load_file_to_db function to load the data into SQLite and stores the connection in session state.
The loaded_file_name check prevents re-loading the same file on every rerun. Once the schema is available, the sidebar displays the CREATE TABLE DDL and column names so the user can see what data is available to query.
- Save app.py.
You will not run the app yet because the main chat area is still missing.
Build the chat interface with message history
Streamlit provides dedicated chat components that handle message bubbles, avatars, and input boxes. The chat history loop replays every previous message on each rerun so the conversation stays visible.
- Below the sidebar block in app.py, add the main area section that displays the chat history and captures user input:
# Main area
if not st.session_state.db_conn:
st.info("\ud83d\udc48 Upload a CSV or Excel file in the sidebar to get started.")
else:
# Display chat history
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
if "dataframe" in msg:
st.dataframe(msg["dataframe"], use_container_width=True)
# Chat input
if prompt := st.chat_input("Ask a question about your data..."):
# Show user message
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
What does this code do?
When no database connection exists, the app shows a helpful info message pointing to the sidebar. Once a file is loaded, the chat history loop renders each stored message with st.chat_message using the correct role (user or assistant).
The := walrus operator assigns the user's input to prompt and checks if it is truthy in one line. When the user submits a question, it is immediately appended to session state and displayed as a user chat bubble.
- Save app.py.
The chat input box is ready, but typing a question does nothing yet. The next substep connects it to Claude.
Wire up the chat to generate SQL and display results
This is where everything comes together. When the user asks a question, the app sends it to Claude with the database schema, extracts the generated SQL, runs it against SQLite, and shows the results in a table.
- Directly below the user message display (still inside the if prompt block), add the assistant response logic:
# Generate and execute
with st.chat_message("assistant"):
with st.status("Generating SQL query..."):
sample_data = get_sample_data(
st.session_state.db_conn, st.session_state.table_name
)
sql_query, full_response = generate_sql(
prompt,
st.session_state.schema_ddl,
sample_data,
st.session_state.table_name,
)
if sql_query:
st.markdown("**Generated SQL:**")
st.code(sql_query, language="sql")
try:
result_df = execute_query(st.session_state.db_conn, sql_query)
st.session_state.last_result = result_df
st.markdown(f"**Results** ({len(result_df)} rows):")
st.dataframe(result_df, use_container_width=True)
st.session_state.messages.append({
"role": "assistant",
"content": f"**Generated SQL:**\n```sql\n{sql_query}\n```\n\n**Results** ({len(result_df)} rows):",
"dataframe": result_df,
})
except Exception as e:
st.error(f"Query execution error: {e}")
st.session_state.messages.append({
"role": "assistant",
"content": f"**Generated SQL:**\n```sql\n{sql_query}\n```\n\nError: {e}",
})
else:
st.markdown(full_response)
st.session_state.messages.append({
"role": "assistant",
"content": full_response,
})
What does this code do?
- st.status("Generating SQL query...") shows a spinner while Claude processes the request.
- generate_sql sends the user's question along with the schema DDL and sample data to Claude, then extracts the SQL from the response.
- If valid SQL is returned, it is displayed with st.code and executed against the database. Results appear in an interactive table.
- If execution fails (for example, Claude hallucinated a column name), the error is caught and displayed gracefully instead of crashing the app.
- Every response is appended to st.session_state.messages so the full conversation persists across reruns.
- Save app.py.
- Before running the app, create a placeholder visualize.py file in your sql-query-generator folder. The import at the top of app.py requires this file to exist. Copy this temporary stub:
# Placeholder - you will build the full visualization module in the next step
def suggest_chart_types(df):
return []
def create_chart(df, chart_type):
return None
Why create a placeholder?
Python resolves all imports when a script starts. Since app.py imports from visualize, the file must exist even though you will replace it with a full implementation in Step 5.
- Save visualize.py.
- Make sure your virtual environment is activated and your ANTHROPIC_API_KEY environment variable is set.
- Launch the app by running this command:
streamlit run app.py
- Your browser should open automatically to http://localhost:8501. You should see the title, caption, and a sidebar with a file upload widget.
- Upload any CSV file you have available (a simple spreadsheet with a few columns works fine).
- After uploading, the sidebar should display the table name and CREATE TABLE schema.
- Type a question like Show me all rows in the chat input at the bottom. You should see Claude generate SQL, execute it, and display the results in a table.
App not loading or showing errors?
- If you see ModuleNotFoundError: No module named 'visualize', make sure you created the placeholder visualize.py file in the same folder as app.py.
- If you see AuthenticationError, your ANTHROPIC_API_KEY environment variable is not set in the terminal session running Streamlit. Set it again and relaunch.
- If the app loads but chat does nothing, check that you pasted the assistant response code inside the if prompt block with proper indentation (8 spaces for the with st.chat_message("assistant") line).
Help me debug my Streamlit app
✔️ Awesome, I've got everything!
Great. Double check you have saved both app.py and visualize.py.
ⓧ I'd like to double check the full code
Here is the complete app.py file at this point:
import streamlit as st
from database import load_file_to_db, get_schema_ddl, get_sample_data, get_column_names, execute_query
from llm import generate_sql
from visualize import suggest_chart_types, create_chart
st.set_page_config(page_title="SQL Query Generator", page_icon="\ud83d\udd0d", layout="wide")
st.title("\ud83d\udd0d LLM-Powered SQL Query Generator")
st.caption("Upload a data file, ask questions in plain English, and get SQL queries + visualizations.")
# Initialize session state
if "messages" not in st.session_state:
st.session_state.messages = []
if "db_conn" not in st.session_state:
st.session_state.db_conn = None
if "table_name" not in st.session_state:
st.session_state.table_name = None
if "schema_ddl" not in st.session_state:
st.session_state.schema_ddl = None
if "last_result" not in st.session_state:
st.session_state.last_result = None
if "loaded_file_name" not in st.session_state:
st.session_state.loaded_file_name = None
# Sidebar: File upload and schema display
with st.sidebar:
st.header("\ud83d\udcc1 Data Source")
uploaded_file = st.file_uploader(
"Upload a CSV or Excel file",
type=["csv", "xlsx", "xls"],
help="Your data will be loaded into an in-memory database for querying.",
)
if uploaded_file and st.session_state.loaded_file_name != uploaded_file.name:
try:
conn, table_name = load_file_to_db(uploaded_file)
st.session_state.db_conn = conn
st.session_state.table_name = table_name
st.session_state.schema_ddl = get_schema_ddl(conn, table_name)
st.session_state.loaded_file_name = uploaded_file.name
st.session_state.messages = []
st.session_state.last_result = None
except Exception as e:
st.error(f"Error loading file: {e}")
if st.session_state.schema_ddl:
st.success(f"Table: `{st.session_state.table_name}`")
st.code(st.session_state.schema_ddl, language="sql")
columns = get_column_names(st.session_state.db_conn, st.session_state.table_name)
st.caption(f"Columns: {', '.join(columns)}")
# Main area
if not st.session_state.db_conn:
st.info("\ud83d\udc48 Upload a CSV or Excel file in the sidebar to get started.")
else:
# Display chat history
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
if "dataframe" in msg:
st.dataframe(msg["dataframe"], use_container_width=True)
# Chat input
if prompt := st.chat_input("Ask a question about your data..."):
# Show user message
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
# Generate and execute
with st.chat_message("assistant"):
with st.status("Generating SQL query..."):
sample_data = get_sample_data(
st.session_state.db_conn, st.session_state.table_name
)
sql_query, full_response = generate_sql(
prompt,
st.session_state.schema_ddl,
sample_data,
st.session_state.table_name,
)
if sql_query:
st.markdown("**Generated SQL:**")
st.code(sql_query, language="sql")
try:
result_df = execute_query(st.session_state.db_conn, sql_query)
st.session_state.last_result = result_df
st.markdown(f"**Results** ({len(result_df)} rows):")
st.dataframe(result_df, use_container_width=True)
st.session_state.messages.append({
"role": "assistant",
"content": f"**Generated SQL:**\n```sql\n{sql_query}\n```\n\n**Results** ({len(result_df)} rows):",
"dataframe": result_df,
})
except Exception as e:
st.error(f"Query execution error: {e}")
st.session_state.messages.append({
"role": "assistant",
"content": f"**Generated SQL:**\n```sql\n{sql_query}\n```\n\nError: {e}",
})
else:
st.markdown(full_response)
st.session_state.messages.append({
"role": "assistant",
"content": full_response,
})
Your chat app is live and generating real SQL from natural language. Next up, you will build the visualization layer so query results come with interactive charts.
Add Smart Visualization
Your app can now generate SQL and display results as a table. Tables are great for exact values, but patterns in data are much easier to spot in a chart.
In this step, you will build a smart visualization engine that inspects each query result and suggests appropriate chart types. The engine uses Plotly Express to render interactive charts directly inside Streamlit.
In this step, get ready to:
- Create a chart suggestion engine that analyzes column types and row count.
- Build a chart creation function with Plotly Express that auto-selects axes.
- Wire the visualization panel into your Streamlit app below the chat area.
Create the chart suggestion engine
Different chart types suit different data shapes. A bar chart needs a categorical column paired with a numeric one. A scatter plot needs at least two numeric columns. Your suggestion engine will inspect the query result and only offer charts that make sense.
- Create a new file called visualize.py in your sql-query-generator folder.
- Add the imports and the suggest_chart_types function by pasting this code:
import pandas as pd
import plotly.express as px
def suggest_chart_types(df):
"""Suggest eligible chart types based on the query result shape."""
if df.empty or len(df.columns) < 2:
return []
suggestions = []
num_cols = df.select_dtypes(include=["number"]).columns.tolist()
cat_cols = df.select_dtypes(exclude=["number"]).columns.tolist()
num_rows = len(df)
# Bar chart: good for categorical + numeric
if cat_cols and num_cols:
suggestions.append("bar")
# Line chart: good for sequential/time data or many rows
if num_cols and num_rows > 2:
suggestions.append("line")
# Scatter plot: needs at least 2 numeric columns
if len(num_cols) >= 2:
suggestions.append("scatter")
# Pie chart: good for categorical + single numeric, few categories
if cat_cols and num_cols and num_rows <= 10:
suggestions.append("pie")
# Histogram: good for single numeric column distribution
if num_cols:
suggestions.append("histogram")
return suggestions
What does this code do?
The function separates columns into two groups using select_dtypes: numeric columns (integers, floats) and categorical columns (strings, dates). It then checks whether the data shape qualifies for each chart type.
- Bar and pie charts need at least one categorical column paired with a numeric column.
- Scatter plots need two numeric columns to plot against each other.
- Pie charts are restricted to 10 or fewer rows so the slices stay readable.
- Save visualize.py.
You won't see a change yet. The next chunk adds the chart creation function that uses these suggestions.
Add the chart creation function
Once the app knows which chart types are eligible, it needs a function that actually builds the chart. Plotly Express handles axis labels, colors, and interactivity automatically based on the column names you pass in.
- In visualize.py, add the create_chart function below suggest_chart_types:
def create_chart(df, chart_type):
"""Create a Plotly chart based on the selected type."""
num_cols = df.select_dtypes(include=["number"]).columns.tolist()
cat_cols = df.select_dtypes(exclude=["number"]).columns.tolist()
if chart_type == "bar":
x_col = cat_cols[0] if cat_cols else df.columns[0]
y_col = num_cols[0] if num_cols else df.columns[1]
fig = px.bar(df, x=x_col, y=y_col, title=f"{y_col} by {x_col}")
elif chart_type == "line":
x_col = cat_cols[0] if cat_cols else df.columns[0]
y_col = num_cols[0] if num_cols else df.columns[1]
fig = px.line(df, x=x_col, y=y_col, title=f"{y_col} over {x_col}")
elif chart_type == "scatter":
x_col = num_cols[0]
y_col = num_cols[1]
fig = px.scatter(df, x=x_col, y=y_col, title=f"{x_col} vs {y_col}")
elif chart_type == "pie":
names_col = cat_cols[0] if cat_cols else df.columns[0]
values_col = num_cols[0] if num_cols else df.columns[1]
fig = px.pie(df, names=names_col, values=values_col, title=f"{values_col} Distribution")
elif chart_type == "histogram":
col = num_cols[0]
fig = px.histogram(df, x=col, title=f"Distribution of {col}")
else:
return None
return fig
What does this code do?
Each branch picks columns automatically based on data type. For bar and line charts, the x-axis gets the first categorical column and the y-axis gets the first numeric column. Scatter plots use the first two numeric columns.
The Plotly Express functions (px.bar, px.line, px.scatter, etc.) return a Figure object that Streamlit can render interactively with hover tooltips and zoom.
- Save visualize.py.
You won't see a change in the app yet. The next substep wires this module into your Streamlit interface.
Seeing a red underline on the import?
If VS Code shows a warning on import plotly.express as px, make sure your virtual environment is selected as the Python interpreter. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Python: Select Interpreter, and choose the one inside your venv folder.
Why is my Plotly import not recognized?
✔️ Awesome, I've got everything!
Great. Make sure you have saved visualize.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete visualize.py file so far:
import pandas as pd
import plotly.express as px
def suggest_chart_types(df):
"""Suggest eligible chart types based on the query result shape."""
if df.empty or len(df.columns) < 2:
return []
suggestions = []
num_cols = df.select_dtypes(include=["number"]).columns.tolist()
cat_cols = df.select_dtypes(exclude=["number"]).columns.tolist()
num_rows = len(df)
# Bar chart: good for categorical + numeric
if cat_cols and num_cols:
suggestions.append("bar")
# Line chart: good for sequential/time data or many rows
if num_cols and num_rows > 2:
suggestions.append("line")
# Scatter plot: needs at least 2 numeric columns
if len(num_cols) >= 2:
suggestions.append("scatter")
# Pie chart: good for categorical + single numeric, few categories
if cat_cols and num_cols and num_rows <= 10:
suggestions.append("pie")
# Histogram: good for single numeric column distribution
if num_cols:
suggestions.append("histogram")
return suggestions
def create_chart(df, chart_type):
"""Create a Plotly chart based on the selected type."""
num_cols = df.select_dtypes(include=["number"]).columns.tolist()
cat_cols = df.select_dtypes(exclude=["number"]).columns.tolist()
if chart_type == "bar":
x_col = cat_cols[0] if cat_cols else df.columns[0]
y_col = num_cols[0] if num_cols else df.columns[1]
fig = px.bar(df, x=x_col, y=y_col, title=f"{y_col} by {x_col}")
elif chart_type == "line":
x_col = cat_cols[0] if cat_cols else df.columns[0]
y_col = num_cols[0] if num_cols else df.columns[1]
fig = px.line(df, x=x_col, y=y_col, title=f"{y_col} over {x_col}")
elif chart_type == "scatter":
x_col = num_cols[0]
y_col = num_cols[1]
fig = px.scatter(df, x=x_col, y=y_col, title=f"{x_col} vs {y_col}")
elif chart_type == "pie":
names_col = cat_cols[0] if cat_cols else df.columns[0]
values_col = num_cols[0] if num_cols else df.columns[1]
fig = px.pie(df, names=names_col, values=values_col, title=f"{values_col} Distribution")
elif chart_type == "histogram":
col = num_cols[0]
fig = px.histogram(df, x=col, title=f"Distribution of {col}")
else:
return None
return fig
Integrate the visualization panel into the app
The visualization module is ready. Now you need to add a section to app.py that appears below the chat area whenever query results exist.
- Open app.py and scroll to the very bottom of the file.
- Find the closing block for the chat input handler. It ends with:
else:
st.markdown(full_response)
st.session_state.messages.append({
"role": "assistant",
"content": full_response,
})
- Add the visualization section directly below that block, still inside the else: branch (indented one level). Paste this code:
# Visualization section
if st.session_state.last_result is not None:
st.divider()
st.subheader("📊 Visualize Results")
result_df = st.session_state.last_result
chart_types = suggest_chart_types(result_df)
if chart_types:
selected_chart = st.selectbox("Choose a chart type", options=chart_types)
fig = create_chart(result_df, selected_chart)
if fig:
st.plotly_chart(fig, use_container_width=True)
else:
st.info("The current result doesn't have enough columns for visualization.")
What does this code do?
This section only renders when last_result contains a DataFrame (meaning the user has already run a query). It calls your suggest_chart_types function to get eligible charts, shows a dropdown with st.selectbox, then renders the chosen chart with st.plotly_chart.
The use_container_width=True parameter makes the chart stretch to fill the available width, keeping the layout responsive.
- Save app.py.
- In your terminal, run the app:
streamlit run app.py
- Upload a CSV file in the sidebar.
- Ask a question like Show me the top 5 rows by revenue and press Enter.
- After the results table appears, scroll down. You should see a "Visualize Results" section with a chart type dropdown and an interactive chart.
Don't see the visualization section?
Make sure the visualization code is indented at the same level as the if prompt := block (one level inside the else: that wraps the main area). If it is outdented too far, Streamlit will not render it at the right time.
Also check that you have already asked a question and received results. The visualization panel only appears after a successful query populates last_result.
Why isn't my visualization section showing up in Streamlit?
Why Plotly over Matplotlib?
Matplotlib produces static images. Plotly produces interactive charts with hover tooltips, zoom, and pan built in. For a data exploration tool where users want to inspect individual data points, interactivity makes a big difference.
Streamlit renders Plotly charts natively with st.plotly_chart, so there is zero extra setup required.
✔️ Awesome, I've got everything!
Great. Make sure both visualize.py and app.py are saved.
ⓧ I'd like to double check the full code
Here is the complete app.py with the visualization section included:
import streamlit as st
from database import load_file_to_db, get_schema_ddl, get_sample_data, get_column_names, execute_query
from llm import generate_sql
from visualize import suggest_chart_types, create_chart
st.set_page_config(page_title="SQL Query Generator", page_icon="🔍", layout="wide")
st.title("🔍 LLM-Powered SQL Query Generator")
st.caption("Upload a data file, ask questions in plain English, and get SQL queries + visualizations.")
# Initialize session state
if "messages" not in st.session_state:
st.session_state.messages = []
if "db_conn" not in st.session_state:
st.session_state.db_conn = None
if "table_name" not in st.session_state:
st.session_state.table_name = None
if "schema_ddl" not in st.session_state:
st.session_state.schema_ddl = None
if "last_result" not in st.session_state:
st.session_state.last_result = None
if "loaded_file_name" not in st.session_state:
st.session_state.loaded_file_name = None
# Sidebar: File upload and schema display
with st.sidebar:
st.header("📁 Data Source")
uploaded_file = st.file_uploader(
"Upload a CSV or Excel file",
type=["csv", "xlsx", "xls"],
help="Your data will be loaded into an in-memory database for querying.",
)
if uploaded_file and st.session_state.loaded_file_name != uploaded_file.name:
try:
conn, table_name = load_file_to_db(uploaded_file)
st.session_state.db_conn = conn
st.session_state.table_name = table_name
st.session_state.schema_ddl = get_schema_ddl(conn, table_name)
st.session_state.loaded_file_name = uploaded_file.name
st.session_state.messages = []
st.session_state.last_result = None
except Exception as e:
st.error(f"Error loading file: {e}")
if st.session_state.schema_ddl:
st.success(f"Table: `{st.session_state.table_name}`")
st.code(st.session_state.schema_ddl, language="sql")
columns = get_column_names(st.session_state.db_conn, st.session_state.table_name)
st.caption(f"Columns: {', '.join(columns)}")
# Main area
if not st.session_state.db_conn:
st.info("👈 Upload a CSV or Excel file in the sidebar to get started.")
else:
# Display chat history
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
if "dataframe" in msg:
st.dataframe(msg["dataframe"], use_container_width=True)
# Chat input
if prompt := st.chat_input("Ask a question about your data..."):
# Show user message
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
# Generate and execute
with st.chat_message("assistant"):
with st.status("Generating SQL query..."):
sample_data = get_sample_data(
st.session_state.db_conn, st.session_state.table_name
)
sql_query, full_response = generate_sql(
prompt,
st.session_state.schema_ddl,
sample_data,
st.session_state.table_name,
)
if sql_query:
st.markdown("**Generated SQL:**")
st.code(sql_query, language="sql")
try:
result_df = execute_query(st.session_state.db_conn, sql_query)
st.session_state.last_result = result_df
st.markdown(f"**Results** ({len(result_df)} rows):")
st.dataframe(result_df, use_container_width=True)
st.session_state.messages.append({
"role": "assistant",
"content": f"**Generated SQL:**\n```sql\n{sql_query}\n```\n\n**Results** ({len(result_df)} rows):",
"dataframe": result_df,
})
except Exception as e:
st.error(f"Query execution error: {e}")
st.session_state.messages.append({
"role": "assistant",
"content": f"**Generated SQL:**\n```sql\n{sql_query}\n```\n\nError: {e}",
})
else:
st.markdown(full_response)
st.session_state.messages.append({
"role": "assistant",
"content": full_response,
})
# Visualization section
if st.session_state.last_result is not None:
st.divider()
st.subheader("📊 Visualize Results")
result_df = st.session_state.last_result
chart_types = suggest_chart_types(result_df)
if chart_types:
selected_chart = st.selectbox("Choose a chart type", options=chart_types)
fig = create_chart(result_df, selected_chart)
if fig:
st.plotly_chart(fig, use_container_width=True)
else:
st.info("The current result doesn't have enough columns for visualization.")
Your SQL query generator now goes beyond raw tables. Users can explore their data visually with a single click. Next up, you will tackle the Secret Mission and add multi-table support with query history export.
Secret mission
Multi-Table Queries and Query History Export
Your SQL generator works great with a single file. But real data analysis involves querying across multiple datasets. Can you upgrade the app to handle multiple uploads, generate JOINs across tables, and export a full history of every query you have run?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. The Anthropic API only charges per request when you actively send queries to Claude.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Project folder with Python files (app.py, database.py, llm.py, visualize.py).
- Python virtual environment with installed packages (Streamlit, Anthropic SDK, Pandas, Plotly, openpyxl).
- ANTHROPIC_API_KEY environment variable configured in your terminal.
- requirements.txt file listing pinned dependencies.
Keep everything running
No action needed. Choose this if you are still actively building or want to experiment with different data files and queries.
- Your project folder stays on your Desktop.
- Your virtual environment and packages remain installed.
- Your ANTHROPIC_API_KEY stays configured. You will only be charged when you actively send requests to the API.
Pause - I'll come back to this later
Shut down the running Streamlit server to free up your terminal. Your files and virtual environment stay intact for when you return.
- In the terminal where Streamlit is running, press Ctrl+C to stop the server.
- Deactivate your virtual environment by running deactivate in your terminal.
- When you come back, reactivate the virtual environment and run streamlit run app.py to pick up where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your code, virtual environment, and configuration.
- Stop the Streamlit server by pressing Ctrl+C in the terminal.
- Delete the entire project folder from your Desktop.
- Remove the ANTHROPIC_API_KEY environment variable from your shell configuration file (.zshrc on macOS or .bashrc on Linux).
- If you no longer need the Anthropic API key, revoke it in your Anthropic console at console.anthropic.com.
Nice Work!
Nice Work!
You just built an LLM-powered SQL query generator from scratch. Anyone can now upload a data file, ask questions in plain English, and get accurate SQL queries with interactive visualizations.
You learned how to:
- Design schema-aware prompts that inject DDL and sample data into Claude for accurate text-to-SQL generation.
- Integrate the Anthropic Messages API with system prompts and regex-based response parsing to extract SQL from LLM output.
- Build a full-stack Streamlit chat application with session state, file uploads, query execution, and smart Plotly visualizations.
- Secret Mission: Added multi-table JOIN support and query history export as a downloadable .sql file.
Ready to quiz yourself?