Parameters#
Pass values to a statement as parameters, never by formatting them into its text. A parameter cannot be mistaken for part of the statement, so it cannot change what the statement does, and the server can reuse a prepared plan across values.
Placeholders#
Placeholders are psycopg’s. %s takes the next value from a sequence:
>>> conn.execute_query(
... "MATCH (m:movie) RETURN m.title ORDER BY m.year DESC LIMIT %s", (2,)
... ).records
[('The Matrix Resurrections',), ('Blade Runner 2049',)]
%(name)s takes a value from a mapping by name:
>>> conn.execute_query(
... "MATCH (m:movie) WHERE m.title = %(title)s RETURN m.year", {"title": "Inception"}
... ).records
[(2010,)]
A literal % in a statement that has parameters is written %%. Cypher’s own $1 notation is not
a placeholder here: psycopg counts only its own, and sending $1 with a value refuses it before
anything is sent.
How each value is sent#
Python |
Sent as |
|
|---|---|---|
|
|
see Strings below |
|
|
psycopg’s defaults |
|
|
NaN and the infinities are refused |
|
|
the property map of a |
|
a PostgreSQL array |
so |
|
|
an element’s identity |
|
|
|
|
|
any JSON value, wrapped explicitly |
|
psycopg’s defaults |
Inside a Cypher expression most parameters are read as jsonb, so WHERE m.year = %s compares the
property with the number you passed and WHERE m.title = %s with the string.
Strings#
A Python str is sent as text. That makes a string mean a string, in Cypher and in SQL:
>>> conn.execute_query("CREATE (:probe {code: '123'})")
Result(records=[], ...)
>>> conn.execute_query("MATCH (n:probe) WHERE n.code = %s RETURN n.code", ("123",)).records
[('123',)]
>>> conn.execute_query("MATCH (n:probe) WHERE n.code = %s RETURN n.code", (123,)).records
[]
The string "123" finds the property holding the string "123", and the number 123 does not,
because the two are different JSON values.
Left with no type, the server would read a string parameter as JSON text: "Inception" would then
be a syntax error, and "123", "true" or "null" would be read as a number, a boolean or null and
quietly match nothing.
The cost is in plain SQL. A string compared with a column that is not text, such as a date, a
uuid, an integer or a numeric, no longer converts on its own, and the server refuses the
statement when it parses it, naming both types. The driver adds a note to the exception saying what
to do:
>>> conn.execute_query("SELECT %s + 1", ("41",))
Traceback (most recent call last):
...
psycopg.errors.UndefinedFunction: operator does not exist: text + integer
...
>>> conn.rollback()
Pass the value as its own type, or cast it where it is used:
>>> conn.execute_query("SELECT %s + 1", (41,)).records
[(42,)]
>>> conn.execute_query("SELECT %s::int + 1", ("41",)).records
[(42,)]
To send one string with no type at all, and have the server work out its type from where it is
used, wrap it in Unspecified:
>>> from agensgraph import Unspecified
>>> conn.execute_query("SELECT %s + 1", (Unspecified("41"),)).records
[(42,)]
That is psycopg’s own default behaviour, and it is the wrong one for Cypher: in a property lookup an unspecified string is parsed as JSON, as described above.
Property maps#
A dict is sent as jsonb, which is what a Cypher property map is:
>>> conn.execute_query(
... "CREATE (m:movie %s) RETURN m.title",
... ({"title": "Arrival", "year": 2016, "genre": "Sci-Fi"},),
... ).records
[('Arrival',)]
It works wherever Cypher takes a map: CREATE (:label %s), SET n = %s, SET n += %s, a value in
WHERE, or MERGE (n:label {key: %s}). It is encoded with msgspec, which is faster than the
standard library’s json, most of all on a map of many numbers, such as an embedding.
JSON has fewer types than Python, so some values are stored as something else:
Written |
Stored as |
Read back as |
|---|---|---|
|
its ISO 8601 text |
|
|
its text |
|
|
base64 text |
|
|
a JSON array |
|
|
a JSON number, every digit kept |
|
|
nothing: the key is left out |
the key is absent |
>>> import datetime, decimal, uuid
>>> (props,) = conn.execute_query(
... "CREATE (n:probe %s) RETURN n.when, n.key, n.price, n.gone",
... ({"when": datetime.date(2026, 1, 2), "key": uuid.UUID(int=1),
... "price": decimal.Decimal("1.10"), "gone": None},),
... ).records
>>> props
('2026-01-02', '00000000-0000-0000-0000-000000000001', 1.1, None)
A key whose value is None is not stored, because in Cypher a property set to null is a property
removed. Reading it back gives None because the property does not exist.
NaN and the infinities are refused, wherever they appear. jsonb cannot store one, and encoding
it would write null in its place, a wrong value instead of an error:
>>> conn.execute_query("CREATE (:probe %s)", ({"score": float("nan")},))
Traceback (most recent call last):
...
ValueError: a property map cannot hold NaN or an infinity: jsonb has no way to store one, and encoding it would silently write null instead
Lists#
A list is sent as a PostgreSQL array, which is what plain SQL wants:
>>> conn.execute_query(
... "SELECT title FROM rating WHERE title = ANY(%s) AND source = 'critics' ORDER BY title",
... (["Inception", "Avatar"],),
... ).records
[('Avatar',), ('Inception',)]
For a list used as a Cypher list, in UNWIND %s or x IN %s, wrap it in Jsonb. A plain list of
one type happens to work there too, but an empty list has no type to send and is refused, and a list
mixing types cannot be sent as an array at all:
>>> from agensgraph import Jsonb
>>> conn.execute_query(
... "MATCH (m:movie) WHERE m.title IN %s RETURN m.year ORDER BY m.year",
... (Jsonb(["Inception", "Avatar"]),),
... ).records
[(2009,), (2010,)]
>>> conn.execute_query("UNWIND %s AS x RETURN x", (Jsonb([]),)).records
[]
A tuple is not a list: psycopg sends it as a row value, which Cypher cannot read.
Identities#
A GraphId is sent as a graphid, so an element read earlier can be found again by identity:
>>> (movie,) = conn.execute_query("MATCH (m:movie {title: 'Inception'}) RETURN m").records[0]
>>> conn.execute_query("MATCH (m) WHERE id(m) = %s RETURN m.title", (movie.id,)).records
[('Inception',)]
An identity kept as text, "3.5", goes in as %s::graphid.
Where a parameter is allowed#
A parameter stands for a value, so it can appear only where Cypher takes a value:
Position |
Allowed |
Read as |
|---|---|---|
an expression in |
yes |
|
|
yes |
|
a property map: |
yes |
|
a dynamic property read, |
yes |
|
|
yes, with a |
|
|
from AgensGraph 2.18 |
|
|
yes, as |
|
a label, |
no |
syntax error |
a property key, |
no |
syntax error; use |
the length of a walk, |
no |
refused by the driver |
n[%s] is how to read a property whose name you only know at run time:
>>> conn.execute_query("MATCH (m:movie {title: 'Inception'}) RETURN m[%s]", ("genre",)).records
[('Sci-Fi',)]
The length of a walk is refused before it is sent. The server does accept a parameter in
[r*1..%s], but reads it as a property map rather than a bound, and returns walks of any length.
The driver recognises it, in either placeholder style, and raises instead:
>>> conn.execute_query("MATCH (a)-[r:related_to*1..%s]->(b) RETURN b", (2,))
Traceback (most recent call last):
...
ValueError: a parameter cannot give the length of a variable-length relationship: '-[r:related_to*1..%s'. The server accepts this and reads the parameter as a property map, matching a walk of any length. Write the length into the statement instead.
Write the bound into the statement, from a value you have checked is an integer:
requested_hops = "2" # say, from a request
hops = int(requested_hops) # raises if it is not a whole number
conn.execute_query(
f"MATCH (a:movie {{title: %s}})-[r:related_to*1..{hops}]->(b) RETURN b", ("The Matrix",)
)
Identifiers#
A label or a property key cannot be a parameter, so a statement that names one at run time has to
carry it in its text. Quote it with quote_identifier:
>>> from agensgraph.cypher import quote_identifier
>>> [quote_identifier(name) for name in ["movie", "Movie", "my label", 'a"b', "MATCH"]]
['movie', '"Movie"', '"my label"', '"a""b"', '"MATCH"']
A name is left bare only when the server would read it unchanged: lower case letters, digits and
underscores, and not a keyword. Anything else is put in double quotes, with any " inside it
doubled. A name holding a null byte is refused, because the server would stop reading the
statement there.
Quoting matters for case. The server folds an unquoted name to lower case, so (:Movie) is the
label movie, while (:"Movie") is a different label whose name has a capital letter. Every part of
the driver that builds a statement around a name, such as the schema declarations,
the bulk loaders and LISTEN, quotes it this way, and so takes the name you give literally.
quote_string does the same for a string literal, for the rare position that takes neither a
parameter nor an identifier:
>>> from agensgraph.cypher import quote_string
>>> quote_string("it's")
"'it''s'"