Skip to main content

post-graph: PostgreSQL-Backed Graph Database Library

PyPI version Python Versions License: MIT

A high-performance Python library for using PostgreSQL as a native graph database. It supports multi-tenant realms, pgvector similarity search across main & data history tables, automatic shadow audit logging, append-only history tables, and high-speed recursive graph traversals (CTEs).


🌟 Key Features

  • Table-Per-Vertex & Table-Per-Edge Architecture: Maps graph elements directly to relational tables, taking advantage of PostgreSQL's foreign keys, indexes, and constraints.
  • pgvector Similarity Search (vector_search):
    • Native HNSW vector indexing (vector(dim)) for vertex embeddings.
    • Cosine distance (<=>), Euclidean L2 (<->), and Inner Product (<#>).
    • Multi-scope searching across main tables, associated data history tables, or both combined (search_scope="both").
  • Dual Isolation Multi-Tenancy:
    • Single-Schema Multi-Tenancy: Logical isolation using a realm column partition.
    • Schema-Per-Realm: Physical isolation creating dedicated PostgreSQL schema namespaces per tenant (CREATE SCHEMA IF NOT EXISTS "realm_name").
  • Autogenerated BIGSERIAL Primary Keys & Computed fqid:
    • Vertex FQID: {realm}/{table_name}/{id}
    • Edge FQID: {realm}/{from_table}-{to_table}/{id} (using hyphen separator)
    • Automatically populated at PostgreSQL level via GENERATED ALWAYS AS ... STORED NOT NULL.
  • Append-Only History Tables ({table_name}_data):
    • Automatically created alongside vertex and edge tables.
    • Stores timestamped JSONB payload updates (data_id, realm, id, payload, timestamp, embedding).
    • Supports vector embeddings on data records for historical semantic matching.
    • Cascades deletion when main vertex/edge is deleted (ON DELETE CASCADE).
  • Shadow Audit Logging ({table_name}_audit):
    • Operates via PostgreSQL triggers to capture all INSERT, UPDATE, and DELETE events.
    • Logs old/new row state and the initiating user_id passed via session parameters (app.current_user_id).
  • Advanced Graph Traversals & Cycle Detection:
    • Recursive CTE queries for neighbor exploration, path discovery, and cycle-free shortest path calculation.
    • Optional check_cycle=True raising CyclicReferenceError during edge creation.
    • Direct object-oriented traversal APIs (vertex.to(), vertex.from_(), step.vertex(), step.add_edge_to()).
  • Multiple Async Client Drivers:
    • High-speed asyncpg client (AsyncPostGraph).
    • SQLAlchemy v2.0 async client (SQLAlchemyPostGraph).

📦 Installation

Install post-graph from PyPI:

# Basic installation (includes asyncpg)
pip install post-graph

# Installation with SQLAlchemy support
pip install "post-graph[sqlalchemy]"

# Installation with all optional dependencies
pip install "post-graph[all]"

Using uv:

uv add post-graph
# or with SQLAlchemy support:
uv add "post-graph[sqlalchemy]"

PostgreSQL Setup

To enable pgvector similarity search, ensure the pgvector extension is enabled in PostgreSQL:

CREATE EXTENSION IF NOT EXISTS vector;

🏗️ Database Architecture

erDiagram
    users {
        text realm PK
        bigserial id PK
        text fqid
        jsonb payload
        vector embedding
        timestamptz created_at
        timestamptz updated_at
    }
    posts {
        text realm PK
        bigserial id PK
        text fqid
        jsonb payload
        vector embedding
        timestamptz created_at
        timestamptz updated_at
    }
    likes {
        text realm PK, FK
        bigserial id PK
        text fqid
        bigint from_id FK
        bigint to_id FK
        text relation_type
        jsonb payload
        timestamptz created_at
        timestamptz updated_at
    }
    users_audit {
        bigint audit_id PK
        text realm
        text action
        text changed_by
        timestamptz changed_at
        jsonb old_row
        jsonb new_row
    }
    users_data {
        bigserial data_id PK
        text realm FK
        bigint id FK
        jsonb payload
        vector embedding
        timestamptz timestamp
    }

    users ||--o{ likes : "from_id"
    posts ||--o{ likes : "to_id"
    users ||--o{ users_audit : "logs changes"
    users ||--o{ users_data : "appends records & embeddings"

🚀 Quick Start Examples

1. Basic Graph Storage & Traversals (asyncpg)

import asyncio
from post_graph import AsyncPostGraph

async def main():
    client = AsyncPostGraph(dsn="postgresql://postgres:postgres@localhost:5432/postgres")
    await client.connect()

    # 1. Create schema tables
    await client.create_vertex_table("users")
    await client.create_vertex_table("posts")
    await client.create_edge_table("likes", from_vertex_table="users", to_vertex_table="posts")

    # 2. Add vertices
    alice = await client.add_vertex("users", realm="realm_a", payload={"name": "Alice"})
    post = await client.add_vertex("posts", realm="realm_a", payload={"title": "Hello PostGraph!"})

    # 3. Add edge connecting Alice -> Post
    edge = await client.add_edge(
        table_name="likes",
        realm="realm_a",
        from_id=alice.id,
        to_id=post.id,
        relation_type="like",
        payload={"reaction": "heart"}
    )

    # 4. Append data history record
    await alice.add_data({"status": "active", "login_count": 1})
    history = await alice.get_data()
    print(f"Alice history count: {len(history)}, last: {history[0].payload}")

    # 5. Object-oriented traversal
    steps = await alice.to("likes")
    for step in steps:
        target_post = step.vertex()
        print(f"Alice liked post: {target_post.payload['title']}")

    await client.close()

asyncio.run(main())

2. pgvector Similarity Search (main, data, or both scopes)

import asyncio
from post_graph import AsyncPostGraph

async def main():
    client = AsyncPostGraph(dsn="postgresql://postgres:postgres@localhost:5432/postgres")
    await client.connect()

    # 1. Create vertex table with 4D embeddings
    await client.create_vertex_table("documents", realm="tech_realm", vector_dim=4)

    # 2. Add vertex with embedding to MAIN table
    doc1 = await client.add_vertex(
        "documents",
        realm="tech_realm",
        payload={"title": "PostgreSQL Overview"},
        embedding=[1.0, 0.0, 0.0, 0.0]
    )

    # 3. Add historical entry with embedding to DATA table
    await doc1.add_data(
        payload={"version": "v2.0", "notes": "Added pgvector support"},
        embedding=[0.0, 1.0, 0.0, 0.0]
    )

    # 4. Vector search on MAIN table
    results_main = await client.vector_search(
        "documents",
        realm="tech_realm",
        query_vector=[0.9, 0.1, 0.0, 0.0],
        top_k=3,
        search_scope="main"
    )
    print("Main Search Hit:", results_main[0][0].payload['title'], "Dist:", results_main[0][1])

    # 5. Vector search on BOTH main & data history tables combined
    results_both = await client.vector_search(
        "documents",
        realm="tech_realm",
        query_vector=[0.05, 0.95, 0.0, 0.0],
        top_k=3,
        search_scope="both"
    )
    print("Combined Scope Best Hit:", results_both[0][0].payload['title'], "Dist:", results_both[0][1])

    await client.close()

asyncio.run(main())

3. Using SQLAlchemyPostGraph

import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from post_graph import SQLAlchemyPostGraph

async def main():
    engine = create_async_engine("postgresql+asyncpg://postgres:postgres@localhost:5432/postgres")
    client = SQLAlchemyPostGraph(engine)

    # DDL & CRUD methods share identical signatures
    await client.create_vertex_table("users")
    await client.create_edge_table("follows", from_vertex_table="users", to_vertex_table="users")

    v1 = await client.add_vertex("users", realm="default", payload={"name": "Bob"})
    v2 = await client.add_vertex("users", realm="default", payload={"name": "Charlie"})
    e = await client.add_edge("follows", realm="default", from_id=v1.id, to_id=v2.id, relation_type="follow")

    await engine.dispose()

asyncio.run(main())

📖 Detailed API Reference

Client Initialization

AsyncPostGraph(
    dsn: Optional[str] = None,
    connection_or_pool: Optional[Union[asyncpg.Pool, asyncpg.Connection]] = None,
    schema_per_realm: bool = False
)

SQLAlchemyPostGraph(
    engine_or_connection: Union[AsyncEngine, AsyncConnection],
    schema_per_realm: bool = False
)
  • schema_per_realm: If True, creates physically isolated schema namespaces per realm (e.g. "realm_a"."users"). If False (default), uses single-schema logical multi-tenancy with a realm column partition.

Schema DDL Operations

create_vertex_table

await client.create_vertex_table(
    table_name="users",
    realm=None,
    vector_dim=None  # Optional integer (e.g. 1536, 4096) for pgvector HNSW index
)

Creates:

  1. {table_name} main vertex table with (realm, id) primary key.
  2. {table_name}_audit shadow audit log table.
  3. {table_name}_data append-only history table with foreign key (realm, id) ON DELETE CASCADE.
  4. If vector_dim is provided, creates embedding vector(dim) column and HNSW index on both {table_name} and {table_name}_data.

create_edge_table

await client.create_edge_table(
    table_name="likes",               # Optional; defaults to "{from_table}TO{to_table}" if empty
    from_vertex_table="users",
    to_vertex_table="posts",
    realm=None,
    cascade_delete_from=False,       # If True, deleting edge deletes source vertex
    cascade_delete_to=False          # If True, deleting edge deletes target vertex
)

Vector Similarity Search (vector_search)

results = await client.vector_search(
    table_name="documents",
    realm="tech_realm",
    query_vector=[0.1, 0.2, 0.3, 0.4],
    top_k=5,
    distance_metric="cosine",        # "cosine" (<=>), "l2" (<->), or "inner_product" (<#>)
    search_data_table=False,         # Deprecated alias; set search_scope="data" instead
    search_scope="main"              # "main", "data", or "both"
)
  • search_scope="main": Searches vector column on main vertex table {table_name}.
  • search_scope="data": Searches vector column on associated data table {table_name}_data joined with main table {table_name}.
  • search_scope="both": Performs a unified CTE vector distance query across main and associated data tables, returning distinct matching Vertex objects sorted by minimum distance.

Vertex Operations

add_vertex

vertex = await client.add_vertex(
    table_name="users",
    realm="tenant_1",
    vertex_id=None,                 # Optional autogenerated BIGSERIAL ID
    payload={"name": "Alice"},       # Dict payload stored as JSONB
    embedding=[0.1, 0.2, 0.3, 0.4],  # Optional vector embedding
    user_id="admin_1"               # Optional user attribution for audit log
)

get_vertex

vertex = await client.get_vertex(table_name="users", realm="tenant_1", vertex_id=1)
# Seamlessly accepts integer ID, FQID ("tenant_1/users/1"), or UUID string ("550e8400-e29b-41d4-a716-446655440000")

get_vertex_by_uuid

vertex = await client.get_vertex_by_uuid(
    table_name="users",
    realm="tenant_1",
    uuid="550e8400-e29b-41d4-a716-446655440000"
)

Performs fast $O(1)$ indexed lookup on the automatically assigned uuid column.

get_edge_by_uuid

edge = await client.get_edge_by_uuid(
    table_name="likes",
    realm="tenant_1",
    uuid="463bf3f0-33f7-47cf-9fa9-46ab83035033"
)

Performs fast indexed lookup for edge records by UUID.

update_vertex

updated = await client.update_vertex(
    table_name="users",
    realm="tenant_1",
    vertex_id=1,
    payload={"name": "Alice Smith"},
    user_id="admin_1"
)

delete_vertex

deleted = await client.delete_vertex(table_name="users", realm="tenant_1", vertex_id=1, user_id="admin_1")

Automatically cascade deletes all connected edges in the database.


Edge Operations

add_edge

edge = await client.add_edge(
    table_name="follows",
    realm="tenant_1",
    edge_id=None,                   # Optional autogenerated BIGSERIAL ID
    from_id=1,
    to_id=2,
    relation_type="friend",
    payload={"since": "2026-01-01"},
    user_id="admin_1",
    check_cycle=True                # Raises CyclicReferenceError if edge creates a cycle
)

delete_edge

deleted = await client.delete_edge(table_name="follows", realm="tenant_1", edge_id=100, user_id="admin_1")

Append-Only Data History ({table_name}_data)

Vertices and edges support appending timestamped historical data snapshots with optional vector embeddings.

Client Data APIs

record = await client.add_vertex_data(
    table_name="users",
    realm="realm_a",
    vertex_id=1,
    payload={"status": "active", "login_count": 10},
    timestamp=None,                 # Optional; defaults to CURRENT_TIMESTAMP
    embedding=[0.1, 0.2, 0.3, 0.4]   # Optional vector embedding
)

history = await client.get_vertex_data(table_name="users", realm="realm_a", vertex_id=1, limit=5)

Model Instance Methods

rec = await vertex.add_data({"status": "active"}, embedding=[0.1, 0.2, 0.3, 0.4])
history = await vertex.get_data(limit=10)

Multi-Tenant Realm Deletion

rows_deleted = await client.delete_realm(realm="tenant_to_remove")

Completely removes all vertices, edges, audit logs, and data history records belonging to the target realm across all tables.


Graph Traversals & Shortest Path (CTEs)

traverse

paths = await client.traverse(
    realm="realm_a",
    start_table="users",
    start_id=1,
    edge_tables=["follows", "likes"],
    max_depth=3
)
for p in paths:
    print(f"Depth: {p['depth']} | Path: {' -> '.join(p['path'])}")

shortest_path

sp = await client.shortest_path(
    realm="realm_a",
    start_table="users",
    start_id=1,
    target_table="posts",
    target_id=42,
    edge_tables=["follows", "likes"],
    max_depth=5
)
if sp:
    print(f"Shortest path found at depth {sp['depth']}: {sp['path']}")

📄 License

This project is licensed under the MIT License.

Developed by Chandan Rajah (chandan.rajah@gmail.com).

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

post_graph-0.2.0.tar.gz (42.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

post_graph-0.2.0-py3-none-any.whl (29.9 kB view details)

Uploaded Python 3

File details

Details for the file post_graph-0.2.0.tar.gz.

File metadata

  • Download URL: post_graph-0.2.0.tar.gz
  • Upload date:
  • Size: 42.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for post_graph-0.2.0.tar.gz
Algorithm Hash digest
SHA256 9b6bf41c06ea968d4c6e2afcf03f76e99c32da5b2dcd0f0e5bd4b96e258cb441
MD5 00880edf0683d42e6c2682cedd576a3c
BLAKE2b-256 712ed01176117422f5548928b98e84d19d8c2cb4e038c149529fb3d2601fe2cf

See more details on using hashes here.

File details

Details for the file post_graph-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: post_graph-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 29.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for post_graph-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 837c44b378b947ce28454a7047ac449090b0ec409b8d316be4a0388bdb621ec1
MD5 12894c139dd0b58fb6254df6d17a6f41
BLAKE2b-256 3de28f03bf12dd5470669127340bd5dd10e294ee2c675e9002a075d5630ffe88

See more details on using hashes here.

Release history Release notifications | RSS feed

1.6.0

2 files

1.5.0

2 files

1.4.0

2 files

1.3.0

2 files

1.2.0

2 files

1.1.0

2 files

1.0.1

2 files

1.0.0

2 files

0.8.0

2 files

0.7.0

2 files

0.6.2

2 files

0.6.1

2 files

0.6.0

2 files

0.5.0

2 files

0.4.0

2 files

0.3.2

2 files

0.3.1

2 files

0.3.0

2 files

This release

0.2.0 This release

2 files

0.1.5

2 files

0.1.4

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page