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. Results and receipts are Apache Arrow tables.
For deeper architecture (project generations, Rust-owned validation, binding parity), see the Book.
Scalar construction (Python)
Section titled “Scalar construction (Python)”Create a graph
Section titled “Create a graph”from graphforge import GraphForge
forge = GraphForge() # in-memory# forge = GraphForge("my-graph/") # Parquet-backed on disk (directory must exist)Add nodes
Section titled “Add nodes”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-00000000a001Properties may be strings, numbers, booleans, or nested lists/maps that the engine accepts as property values.
Add relationships
Section titled “Add relationships”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).
Query what you built
Section titled “Query what you built”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())Construction with Cypher
Section titled “Construction with Cypher”You can also create, update, and delete with openCypher. Prefer parameters over string interpolation.
CREATE
Section titled “CREATE”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'})""")MERGE (idempotent upsert)
Section titled “MERGE (idempotent upsert)”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"})SET / REMOVE / DELETE
Section titled “SET / REMOVE / DELETE”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 relationshipsforge.execute(""" MATCH (p:Person {name: 'Eve'}) DETACH DELETE p""")Batch create with UNWIND
Section titled “Batch create with UNWIND”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.
Atomic bulk construction (Python)
Section titled “Atomic bulk construction (Python)”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 osimport timeimport 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 == 1add_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.
Other bindings (brief)
Section titled “Other bindings (brief)”Rust and Node project onto the same bulk contracts. Identity, ontology validation, idempotency, and publication stay Rust-owned; bindings only convert at the boundary.
// Rust — publish_bulk_nodes / publish_bulk_edges on gf_api::GraphForge// Node — publishBulkNodes / publishBulkEdges over Arrow IPCimport { GraphForge } from "@graphforge/node";Cross-binding bulk parity is exercised by the opt-in conformance runner (not a required PR CI Gate):
python3 scripts/ci/bulk-construction-conformance.py validateBest practices
Section titled “Best practices”- Use parameters in Cypher — never interpolate untrusted strings into queries.
- Prefer
MERGEwhen re-running ETL or scripts that must be idempotent. - Pass
operation_uuidon every bulk call and keep it stable for retries. - Close persistent graphs (
forge.close()) when finished with a directory project. - Consume Arrow results with
to_pandas(),polars.from_arrow, orto_pylist()— there are noCypherValuewrappers in v0.5.0.
Next steps
Section titled “Next steps”- Cypher Guide — full openCypher reference
- Quick Start — five-minute walkthrough
- Tutorial — guided citation-network example
- API Reference — complete method signatures