Essay
Building a text-to-SQL feature with Cloudflare D1
Updated Justin Ahinon
I run whatmedicaidpays.com, a site that surfaces what state Medicaid programs actually pay for medical procedures. There's a page on it called /ask. You type a question in plain English, like What does Medicaid pay for an office visit in California?, and you get back stats, a table, a US choropleth map.
Behind that page, an LLM writes a SQL query against my production database, on the spot, in your tab. Then a second LLM call turns the rows into the answer you read.
If you're building a text-to-SQL feature with SvelteKit and Cloudflare D1, this is the request flow I used. The query checks are worth a close look: my first validator misses valid SQL syntax, and its LIMIT check does less than I intended.
The first time I wired that up, I sat there for a minute with my finger off the deploy button.
Letting a model write SQL and then running that SQL against your real data is the kind of thing that sounds reckless if you've been around long enough to remember Bobby Tables. And it is reckless, by default. Most of what follows is about making the gap between the model's output and what touches my database as wide and as boring as possible.
I'm going to give away the lesson up front, because the rest of this post is the work I had to do to learn it. The AI part of an "AI on top of your database" feature is the easy part. The interesting work, and basically all the work that's load-bearing, is in the boring layer that surrounds the model.
The framing that finally clicked
For a long time I was thinking about this wrong. I was thinking about it as "an AI with access to my database," which is a phrase that immediately puts you in the wrong frame. It makes the AI sound like the hard part. It makes you reach for fancy answers like RAG, agents, function calls, fine-tuning. It makes you think the interesting work is on the model side.
The framing that finally clicked was: this is a junior engineer who's allowed to write queries, but not allowed to merge them.
The model writes the query. Something else, somewhere boring, decides whether the query gets to run. Once you have that picture, the design writes itself. You start asking, what does code review look like? What does the merge gate look like? What's the linter? What's the equivalent of "you don't have prod access"?
Those questions give me application code I can test. The checks below were my first pass, and they leave holes.
What actually runs when you hit Ask
Here's the full pipeline, in order, so the rest of the post has somewhere to hang from:
- The form action receives the question.
- A schema validates it (non-empty, max 500 characters).
- Cloudflare's per-IP rate limiter blocks if you're over ten requests in sixty seconds.
- A regex pass rejects obvious prompt-injection attempts.
- Cache check. If someone already asked this exact question, I redirect to the existing answer.
- LLM #1 generates SQL from the question, with the database schema baked into the system prompt.
- A standalone SQL validator inspects what the model produced.
- The query runs against D1 through the Sessions API, so reads can be served by replicas.
- LLM #2 turns the rows into a structured answer (text, stats, table, chart, or map).
- The result is persisted with a slug, and you get redirected to a shareable URL.
LLMs only show up at steps 6 and 9. Everything else is application code I can inspect and test separately from the model.
The schema goes in the prompt, not in RAG
A lot of "give the AI your database" tutorials reach for RAG as the default move. Embed the schema, embed example queries, retrieve the relevant chunks at query time, hand the model whatever came back.
I didn't do that. The schema for whatmedicaidpays is small enough that I just paste the whole thing into the system prompt:
- state(id TEXT PK, code TEXT UNIQUE, name TEXT)
- hcpcs_code(id TEXT PK, code TEXT UNIQUE, description TEXT, category TEXT)
- reimbursement(id TEXT PK, state_id FK, hcpcs_code_id FK, year INT,
total_providers INT, total_beneficiaries INT, total_claims INT,
total_paid REAL, avg_paid_per_claim REAL, …)
- service_category(id TEXT PK, slug TEXT UNIQUE, name TEXT)
- service_category_code(category_id FK, hcpcs_code_id FK)
Plus the join keys. Plus a sentence saying state codes are uppercase two-letter abbreviations. Plus the years available. That's the whole context the model gets.
This is one of those moments where the boring choice is the correct choice. RAG would let me pretend my schema is bigger than it is. It would also give the model a way to be wrong about which tables exist, because retrieval can miss. A schema pasted in full into the prompt can't miss. The model can refer to a table I told it about, or it can't.
This isn't just my opinion. Vercel published a piece in late 2025 called We removed 80% of our agent's tools, about deleting 18 specialized retrieval and query-planning tools from their internal text-to-SQL agent. They replaced the whole thing with two tools, ExecuteCommand and ExecuteSQL, and let the model navigate their semantic layer with cat, grep, and ls. Success rate went from 80% to 100%. Average run time dropped 3.5x. Token cost dropped 37%. The line that stuck with me was we were doing the model's thinking for it.
The shape of that lesson is the shape of mine. Don't wrap the model in scaffolding it doesn't need. Whatever your custom retrieval or query layer is doing, the model can probably do without it, if you let it.
Where I diverge from Vercel is what happens after the SQL gets written. Their agent runs against internal analytics they trust. They can hand it ExecuteSQL and let it run. I can't. My users are anonymous strangers on the open internet, which is why I need an enforced boundary between the model and D1. The forty-line validator below does not fully provide one. Skip the wrapper around the model generalizes. Skip everything between the model and your database doesn't.
For natural-language-to-SQL, the prompt describes the query I want. It cannot enforce access restrictions, but it can explain the domain rules in plain English. Mine looks like this:
Rules:
- ONLY generate SELECT statements. Never INSERT, UPDATE, DELETE, DROP, or ALTER.
- Always join tables properly when crossing table boundaries.
- Use state.name for display, state.code for filtering.
- Use hcpcs_code.code and hcpcs_code.description for procedures.
- LIMIT results to 50 rows max unless the user explicitly asks for more.
- Format currency columns with 2 decimal places where appropriate.
- Return ONLY the SQL query, no explanation, no markdown, no backticks.
Search strategy:
- When the user asks about a broad service area, first check if it
matches a known service_category. Known categories (by slug):
behavioral-health, caregiver, dental, diabetes-supplies, doctor-visit,
eye-exam, eyeglasses, medical-transportation, mobility-aids,
nursing-home, physical-therapy, speech-therapy, therapy.
- If the topic matches a category, JOIN service_category_code and
service_category to filter by slug. This is more accurate than keyword
search because it uses curated code mappings.
- If the topic does not match any category, fall back to searching
hcpcs_code.description using LIKE '%keyword%' patterns with multiple
OR conditions. Use multiple synonyms. E.g. for "autism":
LIKE '%autism%' OR LIKE '%adaptive behav%' OR LIKE '%behavior%analys%'.
- When combining a category with a keyword refinement (e.g. "wheelchair"
within mobility-aids), use OR between the category filter and the LIKE,
not AND.
- For specific procedure lookups (e.g. "knee replacement", "MRI"), always
use hcpcs_code.description LIKE patterns.
- For "cleaning" in dental context, always search LIKE '%cleaning%'
OR LIKE '%prophylaxis%'. The medical term is prophylaxis.
- For year-over-year spending comparisons, aggregate total spending per
year first using SUM(total_paid) grouped by year, then compare earliest
to latest. Do NOT use MAX/MIN on individual row total_paid values.
A lot of those rules are domain weirdness I had to discover the hard way. The model couldn't figure out on its own that cleaning in a dental context means prophylaxis. It couldn't figure out that you don't compare year-over-year spending by taking the max of a single row's total_paid. Every line in there is a bug I shipped once and then patched in the prompt. I've since started applying the same rule to the way I work with coding agents: write down what the failure taught me, then put a check behind it whenever I can.
The model is going to follow some of those rules and quietly break others. That means every access or execution constraint needs enforcement outside the prompt. My initial validator tried to do that, but it has gaps worth showing.
My first SQL validator, and where it falls short
After the model returns its query, before that query touches D1, it goes through a hand-rolled SQL validator. The whole thing is maybe forty lines. This is the initial implementation, not a secure validator to copy into a public endpoint. In this excerpt, reject() must throw to stop execution.
const DANGEROUS = [
'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER',
'CREATE', 'ATTACH', 'DETACH', 'PRAGMA',
'REPLACE', 'GRANT', 'REVOKE'
];
const ALLOWED_TABLES = [
'state', 'hcpcs_code', 'reimbursement',
'service_category', 'service_category_code'
];
const validateSQL = (sql: string) => {
const trimmed = sql.trim();
if (!trimmed.toUpperCase().startsWith('SELECT')) reject('not a SELECT');
if (trimmed.includes(';')) reject('semicolon');
if (trimmed.includes('--') || /\/\*/.test(trimmed)) reject('comment');
const bad = new RegExp(`\\b(${DANGEROUS.join('|')})\\b`, 'i').exec(trimmed);
if (bad) reject(`dangerous keyword: ${bad[1]}`);
for (const m of trimmed.matchAll(/\b(?:FROM|JOIN)\s+(\w+)/gi)) {
if (!ALLOWED_TABLES.includes(m[1].toLowerCase())) {
reject(`disallowed table: ${m[1]}`);
}
}
return /\bLIMIT\b/i.test(trimmed) ? trimmed : `${trimmed} LIMIT 50`;
};
I wanted the table list to control what /ask could read. The regex misses some of those reads. It only checks an unquoted word immediately after FROM or JOIN. SQLite also accepts quoted table names and comma joins, so this check can miss table references. To enforce that list, I need to check table references throughout the query, including nested queries. SQLite's SELECT documentation shows the syntax this regex overlooks.
The LIMIT check has a similar problem: finding the word anywhere is not the same as enforcing a maximum on the outer query. An existing LIMIT 5000, a negative limit, or a limit inside a subquery passes this check. Even a correctly enforced LIMIT 50 caps returned rows, not the rows scanned for filtering, aggregation, or sorting. D1 charges for rows read, so indexes and query plans still matter. Cloudflare's pricing documentation explains that distinction.
Rejecting semicolons, comments, and certain keywords catches some unwanted output, but it does not make this a SQL parser or an authorization boundary. For a public endpoint, I'd favor constrained query templates with validated parameters where possible. If arbitrary SQL is necessary, it needs structural validation and access restrictions outside the model. Neither of those is implemented by this snippet.
The cheaper layers around it
Three smaller layers sit around the model and the validator.
The first is question validation. Before the user's text reaches the model at all, the question goes through a regex pass for obvious prompt-injection attempts. The actual list:
const INJECTION_PATTERNS = [
/ignore\s+(all\s+)?previous\s+instructions/i,
/\bdebugg?(ing|er|start|end)\b/i,
/print\s+the\s+(prompt|response|system|instructions)/i,
/ignore\s+(the\s+)?(above|system)\s+(prompt|instructions|message)/i,
/you\s+are\s+now\s+(a|an)\s+/i,
/act\s+as\s+(a|an)\s+(different|new)/i,
/forget\s+(all|your|everything)/i,
/\bcurl\b.*\b(localhost|127\.0\.0\.1)\b/i,
/reveal\s+(your|the)\s+(system|secret|api|internal)/i,
/what\s+(is|are)\s+your\s+(instructions|rules|system\s+prompt)/i,
/tool\s*calls?\s+/i
];
This filters a few recognizable phrases. It misses reworded attacks and can reject legitimate questions, so I can't rely on it to stop prompt injection. I still need to check the SQL regardless of whether the question passed this filter.
The second is rate limiting. Cloudflare's per-IP limiter, ten requests per sixty seconds. If you start treating my form like a free LLM playground, you get a 429. This is mostly a cost defense, not a security one. But on a stack where every request is two LLM calls plus a database query, "cost defense" and "security" start to look like the same thing.
The third is caching. Before I bother generating SQL, I check whether someone already asked this exact question. If they did, I redirect to the existing answer. Same input, same output, no model spend. The cache also doubles as the result-sharing mechanism. Every answered question gets its own URL, like /ask/r/what-does-medicaid-pay-for-a-flu-shot-in-texas, which means the LLM-generated content compounds into pages that can rank in search. SEO as a side effect of caching. I'll take it.
Letting D1 route reads to replicas
The actual D1 call uses the Sessions API:
const session = platform.env.whatmedicaidpays_db.withSession();
const stmt = session.prepare(sql);
const response = await stmt.all();
console.log(`served by region=${response.meta.served_by_region}`);
console.log(`primary=${response.meta.served_by_primary}`);
withSession() defaults to first-unconstrained: when read replication is enabled, D1 can serve the first query from the primary or a replica. It does not guarantee a particular region, and it does not make the binding read-only. I log which region served the query and whether the primary handled it on every run, partly so I'd find out fast if replication ever stopped doing what I think it's doing.
This part has nothing to do with AI. All of it is what makes the AI feel usable. A natural-language interface that takes 20 seconds per query feels broken even when the answer is good. A second saved here is a second that buys me more patience for the model on the other end.
Why two LLM calls and not one
A small thing worth explaining, because it took me a while to see why the two-call shape is better than the one-call shape.
The naive version is one prompt: here's the question, here's the schema, write SQL and an answer. That works, and it's tempting because it's cheaper.
The reason I split it is that the two halves have completely different jobs. The first call is generating code that has to be safe to execute. The second is generating prose and structured presentation that has to be safe to read. Different rules, different failure modes, different validators on each side.
If I let one call do both, I lose the seam where the validator fits. There's no clean point to inspect "the SQL the model wrote" if the SQL is mixed in with the answer. The two-call design exists almost entirely so I can put the validator between the two calls. The cost of the extra round trip is small. The clarity it buys me is large.
The layer I haven't shipped
I'm going to be honest about a gap, because writing it down here is the only way I can't pretend later that I didn't see it.
The D1 binding the worker holds is the same binding used by the other routes in the app. The Sessions API routes reads to replicas, but writes still go to the primary. A replica is not a separate permission boundary. Cloudflare's read replication documentation describes that routing.
That leaves two gaps in the implementation shown here: the SQL check can miss unauthorized reads, and the binding itself does not prevent writes. Copying only the public dataset into a separate database could reduce exposure, but a second binding alone would not make queries read-only. I haven't shipped a separate query service that enforces those restrictions. The implementation shown here leaves that work open.
Code Mode is the version I want next
The version of /ask I keep daydreaming about isn't a better validator. It's a different shape entirely, and Cloudflare has been quietly shipping the pieces for it.
In late 2025 they introduced Code Mode. The idea is simple: instead of giving an LLM a list of tools to call, give it a TypeScript API and ask it to write a small program. The program runs in a fresh isolate, calls the API, and only the final result returns to the model's context. It turns out LLMs are much better at writing code than at picking among tool calls, because TypeScript is everywhere in training data and tool-call tokens are not. Cloudflare followed it up in early 2026 with a version that compresses 2,500+ MCP endpoints into about 1,000 tokens of API surface.
The substrate underneath is Dynamic Workers. A Worker Loader binding lets one Worker spin up another at request time, from a string of code, in a fresh V8 isolate. Milliseconds to boot. No filesystem. No env vars. Outbound fetch off by default. You hand the new Worker exactly the bindings it needs and nothing else. The heavier container-backed cousin is the Sandbox SDK, for when an agent actually needs a Linux box. I used Durable Objects for naps.sh, which are great for stateful sessions, but the loader-based isolate model is a different primitive, and one I haven't had a project for yet.
What /ask looks like in that shape: I expose a tiny API (db.query(sql, params), db.schema(), maybe chart(rows)), generate the .d.ts, and ask the LLM to write a short program against it. Multi-step questions stop being two LLM calls and start being one program. The permission question becomes what can this API actually execute? An isolate would still need a query service that enforces allowed operations; passing it the existing D1 binding would preserve the same access problem. Sandboxing generated code would not fix the SQL validator by itself.
I haven't built it. Code Mode would add a lot of machinery to this five-table interface. But the day /ask needs to do something genuinely multi-step, like compare these states across these years, chart the result, and explain the outliers, the right move isn't to make the prompt more elaborate. It's to let the model write code.
The lesson I actually came away with
Generating SQL was the easy part of building /ask. Putting the small schema in the prompt gave the model enough context, and separating query generation from answer formatting gave me a place to inspect the SQL. Rate limiting, caching, and replica routing each addressed a different part of the request path.
The validator deserves more scrutiny than I originally gave it. A short function is easy to read, but SQL has more syntax than its regex accounts for. And a small result set can still require an expensive scan. That's the same distinction behind the $134 Cloudflare D1 bill I traced to missing indexes.
If you're building something similar, keep that separation between generated SQL and execution. Test that the execution code enforces the restrictions you intend, including SQL forms the model hasn't generated in your happy-path examples. I wouldn't use this regex validator as the only check on a public endpoint.