Running LLM-Generated SQL and Analysis Safely
Text-to-SQL demos beautifully and terrifies in production. You wire an LLM up to your warehouse, it turns "which customers churned last quarter" into a tidy SELECT, and everyone claps. Then one day a prompt injection buried in a support ticket turns "summarize this account" into a query that reads every other tenant's rows, or the model hallucinates a helpful `DROP TABLE users;-- but the model meant well`, or it writes a triple-joined cartesian product with no filter and a full-table scan pins the primary for four minutes while your on-call pages. None of these require malice. Non-deterministic code generation is the whole point of the feature, which means you cannot review the SQL before it runs, which means the only real defense is where the SQL runs and what it's allowed to touch.
I'm Ajay — I built PandaStack, a Firecracker microVM sandbox platform. This post is about the two walls you need for LLM-generated SQL and analysis code, because most teams build one and think they're done. Wall one: sandbox the execution environment — the Python client, the pandas transform, the psql call, whatever the agent generates — in a disposable microVM, so arbitrary code can't reach your host, your credentials, or another tenant's process. Wall two: point that sandbox at an isolated database — a per-tenant or per-run Postgres with read-only credentials and a statement timeout — so even a perfectly-executed malicious query only sees data it's allowed to see, and only for as long as you allow it to run.
Two threats, two walls
It helps to separate the failure modes, because they need different defenses and a design can fix one while leaving the other wide open. The agent hands you two kinds of danger, and they arrive through the same tidy little code block.
- The code is dangerous — the model doesn't just emit SQL, it emits a Python program to run the SQL, parse the result, and chart it. That program can read environment variables, open outbound sockets, shell out, or allocate 40GB. `exec()` in your process or a shared-kernel container is not a boundary against this; a container escape or a runaway allocation takes your host with it.
- The query is dangerous — even sandboxed perfectly, the SQL itself can be destructive (DROP/DELETE/UPDATE), abusive (an unbounded scan that saturates I/O), or nosy (a join that pulls another tenant's rows). Sandboxing the process does nothing about this; the query is doing exactly what the database let it do.
- The two are independent — a microVM contains the code but happily forwards a `DELETE FROM orders` to a database with write access. A read-only role blocks the DELETE but does nothing about the Python that also POSTs your API keys to an attacker. You need both walls, or you've hardened one side of an open door.
Wall one: sandbox the execution environment
The generated artifact is rarely just a SQL string. A real analysis agent writes Python: it connects with a driver, runs the query, loads the result into pandas, does a transform the SQL couldn't express, and saves a chart. All of that is arbitrary code you didn't write and can't review, and it runs with whatever the surrounding process can reach. Run it in your API server and a prompt-injected `import os; os.environ` is a credential dump. Run it in a shared container and you're one CVE from the host.
On PandaStack, each run gets its own Firecracker microVM — the same VMM behind AWS Lambda — with its own guest kernel, filesystem, and network namespace. The blast radius of the generated code is one disposable VM. And because every create restores a baked snapshot instead of cold-booting, a create is ~49ms for the restore step (p50 179ms, p99 ~203ms end to end), so a fresh VM per analysis run is actually practical rather than a multi-second tax. The pattern is the one from the code-interpreter guide: create a sandbox, write the generated code to a file, exec it with a timeout, read the result back through the filesystem.
from pandastack import Sandbox
# The model produced BOTH the SQL and the Python around it. We never trust it
# to touch our host — it runs inside a throwaway microVM. The DB connection
# string it gets is a READ-ONLY, per-run credential (see wall two).
agent_code = '''
import os, json
import psycopg
# Injected by the host as an env var — a read-only role on an isolated DB.
dsn = os.environ["ANALYSIS_DSN"]
query = """
SELECT plan, count(*) AS n, round(avg(mrr), 2) AS avg_mrr
FROM accounts
WHERE created_at >= now() - interval '90 days'
GROUP BY plan ORDER BY n DESC
"""
with psycopg.connect(dsn) as conn:
with conn.cursor() as cur:
cur.execute(query) # statement_timeout is set on the role
cols = [d.name for d in cur.description]
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
with open("/workspace/result.json", "w") as f:
json.dump(rows, f)
print(f"rows={len(rows)}")
'''
with Sandbox.create(template="code-interpreter", ttl_seconds=180) as sbx:
# The read-only, per-run DSN goes into the guest env, never into a prompt.
sbx.filesystem.write("/workspace/analysis.py", agent_code)
result = sbx.exec(
"ANALYSIS_DSN=\"$ANALYSIS_DSN\" python3 /workspace/analysis.py",
env={"ANALYSIS_DSN": read_only_dsn}, # per-run, read-only credential
timeout_seconds=45,
)
if result.exit_code != 0:
# Hand stderr straight back to the model so it can self-correct.
raise RuntimeError(result.stderr)
rows = sbx.filesystem.read("/workspace/result.json")
# VM is destroyed here — along with any secrets, temp files, or open sockets.Two things worth calling out. The `timeout_seconds` on exec is your circuit breaker against runaway generated code — model-written loops are more common than you'd like. And the DSN is passed as a per-run environment variable into the guest, not baked into a prompt the model can echo back: the sandbox isolates execution, but it's on you not to hand the running code a credential it shouldn't have. That's the bridge to wall two — the credential itself has to be scoped so that even code running with it can't do much damage.
Wall two: point it at an isolated database
Now assume the worst about the SQL, because you can't review it. The defense is layered: a read-only role so no query can mutate, a statement timeout so no query can run forever, and — for genuinely untrusted or multi-tenant traffic — an isolated database so the query physically can't see data outside its blast radius. The first two are Postgres primitives you should set regardless. The third is where per-tenant or per-run database isolation earns its cost.
Read-only role + statement timeout
The single highest-leverage change you can make: never connect the analysis path with a role that can write. Create a dedicated read-only role, grant it SELECT on exactly the tables the feature should see (not the whole schema), and pin a `statement_timeout` so a runaway scan gets cancelled instead of pinning a core. Set a `default_transaction_read_only` on the role as belt-and-suspenders, and consider a modest `work_mem` and a connection limit so one run can't hog the instance.
-- Run once against the analysis database. This is the role the sandbox's
-- generated code connects as. It cannot mutate, cannot run forever, and
-- can only see the tables you explicitly grant.
CREATE ROLE llm_analyst LOGIN PASSWORD 'rotate-me';
-- No writes, ever — even a hallucinated DELETE fails with a permission error.
ALTER ROLE llm_analyst SET default_transaction_read_only = on;
-- Cancel any statement that runs longer than 10s (the full-table-scan killer).
ALTER ROLE llm_analyst SET statement_timeout = '10s';
-- Don't let one idle transaction hold locks forever.
ALTER ROLE llm_analyst SET idle_in_transaction_session_timeout = '15s';
-- Cap memory per sort/hash so one query can't balloon the instance.
ALTER ROLE llm_analyst SET work_mem = '32MB';
-- Grant SELECT on ONLY the tables the feature is allowed to read.
GRANT USAGE ON SCHEMA analytics TO llm_analyst;
GRANT SELECT ON analytics.accounts, analytics.usage_daily TO llm_analyst;
-- Bound concurrency so a burst of runs can't exhaust connections.
ALTER ROLE llm_analyst CONNECTION LIMIT 20;
-- Verify: this should now FAIL for llm_analyst.
-- DELETE FROM analytics.accounts; -> ERROR: permission denied / read-only txnThis alone stops the DROP/DELETE class of accidents dead and defuses the full-table-scan-melts-the-DB problem: the query gets cancelled at ten seconds and the model sees an error it can retry with a narrower filter. What read-only-plus-timeout does not solve is the nosy query — a valid SELECT that reads a tenant's data it shouldn't. For that, the query has to be pointed at a database that only contains data it's allowed to see.
Per-tenant or per-run isolated Postgres
If the analysis runs against a shared warehouse with every tenant's rows in it, then row-level correctness is the only thing standing between a hallucinated join and a cross-tenant leak — and "the model will always add the tenant_id filter" is not a security control. The stronger answer is that the query connects to a database that physically only contains one tenant's data (or, for the paranoid path, a fresh ephemeral copy scoped to exactly this run). Then a query that forgets its filter returns that tenant's rows and nobody else's, because there's no one else in the database.
PandaStack's managed databases make this a clean unit: each managed Postgres-16 is its own Firecracker microVM with its own durable volume — not a logical database carved out of a shared instance. So a per-tenant analysis DB is a real boundary, hardware-enforced, with its own connection string. You provision one and connect the sandbox to it:
# Provision an isolated managed Postgres-16 (its own microVM + durable volume).
# A create takes 30-90s because it blocks until Postgres is accepting
# connections. For a per-TENANT analysis DB you do this once per tenant; for a
# per-RUN ephemeral copy you fork a seeded snapshot instead (far faster).
curl -sS -X POST https://api.pandastack.ai/v1/databases \
-H "Authorization: Bearer $PANDASTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"label": "analysis-tenant-acme"}'
# The response returns a connection_url. Native TLS connection:
# postgres://pandastack:<pw>@<id>.db.pandastack.ai:5432/pandastack
#
# You DON'T hand the LLM this superuser URL. Connect once as admin, create the
# read-only llm_analyst role from the SQL above, then give the SANDBOX a DSN
# built from that read-only role:
# postgres://llm_analyst:<pw>@<id>.db.pandastack.ai:5432/pandastack
psql "postgres://pandastack:<pw>@<id>.db.pandastack.ai:5432/pandastack" \
-f setup_readonly_role.sqlFor truly untrusted, per-run isolation — where you want each analysis to start from a known-good snapshot with zero chance of state bleeding between runs — the fork path is the interesting one. Fork a seeded database VM instead of provisioning from scratch: a same-host fork lands in 400–750ms (cross-host 1.2–3.5s) and shares memory and disk copy-on-write, so each run gets a private, disposable copy of the data it's allowed to see without paying the 30–90s full-create every time. The run finishes, you destroy the fork, and there's nothing left to leak.
In-process vs. sandbox + isolated DB
Here's the same feature built three ways, so the trade-off is concrete rather than abstract.
- Runs on your host or shared kernel — In-app process / container: the generated Python executes where your service runs. A prompt injection reads your env, opens sockets, or escapes a shared kernel. Sandbox + isolated DB: the code runs in a disposable microVM with its own kernel; the blast radius is one VM you throw away.
- Database credential — In-app process / container: usually the app's own connection, often read-write, often the full schema. A hallucinated DELETE executes. Sandbox + isolated DB: a dedicated read-only role scoped to specific tables, with a statement timeout — writes fail, runaway scans get cancelled.
- Cross-tenant data — In-app process / container: shared warehouse; correctness of a model-written WHERE clause is the only wall. Sandbox + isolated DB: query points at a per-tenant (or per-run forked) database that only contains allowed data; a missing filter leaks nothing.
- Runaway query — In-app process / container: a full-table scan pins the shared primary and pages on-call. Sandbox + isolated DB: statement_timeout cancels it at 10s on an isolated instance whose resource ceiling is the VM boundary.
- Cleanup — In-app process / container: leaked temp files, lingering connections, half-written state on your box. Sandbox + isolated DB: destroy the VM (and the forked DB) and every trace is gone.
- Cost of a fresh run — In-app process / container: ~0, but you paid for it in blast radius. Sandbox + isolated DB: ~49ms restore step for the sandbox; a forked ephemeral DB is 400–750ms same-host — cheap enough to do per run.
The in-process version is faster to ship and fine if the SQL is fully trusted and never model-generated. The moment an LLM writes the query — or the code around it — you're running someone else's non-deterministic program against your data, and the two-wall version is the honest architecture.
Putting it together
The full loop for a production text-to-SQL or analysis feature is: (1) the LLM generates the SQL and the Python around it; (2) you spin up a fresh microVM per run and write that code into it; (3) the code connects with a read-only, per-run credential to a database that only contains data this request is allowed to see — a per-tenant managed Postgres, or a per-run fork of a seeded one; (4) a statement timeout and read-only role cap what any single query can do; (5) you read the result back through the filesystem, hand any error straight to the model so it self-corrects, and destroy the VM (and the fork) when you're done.
Neither wall is optional. A sandbox with a superuser DSN is a well-isolated way to drop your production tables. A read-only role reached by code running in your own process is a data-leak or a credential-dump away from being someone else's problem. Build both, and a hallucinated `DROP TABLE`, a cartesian-product table scan, and a nosy cross-tenant join all fail the same boring way — an error the model retries, an outage that never happened, and a VM you were going to throw away anyway. Start with the code-interpreter template for wall one and /docs/concepts/databases for wall two.
Frequently asked questions
How do I safely run SQL generated by an LLM?
Use two independent walls. First, run the generated code (the SQL plus the Python or shell around it) inside an isolated sandbox — a Firecracker microVM on PandaStack — so arbitrary code can't reach your host, secrets, or other tenants. Second, point that code at an isolated database with a scoped, read-only credential and a statement timeout, so even a perfectly-executed query can't mutate data, run forever, or read rows it isn't allowed to see. The sandbox protects your infrastructure from the code; the isolated database with scoped credentials protects your data from the query. Neither substitutes for the other.
Does a read-only Postgres role stop a hallucinated DROP TABLE?
Yes. Create a dedicated role with default_transaction_read_only = on and GRANT it SELECT on only the tables the feature should see — then a DROP, DELETE, or UPDATE fails with a permission error no matter what the model generated. Pair it with a statement_timeout (e.g. 10s) so a runaway full-table scan gets cancelled instead of pinning a core, and an idle_in_transaction_session_timeout so a stuck transaction can't hold locks. A read-only role does not, however, stop a valid SELECT from reading another tenant's rows — for that you need the query pointed at an isolated, per-tenant (or per-run) database.
Why isn't a WHERE tenant_id = ? filter enough to isolate tenants?
Because when an LLM writes the query, the tenant filter is generated non-deterministically and can be forgotten, mis-joined, or defeated by prompt injection — and 'the model will always add the filter' is not a security control you'd want to defend in an audit. The stronger design is that the query connects to a database that physically only contains one tenant's data, so a query that forgets its filter returns that tenant's rows and nobody else's. On PandaStack each managed Postgres-16 is its own Firecracker microVM with its own durable volume, so a per-tenant analysis database is a hardware-enforced boundary rather than a WHERE clause.
Isn't spinning up a database or VM per run too slow?
For the sandbox, no — every create restores a baked snapshot rather than cold-booting, so a create is ~49ms for the restore step (p50 179ms end to end), which makes a fresh microVM per analysis run practical. For the database, a full managed create takes 30-90s because it blocks until Postgres is accepting connections, so you provision a per-tenant database once, not per run. When you do want a disposable per-run copy, you fork a seeded database snapshot instead: a same-host fork is 400-750ms (cross-host 1.2-3.5s) and shares data copy-on-write, so each run gets a private, throwaway database without the full-create cost.
Can't I just sandbox the code and skip the database isolation?
No — that hardens one side of an open door. A microVM contains the generated code, but it will happily forward a DELETE FROM orders or a cross-tenant SELECT to whatever database credential you handed it. If that credential is a superuser DSN, a prompt injection can drop your tables from inside the VM. The sandbox and the scoped, isolated database solve different threats: the VM stops the code from touching your infrastructure, and the read-only role plus per-tenant database stops the query from touching data it shouldn't. LLM-generated SQL needs both.
49ms p50 cold start. Fork, snapshot, and scale to zero.