Download all docs

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 operationsmigrate 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 field migration) 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.
  • query is SELECT-only (SELECT / WITH..SELECT). Sending DDL or a write through it returns 400 Disallowed SQL statement type — by design, so a read path can never mutate.
  • insert / update / delete (or migrate for 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/http gateway or an automation step) 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

SymptomLikely causeFix
Invoke hangs ~30s then times out; SQL element’s own query op is instantDriver connection from sandbox to injected DSNUse 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 buildingDeclare 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 appearThe write path never actually ran (check the run log), or the code wrote to a local file — sandbox filesystems are throwaway per-invokePersist through the SQL element; verify with a read-back query
400 Disallowed SQL statement type: CREATE TABLE on queryDDL sent through the SELECT-only query op — usually lazy schema creation inside a request handlerMove the DDL to migrate as a build step; keep handlers to reads/writes via the matching ops

See also