# 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: ```pycon >>> 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: ```pycon >>> 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](#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](#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](../data/vectors.md) | | `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: ```pycon >>> 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: ```pycon >>> 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: ```pycon >>> 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`: ```pycon >>> 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: ```pycon >>> 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](graph-values.md#numbers) | | `None` | nothing: **the key is left out** | the key is absent | ```pycon >>> 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: ```pycon >>> 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: ```pycon >>> 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: ```pycon >>> 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: ```pycon >>> (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: ```pycon >>> 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: ```pycon >>> 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: ```python 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`: ```pycon >>> 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](../schema/declaring.md), 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: ```pycon >>> from agensgraph.cypher import quote_string >>> quote_string("it's") "'it''s'" ```