Schema Representation for Text-to-SQL: How to Describe Your Database to LLMs
Master the art of schema representation for text-to-SQL systems. Learn how to format CREATE TABLE statements, add column descriptions, encode foreign key relationships, and provide sample data for maximum query accuracy.
Schema Representation Is the Highest-Leverage Optimization
When building text-to-SQL systems, teams typically focus on model selection and prompt engineering. But the single biggest factor in query accuracy is how you represent the database schema to the LLM. A well-formatted schema description can improve accuracy by 10-20% without changing anything else.
The reason is straightforward: the LLM can only reference columns and tables it knows about. Ambiguous column names, missing relationships, and absent context about what data each column actually contains are the root cause of most query failures.
Format 1: Raw CREATE TABLE Statements
The simplest approach is to dump the DDL directly. This works well for small schemas with self-descriptive column names.
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
full_name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
total_amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
ordered_at TIMESTAMP DEFAULT NOW()
);
This format is effective because LLMs have been trained on millions of SQL DDL examples and can parse it natively.
Format 2: Annotated Schema with Column Descriptions
For real-world databases where column names are cryptic or ambiguous, add inline comments explaining what each column means.
def format_annotated_schema(tables: list[dict]) -> str:
"""Format schema with column descriptions as SQL comments."""
output = []
for table in tables:
lines = [f"CREATE TABLE {table['name']} ("]
for col in table["columns"]:
line = f" {col['name']} {col['type']}"
if col.get("constraints"):
line += f" {col['constraints']}"
if col.get("description"):
line += f" -- {col['description']}"
lines.append(line + ",")
# Remove trailing comma from last column
lines[-1] = lines[-1].rstrip(",")
lines.append(");")
output.append("\n".join(lines))
return "\n\n".join(output)
# Example usage
tables = [
{
"name": "transactions",
"columns": [
{"name": "id", "type": "SERIAL", "constraints": "PRIMARY KEY", "description": "Auto-incremented transaction ID"},
{"name": "acct_no", "type": "VARCHAR(20)", "constraints": "NOT NULL", "description": "Customer account number (format: ACC-XXXXX)"},
{"name": "txn_amt", "type": "DECIMAL(12,2)", "constraints": "NOT NULL", "description": "Transaction amount in USD, negative for debits"},
{"name": "txn_type", "type": "VARCHAR(10)", "constraints": "", "description": "One of: credit, debit, transfer, fee"},
{"name": "posted_dt", "type": "DATE", "constraints": "NOT NULL", "description": "Date the transaction was posted, not initiated"},
],
}
]
print(format_annotated_schema(tables))
The output includes comments like -- Transaction amount in USD, negative for debits that tell the LLM exactly how to interpret the data. Without this, a question like "total deposits" might incorrectly include negative values.
See AI Voice Agents Handle Real Calls
Book a free demo or calculate how much you can save with AI voice automation.
Format 3: Schema with Sample Data
Including a few sample rows gives the LLM concrete examples of what the data looks like. This is especially valuable for columns with encoded values or non-obvious formats.
def format_schema_with_samples(db_path: str, table_name: str, n_rows: int = 3) -> str:
"""Generate schema + sample rows for a table."""
import sqlite3
conn = sqlite3.connect(db_path)
# Get CREATE TABLE
ddl = conn.execute(
"SELECT sql FROM sqlite_master WHERE name = ?", (table_name,)
).fetchone()[0]
# Get sample rows
cursor = conn.execute(f"SELECT * FROM {table_name} LIMIT {n_rows}")
columns = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
conn.close()
# Format as a readable table
sample = f"\n/* Sample data from {table_name}:\n"
sample += " | ".join(columns) + "\n"
sample += "-" * 60 + "\n"
for row in rows:
sample += " | ".join(str(v) for v in row) + "\n"
sample += "*/"
return f"{ddl}\n{sample}"
This produces output like:
CREATE TABLE products (
id INTEGER PRIMARY KEY,
sku VARCHAR(20) NOT NULL,
name TEXT NOT NULL,
category VARCHAR(50),
price DECIMAL(8,2)
);
/* Sample data from products:
id | sku | name | category | price
------------------------------------------------------------
1 | SKU-A100 | Wireless Keyboard | Electronics | 49.99
2 | SKU-B200 | Ergonomic Chair | Furniture | 299.00
3 | SKU-C300 | USB-C Hub | Electronics | 34.99
*/
Format 4: Relationship-Focused Representation
For schemas with many tables, explicitly stating relationships prevents the LLM from guessing wrong JOIN paths.
def format_relationships(tables: list[dict]) -> str:
"""Generate a relationship summary for multi-table schemas."""
lines = ["## Table Relationships\n"]
for table in tables:
for fk in table.get("foreign_keys", []):
lines.append(
f"- {table['name']}.{fk['column']} references "
f"{fk['ref_table']}.{fk['ref_column']} "
f"({fk.get('relationship', 'many-to-one')})"
)
return "\n".join(lines)
Place this relationship summary before the CREATE TABLE statements in your prompt. This gives the LLM a high-level map before it dives into column details.
Choosing the Right Format
| Schema Size | Best Format | Why |
|---|---|---|
| Under 10 tables | Raw DDL + sample data | Full context fits easily in the prompt |
| 10-50 tables | Annotated DDL + relationships | Comments resolve ambiguity |
| 50+ tables | Two-stage (select relevant tables first) | Prevents context window overflow |
FAQ
Should I include indexes in the schema representation?
Generally no. Index definitions add noise without helping the LLM generate correct queries. The exception is if you want the LLM to generate performance-optimized queries — in that case, including index information helps it choose covered queries and avoid full table scans.
How do I handle views and materialized views?
Include views in your schema representation if users might ask questions about them. Format them as CREATE VIEW view_name AS ... so the LLM knows they are queryable. For materialized views, add a comment noting that data may be stale.
What if column names conflict across tables?
Explicitly note conflicts in your schema context: "Both orders.status and shipments.status exist but have different meanings. orders.status is one of pending/confirmed/cancelled. shipments.status is one of preparing/shipped/delivered." This prevents the LLM from confusing them in JOIN queries.
#SchemaDesign #TextToSQL #PromptEngineering #DatabaseContext #LLM #AgenticAI #SQLAccuracy #DataModeling
CallSphere Team
Expert insights on AI voice agents and customer communication automation.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.