better-drizzle

Explain

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

Every read operation in Better Drizzle returns an explainable result: a lazy thenable, awaited like a 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

  • Read methods are lazy, like Drizzle's own query builders: calling one builds the read but runs nothing. The read, and its query hooks, run only when the result is awaited (or consumed through then, catch, finally, or Promise.all), and at most once. .explain() alone never runs the read.
  • .explain() runs only EXPLAIN statements for the SQL the read would send. Query hooks do not run for it. Cursor probes that depend on page contents appear as conditional statements; .explain() does not fetch the page to decide whether they will run.
  • 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 } });

// nothing has run yet; this only plans the statements
const { statements } = await result.explain();

// the read runs here
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 standard fields and an optional deferredProbes field:

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
deferredProbes{ key, reason }[]?Cursor probes whose SQL needs a returned page-edge row, so they cannot be explained without running the read

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"
conditionstring?When present, describes when a cursor probe runs
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. An empty page may also need a hasNext / hasPrevious probe. The plan includes that probe with condition: 'when the data page is empty', even if the page would be populated:

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

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

console.log(result.statements[1].condition);
// "when the data page is empty"

For other cursor query shapes, the probe binds a cursor from the first or last returned row. .explain() reports it under deferredProbes instead of showing SQL with guessed parameters.

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:

import { better } from 'better-drizzle';
import { definePlugin } from 'better-drizzle/plugins';

// Injects { active: true } into every findMany
const forceActive = definePlugin({
	id: 'force-active',
	transform(operation) {
		if (operation.kind === 'findMany')
			operation.where = (
				operation.where
					? { AND: [operation.where, { active: true }] }
					: { active: true }
			) as typeof operation.where;
		return operation;
	},
});

const client = better(db, { plugins: [forceActive] });

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