Skip to content

Python driver: Age.connect() / setUpAge() unconditionally runs LOAD 'age', failing on managed PostgreSQL (Azure) where extension is pre-loaded via shared_preload_libraries #2353

@uesleilima

Description

@uesleilima

Description

The setUpAge() function unconditionally executes LOAD 'age' (or LOAD '$libdir/plugins/age'). The Age.connect() method always calls setUpAge() with no option to skip the LOAD statement.

On managed PostgreSQL services like Azure Database for PostgreSQL, the AGE extension is loaded server-side via shared_preload_libraries. The extension binary may not be accessible at the file path expected by LOAD, causing a psycopg.errors.UndefinedFile error. Users have no way to use Age.connect() in these environments without bypassing the driver's setup entirely.

This is related to #839, which discussed non-superuser access. However, our scenario is different: on Azure managed PostgreSQL the LOAD command fails regardless of privileges because the binary is not at the expected file path. The extension is already loaded and functional — the driver just needs a way to skip the LOAD statement.

Steps to Reproduce

  1. Set up Azure Database for PostgreSQL (Flexible Server) with AGE extension enabled via shared_preload_libraries
  2. Create the extension: CREATE EXTENSION IF NOT EXISTS age;
  3. Attempt to connect using the Python driver:
import age

ag = age.connect(
    dsn="host=myserver.postgres.database.azure.com port=5432 dbname=mydb user=myuser password=mypass",
    graph="my_graph"
)

Expected Behavior

The connection should succeed since AGE is already loaded and available in the database.

Actual Behavior

With load_from_plugins=False (the default):

psycopg.errors.UndefinedFile: could not access file "age": No such file or directory

With load_from_plugins=True:

psycopg.errors.UndefinedFile: could not access file "$libdir/plugins/age": No such file or directory

Neither option works because the binary path is managed by the cloud provider and not directly accessible.

Root Cause

In age.py setUpAge():

def setUpAge(conn, graphName, load_from_plugins=False):
    with conn.cursor() as cursor:
        if load_from_plugins:
            cursor.execute("LOAD '$libdir/plugins/age';")
        else:
            cursor.execute("LOAD 'age';")
        # ... rest of setup (search_path, adapters, graph check)

And in Age.connect():

def connect(self, graph=None, dsn=None, ..., load_from_plugins=False, **kwargs):
    conn = psycopg.connect(dsn, cursor_factory=cursor_factory, **kwargs)
    setUpAge(conn, graph, load_from_plugins)  # always called, no way to skip LOAD

The LOAD statement is only needed when the AGE shared library has not been pre-loaded into the PostgreSQL backend. On managed services, the library is already loaded via shared_preload_libraries, making the LOAD statement unnecessary and harmful.

Suggested Fix

Add a skip_load parameter to both setUpAge() and Age.connect() that skips the LOAD statement while still performing all other setup (search_path, agtype adapter registration, graph creation):

def setUpAge(conn, graphName, load_from_plugins=False, skip_load=False):
    with conn.cursor() as cursor:
        if not skip_load:
            if load_from_plugins:
                cursor.execute("LOAD '$libdir/plugins/age';")
            else:
                cursor.execute("LOAD 'age';")

        cursor.execute("SET search_path = ag_catalog, '$user', public;")
        # ... rest of adapter registration and graph setup unchanged
def connect(self, graph=None, dsn=None, ..., load_from_plugins=False, skip_load=False, **kwargs):
    conn = psycopg.connect(dsn, cursor_factory=cursor_factory, **kwargs)
    setUpAge(conn, graph, load_from_plugins, skip_load=skip_load)
    # ...

Current Workaround

Users must bypass Age.connect() entirely and manually replicate the setup logic, which is fragile and tightly coupled to internal driver implementation details:

import age
import psycopg
from psycopg.types import TypeInfo

ag = age.Age()
ag.connection = psycopg.connect(dsn, cursor_factory=age.age.ClientCursor)

with ag.connection.cursor() as cur:
    cur.execute('SET search_path = ag_catalog, "$user", public;')

ag_info = TypeInfo.fetch(ag.connection, "agtype")
ag.connection.adapters.register_loader(ag_info.oid, age.age.AgeLoader)
ag.connection.adapters.register_loader(ag_info.array_oid, age.age.AgeLoader)

ag.graphName = "my_graph"
age.checkGraphCreated(ag.connection, "my_graph")
ag.connection.commit()

Environment

  • Apache AGE: 1.6.0 (also affects master)
  • Python driver: master branch (psycopg3 version)
  • psycopg: 3.2.x
  • Python: 3.13
  • Managed PostgreSQL: Azure Database for PostgreSQL – Flexible Server

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions