Synchronous Backend ECSQL Queries with withQueryReader

Use IModelDb.withQueryReader or ECDb.withQueryReader when backend code requires synchronous query execution. These APIs are currently beta.

withQueryReader prepares a query, invokes a callback with an ECSqlSyncReader, and returns the callback's result. The reader steps synchronously on the owning database connection, one row at a time, without buffering result batches. Query execution blocks the calling JavaScript thread.

For asynchronous queries on either the frontend or backend, use createQueryReader. Its reader is consumed asynchronously and buffers batches of results. See Choosing a query reader for the differences in execution, connection selection, options, and lifetime.

The withQueryReader Function

withQueryReader<T>(ecsql: string, callback: (reader: ECSqlSyncReader) => T, params?: QueryBinder, config?: SynchronousQueryOptions): T
  • ecsql is the ECSQL query to execute.
  • callback consumes the reader and can return materialized rows or a computed value. Finish using the reader before the callback completes.
  • params is a QueryBinder containing any parameter bindings.
  • config is a SynchronousQueryOptions object. Use rowFormat and abbreviateBlobs to control result formatting. The inherited convertClassIdsToClassNames option is deprecated; project class names explicitly with ec_classname(). See ECSQL Row Formats.

The synchronous options do not include usePrimaryConn: this reader already uses the owning connection and can read its unsaved changes. They also omit concurrent-query controls such as priority, restartToken, and quota. To limit the result count, use an ECSQL LIMIT clause; the async reader's config.limit option is not available here.

Iterating Over Query Results

The examples below use an open IModelDb or ECDb named iModel, with these imports:

import { Id64String } from "@itwin/core-bentley"; import { QueryBinder, QueryRowFormat } from "@itwin/core-common";

Synchronous iterator

Use for...of to step through the result. Each iteration exposes a QueryRowProxy:

const ids: Id64String[] = []; iModel.withQueryReader("SELECT ECInstanceId FROM bis.Element LIMIT 5", (reader) => { for (const row of reader) ids.push(row[0]); });

Manual stepping

step() returns true when a row is available through reader.current, or false when the result is exhausted:

const ids: Id64String[] = []; const classIds: Id64String[] = []; iModel.withQueryReader("SELECT ECInstanceId, ECClassId FROM bis.Element LIMIT 5", (reader) => { while (reader.step()) { ids.push(reader.current.ECInstanceId); classIds.push(reader.current.ECClassId); } });

Collecting rows and returning a value

reader.toArray() collects all remaining rows. By default, each row is an array of values in SELECT-column order. Select UseECSqlPropertyNames to collect objects:

const rows = iModel.withQueryReader("SELECT ECInstanceId, ECClassId FROM bis.Element LIMIT 5", (reader) => { return reader.toArray(); }, undefined, { rowFormat: QueryRowFormat.UseECSqlPropertyNames });

Materialized rows can be used after the callback completes. Collecting all rows uses memory proportional to the result size; prefer iteration for large results.

Handling Row Values

The async and sync readers share the same row formats and materialization methods:

  • Use row[index] or row.propertyName to read the current row.
  • Use row.toRow() to retain a plain object. It uses ECSQL names unless the deprecated UseJsPropertyNames format was selected.
  • Use row.toArray() for the current row's raw values, or reader.toArray() for all remaining rows.

The proxy follows the reader's current row. Materialize a row before retaining it across calls to step() or iterator advances.

JavaScript-friendly property names

Use aliases with QueryRowFormat.UseECSqlPropertyNames, and project class names with ec_classname():

const row = iModel.withQueryReader("SELECT ECInstanceId AS id, ec_classname(ECClassId, 's.c') AS className FROM bis.Element WHERE ECInstanceId=?", (reader) => { return reader.step() ? reader.current.toRow() : undefined; }, new QueryBinder().bindId(1, "0x1"), { rowFormat: QueryRowFormat.UseECSqlPropertyNames });

This avoids the deprecated UseJsPropertyNames format while producing an explicit, stable result contract. See property names and values.

Parameter Bindings

Supply a QueryBinder before execution. This example binds a class name and converts it to a class ID in the query with ec_classid():

const rows = iModel.withQueryReader("SELECT ECInstanceId, ECClassId FROM bis.Element WHERE ECClassId=ec_classid(:className)", (reader) => { return reader.toArray(); }, new QueryBinder().bindString("className", "BisCore.Subject"));

The shared binding examples also apply to this API. Pass the binder as the third argument to withQueryReader. Bind navigation and struct members individually; whole navigation/struct values and arbitrary ECSQL array parameters are not supported by the readers. See ECSQL parameter types.

ECSqlSyncReader.getMetaData returns metadata for the selected columns and can be called inside the callback before or after stepping. The synchronous call returns the metadata directly; the async reader's getMetaData() returns a promise.

Reader Lifetime

Keep the reader inside its callback. Return materialized rows or computed results rather than the reader or its current-row proxy. If the callback returns a Promise, the reader remains valid until that promise settles; each reader operation is still synchronous. After the callback completes, its statement is released and may be reused from the statement cache. Statement reuse does not buffer query results.

Do not close the database or call clearCaches() while using the reader; these actions invalidate its statement.

See Migrating backend ECSQL code for guidance on replacing withPreparedStatement.

Last Updated: 02 October, 2026