Connection.Explain(sql, analyze), Connection.ExplainAsync and Statement.Plan return a QueryPlan: a tree of PlanNodes with a text rendering
(Render()) and a structured form (Root, Children, Kind, Table, Index, Detail, Reason, Rejected, Runs, EstimatedRows,
ActualRows, Loops). The EXPLAIN and EXPLAIN QUERY PLAN statements are separate and not covered here. Plans are not part of the compatibility promise and their wording may change.
The plan is the program's structure
The compiler records each step as it generates the program: the access-path object that drives a loop is the same object the loop is emitted from, the index candidates are the ones the planner scored, and subqueries, materializations, sorts and aggregation are noted where their code is made. Nothing re-derives a choice afterwards, so the plan cannot disagree with the program. Compiling a statement without asking for a plan builds no recorder; every hook is a null check.
Shape
The root is the statement (SELECT, INSERT, UPDATE or DELETE; statements without table access, such as schema changes, pragmas and transaction
control, have no plan and Explain returns null). A node's children are the steps made inside its loop, in the order they run:
ScanandSearchread one table, index or materialized result. A join nests: the inner table is the child of the outer table's node, and the join order is the nesting order.Detailshows how, for exampleUSING INDEX orders_user (user_id=u.id)orUSING INTEGER PRIMARY KEY (id>10 AND id<=20).Reasonsays in words which terms of the WHERE clause the access path answers, why a covering index was read instead of the table, or that anORDER BYorGROUP BYis satisfied by its order.Rejectedlists the other indexes the planner scored and why each was not used.Materialize(a subquery in FROM, a view or a common table expression),RecursiveCte,TableFunction,Subquery(scalar,EXISTS,IN),Compoundwith anArmper SELECT.Runssays whether a subquery or materialization runsonceorper outer row. A subquery sits under the node whose loop it was compiled in, even when it runs only once.- After the loops:
Window,Aggregate(GROUP BYkeys; whether groups arrive in key order or go through a sorter),Distinct,Sort(ORDER BY).
Estimates
Estimates are the planner's guess, not statistics: sqlite_stat1 is not read. A table is assumed to hold about a million rows; an equality on
k columns of an index leaves 10 / k rows (one row for the full key of a unique index or a rowid); each range bound divides by 4 (16 for both bounds).
Nodes that are not table access carry none.
Actual rows
Connection.Prepare(sql, collectActualRows: true) compiles one counter instruction at the start of each table loop and one at the top of its body; a
statement prepared otherwise carries none, so the running loop is unchanged (the allocation tests show 0 bytes per step either way; the counting op is an
array increment). ActualRows is the number of rows the loop yielded in all (before the rest of the WHERE clause filters them), Loops the number of
times the loop was started; for an inner table of a join, the totals over all outer rows. The counters accumulate over executions of the same
statement object. Explain(sql, analyze: true) prepares with counting, runs the statement with no values bound, and returns the plan; a statement that
changes the database runs inside a savepoint that is rolled back.