Skip to content

Prepare a statement, and see what one would do - #25

Merged
tamnd merged 1 commit into
mainfrom
prepared-and-plans
Aug 19, 2026
Merged

Prepare a statement, and see what one would do#25
tamnd merged 1 commit into
mainfrom
prepared-and-plans

Conversation

@tamnd

@tamnd tamnd commented Aug 19, 2026

Copy link
Copy Markdown
Owner

prepare compiles a statement once and gives back an object that runs it, explain says what the engine would run without running it, and profile runs it and says what it really did. This is the Python half of the milestone item that the api-map scorecard was blocked on; the Node half landed in zu-node #19.

Preparing

find = conn.prepare("MATCH (p:person) WHERE p.name = $name RETURN p.uid AS uid")
find.params  # ['name']
find.execute({"name": "ada"}).fetchall()  # [(10,)]
find.close()

The README says what this saves, which is less than the word usually promises, and the numbers are printed rather than hidden. The database is in this process, so there is no round trip to skip, and the engine already keeps the plan it compiled for a statement under the text of that statement. Over two thousand rows on this machine a prepared statement bound per run costs 30.1 microseconds, the same text handed to execute and bound per run costs 30.1, and a statement whose text differs every time costs 35.8. A statement of 1.2 KB with sixty predicates says the same thing: 34.9 microseconds either way.

What preparing does buy is worth having anyway. The compile happens at the line that asked for it, so a statement that will not compile fails where it was written rather than in the middle of a loop. params says which names the statement wants, which is the difference between binding what it asked for and finding out at the run that a key was misspelled. And it gives the intent a name: a prepared statement is an object a program can hold on a class, pass around and close.

A prepared statement runs through the same call execute does, by way of a Source enum inside the connection, so it gets the GIL released, the interrupt handling and the catalog names by being that call rather than a second path beside it.

Explaining and profiling

plan = conn.explain("MATCH (p:person) WHERE p.name = $name RETURN p.uid AS uid")
print(plan)
# Project p.uid AS uid
#   Filter p.name = $name
#     ScanNodes p: person
plan.root.children[0].children[0].tables  # ['person']

Both calls answer twice over. print gives the engine's own listing, rendered by the engine rather than assembled here so the two cannot drift apart from one release to the next, and root gives the same plan as objects, which is what a program walks: a test that wants to know a scan became a seek asks the tree rather than matching on a string written to be read. An operator carries op for what it is and name for what the listing calls it, which differ inside a bracket, so an expand under an OPTIONAL MATCH is op Expand, name OptionalExpand, bracket Optional, and a program should match on op because that is the one that does not change with the company an operator keeps.

explain takes no parameters, which is not an oversight: a plan is chosen from the shape of the statement and the values are bound when it runs, so a plan asked for with values would suggest the values had changed it. profile does take them, because it is a run. Its counts are int, exact at any size, and an operator the optimizer had nothing to say about carries None for estimate, bound and qerror rather than a zero somebody would read as an estimate of none. stage.ops runs from the operator that read to the one that fed the sink, which is the reverse of the order the listing prints them, and there is a test that pins exactly that. Explaining costs 1.3 microseconds since the plan is already cached; profiling costs what the statement costs plus the counters, and a statement that writes is refused rather than profiled.

The rest of the surface

zudb.aio mirrors all three, with AsyncPrepared opened by async with, and statement, params and closed as properties there because the names were read at the compile and nothing has to wait to say them again. Seven new classes are exported from zudb and stubbed in _zudb.pyi, which the griffe gate checks against the built module. Plan and Profile have __str__ and _repr_html_, so print(plan) in a shell and a plan in a notebook cell both give the listing, preformatted because the indentation is what says which operator pulls from which.

Tests

Forty-two: nineteen for the lifetime of a prepared statement, covering closing twice, a closed one refusing, a connection that closed first, the with block and the block an exception leaves, and twenty-three for the shape of a plan and a profile, including the listing asserted against the tree it was rendered from rather than against a string written out here, which is a test that would otherwise fail every time the optimizer learned to print one better.

Local gate is green: pytest 644 passed and 5 skipped, ruff check, ruff format --check, cargo clippy --all-targets -- -D warnings, cargo fmt --check.

`prepare` compiles a statement once and gives back an object that runs
it, `explain` says what the engine would run without running it, and
`profile` runs it and says what it really did.

The honest part is in the README. There is no round trip to save here,
and the engine already caches the plan it compiled under the text of
the statement, so a prepared statement bound per run costs 30.1
microseconds and the same text handed to `execute` costs 30.1 as well.
What preparing buys is the compile happening at the line that asked for
it, and `params` saying which names the statement wants, and an object
a program can hold and close rather than a string it passes around.

A plan and a profile each answer twice: `print` gives the engine's own
listing, rendered by the engine so the two cannot drift apart, and
`root` gives the same plan as objects for a program to walk. An
operator carries `op` for what it is and `name` for what the listing
calls it, which differ inside a bracket, and a program should match on
`op` because that is the one that does not change with the company an
operator keeps. Profile counts are `int`, exact at any size, and an
operator the optimizer had nothing to say about carries `None` rather
than a zero somebody would read as an estimate.

A prepared statement runs through the same call `execute` does, so it
gets the GIL released, the interrupt handling and the catalog names by
being that call rather than a second path beside it. `zudb.aio` mirrors
all three, and `statement`, `params` and `closed` are properties there
because the names were read at the compile and nothing waits to say
them again.

Forty-two tests: nineteen for the lifetime of a prepared statement and
twenty-three for the shape of a plan and a profile, including the
listing asserted against the tree it was rendered from rather than
against a string written out here.
@tamnd
tamnd merged commit 863da61 into main Aug 19, 2026
10 of 11 checks passed
@tamnd
tamnd deleted the prepared-and-plans branch August 19, 2026 09:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant