better-drizzle

Explain

Inspect the query plan of any read operation with .explain().

Every read operation in Better Drizzle returns an explainable result: a normal promise with an extra .explain() method. Calling .explain() sends an EXPLAIN for the compiled SQL and returns the plan.

Basic usage

Chain .explain() off any read call to get the query plan:

const {
	driver, // "sqlite" | "pg" | "mysql"
	operation, // "findMany"
	statements, // array of ExplainStatement
} = await client.users.findMany({ where: { active: true } }).explain();

What runs, and when

  • Calling a read method starts the read right away, on the next microtask, whether or not you await it. .explain() does not delay or cancel it.
  • .explain() runs a separate EXPLAIN statement for each SQL statement the read would send. Query hooks do not run for it.
  • A plain EXPLAIN (the default) only plans the statement. With analyze: true, PostgreSQL (EXPLAIN (ANALYZE true)) and MySQL (EXPLAIN ANALYZE) execute the statement to collect real timings. SQLite ignores analyze.
const result = client.users.findMany({ where: { active: true } });

// the read above has already been scheduled
const { statements } = await result.explain();
const users = await result;

analyze executes the query

Reads have no side effects, but analyze: true still costs a full query execution. Avoid it on hot paths and against large tables in production.

Result shape

ExplainResult contains four fields:

FieldTypeDescription
driver'sqlite' | 'pg' | 'mysql'The dialect that produced the plan
operationstringThe operation name (e.g. "findMany", "paginate")
statementsExplainStatement[]One or more statement details
deferredRelationsobject[]Relation queries from include / relation select that depend on root rows, so they are described but not explained

Each deferredRelations entry has path, table, cardinality ('one' \| 'many'), filtered, sorted, paginated, and through (the junction table, for many-to-many).

Each ExplainStatement contains:

FieldTypeDescription
keystringRole of the statement: "data", "total", "count", "exists", "probe:hasNext", "probe:hasPrevious"
sqlstringThe compiled SQL without the EXPLAIN prefix
paramsunknown[]Bound parameter values
appliedOptionsPartial<ExplainOptions>Options the driver actually recognized
ignoredOptionsArray<keyof ExplainOptions>Options the driver silently dropped
rawunknownThe raw database output (shape varies by driver)

Multi-statement operations

Most operations produce a single statement. Two exceptions:

paginate produces two: the data query and the count query.

const result = await client.users
	.paginate({ limit: 10, orderBy: { id: 'asc' } })
	.explain();

console.log(result.statements.map((s) => s.key));
// ["data", "total"]

cursor includes an indexed existence check in the data query for simple primary-key pages. Empty pages and other query shapes may also need a hasNext / hasPrevious probe:

const result = await client.users
	.cursor({
		after: previousCursor,
		limit: 25,
		orderBy: { id: 'asc' },
	})
	.explain();

console.log(result.statements.map((s) => s.key));
// ["data"] for a populated primary-key page

Options

Pass an ExplainOptions object to control the EXPLAIN output:

const plan = await client.users
	.findMany({ where: { active: true } })
	.explain({
		analyze: true,
		verbose: true,
		costs: false,
		timing: true,
		summary: false,
		name: 'users.findMany',
		comment: 'active user lookup',
		timeoutMs: 5000,
	});

Options reference

OptionTypePostgreSQLMySQLSQLiteDescription
analyzebooleanYesYesignoredExecute the statement and report actual execution statistics
verbosebooleanYesignoredignoredInclude output schema of each plan node
costsbooleanYesignoredignoredWhen false, omit estimated startup and total cost
timingbooleanYesignoredignoredWhen false, omit actual time even with analyze
summarybooleanYesignoredignoredWhen false, omit the summary line
namestringlabel onlyignoredignoredA label for the call. Reported in appliedOptions on PostgreSQL but not added to the SQL
commentstringYesignoredignoredBlock comment prepended to the explained query (*/ is escaped)
timeoutMsnumberYesYesYesTimeout for the EXPLAIN itself. Throws RAW_TIMEOUT; 0 or less throws immediately

Unsupported options are silently ignored. Check statement.ignoredOptions to detect when a flag had no effect on the current dialect.

Checking applied vs ignored options

const {
	statements: [stmt],
} = await client.users
	.findMany({ where: { active: true } })
	.explain({ analyze: true, verbose: true });

// On SQLite, both are ignored
console.log(stmt.ignoredOptions); // ["analyze", "verbose"]

// On PostgreSQL, both are applied
console.log(stmt.appliedOptions); // { analyze: true, verbose: true }

Plugin transforms are reflected

.explain() runs the plugin transform pipeline. If a plugin modifies where, select, or other args, the explained SQL reflects those changes:

const client = better(db, {
	schema,
	plugins: [forceActivePlugin], // injects { active: true } into every findMany
});

const {
	statements: [{ sql }],
} = await client.users.findMany({ where: { id: 1 } }).explain();

// The SQL includes the plugin-injected active filter
console.log(sql);
// contains "active" clause

Query hooks (beforeQuery, afterQuery) are skipped during .explain(). Only plugin transforms apply.

Cross-dialect behavior

FeaturePostgreSQLMySQLSQLite
EXPLAIN prefixEXPLAIN (OPTIONS) ...EXPLAIN [ANALYZE] ...EXPLAIN QUERY PLAN ...
analyzeExecutes the statementExecutes the statementignored
verboseExtra columnsignoredignored
costsToggle cost displayignoredignored
timingToggle timingignoredignored
summaryToggle summaryignoredignored
nameReported as applied, not sentignoredignored
commentPrepended as block commentignoredignored
timeoutMsYesYesYes

Which operations support .explain()?

OperationSupports .explain()
findManyYes
findFirstYes
findOneYes
findUniqueYes
countYes
existsYes
paginateYes
cursorYes
create, createManyNo
update, updateMany, updateEachNo
delete, deleteManyNo
upsert, upsertManyNo

On this page