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

str

text

see Strings below

int, bool, Decimal

bigint/numeric, boolean, numeric

psycopg’s defaults

float

float8

NaN and the infinities are refused

dict

jsonb

the property map of a CREATE or SET; see Property maps

list

a PostgreSQL array

so = ANY(%s) works in SQL

GraphId

graphid

an element’s identity

Vector, SparseVector

vector, sparsevec

see Embedding vectors

Jsonb(value)

jsonb

any JSON value, wrapped explicitly

date, datetime, UUID, bytes, None

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

datetime, date, time

its ISO 8601 text

str

UUID

its text

str

bytes

base64 text

str

set, tuple

a JSON array

list

Decimal

a JSON number, every digit kept

float, or Decimal with exact numbers

None

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 WHERE, RETURN, WITH, ORDER BY, a list or map value

yes

jsonb

LIMIT %s, SKIP %s

yes

bigint

a property map: CREATE (:l %s), SET n = %s, SET n += %s

yes

jsonb

a dynamic property read, n[%s]

yes

jsonb

id(n) = %s

yes, with a GraphId

graphid

size(%s)

from AgensGraph 2.18

text

UNWIND %s, x IN %s

yes, as Jsonb(list)

jsonb

a label, (n:%s)

no

syntax error

a property key, n.%s or {%s: v}

no

syntax error; use n[%s] to read

the length of a walk, [r*1..%s]

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'"