Skip to content

Graph Construction

Build graphs with the Python API or openCypher. This page is the everyday construction path for v0.5.0: scalar nodes and edges first, then atomic bulk batches. Scalar add_node / add_edge return construction handles; atomic bulk publish and Cypher/analyst paths return Apache Arrow tables (including bulk receipts).

For deeper architecture (project generations, Rust-owned validation, binding parity), see the Book.


from graphforge import GraphForge
forge = GraphForge() # in-memory
# forge = GraphForge("my-graph/") # Parquet-backed on disk (directory must exist)

add_node returns a NodeHandle with stable .uuid identity (UUIDv7) — there is no numeric storage surrogate.

alice = forge.add_node("Person", name="Alice", age=30, city="NYC")
bob = forge.add_node("Person", name="Bob", age=25)
print(alice.uuid) # e.g. 018f0f4e-7b8c-7000-8000-00000000a001

Properties may be strings, numbers, booleans, or nested lists/maps that the engine accepts as property values.

forge.add_edge(alice, "KNOWS", bob, since=2020, strength="strong")

add_edge returns an EdgeHandle with its own .uuid. Endpoints are NodeHandle values from the same graph (or UUID selectors where the API accepts them).

table = forge.execute("""
MATCH (a:Person)-[r:KNOWS]->(b:Person)
RETURN a.name AS from, b.name AS to, r.since AS since
""")
print(table.to_pandas())

You can also create, update, and delete with openCypher. Prefer parameters over string interpolation.

forge.execute(
"CREATE (p:Person {name: $name, age: $age})",
{"name": "Charlie", "age": 35},
)
forge.execute("""
CREATE (a:Person {name: 'Dana'})-[:KNOWS {since: 2021}]->(b:Person {name: 'Eve'})
""")

Standalone new-node MERGE and ON CREATE / ON MATCH property sets are supported. Prefer binding endpoints first, then merging the relationship — inline multi-node MERGE patterns are rejected. See MERGE status.

forge.execute("""
MERGE (p:Person {email: $email})
ON CREATE SET p.name = $name, p.created = 2024
ON MATCH SET p.last_seen = 2024
""", {"email": "alice@example.com", "name": "Alice"})
forge.execute("""
MATCH (p:Person {name: 'Alice'})
SET p.age = 31, p.city = 'Boston'
""")
forge.execute("""
MATCH (p:Person {name: 'Alice'})
REMOVE p.temp
""")
# DETACH DELETE removes the node and its relationships
forge.execute("""
MATCH (p:Person {name: 'Eve'})
DETACH DELETE p
""")
forge.execute("""
UNWIND $people AS person
CREATE (p:Person)
SET p = person
""", {
"people": [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25},
{"name": "Charlie", "age": 35},
],
})

See the Cypher Guide for the full clause reference.


For many rows at once, use the Rust-owned bulk surfaces. Prefer publish_bulk_nodes / publish_bulk_edges with a canonical Arrow table, or the convenience helpers add_nodes / add_edges. Always pass a stable operation_uuid; the call returns a canonical receipt table (entity_uuid, row_ordinal, generation identity, …).

Exact retry with the same operation UUID and same normalized input returns the same ordered receipt without another generation. Reusing the UUID with different input fails with GF_IDEMPOTENCY_CONFLICT and does not mutate storage.

The operation identity must be a UUIDv7; a UUIDv4 is rejected with GF_BULK_VALIDATION(invalid_uuid). Python 3.14 ships uuid.uuid7(); on earlier versions generate one with the stdlib helper below.

import os
import time
import uuid
def uuid7() -> uuid.UUID:
"""RFC 9562 UUIDv7. Use uuid.uuid7() directly on Python 3.14+."""
stamp = int(time.time() * 1000).to_bytes(6, "big")
raw = bytearray(stamp + os.urandom(10))
raw[6] = (raw[6] & 0x0F) | 0x70 # version 7
raw[8] = (raw[8] & 0x3F) | 0x80 # RFC 9562 variant
return uuid.UUID(bytes=bytes(raw))
node_op = str(uuid7())
edge_op = str(uuid7())
nodes = forge.add_nodes(
"Person",
[{"name": "Alice"}, {"name": "Bob"}],
operation_uuid=node_op,
)
edges = forge.add_edges(
"KNOWS",
[
{
"src_id": nodes.column("entity_uuid")[0].as_py(),
"dst_id": nodes.column("entity_uuid")[1].as_py(),
}
],
operation_uuid=edge_op,
)
assert nodes.num_rows == 2 and edges.num_rows == 1

add_edges renames endpoint columns (src / dst, default src_id / dst_id) to source_uuid / target_uuid before publication. Endpoint UUIDs must already identify committed nodes.

For pandas / Polars / Arrow inputs and the full receipt schema, see the API Reference.


Rust and Node project onto the same bulk contracts. Identity, ontology validation, idempotency, and publication stay Rust-owned; bindings only convert at the boundary.

Node intentionally exposes only publishBulkNodes / publishBulkEdges for bulk construction. The Python-only add_nodes / add_edges helpers normalize Python containers before calling those same Rust-owned publication operations; there are no Node addNodes / addEdges stubs or JavaScript publication path.

// Rust — publish_bulk_nodes / publish_bulk_edges on graphforge_api::GraphForge
// Node — publishBulkNodes / publishBulkEdges over Arrow IPC
import { GraphForge } from "@curatelabs/graphforge";

Cross-binding bulk parity is exercised by the opt-in conformance runner (not a required PR CI Gate):

Terminal window
python3 scripts/ci/bulk-construction-conformance.py validate

  1. Use parameters in Cypher — never interpolate untrusted strings into queries.
  2. Prefer MERGE when re-running ETL or scripts that must be idempotent.
  3. Pass operation_uuid on every bulk call and keep it stable for retries.
  4. Close persistent graphs (forge.close()) when finished with a directory project.
  5. Consume Arrow results with to_pandas(), polars.from_arrow, or to_pylist() — there are no CypherValue wrappers in v0.5.0.

Successful resumable construction updates the same facade’s workspace, property inventory, adjacency provider and ordinal identity authority together. Immediate queries therefore observe the published relationships and endpoints without requiring reopen. Publication prepares one new workspace and adopts its owner; it does not rename directories pinned by existing readers. A lazy stream and its runtime guard retain the workspace selected when the stream was created, so subsequent construction cannot change that stream’s identities or remove its files. Those overlapping live workspaces remain real resource owners until the last reader releases them.

If reader preparation fails after durable publication, the error still reports committed=true and recovery by reopen or resume. The facade retains its prior workspace until all replacement readers are ready; exact retry adopts the already committed generation.

import-session validate and import-session commit JSON receipts include operation_timings. Each of the five closed rows (begin, resume, append, seal, publish) contains calls, errors, and elapsed_ns. These are monotonic wall-time observations around disjoint public construction calls. These diagnostics are exposed through Rust and the CLI. GraphImportSession::operation_timings() exposes the same observations after the latest validation or commit, including returned errors. Each invocation resets them before checking preconditions; a repeated operation cannot replay previously reported time.

begin measures fresh construction creation; resume measures reopening and reconciling construction authority. append covers construction append calls, seal covers validation/sealing, and publish covers seal-and-publication. Internal authentication performed within those calls belongs to that call’s wall time: the timing rows do not have the same boundaries as the durable application_io attribution rows. In particular, resume is not the total time of every recovery-authentication read elsewhere in sealing or publication.

Source decoding, normalization, facade opening and import-manifest checkpoints outside those calls are excluded. The sum therefore does not equal whole-ingest time. These values are observations, not performance limits or CPU measurements. The certifier preserves only the closed numeric timing object; it rejects unknown fields and invalid counters.

Timings are held only by the live handle and never written into import or construction manifests. Durable status does not return old operation timings. Successful CLI validate/commit receipts can be summed for an invocation sequence. A failed CLI command does not emit a progress receipt, and a lost process cannot report its missing time; neither duration is reconstructed from persisted state. Existing durable I/O, allocation, accepted-row and recovery evidence remains independent of these timing observations.