Write to Neo4j (a property graph database) with support for node tables, relationship tables (edges), per-database multitenancy, atomic-batch writes via Bolt transactions, real CREATE CONSTRAINT uniqueness, and vector indexes with cosine / euclidean similarity.
Version
v 1.0.14
Last reviewed
Nov 4, 2026
ExperimentalThe Rust SDK is experimental. It is outside CocoIndex's compatibility promise: any release, including a minor one, may change or remove its API. It is not yet published on crates.io.
The neo4j connector writes records to Neo4j, a property graph database. It supports node tables (labels), relationship tables (edge types), per-database multitenancy (one Neo4j cluster, many isolated databases), real Cypher uniqueness constraints, and vector indexes via the CREATE VECTOR INDEX DDL form.
use cocoindex::connectors::neo4j::{self, TableSchema};
The module is compiled with the neo4j Cargo feature of the cocoindex crate.
Dependencies
Targets Neo4j 5.18+. Vector-index DDL (CREATE VECTOR INDEX … OPTIONS { indexConfig: { … } }) shipped in 5.18 — older 5.x servers will reject the DDL the connector emits.
Connection setup
Connect a neo4j::Graph and provide it under a context key. A Graph holds a connection pool and the name of the database its statements run on; all tables and batches share it.
Note
The key name is load-bearing across runs — it’s the stable identity CocoIndex uses to track managed rows. See ContextKey as stable identity before renaming.
use cocoindex::connectors::neo4j;use cocoindex::prelude::*;// The graph connection, provided to the environment in `main`. Its name is the// graph's stable identity in the target states.cocoindex::context_key!(static GRAPH: neo4j::Graph);#[tokio::main]async fn main() -> Result<()> { let graph = neo4j::Graph::connect(&uri, &user, &password, &database).await?; let app = Environment::builder() .provide_key(&GRAPH, graph) .build() .await? .app("meeting_notes_graph_neo4j") .await?; // ... Ok(())}
neo4j::Graph::connect(uri, user, password, database) opens a connection pool with basic authentication; the user, password and database are required arguments. For TLS, a custom pool size or other driver settings, build a neo4rs::Graph with neo4rs::ConfigBuilder and wrap it with neo4j::Graph::from_client(client, database). The example provides the graph in main; a #[cocoindex::lifespan] function can do the same with builder.provide_key(&GRAPH, graph), and builder.on_shutdown(..) is the place for teardown. A Graph has no close() method.
Multitenancy
A single Neo4j cluster can host many isolated databases. Pair each database with its own context key and neo4j::Graph:
context_key! names the key after the static by default; key = "..." sets the name the target states are tracked under.
Different context keys with different database names produce fully separate target-state trees — changes to one never spill into the other.
As target
The neo4j connector provides target state APIs for writing nodes to node tables and edges to relationship tables. CocoIndex tracks what records should exist and automatically handles upserts and deletions.
The record writes of all tables of one database are applied together, in batches. Within a batch, writes are ordered as node upserts → relation upserts → relation deletes → node deletes so dependent edges always see their endpoints. A batch is applied in Neo4j transactions of at most 1,000 record writes each, one after the other. A transaction that fails rolls back, and the components of the batch are retried; earlier transactions of the batch stay committed, which is safe because the writes are idempotent (MERGE). A transaction that fails with a transient error (typically a deadlock between concurrent writers merging edges onto the same nodes) is retried with backoff, up to 8 attempts, before the failure is reported. A transaction that goes over the server’s transaction memory limit is split in two instead.
Within a transaction, the records of one statement shape (the node upserts of one label, the edge upserts of one relationship type and endpoint labels, and so on) are written by one UNWIND $rows AS row … statement.
Different from Python
The Rust functions keep the names mount_table_target / TableTarget and mount_relation_target / RelationTarget. They have the semantics of Python’s node and relationship APIs (mount_node_target, mount_relationship_target), not of Python’s deprecated table and relation APIs: a node table and a relationship table live in separate key spaces, so a label and a relationship type may share a name, and a derived edge id has the length-prefixed form described under Relationships. The Rust SDK has no deprecated API.
Declaring target states
Node tables (parent state)
Declares a node label as a target state. Returns a TableTarget for declaring nodes.
declare_table_target declares the table in the current component; its records resolve when the component commits. mount_table_target and mount_table_target_with_options take the same arguments, are async, and return a target whose records can be declared at once.
Parameters:
graph — The context key of the neo4j::Graph.
table_name — The Cypher node label (e.g. "Meeting"). It must not contain __ or end with _: constraint and index names join the label to a property name with __, so such a label could share a name with another table’s.
schema — The table schema (see Table schema: from a row struct). It names the single primary-key property; compound primary keys are not supported. TableSchema::from_row::<Row>("id")? derives the columns from a row struct, and TableSchema::schemaless("id")? declares no columns. The schema participates in CocoIndex’s fingerprint (so two flows declaring the same label must agree); no per-property type DDL is emitted.
options — ManagedTargetOptions::user_managed() assumes the table exists; the default (ManagedTargetOptions::default()) lets CocoIndex manage its lifecycle.
Returns: A TableTarget. A TableTarget serializes to its stable key (the graph’s context key and the label), so it can be an argument of mount!, use_mount!, mount_each! and memoized functions.
Nodes (child states)
Once a TableTarget is resolved, declare nodes to be upserted (translated to UNWIND $rows AS row MERGE (n:Label {pk: row.key}) SET n += row.props):
The node’s primary-key value is passed as id (a &str, String, i64 or u64) and the row serializes to the node’s properties with serde.
A row that also carries the primary-key field must hold the same value as id (a null counts as absent), or the declaration fails. A property value must be a primitive or a list of primitives: a map, a list holding a null or a map, an integer beyond i64, or a NaN or infinite float fails the declaration before anything is written.
Relationship tables (parent state)
Declares a relationship type as a target state. Returns a RelationTarget for declaring edges.
Without a schema the relationship is schemaless and keyed by its id property. A schema names the primary key: TableSchema::from_row::<Edge>("edge_id")? or TableSchema::schemaless("edge_id")?. Each function has an asyncmount_relation_target* twin with the same arguments, whose edges can be declared at once.
Parameters:
graph — The context key of the neo4j::Graph.
relation_name — The Cypher relationship type (e.g. "ATTENDED"). Same naming rule as a node label: no __, no trailing _. A node label and a relationship type may share a name.
from_table — The node table whose nodes are the source endpoints of edges in this relationship.
to_table — The node table whose nodes are the target endpoints of edges in this relationship.
schema — The schema for the relationship’s own properties; its primary key uniquely identifies each edge. Without it, the edges are keyed by the id property.
options — ManagedTargetOptions::user_managed() assumes the relationship type exists; the default lets CocoIndex manage its lifecycle.
Returns: A RelationTarget, which serializes to its stable key like a TableTarget.
Relationships (child states)
Once a RelationTarget is resolved, declare edges. Each declaration MERGEs the source endpoint, the target endpoint, then the relationship.
declare_relation declares an edge without properties. declare_relation_record adds the properties of record; a record that carries the primary key is keyed by it (a string or an integer), otherwise the id is derived from the endpoints.
Parameters:
from_id — The source node’s primary-key value. The connector MERGEs (s:FromLabel {pk: $from_id}) so endpoints are auto-created if absent.
to_id — The target node’s primary-key value. Same MERGE behavior.
record — The row whose fields populate the relationship’s properties (declare_relation_record only). Its primary key field, if present, is the edge’s id.
If the edge is declared with declare_relation or the record has no primary key value, the connector derives the edge id from its endpoints: the source label, source id, target label and target id, each written as <UTF-8 byte length>:<value>;. For example, an edge from Person"alice" to Task42 gets the id 6:Person;5:alice;4:Task;2:42;. A string id that reads as an integer, or that starts with s:, is written with an s: prefix, so the ids 1 and "1" never share an edge.
An edge id names one edge. When the endpoints of an edge with an explicit id change, the edge moves: the upsert deletes the relationship of this type with the same id between the old endpoints. To find it, every upsert looks the edge up by its primary key property. A system-managed relationship table has an index on it; for a user-managed table, create one yourself.
Vector indexes (attachment)
Declares a vector index on a column of a node table. Vector indexes are an attachment to a TableTarget:
VectorMetric::Cosine and VectorMetric::Euclidean are the Neo4j metrics; VectorMetric::InnerProduct is FalkorDB-only and fails on Neo4j. The Rust TableTarget also has declare_node_index(ctx, &["name"]), which declares a plain secondary index on the listed properties.
Parameters:
name — Logical name for the index (declare_vector_index_named only). Defaults to vec_{table_name}__{field}.
field — The node property holding the vector.
metric — Similarity metric: VectorMetric::Cosine or VectorMetric::Euclidean. Translated to Neo4j’s vector.similarity_function option.
dimension — The vector’s dimension. Required.
The connector emits:
CREATE VECTOR INDEX `coco_vec_<Label>__<field>` IF NOT EXISTSFOR (n:`Label`) ON n.`field`OPTIONS { indexConfig: { `vector.dimensions`: <N>, `vector.similarity_function`: '<metric>'} }
The Neo4j index is named after the label and field, not after name, so a field has at most one vector index. Renaming name keeps the index. Declaring a second name for the same field is an error.
primary_key has no default and must name a field of the struct. An Option<T> field is a nullable column.
To override the type of one column, put a #[coco(..)] attribute on the field (see Column type overrides), or call with_column_type on the derived schema:
let schema = TableSchema::from_row::<Row>("id")?.with_column_type("location", "POINT")?;
with_column_type(field, cypher_type) replaces the recorded Cypher type of one column and keeps its nullability. It fails if the schema has no column named field.
Default Rust → Neo4j type mapping
Most types pass through native Bolt encoding — no per-value transform applied:
Rust type
Neo4j type
Notes
bool
BOOLEAN
i16, i32, i64 (and the unsigned widths)
INTEGER
f32, f64
FLOAT
rust_decimal::Decimal
STRING
Written as a string — Neo4j has no decimal type.
String
STRING
Vec<u8>
BYTES
Native Bolt type — no encoder.
uuid::Uuid
STRING
Written as a string.
chrono::NaiveDate
DATE
Native Bolt type.
chrono::DateTime, chrono::NaiveDateTime
ZONED_DATETIME
Native Bolt type.
chrono::NaiveTime
LOCAL_TIME
Native Bolt type.
std::time::Duration
DURATION
Native Bolt type.
#[coco(vector)] Vec<f32>
LIST<FLOAT>
Paired with vector-index DDL.
other Vec<T>, maps, nested structs, #[coco(json)]
MAP
Passed through native parameter binding.
Column type overrides
Override the default mapping for a single column with a #[coco(..)] field attribute:
#[coco(type = "…")] records the type string verbatim, #[coco(json)] records MAP for a field that would map to another type, and #[coco(rename = "…")] names the property. A value is written as it serializes with serde.
Different from Python
There is no per-column encoder: to change how a value is written, change how its field serializes (for example with #[serde(serialize_with = "…")]).
The type string is metadata-only — it participates in the schema fingerprint (so two flows declaring the same table must agree) but no per-property type DDL is emitted from it.
Vector fields
Mark a vector field #[coco(vector)] (or #[coco(vector = N)]). The property is recorded as LIST<FLOAT>; the dimension belongs to the vector index declaration, not the property.
Table schema: explicit column definitions
Build a TableSchema directly from column definitions when the row type is dynamic:
ColumnDef::new(cypher_type) takes the Neo4j type string (metadata only; see the table above). A column is NOT NULL unless .nullable() is called.
DDL: indexes and constraints
For each managed table, the connector creates supporting Cypher artifacts on first run:
For node tables: a uniqueness constraint on the primary key —
CREATE CONSTRAINT `coco_uniq_<Label>__<pk>` IF NOT EXISTSFOR (n:`<Label>`) REQUIRE n.`<pk>` IS UNIQUE
Neo4j auto-creates a backing index for each constraint, so a separate CREATE INDEX is redundant on nodes.
For relationship tables:
CREATE INDEX `coco_idx_rel_<RelType>__<pk>` IF NOT EXISTSFOR ()-[r:`<RelType>`]-() ON (r.`<pk>`)
A system-managed table owns its label or relationship type, the way a Postgres table owns its rows. When the table is no longer declared, or on cocoindex drop, the connector destroys it: every node carrying the label is deleted (DETACH DELETE, so the relationships attached to those nodes go with them) or every relationship of the type is deleted, and then the constraint or index is dropped. A node table’s vector indexes (the coco_vec_<Label>__<field> indexes on its label) are dropped with it. This includes nodes and relationships written under that label or type by anything other than CocoIndex — to share a label with data CocoIndex doesn’t own, declare the table with ManagedTargetOptions::user_managed().
The same teardown happens when a table’s identity changes — its primary key field, or a relationship table’s endpoint tables or their primary keys: the table is destroyed and recreated, and the components that declared its records are reprocessed. Its vector indexes are dropped and created again. Attaching a schema or changing a property type does not rebuild the table; at most the records are re-upserted.
When a table is user-managed, the connector skips DDL entirely (you’re responsible for creating and dropping the schema) and never bulk-deletes: when the table is no longer declared or the app is dropped, its nodes or relationships are left as-is (see Destroy vs. Abandon). Record-level upserts and deletes still work while the table is declared. Pass ManagedTargetOptions::user_managed() to mount_table_target_with_options or mount_relation_target_with_options.
The full program is examples/rust/meeting_notes_graph_neo4j.
Example: Relationship tables (knowledge graph)
#[derive(Clone, Debug, Serialize, Deserialize, cocoindex::SchemaFields)]struct Person { name: String,}#[derive(Clone, Debug, Serialize, Deserialize, cocoindex::SchemaFields)]struct Task { description: String,}#[derive(Clone, Debug, Serialize, Deserialize)]struct AttendedRel { is_organizer: bool,}async fn build_graph(ctx: &Ctx, input_dir: &str) -> Result<()> { let meeting_table = neo4j::mount_table_target( ctx, &GRAPH, "Meeting", TableSchema::from_row::<Meeting>("id")?, ) .await?; let person_table = neo4j::mount_table_target( ctx, &GRAPH, "Person", TableSchema::from_row::<Person>("name")?, ) .await?; let task_table = neo4j::mount_table_target( ctx, &GRAPH, "Task", TableSchema::from_row::<Task>("description")?, ) .await?; // Relations are schemaless: their ids are derived from the endpoints. let attended_rel = neo4j::mount_relation_target(ctx, &GRAPH, "ATTENDED", &person_table, &meeting_table) .await?; let assigned_rel = neo4j::mount_relation_target(ctx, &GRAPH, "ASSIGNED_TO", &person_table, &task_table) .await?; // populate ... person_table.declare_record(ctx, "Alice Chen", &Person { name: "Alice Chen".to_string() })?; task_table.declare_record( ctx, "Write the roadmap", &Task { description: "Write the roadmap".to_string() }, )?; attended_rel.declare_relation_record( ctx, "Alice Chen", meeting_id, &AttendedRel { is_organizer: true }, )?; assigned_rel.declare_relation(ctx, "Alice Chen", "Write the roadmap")?; Ok(())}
An edge with properties of its own is declared with declare_relation_record; one without is declared with declare_relation. To key edges by a property, mount the relation with mount_relation_target_with_schema(ctx, &GRAPH, "KNOWS", &people, &people, TableSchema::from_row::<Knows>("edge_id")?) and put the edge_id in the record.
The Person and Task tables are declared up-front (via mount_table_target) so their uniqueness constraints are reconciled before any edge MERGEs their endpoints. The relationship’s three-MERGE pattern (source endpoint → target endpoint → edge) means missing endpoints are auto-created — but it’s good practice to declare them explicitly so deletion-cascade behavior stays predictable.