On this page
Key takeaways
- A natural-language layer on Superset has five parts: schema context, prompt, validation, execution and chart selection.
- Schema context matters more than the model. Describe columns, grains and business definitions explicitly.
- Validate every generated query before it runs: read-only, known tables only, a row limit and a timeout.
- Run queries through Superset so existing database connections and permissions still apply.
The architecture
A natural-language layer on Apache Superset sits between the user and Superset's query engine. The request flows through five stages:
- Schema context: collect a compact description of the tables and columns the user may query.
- Prompt: send the question plus that context to a language model and ask for SQL.
- Validation: parse the SQL and reject anything unsafe or invalid before it runs.
- Execution: run the query through Superset against the database.
- Presentation: choose a chart for the result and write a short explanation.
Each stage can fail on its own, so build and test them separately. The code below is illustrative Python; adapt it to your stack and your Superset version.
Stage 1: Build the schema context
The model only knows what you tell it. Raw information_schema output is too noisy and too thin at the same time: it lists every column but explains none.
Build a compact profile per table instead:
table: subscriptions
grain: one row per subscription change
columns:
- name: customer_id role: id note: join key to customers.id
- name: plan role: category values: [starter, team, enterprise]
- name: mrr_usd role: measure unit: USD per month
- name: started_at role: time primary: true tz: UTC
- name: canceled_at role: time note: null if active
definitions:
active_subscription: canceled_at IS NULL
churned_in_month: date_trunc('month', canceled_at) = :monthThree parts of this matter most:
- Grain: what one row represents. Most wrong totals come from getting this wrong.
- Roles: which columns are measures, categories, times and IDs.
- Definitions: the business terms people actually use, written once as SQL.
In DashboardGPT this profile is generated automatically when a file is uploaded or a source is connected, then refined as columns are confirmed.
Stage 2: Write the prompt
Keep the prompt structured and strict. A good pattern:
You write SQL for {dialect}. Use only the tables and columns below.
Return one SELECT statement and nothing else.
Schema:
{schema_profile}
Rules:
- Use the primary time column for "last N days/months" unless told otherwise.
- Aggregate to the grain the user asks for before averaging.
- Use the definitions section for business terms.
- If the question is ambiguous, return: AMBIGUOUS: <one short question>.
- Always include LIMIT {row_limit}.
Question: {question}The AMBIGUOUS escape hatch matters. A model that asks "Gross or net revenue?" is far more useful than one that guesses.
For follow-up questions, include the previous question and its SQL in the context, so "now only enterprise" modifies the last query instead of starting over.
Stage 3: Validate the SQL
Never run generated SQL directly. Parse it and check it first. With the sqlglot library:
import sqlglot
from sqlglot import exp
ALLOWED_TABLES = {"subscriptions", "customers", "invoices"}
def validate(sql: str, dialect: str, row_limit: int = 5000) -> str:
statements = sqlglot.parse(sql, read=dialect)
if len(statements) != 1:
raise ValueError("Exactly one statement is allowed")
tree = statements[0]
if not isinstance(tree, exp.Select):
raise ValueError("Only SELECT queries are allowed")
tables = {t.name for t in tree.find_all(exp.Table)}
unknown = tables - ALLOWED_TABLES
if unknown:
raise ValueError(f"Unknown tables: {sorted(unknown)}")
if not tree.args.get("limit"):
tree = tree.limit(row_limit)
return tree.sql(dialect=dialect)Add checks for your environment: known columns only, no cross-database references, and a cap on the date range for very large tables. Also connect with a read-only database user, so a validation gap can never become a write.
Stage 4: Execute through Superset
Running queries through Superset, rather than directly against the database, means you reuse the database connections, drivers and access rules your team already maintains.
Superset exposes a REST API. A typical flow authenticates, then submits SQL for a database:
import requests
BASE = "https://superset.example.com"
def superset_session(username: str, password: str) -> requests.Session:
s = requests.Session()
r = s.post(f"{BASE}/api/v1/security/login",
json={"username": username, "password": password,
"provider": "db", "refresh": True})
r.raise_for_status()
s.headers["Authorization"] = f"Bearer {r.json()['access_token']}"
csrf = s.get(f"{BASE}/api/v1/security/csrf_token/").json()["result"]
s.headers["X-CSRFToken"] = csrf
return s
def run_sql(s: requests.Session, database_id: int, sql: str) -> dict:
r = s.post(f"{BASE}/api/v1/sqllab/execute/",
json={"database_id": database_id, "sql": sql,
"runAsync": False, "queryLimit": 5000})
r.raise_for_status()
return r.json()Endpoints and payloads vary between Superset versions, so check the API reference for yours. Use a dedicated service account with access only to the databases and schemas the natural-language layer should reach, and set query timeouts on the database connection.
Stage 5: Choose the chart and explain it
Pick the visual from the shape of the result, not from the question text:
| Result shape | Chart |
|---|---|
| One row, one number | Metric card |
| A time column plus one measure | Line chart |
| A time column plus a category plus a measure | Multi-line or stacked bars |
| A category plus a measure, up to about 15 rows | Bar chart, sorted |
| A category plus a measure, parts of a whole, up to 5 rows | Share chart |
| Many columns or many rows | Table |
Then write a short explanation from the result itself: the largest value, the biggest change and anything unusual. Pass the aggregated rows to the model, not raw data, and ask it to cite the figures it uses. Show the SQL next to the chart so anyone can check it.
Testing the layer
Build an evaluation set before you ship:
- Collect 50 to 100 real questions from the people who will use it.
- For each, write the correct SQL or the correct answer.
- Run the set after every change to the prompt, the schema profile or the model.
- Track three rates: correct, ambiguous (the model asked a question) and wrong.
A falling "wrong" rate matters more than a rising "correct" rate. An answer that asks for clarification is safe; a confident wrong number is not.
Or use a layer that already exists
Building this well takes profiling, prompts, validation, execution, chart selection, a UI for follow-ups and continuous evaluation. DashboardGPT provides these on top of Apache Superset, with an infinite canvas for follow-ups and bring-your-own-key for the model.