Skip to content

Graph Operations

ArcadeDB is a native multi-model database with first-class support for property graphs. This guide covers working with vertices, edges, and graph traversals using the Python bindings.

Bulk Graph Ingest Guidance

For bulk graph ingest from Python, the repository recommendation is GraphBatch. Example 16 and the Stack Overflow graph examples use it as the preferred path for loading vertices and edges. Prefer its bulk methods, create_vertices() and new_edges(), over per-record calls: they cost one JVM crossing per batch and run at or near Java speed (see the Performance guide).

Settings, as ArcadeDB's maintainers recommend them (ArcadeData/arcadedb#8287): pass use_wal=True for an import that must survive a crash, because GraphBatch turns the write-ahead log off by default while it imports; pass expected_edge_count so the batch size tunes itself; and leave batch_size, commit_every, and parallel_flush at their defaults. Measured on 50,000 vertices and 1,045,738 edges: about 7.5 s with the WAL on, against about 30 s one element at a time. See the GraphBatch API.

Against a server, the bulk path is POST /api/v1/batch/{db} (JSONL with @type/@class/@id and @from/@to, GraphBatch underneath), with the same options as query parameters (wal=true, expectedEdgeCount=...). A sqlscript of CREATE VERTEX/CREATE EDGE statements parses and plans every statement and measured 6 to 7 times slower for the same graph.

Async SQL graph insert is not a bulk graph ingest path at all. Before 26.10.1, async_executor().command(...) could silently drop records above parallel level 1 (ArcadeData/arcadedb#7615, fixed in #7625); see Bulk Ingest Recommendation. Example 16 keeps it as a comparison arm, pinned to one worker and checked against what it submitted. GraphBatch flushes its edges through that same executor and is measured exact.

Edge direction must match the schema. CREATE EDGE TYPE makes a two-way type by default, and GraphBatch stores both directions unless you pass bidirectional=False. Pass it only for a type declared one-way (CREATE EDGE TYPE ... UNIDIRECTIONAL). From 26.10.1 the engine refuses a one-way edge in a two-way type: new_edge raises ArcadeDBError naming the type, and nothing is written. Before 26.10.1 the batch did not check, and such edges made any query the planner walked from the target end return 0 rows with no error (ArcadeData/arcadedb#8625, fixed in #8628).

What sees a one-way edge. An edge of a UNIDIRECTIONAL type is stored on its source vertex only. From 26.10.1:

  • Patterns see every such edge, whichever way they are written: Cypher ((t:Tag)<-[:TAGGED_WITH]-(q:Question), (q)-[:TAGGED_WITH]->(t), and the undirected (t)-[:TAGGED_WITH]-(q)) and SQL MATCH. The planner walks from the source when it can; otherwise it scans the edge type once per query and reuses that scan.
  • in(), inE(), both(), and bothE() in SQL, and the vertex API (get_in_edges(), get_both_edges() on the target), read what the target vertex stores, which for a one-way edge is nothing. out() and get_out_edges() on the source see it.

Before 26.10.1, patterns walked from the target end returned 0 rows as well. If a type is often queried from its target, declare it two-way (the default): a pattern from the target of a one-way type pays a scan of the whole edge type.

Overview

ArcadeDB's graph model consists of:

  • Vertices (Nodes): Entities in your graph with properties
  • Edges (Relationships): Connections between vertices with optional properties
  • Types: Schema definitions for vertices and edges

Creating Graph Schema (SQL)

Define vertex and edge types with SQL statements:

import arcadedb_embedded as arcadedb

with arcadedb.create_database("./social") as db:
    # Create vertex types
    db.command("sql", "CREATE VERTEX TYPE Person")
    db.command("sql", "CREATE VERTEX TYPE Company")

    # Create edge types
    db.command("sql", "CREATE EDGE TYPE Knows")
    db.command("sql", "CREATE EDGE TYPE WorksFor")

    # Add properties to vertices
    db.command("sql", "CREATE PROPERTY Person.name STRING")
    db.command("sql", "CREATE PROPERTY Person.age INTEGER")
    db.command("sql", "CREATE PROPERTY Person.email STRING")

    # Add properties to edges
    db.command("sql", "CREATE PROPERTY Knows.since DATE")
    db.command("sql", "CREATE PROPERTY WorksFor.role STRING")

    print("✅ Graph schema created")

DSL-first guidance

Prefer SQL/OpenCypher for schema and CRUD examples in all app-facing docs.

Creating Vertices

Create vertices declaratively with SQL:

with db.transaction():
    db.command(
        "sql",
        "INSERT INTO Person SET name = ?, age = ?, email = ?",
        "Charlie",
        35,
        "charlie@example.com",
    )

    print("✅ Created vertex")

Creating Edges

Important: In ArcadeDB, edges connect existing vertices. In these docs, the recommended write path is SQL/OpenCypher so graph creation looks the same across embedded and server usage.

Why create edges via SQL CREATE EDGE?

CREATE EDGE ... FROM (...) TO (...) is the recommended graph-write pattern in docs:

  • Clear source/destination semantics
  • Works consistently in embedded and server modes
  • Avoids wrapper-level coupling in examples

To create edges declaratively, identify source and destination vertices in subqueries:

with db.transaction():
    db.command("sql", "INSERT INTO Person SET id = 1, name = 'Alice'")
    db.command("sql", "INSERT INTO Person SET id = 2, name = 'Bob'")
    db.command("sql", """
        CREATE EDGE Knows
        FROM (SELECT FROM Person WHERE id = 1)
        TO (SELECT FROM Person WHERE id = 2)
        SET since = date('2020-01-15')
    """)

    print("✅ Created edge: Alice -> Bob")

Creating Edges with Retrieved Vertices (SQL pattern)

with db.transaction():
    db.command(
        "sql",
        """
        CREATE EDGE Knows
        FROM (SELECT FROM Person WHERE name = 'Alice')
        TO (SELECT FROM Person WHERE name = 'Bob')
        SET since = date('2020-01-15')
        """,
    )

Vertex must exist before edge creation

CREATE EDGE ... FROM (...) TO (...) connects the persisted vertices its endpoint subqueries return. If either side matches no record, it creates no edge and raises no error, so check the returned rows when a missing endpoint matters.

Listing Edges from a Vertex

Use SQL/OpenCypher traversals for edge listing:

out_edges = db.query(
    "sql",
    "SELECT expand(outE('Knows')) FROM Person WHERE name = 'Alice'",
).to_list()

Creating Edges with OpenCypher

OpenCypher provides clear graph patterns for creating edges:

with db.transaction():
    # Create vertices and edge using OpenCypher
    db.command("opencypher", """
        CREATE (alice:Person {name: 'Alice', age: 30})
        CREATE (bob:Person {name: 'Bob', age: 25})
        CREATE (alice)-[:Knows {since: 2020}]->(bob)
    """)

Or connect existing vertices:

with db.transaction():
    db.command("opencypher", """
        MATCH (alice:Person {name: 'Alice'}), (bob:Person {name: 'Bob'})
        CREATE (alice)-[:Knows {since: 2020}]->(bob)
    """)

Complete Example: Social Network

Here's a complete example building a small social network:

import arcadedb_embedded as arcadedb

def create_social_network():
    with arcadedb.create_database("./social_network") as db:
        # 1. Create schema
        print("Creating schema...")
        db.command("sql", "CREATE VERTEX TYPE Person")
        db.command("sql", "CREATE EDGE TYPE Knows")
        db.command("sql", "CREATE PROPERTY Person.name STRING")
        db.command("sql", "CREATE PROPERTY Person.age INTEGER")
        db.command("sql", "CREATE PROPERTY Knows.since INTEGER")

        # 2. Create vertices and edges
        print("Creating graph data...")
        with db.transaction():
            db.command("sql", "INSERT INTO Person SET name = 'Alice', age = 30")
            db.command("sql", "INSERT INTO Person SET name = 'Bob', age = 25")
            db.command("sql", "INSERT INTO Person SET name = 'Charlie', age = 35")

            db.command(
                "sql",
                "CREATE EDGE Knows FROM (SELECT FROM Person WHERE name='Alice') TO (SELECT FROM Person WHERE name='Bob') SET since = 2020",
            )
            db.command(
                "sql",
                "CREATE EDGE Knows FROM (SELECT FROM Person WHERE name='Bob') TO (SELECT FROM Person WHERE name='Charlie') SET since = 2019",
            )
            db.command(
                "sql",
                "CREATE EDGE Knows FROM (SELECT FROM Person WHERE name='Charlie') TO (SELECT FROM Person WHERE name='Alice') SET since = 2021",
            )

        print("✅ Graph created\n")

        # 3. Query the graph with OpenCypher
        print("Finding Alice's friends:")
        result = db.query("opencypher", """
            MATCH (p:Person {name: $name})-[:Knows]->(friend:Person)
            RETURN friend.name as name, friend.age as age
            ORDER BY name
        """, {"name": "Alice"})

        for record in result:
            name = record.get('name')
            age = record.get('age')
            print(f"  - {name}, age {age}")

        print("\nFinding friends of friends:")
        result = db.query("opencypher", """
            MATCH (p:Person {name: $name})-[:Knows]->(:Person)-[:Knows]->(fof:Person)
            WHERE fof.name <> $name
            RETURN DISTINCT fof.name as name
            ORDER BY name
        """, {"name": "Alice"})

        for record in result:
            print(f"  - {record.get('name')}")

if __name__ == "__main__":
    create_social_network()

Best Practices

1. Always Use Transactions for Writes

# ✅ Good - wrapped in transaction
with db.transaction():
    db.command("sql", "INSERT INTO Person SET name = 'Alice'")

# ❌ Bad - will fail
db.command("sql", "INSERT INTO Person SET name = 'Alice'")  # Error: No transaction!

2. Create Indexes for Frequent Lookups

# Index properties used in WHERE clauses
db.command("sql", "CREATE INDEX ON Person (name, email) NOTUNIQUE")

3. Use OpenCypher for Graph Queries

OpenCypher provides expressive graph patterns and path queries.

result = db.query("opencypher", """
    MATCH (p:Person {name: $name})-[:Knows]->(friend:Person)
    RETURN friend.name as name
    ORDER BY name
""", {"name": "Alice"})

4. Leave Relationships You Do Not Read Unnamed

From 26.10.1, a Cypher hop over an anonymous relationship takes the target vertex and the edge identity from the vertex's edge list without loading the edge record. Naming the relationship, giving it a property map, or filtering on it loads every edge it crosses (ArcadeData/arcadedb#8537).

# ✅ Good - the relationship is not read, so it stays anonymous
db.query("opencypher", "MATCH (p:Person {id: $id})-[:Knows]->()-[:Knows]->(f) RETURN count(DISTINCT f) AS n", {"id": 42})

# ❌ Slower - `r` is bound, so each edge record is loaded
db.query("opencypher", "MATCH (p:Person {id: $id})-[r:Knows]->()-[:Knows]->(f) RETURN count(DISTINCT f) AS n", {"id": 42})

5. Ensure Vertices Exist Before Creating Edges

with db.transaction():
    db.command("sql", "INSERT INTO Person SET name = 'Alice'")
    db.command("sql", "INSERT INTO Person SET name = 'Bob'")
    db.command(
        "sql",
        "CREATE EDGE Knows FROM (SELECT FROM Person WHERE name='Alice') TO (SELECT FROM Person WHERE name='Bob')",
    )

Deleting Records

Deleting vertices, edges, and documents requires understanding cascade behavior.

SQL DELETE

Use SQL DELETE for deletion in all scenarios:

with db.transaction():
    # Delete vertex by RID
    db.command("sql", "DELETE FROM Person WHERE @rid = #1:0")

    # Delete by property
    db.command("sql", "DELETE FROM Person WHERE name = 'Alice'")

    # Delete edges
    db.command("sql", "DELETE FROM Knows WHERE @rid = #2:0")

Advantages:

  • ✅ Works reliably in all situations
  • ✅ Supports complex WHERE clauses
  • ✅ Batch delete multiple records
  • ✅ Best for query results

Cascade Behavior

Vertex Deletion

When you delete a vertex, all connected edges are automatically deleted:

with db.transaction():
    # Create test data
    db.command("sql", "INSERT INTO Person SET name = 'Alice'")
    db.command("sql", "INSERT INTO Person SET name = 'Bob'")
    db.command(
        "sql",
        "CREATE EDGE Knows FROM (SELECT FROM Person WHERE name='Alice') TO (SELECT FROM Person WHERE name='Bob')",
    )

# Verify setup
vertices_before = list(db.query("sql", "SELECT FROM Person"))
edges_before = list(db.query("sql", "SELECT FROM Knows"))
print(f"Before: {len(vertices_before)} vertices, {len(edges_before)} edges")

# Delete vertex
with db.transaction():
    db.command("sql", "DELETE FROM Person WHERE name = 'Alice'")

# Check results
vertices_after = list(db.query("sql", "SELECT FROM Person"))
edges_after = list(db.query("sql", "SELECT FROM Knows"))
print(f"After: {len(vertices_after)} vertices, {len(edges_after)} edges")
# Output: After: 1 vertices, 0 edges (edge cascaded!)

Edge Deletion

When you delete an edge, vertices remain intact:

with db.transaction():
    # Create test data
    db.command("sql", "INSERT INTO Person SET name = 'Alice'")
    db.command("sql", "INSERT INTO Person SET name = 'Bob'")
    db.command(
        "sql",
        "CREATE EDGE Knows FROM (SELECT FROM Person WHERE name='Alice') TO (SELECT FROM Person WHERE name='Bob')",
    )

# Delete edge
with db.transaction():
    db.command("sql", "DELETE FROM Knows")

# Check results - vertices still exist
vertices = list(db.query("sql", "SELECT FROM Person"))
edges = list(db.query("sql", "SELECT FROM Knows"))
print(f"Vertices: {len(vertices)}")  # Output: 2
print(f"Edges: {len(edges)}")        # Output: 0

Best Practices for Deletion

Scenario Recommended Approach Why
Delete from query results SQL DELETE Works predictably
Delete single record by RID SQL DELETE Consistent style
Delete with complex WHERE clause SQL DELETE Readable and powerful
Delete in loop SQL DELETE with WHERE clause Faster than per-record calls
Interactive/programmatic delete SQL DELETE Same behavior across modes

Summary: use SQL DELETE.

OpenCypher Queries

ArcadeDB supports OpenCypher for declarative graph pattern matching.

Using OpenCypher

import arcadedb_embedded as arcadedb

with arcadedb.create_database("./graph_db") as db:
    db.command("sql", "CREATE VERTEX TYPE Person")
    db.command("sql", "CREATE EDGE TYPE Knows")

    # Insert data and query via OpenCypher
    with db.transaction():
        db.command("sql", "INSERT INTO Person SET name = 'Alice', age = 30")
        db.command("sql", "INSERT INTO Person SET name = 'Bob', age = 25")
        db.command(
            "sql",
            "CREATE EDGE Knows FROM (SELECT FROM Person WHERE name='Alice') TO (SELECT FROM Person WHERE name='Bob')",
        )

    # Query with OpenCypher
    results = db.query("opencypher", """
        MATCH (p:Person)
        WHERE p.age > 25
        RETURN p.name as name
    """)

    for record in results:
        print(record.get("name"))  # Outputs: Alice

OpenCypher vs SQL

Feature OpenCypher SQL
Style Declarative graph patterns Declarative
Graph Traversal ✅ Patterns and paths ✅ MATCH patterns
Readability High High
Standards Body openCypher ANSI SQL
Best For Graph patterns and paths Relational queries

Common OpenCypher Patterns

# Find all vertices
results = db.query("opencypher", "MATCH (n) RETURN n")

# Find vertices by label
results = db.query("opencypher", "MATCH (p:Person) RETURN p")

# Find vertices by property
results = db.query("opencypher", "MATCH (p:Person {name: $name}) RETURN p", {"name": "Alice"})

# Traverse outgoing edges
results = db.query("opencypher", """
    MATCH (p:Person {name: 'Alice'})-[:Knows]->(friend:Person)
    RETURN friend.name as name
""")

# Multi-hop traversal
results = db.query("opencypher", """
    MATCH (p:Person {name: 'Alice'})-[:Knows*2]->(fof:Person)
    RETURN DISTINCT fof.name as name
""")

When to Use OpenCypher

  • Pattern matching: Express graph structures declaratively
  • Multi-hop traversals: Variable-length paths
  • Readable queries: Clear syntax for graph relationships

For more details, see OpenCypher Tests.

Next Steps

  • Vector Search: Add vector embeddings to vertices for similarity search
  • Data Import: Import graph data from CSV or ArcadeDB JSONL exports
  • Server Mode: Visualize your graph in Studio UI