all posts

Run AI-Agent DB Migrations in an Isolated microVM

Ajay Kumar··9 min read

You point an AI agent at your schema and ask it to write the seed script, the fixtures, and the migration to add a `deleted_at` column. It hands you a tidy block of SQL and some ORM code. Do you run it against your database? A bad migration drops a table. A seed script fixtures its way into a `DELETE FROM users` with no `WHERE`. And the `npm install` the agent added to set up the ORM just pulled a package you've never audited. The generated code is non-deterministic by design — you can't fully review it, and "the model was confident" is not a rollback plan.

The pattern that fixes this: spin up an ephemeral, per-run microVM that contains both a throwaway Postgres and the agent's generated code, run the migration/seed there, assert it did what it claimed, and diff the schema — all before a single statement touches a real database. I'm Ajay, I built PandaStack. This post walks the loop end to end with the Python SDK: create the sandbox, write the migration in, stand up a local Postgres, run it, and verify.

Why not just run it against a dev database?

Because "dev database" is doing a lot of work in that sentence. If it shares a connection pool, a host, or a set of credentials with anything that matters, an agent-written script is one confused `psql` invocation away from the wrong target. And the danger isn't only the SQL — it's the whole run: the dependency install, the ORM's own migration engine deciding to "helpfully" reset state, an `os.system` buried in a fixtures helper. You want the entire execution, database and all, to happen somewhere it can be deleted without consequence.

  • Run on your host — the migration talks to a real Postgres over a real socket; a bad `DROP` or a compromised dependency hits actual data, and cleanup means restoring a backup.
  • Run in a container — better, but the container shares the host kernel; a container escape or a resource-exhausting migration can still take neighbors (and the host) with it, and the DB usually lives outside the container anyway.
  • Run in an ephemeral microVM — the throwaway Postgres and the agent's code live inside one hardware-isolated Firecracker VM with its own guest kernel; delete the VM and every trace of the run, database included, is gone.
The mental model: the microVM is the disposable staging ground, not the destination. The agent's migration proves itself against a Postgres that exists only for this run. Only after it passes — schema diff clean, assertions green — do you promote it toward a real database.

The loop: seed a throwaway Postgres, run the migration, verify

The `code-interpreter` and `base` templates both ship with enough to run a local Postgres inside the guest — either one already present or a quick `apt`/package step in the run. The flow is: create a sandbox, write the agent's SQL and any ORM code in with `filesystem.write`, `exec` a script that boots Postgres and applies the migration, capture stdout/stderr/exit_code, and then run a verification query. Set `PANDASTACK_API_KEY` in your environment and the SDK picks it up. The `with` form kills the VM on exit, so cleanup is automatic.

from pandastack import Sandbox

# The agent handed you these. You have NOT reviewed them line by line.
migration_sql = """
BEGIN;
CREATE TABLE IF NOT EXISTS users (
  id    BIGSERIAL PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
COMMIT;
"""

seed_sql = """
INSERT INTO users (email) VALUES
  ('[email protected]'), ('[email protected]'), ('[email protected]')
ON CONFLICT (email) DO NOTHING;
"""

# This shell script boots a local Postgres and applies the agent's work.
run_sh = """#!/usr/bin/env bash
set -euo pipefail
export PGDATA=/workspace/pgdata
export PATH="$PATH:$(ls -d /usr/lib/postgresql/*/bin | head -1)"

initdb -U app -A trust >/dev/null
pg_ctl -D "$PGDATA" -o "-p 5433" -w start >/dev/null
createdb -p 5433 -U app scratch

psql -p 5433 -U app -d scratch -v ON_ERROR_STOP=1 -f /workspace/migration.sql
psql -p 5433 -U app -d scratch -v ON_ERROR_STOP=1 -f /workspace/seed.sql

# Verify: the migration must have created the column and the seed rows.
psql -p 5433 -U app -d scratch -Atc \
  "SELECT count(*) FROM users WHERE deleted_at IS NULL;"
"""

with Sandbox.create(template="code-interpreter", ttl_seconds=600) as sbx:
    sbx.filesystem.write("/workspace/migration.sql", migration_sql)
    sbx.filesystem.write("/workspace/seed.sql", seed_sql)
    sbx.filesystem.write("/workspace/run.sh", run_sh)

    result = sbx.exec("bash /workspace/run.sh", timeout_seconds=180)

    print("exit:", result.exit_code)
    print("stdout:\n", result.stdout)
    if result.exit_code != 0:
        print("stderr:\n", result.stderr)
        raise SystemExit("migration failed inside the sandbox — do NOT promote it")

    seeded = int(result.stdout.strip().splitlines()[-1])
    assert seeded == 3, f"expected 3 live users, got {seeded}"
    print("migration + seed verified against throwaway Postgres")
# sandbox (and its entire Postgres) is destroyed here

`exec` returns a result with `stdout`, `stderr`, and `exit_code`. Always pass `timeout_seconds` — an agent-written migration that deadlocks on a lock it didn't expect will otherwise hang forever, and the timeout is your circuit breaker. The verification step matters as much as the run: a migration that exits 0 but left the schema wrong is still a failed migration. Here we assert on a `count(*)`; in practice you also want to diff the resulting schema against what you expected.

What actually runs inside the guest

The script above is what executes inside the isolated VM. It's worth seeing in isolation, because this is the exact code you would never want to run blind against anything real. A local Postgres comes up on a throwaway port, the agent's migration and seed are applied under `ON_ERROR_STOP`, and a verification query reads back state. If the agent's migration were the infamous one, this is where it detonates harmlessly:

# Inside the microVM — a self-contained, disposable Postgres run.
export PGDATA=/workspace/pgdata
initdb -U app -A trust
pg_ctl -D "$PGDATA" -o "-p 5433" -w start
createdb -p 5433 -U app scratch

# Apply the agent's migration. ON_ERROR_STOP means a bad statement fails loudly.
psql -p 5433 -U app -d scratch -v ON_ERROR_STOP=1 <<'SQL'
BEGIN;
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;
-- If the model was "confident" and wrote this instead...
-- DROP TABLE users; -- oops
-- ...it drops a table that exists only in this doomed VM. No prod data is reachable.
COMMIT;
SQL

# Diff the resulting schema against the version you committed to git.
pg_dump -p 5433 -U app -s scratch > /workspace/schema.after.sql
diff -u /workspace/schema.expected.sql /workspace/schema.after.sql \
  && echo "SCHEMA OK" || echo "SCHEMA DRIFT — review before promoting"

The `DROP TABLE users` case is the whole point. If a prompt injection or an overconfident completion turns "add a soft-delete column" into a destructive statement, it runs against a Postgres whose entire lifetime is this one sandbox. When the `with` block exits, the VM — data directory, WAL, sockets, and all — ceases to exist. There is no real table to drop because there is no real database in reach.

A fresh VM per run — the multi-tenant default

If these migration jobs come from different users, tenants, or agent sessions, do not reuse one sandbox across them. The isolation boundary is the VM; sharing a VM across trust domains mixes their code and their (throwaway) data in the same place and defeats the point. Create a new sandbox per run and kill it after. This is cheap enough to do per request: every create restores a baked snapshot on demand rather than cold-booting, so a create is p50 179ms (p99 ~203ms), not the multi-second boot that historically made per-request VMs impractical.

Network egress from the guest is real by default. A seed script that reaches for an external URL — to "validate" an email, or to POST your fixtures somewhere — can do so unless you restrict it. If your threat model includes exfiltration, cut outbound access at the network layer rather than trusting the generated code to behave. Isolation contains a crash; it does not, by itself, stop a determined script from phoning home.

Fork a seeded database state instead of re-seeding

Seeding a realistic dataset is often the slow part — you don't want to `initdb` and re-import a million rows for every candidate migration. So do it once. Bring a sandbox up to a fully seeded, known-good Postgres state, snapshot it, then `fork` that snapshot for each migration you want to try. A same-host fork lands in roughly 400–750ms and shares the parent's memory and disk copy-on-write, so each branch starts from the identical seeded state but diverges independently. Try ten migrations against ten forks, keep the one whose schema diff is clean, and throw the rest away.

from pandastack import Sandbox

# 1. Build the seeded baseline ONCE (Postgres up, schema loaded, rows imported).
base = Sandbox.create(template="code-interpreter", persistent=True)
base.filesystem.write("/workspace/setup.sh", setup_and_seed_script)
assert base.exec("bash /workspace/setup.sh", timeout_seconds=300).exit_code == 0
snap = base.snapshot()  # capture the seeded state

# 2. Fork the seeded state per candidate migration — each fork is independent.
candidates = load_agent_migrations()  # list of SQL strings
for i, migration in enumerate(candidates):
    fork = snap.fork()  # same-host fork ~400-750ms, CoW memory + disk
    try:
        fork.filesystem.write("/workspace/m.sql", migration)
        r = fork.exec(
            "psql -p 5433 -U app -d scratch -v ON_ERROR_STOP=1 -f /workspace/m.sql",
            timeout_seconds=120,
        )
        print(f"candidate {i}: exit={r.exit_code}")
    finally:
        fork.kill()

base.kill()

This turns "evaluate a batch of AI-generated migrations" into a cheap fan-out: pay the seeding cost once, then branch it for pennies of latency each. It's the same copy-on-write fork that makes branch-and-explore agent loops practical, applied to database state instead of a filesystem.

Promoting to a real (managed) database

Once a migration survives the sandbox — clean diff, assertions green, no surprise egress — it's earned the right to touch something durable. On PandaStack the "real" target is a managed database: each one is its own dedicated Firecracker VM with a durable volume, created in 30–90s (it blocks until Postgres is genuinely ready). The throwaway Postgres inside the sandbox and the managed database are the same primitive — a Firecracker microVM running Postgres — but one exists for a single verification run and the other persists your data behind a TLS connection string.

So the pipeline reads: agent generates the migration → run it against a disposable in-sandbox Postgres → assert and schema-diff → and only then apply the identical migration to the managed database. The sandbox is the airlock. Nothing reaches your real data until it has already proven, in an environment that can be deleted, that it does what it says.

When is this overkill? If you wrote the migration yourself and you trust it, a plain `psql` against a staging database is simpler — don't stand up a VM to run code you already reviewed. But the moment the SQL is model-generated, multi-tenant, or arriving at runtime from somewhere you don't control, the ephemeral-microVM airlock is the difference between "the model was confident" being a funny code comment and being an incident report.

Frequently asked questions

How do I safely run an AI-generated database migration?

Run it against a throwaway database inside an isolated microVM before it touches anything real. With PandaStack, create a sandbox on the code-interpreter or base template, write the migration and seed SQL in with filesystem.write, exec a script that boots a local Postgres and applies them, then assert on a verification query and diff the resulting schema. The database and the agent's code live inside one disposable Firecracker VM, so a bad DROP or a DELETE with no WHERE hits data that only exists for that run — delete the VM and it's gone.

Why run the database inside the sandbox instead of pointing the migration at a dev DB?

Because the danger is the whole run, not just the SQL: the dependency install, the ORM's own migration engine, or a helper with an os.system in it can all cause damage. Putting a throwaway Postgres inside the same microVM as the generated code means the entire execution — database included — is contained and deletable. A shared dev database still shares a host, credentials, or a connection pool with something you care about.

How do I test many candidate migrations against the same seeded data cheaply?

Seed the data once, then fork it. Bring a sandbox up to a fully seeded Postgres state, snapshot it, and fork that snapshot per candidate migration. A same-host fork is around 400–750ms and shares the parent's memory and disk copy-on-write, so every fork starts from identical seeded state but diverges independently. You pay the seeding cost once and branch it for pennies of latency, keeping the migration whose schema diff comes back clean.

Can a seed script exfiltrate data from the sandbox?

Network egress from a sandbox guest is real by default, so a seed or fixtures script could reach an external URL unless you stop it. The microVM contains a crash or a destructive statement, but it does not by itself prevent a determined script from making outbound requests. If your threat model includes exfiltration, restrict outbound access at the network layer rather than trusting the generated code to behave.

What's the difference between the throwaway Postgres and a PandaStack managed database?

They're the same primitive — a Firecracker microVM running Postgres — used at two ends of the pipeline. The throwaway Postgres lives inside a sandbox for a single verification run and is destroyed with the VM. A managed database is its own dedicated Firecracker VM with a durable volume, created in 30–90s, that persists your data behind a TLS connection string. You verify a migration against the disposable one, then apply the identical migration to the managed one.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.