Reading and Writing Data from Action Code
How code inside a python / javascript (or any action) element should reach a
sql data element — and the one pattern that does not work in production,
even though older docs used to show it.
The reality: your code runs in a network-isolated sandbox
Action code executes in a Firecracker microVM on the isolation fleet. That sandbox is a deliberate security boundary: it has no internet egress and no direct route to the platform’s internal databases. This is what makes it safe to run arbitrary user code — the code can compute, but it cannot phone home and it cannot dial infrastructure directly.
A consequence that surprises people: when you attach a sql element to your
action, the injected RESOURCE_SQL_{NAME} environment variable is present
but not currently dialable from inside a production sandbox. A driver-level
connection attempt (psycopg2.connect(os.environ["RESOURCE_SQL_MY_DB"]),
pg.Client in Node, etc.) resolves DNS fine and then times out on TCP
connect after ~30 seconds — the sandbox’s network origin has no route to the
database’s connection pooler. The failure is slow and unhelpful: every invoke
burns its timeout budget and dies with a generic timeout, while the SQL element
itself answers queries instantly through its own API.
If your saves “hang”, your reads time out at exactly the invoke timeout, and a
__diag__ probe shows DNS resolving but TCP timing out — this is what you are
hitting. It is a platform limitation being worked on, not a bug in your code or
your attachment.
What works: drive the SQL element through its operations
The sql element is itself a full database API. Instead of opening a raw
driver connection from inside the sandbox, do the data work through the SQL
element’s own operations — migrate for schema, query for reads,
insert / update / delete for row work. These execute in the platform’s
runtime (outside the sandbox), so they are fast, credentialed, and work the
same in every environment:
POST /api/{circle}/{db-slug}/ops/migrate { "migration": "CREATE TABLE IF NOT EXISTS ..." }
POST /api/{circle}/{db-slug}/ops/insert { "table": "processes", "rows": [{ ... }] }
POST /api/{circle}/{db-slug}/ops/query { "sql": "SELECT * FROM processes ORDER BY created_at DESC", "params": [] }
The op contract is two-phase: schema at build time, data at runtime
Each op accepts only its own statement class, and the split is deliberate:
migrate(canonical input fieldmigration) is the only op that accepts DDL —CREATE TABLE,ALTER, indexes. It is idempotent by convention (CREATE TABLE IF NOT EXISTS ...) and belongs in the build phase: run it once when you assemble the app, right after creating the SQL element.queryis SELECT-only (SELECT / WITH..SELECT). Sending DDL or a write through it returns 400Disallowed SQL statement type— by design, so a read path can never mutate.insert/update/delete(ormigratefor bulk/parameterized DML) carry the runtime writes.
The trap this prevents: “lazy schema” — tucking a CREATE TABLE IF NOT EXISTS
into the request handler so the table appears on first use. That habit comes
from environments with one all-powerful connection; here it fails at the first
real request (the handler’s read/write op rejects the DDL), and it fails
after the app looked done, because nothing exercised the handler at build
time. So architect it as two phases from the start: when you design the app,
the schema migration is a build step (run migrate, confirm it applied,
maybe seed a row); handlers and pages then assume the schema exists and speak
only reads and writes through the matching ops.
The composition shape this leads to is also the better architecture: the flow owns data access, the action owns computation.
- A frontend or external caller needs data? Route the request to the SQL
element’s ops (directly, or through an
io/httpgateway or anautomationstep) rather than through an action that proxies to the database. - An action needs to transform data? Have the flow read the rows first and hand them to the action as input; have the flow write the action’s output back. The action stays a pure function — easier to test, no credentials inside untrusted code, and immune to the sandbox’s network boundary.
An agent building on the platform can call these ops with triform_call_op;
frontends reach them over HTTP; automations wire them as steps.
Why not just open the network route?
Because the isolation boundary is the product’s security model: sandboxed code that could dial internal infrastructure would put every circle one dependency compromise away from the platform’s data plane. If a credentialed sandbox-to-database path ships later, it will be announced in these docs; until then, treat the element-ops pattern as the way, not a workaround.
Symptoms → causes, quickly
| Symptom | Likely cause | Fix |
|---|---|---|
Invoke hangs ~30s then times out; SQL element’s own query op is instant | Driver connection from sandbox to injected DSN | Use the SQL element’s ops (this guide) |
ModuleNotFoundError: psycopg2 (or any import) | Package not declared in spec.source.requirements, or the dependency layer hasn’t finished building | Declare the package, run ops/build, wait for a terminal build state — but also reconsider whether you need a DB driver at all |
| Rows “save” but never appear | The write path never actually ran (check the run log), or the code wrote to a local file — sandbox filesystems are throwaway per-invoke | Persist through the SQL element; verify with a read-back query |
400 Disallowed SQL statement type: CREATE TABLE on query | DDL sent through the SELECT-only query op — usually lazy schema creation inside a request handler | Move the DDL to migrate as a build step; keep handlers to reads/writes via the matching ops |
See also
- Public pages that show live data — letting anonymous visitors trigger the read path safely.
- Runs — how async invokes report status and how to poll for results.
- Isolation — why the sandbox boundary exists.