Skip to content

v2.0.0

Latest

Choose a tag to compare

@github-actions github-actions released this 17 Sep 22:48
· 18 commits to master since this release

A Python driver for AgensGraph, the graph database built on PostgreSQL. A vertex, an edge and a path come back as values you can use rather than strings you have to parse, and the connection is a psycopg 3 connection underneath, so Cypher and SQL run on one connection inside one transaction.

import agensgraph

with agensgraph.connect("host=localhost dbname=graph") as conn:
    conn.graph("social")
    for (person,) in conn.execute_query("MATCH (n:Person) RETURN n").records:
        print(person.properties["name"])

Every call below has a blocking form and an awaiting form. The awaiting one is written and the blocking one is generated from it, so the two cannot drift apart.

This page is the tour. The README is the reference, and each section here ends with a pointer into it.

Graph values, not strings

A vertex, an edge and a path arrive with an identity, a label and properties. They are immutable and hashable, so a vertex can be a dict key or a set member, and the label is kept apart from user properties, so a property named label does not collide with the real one.

A property map is decoded the first time you read it, so a row whose properties you never touch never pays to build them. read_numbers_exactly() switches to Decimal for the cases where every digit matters.

The server offers two renderings and this reads both. Text is the default; binary_=True asks for the composite one for a single statement, which over five thousand rows is worth having on edges, at a median of 1.29, and sits inside the run-to-run spread on paths and on whole vertices. The two routes decode independently, and the suite compares them against values the server itself produced.

More: Graph values, Two wire formats, Numbers in a property map.

Running statements

execute_query() runs one statement and returns its rows, its column names and what the write changed. conn.execute() is psycopg's own cursor, untouched. stream() reads a result larger than memory through a server-side cursor, and refuses by name the statements that cannot be wrapped in one instead of letting the server emit a bare syntax error. pipeline_query() sends a burst of reads in one round trip and reads every answer back.

A str parameter is declared text. That matters: sent untyped, the server parses it as JSON, and '123' or 'true' is then accepted and matches nothing. Where the column is a date or a uuid, write %s::date, which the error tells you at parse time rather than leaving you with no rows.

More: Running a statement, Parameters, Streaming a large result, A burst of statements.

Loading data

load_vertices() and load_edges() stream elements in over a binary COPY, about thirty times a loop of one statement a row in a single transaction. Identities are left to the server, so what lands is indistinguishable from what CREATE would have produced.

conn.load_vertices("Doc", [{"key": "a", "title": "..."}, {"key": "b", "title": "..."}])
by_key = conn.identity_map("Doc", "key")
conn.load_edges("Cites", [(by_key["a"], by_key["b"], {"weight": 1})])

load_vertex_frame() and load_edge_frame() take an Arrow, polars or pandas frame straight in without building Python rows first. upsert_vertices() and upsert_edges() write only what is not already there, keyed on a property or on the pair of elements an edge joins, and refuse a key that nothing keeps unique rather than quietly writing duplicates.

More: Bulk loading.

Embedding vectors

Dense and sparse vectors through pgvector, asked of the connection rather than of the version, because the extension is created per database. The index statement and the search statement come from one function, so the cast and the operator class cannot drift apart and silently lose the index, which is the failure that costs the most and says the least.

from agensgraph.vector import vector_index, nearest, Distance

conn.execute(vector_index("Doc", "embedding", dimensions=1024,
                          operator_class=Distance.COSINE.operator_class))
rows = conn.execute_query(
    nearest("Doc", "embedding", dimensions=1024, operator=Distance.COSINE, limit=10),
    (query_vector,),
).records

A vector can live in the property map or in a column of its own, and a column is declared with the label and reconciled like anything else. vector_indexes() reads an index back in parts instead of leaving you to parse its definition. Sending 1,536 numbers as a Vector puts 6 KB on the wire where the same numbers as decimal text are 31 KB.

More: Embedding vectors.

Schema, read and declared

graphs(), labels(), declared_properties(), indexes() and constraints() read the catalogs, which is the only introspection there is: psql has no describe support for them. describe() builds a graph's whole shape without scanning it and without installing anything in the database.

from agensgraph import DesiredIndex, DesiredLabel, PromotedProperty, Unique

conn.ensure_labels([
    DesiredLabel("Doc", properties=[PromotedProperty("embedding", "vector(1024)")]),
    DesiredLabel("Cites", kind="e"),
])
conn.ensure_indexes([DesiredIndex("Doc", ["key"], unique=True)])
conn.ensure_constraints([Unique("Doc", "key")])

The reconcilers emit only the difference, take dry_run=True to show the statements first, and never drop a label, because removing one is a decision about data rather than about schema. Declaring the labels a service writes to is worth doing at startup: measured with eight writers merging one edge of a label that did not exist yet, seven of the eight failed.

More: Reading the catalogs, Describing a graph, Declaring what should exist.

Connections that survive production

A pool with a generation counter, so one server restart retires the whole set instead of handing a failure to every caller in turn, and a pool that keeps nothing for a process serving one request. deadline() bounds a block with a single budget covering the wait for a connection and the statements run on it.

Retryability is six recoveries rather than a boolean, with full jitter under a cap and a process-wide allowance that time refills. If the connection is lost while committing, a different connection is asked what became of the transaction instead of the write being replayed blind. Keepalive is asked for by default, because a connection waiting for a reply has transmitted nothing, so tcp_user_timeout does not bound the wait.

More: Pooling, Deadlines, Retrying, Losing a connection mid-commit, When the network goes quiet.

Running a statement you did not write

A read-only transaction the server enforces, refused up front to a role that could run a command on the server's host, since a read-only transaction does not stop COPY ... TO PROGRAM. One statement at a time, so a second cannot be smuggled in after a semicolon. A parameter used as the upper bound of a variable-length relationship is refused before it is sent, because the server accepts it and then silently matches a walk of any length. A failure keeps row data out of what it prints as, because PostgreSQL puts row data in DETAIL.

More: Running a statement you did not write, Errors.

Observability and interoperability

Every statement can be reported to a query logger, the driver's own catalog reads included, and spans go through OpenTelemetry behind a module-level flag that costs a boolean read when it is off. The server's notices arrive as structured values.

The PEP 249 surface is there, proved by a SQLAlchemy dialect in the test suite. Results export to Arrow, pandas and polars, in chunks where the whole will not fit. to_json() hands a graph value to anything that wants JSON without decoding a property map nobody read. LISTEN/NOTIFY and two-phase commit are psycopg's own, unchanged.

More: Observability, Columnar export, Generic tools and SQLAlchemy.

Install

pip install agensgraph-python

Optional extras, imported only where they are used, so they never load on a read path:

pip install "agensgraph-python[arrow]"     # to_arrow
pip install "agensgraph-python[pandas]"    # to_pandas
pip install "agensgraph-python[polars]"    # to_polars
pip install "agensgraph-python[otel]"      # spans through opentelemetry-api

Requirements

Python 3.11 through 3.14. AgensGraph 2.17 or later: the version arrives in the startup packet, so a server below the floor is refused when you connect rather than at whichever later query first wants a catalog it never had.

psycopg[binary]>=3.3.4, psycopg-pool>=3.3.1 and msgspec>=0.19. The package ships py.typed and is checked under mypy --strict and pyright strict. Every change is tested against the AgensGraph 2.17 and 2.18 lines and against main.

More: Server versions.

Coming from 1.x

Almost nobody is, and nothing written against 1.x runs against this. 1.x was a psycopg2 type-extension module of under five hundred lines.

If you are on it: agensgraph.connect() replaces importing the module for its side effect, conn.graph(name) replaces SET graph_path, .id and .properties replace .vid and .props, p.length gives the hops len(p) used to, and a plain dict replaces Property({...}). Placeholders are still %s. One habit has no replacement: importing 1.x registered its casters globally, so every psycopg2 connection in the process read graph types. This driver keeps its adapters on its own connections, and agensgraph.adapters.register_text() puts them on a psycopg 3 connection it did not make.

The rewrite happened because 1.x read the wire format with regular expressions and was wrong on output the server legitimately produces. An empty path raised, NULL elements had no representation, a label containing { leaked a JSONDecodeError, an id reading 7.9.5 came back as 7.9, every value was unhashable, and a one-vertex path was falsy.

More: Relationship to 1.x.


The full list of changes is in CHANGELOG.md.