Skip to content

Zero-Allocation Fetch

Every fetch() allocates a fresh Row. That’s the right default — Row is val, safe to hold across fetches, safe to send across actors — but a hot loop over a million rows allocates a million rows.

MutableRow is the same container, reusable. Allocate one, hand it to fetch_into() on each iteration, and the column values are overwritten in place.

class ref MutableRow
  new ref create(num_cols: USize = 0)
  fun column(i: ColIndex):     SqlValue ?
  fun int(i: ColIndex):        (I64 | SqlNull) ?
  fun float(i: ColIndex):      (F64 | SqlNull) ?
  fun text(i: ColIndex):       (String val | SqlNull) ?
  fun bool(i: ColIndex):       (Bool | SqlNull) ?
  fun date(i: ColIndex):       (SqlDate | SqlNull) ?
  fun time(i: ColIndex):       (SqlTime | SqlNull) ?
  fun timestamp(i: ColIndex):  (SqlTimestamp | SqlNull) ?
  fun decimal(i: ColIndex):    (SqlDecimal | SqlNull) ?
  fun is_null(i: ColIndex):    Bool ?
  fun size():                  USize

Same accessor API as Row. The differences are the capability and the fetch method.

fetch_into instead of fetch

Both Statement and Cursor have fetch_into:

fun ref fetch_into(row: MutableRow): (MutableRow | EndOfRows | FetchError)

Same three branches as fetch(). On success you get the same MutableRow back — conceptually unchanged, actually overwritten.

A reuse loop

use "lib:odbc"
use "odbc"

actor Main
  new create(env: Env) =>
    let dsn_name =
      try env.args(1)?
      else "psqlred"
      end

    match Odbc.connect(Dsn("DSN=" + dsn_name))
    | let conn: Connection =>
      conn.exec("DROP TABLE IF EXISTS tut_mutable")
      match \exhaustive\ conn.exec(
        "CREATE TABLE tut_mutable (id INTEGER, label VARCHAR(32))")
      | let _: (USize | NoRowCount) => None
      | let e: ExecError =>
        env.err.print("create: " + e.string()); conn.close(); return
      end
      match \exhaustive\ conn.exec(
        "INSERT INTO tut_mutable VALUES "
          + "(1, 'alpha'), (2, 'bravo'), (3, 'charlie')")
      | let _: (USize | NoRowCount) => None
      | let e: ExecError =>
        env.err.print("insert: " + e.string()); conn.close(); return
      end

      match \exhaustive\ conn.prepare(
        "SELECT id, label FROM tut_mutable ORDER BY id")
      | let stmt: Statement =>
        match \exhaustive\ stmt.execute()
        | Executed =>
          // Reuse one MutableRow across every fetch — no per-row
          // container allocation.
          let row = MutableRow
          var done: Bool = false
          while not done do
            match \exhaustive\ stmt.fetch_into(row)
            | let r: MutableRow =>
              try
                let id =
                  match \exhaustive\ r.int(ColIndex(1))?
                  | let v: I64 => v.string()
                  | SqlNull => "NULL"
                  end
                let label =
                  match \exhaustive\ r.text(ColIndex(2))?
                  | let v: String val => v
                  | SqlNull => "NULL"
                  end
                env.out.print(id + " " + label)
              end
            | EndOfRows => done = true
            | let e: FetchError =>
              env.err.print("fetch: " + e.string()); done = true
            end
          end
          stmt.close_cursor()
        | let e: ExecError =>
          env.err.print("execute: " + e.string())
        end
        stmt.close()
      | let e: PrepareError =>
        env.err.print("prepare: " + e.string())
      end

      conn.exec("DROP TABLE IF EXISTS tut_mutable")
      conn.close()

    | let e: ConnectError =>
      env.err.print("connect: " + e.string())
    end
./build/10-mutable-row
1 alpha
2 bravo
3 charlie

What you save

Each iteration reuses the row container. SqlText and SqlDecimal columns still allocate fresh String vals, but the Array[SqlValue] and the SqlInteger/SqlBool/etc. boxes inside are reused.

For tiny rows (a few integer columns) over millions of rows this is measurable. For rows dominated by large text columns it’s marginal — most of the allocation is the strings.

Rule of thumb: default to fetch() and Row. Switch to MutableRow when a profile shows row-container allocation at the top.

Capability: ref, not val

Row is val — sendable, multiply-aliasable, stable across close().

MutableRow is ref — not sendable, single-reference, and its values are invalidated by the next fetch_into().

The capability difference is what makes reuse safe. ref promises no aliasing, which is what lets the library mutate the internal array without breaking Pony’s concurrency guarantees.

Don’t mix fetch() and fetch_into() on the same cursor

The two methods aren’t designed to interleave on one open cursor. Pick one per result set. Using both against different result sets on the same connection is fine — just don’t switch mid-iteration.