Steps

The sql step

A sql step runs one read-only query against a database the plan declares in connections, and puts the rows in front of the assertions. It never writes, and that is enforced twice: the query is inspected at load and refused if it could write, and the connection is opened read-only. Every query also runs in a transaction that is rolled back. Read-only plus rollback is redundant on purpose, because the role is the layer that gets forgotten. The point of the step is the check an API cannot make for you: the 201 came back, and the row is really there, for the right tenant. Plan format has the keys every step shares and the connections block; this page has what a sql step adds.

Key Type Meaning
connection string a key of connections
query string a literal: one SELECT, WITH ... SELECT, EXPLAIN, SHOW or VALUES
args list of strings templated, bound as parameters $1, $2, ..., never interpolated
timeout duration alternative to the step's timeout; not both

A plan

sqlite.yaml creates an order through the API and then reads the row the fixture server persisted, through a SQLite file beside the plan:

connections:
  orders: { driver: sqlite, dsn: orders.db }
resources:
  - name: db/orders
tasks:
  - name: order-is-persisted
    needs: [create-order]
    holds: [db/orders]
    steps:
      - sql:
          connection: orders
          query: |
            select status, total_cents, tenant_id
            from orders where id = $1
          args: ["{{ tasks.create-order.results.orderId }}"]
        assert:
          - rows count 1
          - rows[0].status == "pending"
          - rows[0].total_cents == 2000
          - rows[0].tenant_id == env.tenantId

postgres.yaml is the same plan with orders: { driver: postgres, dsn: "${VERO_TEST_PG_DSN}" }. Against a Postgres on this repository's k3s cluster, with the fixture server writing through one role and the plan reading through another:

run 01M3ZS5T539FAC71T4RCMASKXX  postgres.yaml  --jobs 4  lifecycle unique

3 passed  0 failed  0 errored  0 timed out  0 skipped  0 blocked  0 not run   (3 tasks, 2 requests, 0.0s)

A sql step counts no request. Running the examples has the fixture flags and the two Postgres roles.

Connections

A connection names a driver, sqlite or postgres, and a dsn. The DSN may use ${VAR}, ${VAR:-default}, {{ env.x }} and {{ run.id }}, or be { secretRef: NAME }, and never a task result: a connection belongs to the whole plan. It is rendered and opened the first time a step uses it, and closed when the run ends. A connection no step uses is a load error, since an unused connection is a typo's other half. A connection declared writes: true is for fixture steps, and a sql step may not read through it: declare the same database a second time without writes for the reads.

SQLite is opened with mode=ro whatever the DSN said. A relative path is relative to the plan file's directory, :memory: is left alone, and a file: prefix is accepted. Unless the DSN sets its own busy_timeout pragma, vero adds a 5 s one, so a step waits for a writer's lock instead of failing: the application under test may be writing the file while the step reads it, and SQLITE_BUSY is not an answer about the data.

Postgres runs every step in BEGIN ISOLATION LEVEL READ COMMITTED READ ONLY, and asks the server SHOW transaction_read_only inside that transaction before the query. A pooler or a proxy that dropped READ ONLY is refused: the server says transaction_read_only = off inside the step's READ ONLY transaction; refusing to run the query.

The role is checked too. vero plan and vero run both open every Postgres connection that is not writes: true and read what its role could write from the catalogs, so vero plan is not entirely offline when a plan has one. A role that could write draws a warning on stderr; the step still runs, since the transaction is read-only either way:

warning: connections.orders: role vero_fixture can write (CREATE on schema public, DELETE on public.countries, DELETE on public.orders, DELETE on public.pg_nums, DELETE on public.pg_probe, INSERT on public.countries); a sql step's connection should use a read-only role (chapter 6.3). vero still runs every query READ ONLY and rolls it back

The check names a superuser, CREATE on the database or on schema public, and up to five INSERT, UPDATE, DELETE or TRUNCATE table grants. A connection vero could not reach says connections.orders: could not check its role: ... instead. Neither is a lint, so --strict does not turn them into errors.

What the query may do

The query is inspected at load. Its first word is SELECT, WITH, EXPLAIN, SHOW or VALUES, it is one statement, and none of these appear anywhere outside a string literal, a quoted identifier or a comment: INSERT, UPDATE, DELETE, MERGE, UPSERT, REPLACE, DROP, CREATE, ALTER, TRUNCATE, RENAME, COMMENT, GRANT, REVOKE, COPY, VACUUM, REINDEX, ATTACH, DETACH, INTO, LOCK, CALL, DO, PRAGMA, BEGIN, COMMIT, ROLLBACK, SAVEPOINT, RELEASE, CLUSTER, REFRESH, NOTIFY, LISTEN, DISCARD, RESET, SECURITY, IMPORT, LOAD. That refuses a data-modifying CTE, SELECT ... INTO, and an EXPLAIN ANALYZE of a write, which would run it. ANALYZE on its own is allowed. Dollar-quoted strings, -- and /* */ comments and doubled quotes are read the way the database reads them.

The query is a literal. Values go in args, which are templated and bound as parameters $1, $2, and so on; a {{ in the query is a load error, because SQL built from strings could not be inspected here. The step's deadline is its timeout, or sql.timeout, 30 s when neither is set; setting both is a load error. repeat does not apply.

Rows

rows is a list of maps, one per row, keyed by column name, in the order the query returned them. It is empty when the query ran and matched nothing, and nil when the query did not run: the two are different facts, and the failure output says which.

Values compare exactly. An integer column is an exact number, so a 64-bit id past 2^53 stays itself: rows[0].id == 9007199254740993 holds and rows[0].id == 9007199254740992 does not. A float is an exact decimal, bytes are a string, and a time is an RFC 3339 string in UTC with nanoseconds. On Postgres, NUMERIC and DECIMAL are exact numbers (NaN and Infinity stay strings), JSON and JSONB are decoded with the strictness a response body gets, duplicate keys refused, and a DATE is YYYY-MM-DD. A column vero cannot convert fails the step with column <name>: <reason>.

Names in scope

Name Meaning
rows the rows, a list of column maps
duration.total the whole step: begin, verify, query, rollback
env, tasks, run as in every step

rows count 1 is the assertion that makes a sql step honest. Without it, rows[0].status == "pending" on an empty result is an error about an index, which fails the task too, but says less. Reading a result of another task in args or in an assertion needs a graph path from that task; the template in args above is the edge, and the example adds needs: [create-order] beside it. duration on its own is refused: name duration.total. Assertions has the syntax.

What a failure shows

The failure block prints the query as written, line by line, every bound argument, how many rows came back and that the transaction was rolled back, then the first five rows, then each assertion that did not hold with the values it read. The order this run looked for does not exist:

FAIL  order-is-persisted › step 1 › assert #1                     sqlite.yaml:43

  sql  select status, total_cents, tenant_id
  sql  from orders where id = $1
    $1 = "no-such-order"
    0 rows, 0.6ms, read-only and rolled back

  assert  rows count 1    failed
          rows = [] (0 rows)
          sqlite.yaml:43
  assert  rows[0].status == "pending"    error
          column 5: index out of range: 0 (array length is 0)
          rows[0].status: index out of range: 0 (array length is 0)
          sqlite.yaml:44
  assert  rows[0].total_cents == 2000    error
          column 5: index out of range: 0 (array length is 0)
          rows[0].total_cents: index out of range: 0 (array length is 0)
          sqlite.yaml:45
  assert  rows[0].tenant_id == env.tenantId    error
          column 5: index out of range: 0 (array length is 0)
          rows[0].tenant_id: index out of range: 0 (array length is 0)
          env.tenantId = "tenant-42"
          sqlite.yaml:46

2 passed  1 failed  0 errored  0 timed out  0 skipped  0 blocked  0 not run   (3 tasks, 2 requests, 0.0s)

A query the database refused ends the step errored with the database's message, and the block reads no rows: the query did not run. A query the deadline cut off is timed_out with the query's deadline fired: .... Rows wider than 200 characters are cut in the block; the event log carries the whole query, its bound arguments, the row count and the column names as a query event (vero.events/v1):

{"schema":"vero.events/v1","ts":"2026-10-03T02:23:33.088Z","runId":"01M3ZS5T6HZQ60C7XQGBVHN1S1","type":"query","task":"order-is-persisted","step":1,"connection":"orders","query":"select status, total_cents, tenant_id\nfrom orders where id = $1\n","args":["01M3ZS5T6KBW7SMRGXEAAPDHTD"],"rows":1,"columns":["status","total_cents","tenant_id"],"durationMs":8.285}

Load errors

Plan Load error
no connection a sql step needs connection, the name of an entry in connections
a connection the plan does not declare connection order is not declared in connections (did you mean orders?)
a connection with writes: true connection orders has writes: true, and a sql step reads through a read-only connection (chapter 6.3); declare a second connection without writes for it
no query a sql step needs a query
a {{ in the query a sql query is a literal; put values in args, which are bound as parameters, never interpolated
delete from orders where id = $1 a sql step reads only: SELECT, WITH ... SELECT, EXPLAIN or SHOW, not DELETE (chapter 6.3). Writes belong in an exec step or an API call
with gone as (delete from orders returning id) select count(*) from gone the query contains DELETE; a sql step is read-only (chapter 6.3), so DELETE is refused even inside a SELECT or a WITH
select 1; select 2 a sql step runs one statement; this one has 2. Writes belong in an exec step or an API call (chapter 6.3)
a connection no step uses connection spare is declared but no sql step uses it
a connection without driver or with another one connection orders needs driver: sqlite or postgres, driver must be sqlite or postgres, got "mysql"
no dsn connection orders needs a dsn
timeout on the step and inside sql set the timeout once, on the step or inside sql, not both

The messages cite chapter 6.3 of the design dossier that vero was built from; the rule is the one this page describes.

This page is docs/content/steps/sql.md in the repository.

verodocs