SQL

Query plans

What EXPLAIN shows, and what the optimizer does before it.

console.log(
  await db.explain(`
    SELECT c.city, COUNT(*) AS orders
    FROM orders o JOIN customers c ON c.customer_id = o.customer_id
    WHERE o.status = 'completed' AND o.total > 100
    GROUP BY c.city
  `),
);

The plan is the optimized one — what will actually run, not the shape you wrote.

What the optimizer does

Early filtering. Filters move as close to their table scan as they can get, so rows are discarded before a join builds a hash table out of them. A filter on a derived table or CTE moves inside the block when it names a plain projected column, but never past a LIMIT or OFFSET, literal or placeholder: filtering before the window would change which rows the window keeps.

Same-column OR becomes IN. An OR of equalities on one column against literals or parameters — status = 'a' OR status = 'b' — rewrites to status IN ('a', 'b'), which runs as a vectorized membership test and can skip blocks by value range. Any other OR keeps its shape and is evaluated per row.

Constants cross join keys. An inner equi-join or a semi-join (a decorrelated EXISTS) makes its two key columns equal, so an equality, IN list, or range on one side reaches the other: WHERE c.customer_id = 42 with ON o.customer = c.customer_id also filters orders by customer = 42 before the join, through a secondary index when one exists, or through the unique key when the constant names it. A placeholder is a constant here too: WHERE c.customer_id >= ? mirrors the bound value the same way. A LEFT join keeps its null-extended rows, which a mirrored predicate would fail, so it gets none.

Calendar equalities become ranges. DATE_TRUNC('month', placed_at) = TIMESTAMP '2025-06-01 00:00:00' and EXTRACT(YEAR FROM placed_at) = 2025 are planned as placed_at >= start AND placed_at < start + 1 unit, which skips blocks by value range and runs on the raw datetime kernel instead of computing a calendar value per row. A timestamp not aligned to its unit can never equal a truncation, so that predicate is a constant false. With a placeholder (DATE_TRUNC('day', placed_at) = ?) the same rewrite runs when the value is bound, so a parameterized query reads the same blocks as its literal form.

Skipping by value range. Every block records the minimum and maximum of the column it holds. A comparison that cannot be true inside a block's range skips the block without decoding or decompressing it. This is why WHERE placed_at >= '2025-01-01' on a table written in date order reads a fraction of the bytes.

Reading only needed columns. Only the columns a query mentions are read. A SELECT of two columns from a fourteen-column table reads two columns' worth of blocks.

Join reordering. Minnow estimates how many rows each side will produce and builds its lookup from the smaller side. An equality join can use a table's unique-key lookup instead. Pure comma/CROSS-join groups keep the streamed base fixed, then choose connected sources from the WHERE equality graph and turn one safe equality per source into a hash-join key. A permuted FROM list therefore does not force the executor through disconnected Cartesian products.

Decorrelation. A correlated EXISTS or NOT EXISTS subquery is rewritten into a semi-join or anti-join rather than executed once per outer row; both equality and range comparisons take this path. Correlated scalar aggregate expressions and ordinary single-row projections join distinct outer probe values to the inner rows; per-probe window ranks implement their ordering, limits, and offsets before the grouped value joins back. The probe block carries the outer WHERE conjuncts that read only its own sources, so WHERE o.id BETWEEN ? AND ? probes a hundred keys rather than every key in the table, and every equality-correlated scalar — the plain grouped aggregate included — mirrors those key conjuncts onto its inner scan as well, so a WHERE c.id = ? outside becomes o.customer_id = ? inside and the inner scan takes that column's index. Correlated IN uses equality-key joins directly or distinct probes for a range comparison. An uncorrelated column IN (SELECT …) is a join to the subquery's distinct values, so a constant on the column reaches the subquery's own scan through the join key instead of the subquery materializing every value first; NOT IN and a probe that is an expression rather than a column keep the membership test. Correlated NOT IN carries explicit empty-set and NULL flags alongside its anti-join, including for range comparisons. Below OR, NOT, CASE, or a select expression, IN, NOT IN, ANY, and ALL group true, false, unknown, and empty-set state per distinct outer probe tuple. Generated aliases are unique across the complete plan tree. When an outer reference occurs only inside a deeper scalar or boolean expression, distinct outer probe tuples lift that reference into the nested plan before ordinary decorrelation. See the feature matrix.

Top-N. ORDER BY … LIMIT n keeps only the best n rows as it scans instead of sorting the whole input, while n is under a tenth of the rows scanned. A page deeper than that keeps every row and sorts once, which is faster than maintaining the bound.

Sorting. Numbers, datetimes, booleans, and strings sort as typed keys, not by comparing values: strings are ranked through their distinct set first. Input that already arrives in order is recognized and not sorted again.

Execution

Keyed point reads. A single-table WHERE that is a conjunction of column = value equalities covering the table's unique key — scalar or composite — names at most one row, so it skips the batch pipeline entirely and answers from cached decoded blocks: binary search over ascending key components, dictionary probes for string keys. Projected columns may carry a logical domain — NUMERIC, UUID, JSON, DATE, enums, arrays — and render exactly as the ordinary executor renders them, declared-scale padding included. Pending update and delete deltas do not cost a scalar key its fast path: the key's own history is replayed in visible order — an insert lands the row, a delete removes it, an update patches the columns it changed — so a row read right after it was updated still answers in microseconds. A composite key with pending deltas, a cross-type comparison, a view, or a domain-typed column in the WHERE clause falls back to the ordinary executor, which stays the authority on answers and errors. EXPLAIN reports eligibility as key-covering equality answers as a point read when the visible history allows.

One value at a time, not one row. A predicate that reads exactly one string column — LOWER(status) = 'paid', COALESCE(region, 'none') = 'none', a CASE over one column — is evaluated once per distinct value of that column and applied by dictionary code. So is a conditional aggregate: SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) decides the branch once per distinct status and then reads the number column directly. A GROUP BY over one string column or an expression of it, or over one numeric or datetime column, resolves its group the same way, and a GROUP BY over several bare columns — string, number, or datetime — packs one code per column into a single slot. Plain number and string comparisons, number arithmetic, whole-number ROUND, and the calendar functions run as typed operations without domain checks per row.

The query runner works on batches of column values rather than one row at a time, and scans stream block by block so a table is never required to be resident. When a hash table, sort, or grouping payload would exceed the memory budget, the operator spills to storage and continues rather than failing.

Measuring a query honestly

Repeating one statement over unchanging data measures the result memo, not execution. In a timing loop, turn it off:

const start = performance.now();
await db.query(sql, { memoize: false });
const elapsed = performance.now() - start;

onStats reports what an execution actually cost, including peak modeled memory — something the engine can report because it reserves before it allocates. It works through MinnowDatabaseClient too; the callback stays on the page and receives an ordered worker event:

await db.query(sql, {
  memoize: false,
  onStats: (stats) => {
    console.log(stats);
  },
});

The benchmarks page runs the full read and write suites against SQLite WASM and PGlite in your own browser, on a dataset size you pick. Read columns measure engine execution inside the benchmark worker, with optional cached repeats shown separately. The live-query suite uses MinnowDatabaseClient and times a commit until every affected subscription has been notified, including notification delivery across the worker channel. Only Minnow’s subscription drivers are implemented in this harness.

On this page