BoltexNif
Elixir driver for Neo4j, implemented as a
Rustler-powered NIF around the
official Rust driver neo4rs.
-
Full Bolt v5 protocol (via
neo4rs'sunstable-v1feature bundle). - No Rust toolchain required — precompiled NIFs ship for macOS, Linux
(glibc), and Windows via
rustler_precompiled. - Async on the inside, sync on the outside: every NIF call returns a
refimmediately and the work runs on a shared Tokio runtime; the Elixir API you see is plain synchronous —{:ok, ...}/{:error, ...}with proper timeouts. - First-class types: nodes, relationships, paths, points, temporals, durations, bytes, nested maps/lists — all marshalled to idiomatic Elixir structs.
- Production essentials: connection pool, explicit transactions, lazy
row streaming, result summary (counters + notifications + bookmarks),
TLS, user impersonation, and structured
Neo4jErrorwith retryable classification.
Table of contents
- Installation
- Quick start
- Connecting
- Running queries
- Transactions
- Streaming
- Type mapping
- Error handling
- Concurrency & the connection pool
- Using it from Phoenix
- Local development
- Testing
- Roadmap
- Releasing new versions (maintainers)
- License
Installation
Add to mix.exs and run mix deps.get — that's it:
def deps do
[
{:boltex_nif, "~> 0.1"}
]
endNo Rust toolchain required.boltex_nif ships precompiled NIFs for:
| OS | Architectures |
|---|---|
| macOS | aarch64 (Apple silicon), x86_64 (Intel) |
| Linux (glibc) | x86_64, aarch64 |
| Windows | x86_64 (MSVC) |
For unsupported targets (musl/Alpine, 32-bit ARM, RISC-V, …) or to force a
local build, set FORCE_BOLTEX_BUILD=1 and add {:rustler, "~> 0.37"} to
your own deps:
FORCE_BOLTEX_BUILD=1 mix deps.compile boltex_nifBuilding from source needs Rust ≥ 1.81.
Runtime requirements
- Elixir 1.19 / OTP 28 (the regression-tested matrix). NIF 2.16 binaries are published too, so older Elixir/OTP combos with NIF ≥ 2.16 should also work, they just aren't part of the test matrix.
-
A reachable Neo4j 5.x instance (Community or Enterprise). The repo ships
docker-compose.yml(local dev) anddocker-compose.production.yml(Coolify-ready) — see Local development.
Quick start
{:ok, graph} =
BoltexNif.connect(
uri: "bolt://localhost:7687",
user: "neo4j",
password: "boltex_nif_pass"
)
:ok = BoltexNif.run(graph, "MERGE (:Greeter {name:$n})", %{"n" => "Ada"})
{:ok, rows} =
BoltexNif.execute(graph, "MATCH (g:Greeter) RETURN g.name AS name")
Enum.map(rows, & &1["name"])
#=> ["Ada"]Connecting
BoltexNif.connect/1 takes a keyword list or a map:
{:ok, graph} =
BoltexNif.connect(
uri: "bolt://localhost:7687", # required — bolt:// or neo4j:// (routing)
user: "neo4j", # required
password: "secret", # required
db: "neo4j", # optional — default database
fetch_size: 500, # optional — rows per pull (driver default 200)
max_connections: 16, # optional — pool size (driver default 16)
impersonate_user: "alice", # optional — Bolt v5 impersonation
tls: :skip_validation, # optional — see below
timeout: 15_000 # optional — connect handshake timeout (ms)
)TLS
tls: nil # default — honors scheme (neo4j+s://, bolt+ssc://)
tls: {:ca, "/etc/ssl/neo4j-ca.pem"} # validate server cert against the CA
tls: {:mutual, ca: "/etc/ssl/ca.pem", cert: "/etc/ssl/client.pem", key: "/etc/ssl/client.key"}
tls: :skip_validation # accept anything — DO NOT use in prod
The returned graph is an opaque reference() you pass to every query
function. It can be safely shared across processes — internal connection
pooling is handled by the Rust layer.
Running queries
Three flavors, increasing in what they return:
run/4 — fire-and-forget
:ok = BoltexNif.run(graph, "CREATE (:Foo {i:$i})", %{"i" => 1})
# or with an options keyword:
:ok = BoltexNif.run(graph, "CREATE (:Foo)", nil, timeout: 30_000)run_with_summary/4 — write stats without rows
{:ok, %BoltexNif.Summary{stats: stats, query_type: type}} =
BoltexNif.run_with_summary(graph, "CREATE (:Foo {i:1}), (:Foo {i:2})")
stats.nodes_created #=> 2
stats.properties_set #=> 2
type #=> "write" (or "read" / "read_write" / "schema_write")
Full fields on %BoltexNif.Summary{}:
bookmark—String.t() | nil— Bolt v5 bookmark forstart_txn_as.available_after_ms,consumed_after_ms— server-side timings.query_type—"read" | "write" | "read_write" | "schema_write".db— database the query ran against.stats—%BoltexNif.Summary.Counters{}withnodes_created,relationships_created,properties_set,labels_added,indexes_added,constraints_added, their*_deleted/*_removedcounterparts, andsystem_updates.notifications—[%BoltexNif.Notification{}](code, title, severity, category, sourceInputPosition).
execute/4 — collect all rows
{:ok, rows} =
BoltexNif.execute(
graph,
"MATCH (p:Person {age: $age}) RETURN p.name AS name, p.age AS age ORDER BY name",
%{"age" => 30},
timeout: 60_000
)
rows
#=> [%{"name" => "Ada", "age" => 30}, %{"name" => "Grace", "age" => 30}]
Rows are plain maps keyed by the AS alias you declare in the Cypher.
Transactions
Imperative — full control over commit/rollback
{:ok, txn} = BoltexNif.begin_transaction(graph)
{:ok, _summary} = BoltexNif.txn_run(txn, "CREATE (:T {x:1})")
{:ok, rows} = BoltexNif.txn_execute(txn, "MATCH (t:T) RETURN t")
# Either:
:ok = BoltexNif.rollback(txn)
# or (Bolt v5 returns the bookmark, otherwise just :ok):
{:ok, bookmark_or_nil} = BoltexNif.commit(txn)
Declarative — transaction/3
Commits on {:ok, value}, rolls back on {:error, _}, re-raises on
exceptions (rollback first):
{:ok, count} =
BoltexNif.transaction(graph, fn txn ->
{:ok, _} = BoltexNif.txn_run(txn, "CREATE (:T {x:1})")
{:ok, rows} = BoltexNif.txn_execute(txn, "MATCH (t:T) RETURN count(t) AS c")
{:ok, rows |> hd() |> Map.get("c")}
end)Streaming
For result sets bigger than memory, stream row by row:
{:ok, stream} =
BoltexNif.stream_start(graph, "MATCH (n) RETURN n LIMIT 100_000")
Stream.repeatedly(fn -> BoltexNif.stream_next(stream) end)
|> Enum.take_while(&(&1 != :done))
|> Enum.each(fn {:ok, row} -> process(row) end){:ok, row}— one row returned.:done— stream exhausted (connection is returned to the pool).{:error, :closed}—stream_nextcalled after:doneorstream_close/1.BoltexNif.stream_close(stream)— drop early without draining;:okeven if already closed.
Type mapping
Parameters (Elixir → Bolt)
| Elixir value | Becomes (Bolt) |
|---|---|
nil | Null |
true / false | Boolean |
integer() | Integer (i64) |
float() | Float (f64) |
binary() (UTF-8) | String |
{:bytes, binary()} | Bytes |
list() | List |
map() — keys must be strings or atoms | Map |
%Date{} | Date |
%Time{} | LocalTime |
%NaiveDateTime{} | LocalDateTime |
%DateTime{} | DateTime |
%BoltexNif.Time{time: %Time{}, offset_seconds} | Time |
%BoltexNif.DateTime{naive: %NaiveDateTime{}, offset_seconds} | DateTime |
%BoltexNif.DateTimeZoneId{naive, tz_id} | DateTimeZoneId |
%BoltexNif.Duration{months, days, seconds, nanoseconds} | Duration |
%BoltexNif.Point{srid, x, y, z \\ nil} | Point2D/3D |
%BoltexNif.Node{id, labels, properties} | Node |
%BoltexNif.Relationship{...} | Relationship |
%BoltexNif.UnboundRelationship{id, type, properties} | UnboundRel |
Results (Bolt → Elixir)
Symmetric to the param table. Highlights:
Bolt String→ UTF-8binary().Bolt Bytes→{:bytes, binary()}.Bolt Date/LocalTime/LocalDateTime→ stdlib%Date{}/%Time{}/%NaiveDateTime{}.Bolt DateTime(FixedOffset) →%BoltexNif.DateTime{}(keeps offset without needing a TZ database).Bolt DateTimeZoneId→%BoltexNif.DateTimeZoneId{}.Bolt Time(with offset) →%BoltexNif.Time{}.Bolt Duration→%BoltexNif.Duration{}(Bolt keeps months/days/ seconds/nanos separately; we never collapse them).Bolt Point2D→%BoltexNif.Point{z: nil};Point3D→z: float().Bolt Node→%BoltexNif.Node{id, labels, properties}.Bolt Relationship→%BoltexNif.Relationship{id, start_node_id, end_node_id, type, properties}.Bolt Path→%BoltexNif.Path{nodes, relationships, indices}whererelationshipsis a list of%BoltexNif.UnboundRelationship{}.
Error handling
Every {:error, _} response is one of:
{:error, {:neo4j, %BoltexNif.Neo4jError{code: code, message: msg, kind: kind}}}
{:error, {:invalid_config, msg}}
{:error, {:io, msg}}
{:error, {:deserialization, msg}}
{:error, {:unexpected_type, msg}}
{:error, {:unexpected, msg}}
{:error, {:argument, msg}}
{:error, :timeout} # NIF didn't answer within the caller's timeoutkind on a Neo4j error classifies the failure for retry decisions. One of:
:authentication, :authorization_expired, :token_expired,
:other_security, :session_expired, :fatal_discovery,
:transaction_terminated, :protocol_violation, :client_other,
:client_unknown, :transient, :database, :unknown.
case BoltexNif.execute(graph, cypher, params) do
{:ok, rows} -> rows
{:error, {:neo4j, err}} ->
if BoltexNif.Neo4jError.retryable?(err), do: retry(), else: raise("boom: #{err.message}")
{:error, :timeout} -> retry()
endConcurrency & the connection pool
-
The underlying
neo4rspool is bounded by:max_connections(default 16). All BoltexNif functions are safe to call concurrently from any number of processes — they queue up on the Rust-side pool transparently. -
Each call returns a fresh
refand waits on a single Erlang message ({ref, result}), so it obeys:timeoutcleanly even while queued. -
A request that times out on the Elixir side only stops waiting for
the result; the Rust task finishes anyway and its reply is dropped. Keep
:timeoutgenerous when you know you might be behind a deep queue. -
Measured throughput against a remote Coolify Neo4j (≈ 430 ms RTT): ~9
q/s per connection, 50× pool over-subscription handled without losses.
Against a local
docker compose up -dNeo4j: order of magnitude higher.
See phoenix_neo4j/test/phoenix_neo4j/neo4j_stress_test.exs for the
:stress suite (parallel reads/writes, transaction interleaving,
streaming concurrency, pool saturation).
Using it from Phoenix
The repo includes phoenix_neo4j/ — a minimal Phoenix 1.8 app that
wires boltex_nif into a supervision tree, exposes the pool, and serves a
demo /neo4j page (list/create/delete :Greeter nodes).
Core pattern in phoenix_neo4j/lib/phoenix_neo4j/neo4j.ex:
defmodule MyApp.Neo4j do
use GenServer
@key {__MODULE__, :graph}
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
def graph, do: :persistent_term.get(@key)
def run(cypher, params \\ nil, opts \\ []),
do: BoltexNif.run(graph(), cypher, params, opts)
def execute(cypher, params \\ nil, opts \\ []),
do: BoltexNif.execute(graph(), cypher, params, opts)
@impl true
def init(opts) do
{:ok, graph} = BoltexNif.connect(opts)
:persistent_term.put(@key, graph)
{:ok, %{graph: graph}}
end
endAdd to your Application:
children = [
# …,
{MyApp.Neo4j, uri: System.fetch_env!("NEO4J_URI"),
user: System.fetch_env!("NEO4J_USER"),
password: System.fetch_env!("NEO4J_PASSWORD"),
max_connections: 16}
]
Callers just use MyApp.Neo4j.execute/2 — no GenServer bottleneck, pool
is handled in Rust.
To run the demo:
docker compose up -d
cd phoenix_neo4j
mix deps.get
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=boltex_nif_pass \
mix phx.server
# visit http://localhost:4000/neo4jLocal development
Two compose files ship with the repo:
docker-compose.yml — local dev
Neo4j 5.26 Community, bound to localhost:7687:
docker compose up -d # boot
docker compose down -v # tear down and drop volumes
Default creds: neo4j / boltex_nif_pass.
docker-compose.production.yml — Coolify-ready
Production-oriented: APOC auto-install, query logging, healthcheck via
cypher-shell, memory & ulimit tuning, opt-in backup sidecar using APOC
export. Drop into Coolify's "Docker Compose" resource and pass the env
vars from .env.production.example.
Automatic SERVICE_FQDN_NEO4J substitution means you only need to set
CFG_NEO4J_PASSWORD — the rest has sensible defaults. See comments at
the top of the file for the TLS-for-Bolt block and the :port gotchas
around Cloudflare (Bolt TCP needs DNS-only, not proxied).
Testing
The test suite is live-by-default — it will refuse to run the DB-touching
tests unless NEO4J_URI is set. Two tag tiers:
:live— touches Neo4j. Excluded whenNEO4J_URIisn't set.:stress— opt-in, long-running concurrency tests. Always excluded by default.
# Library tests (14 cases):
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=boltex_nif_pass \
mix test --include live
# Phoenix demo tests (48 cases):
cd phoenix_neo4j
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=boltex_nif_pass \
mix test --include live
# Full concurrency/stress suite (add --only stress to isolate):
cd phoenix_neo4j
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=boltex_nif_pass \
STRESS_SCALE=1.0 \
mix test --include live --include stressOne-shot smoke
scripts/smoke.sh runs an end-to-end check: Phoenix HTTP endpoints (if a
server is up at $PHX_URL), the BoltexNif type / transaction / streaming
probes in scripts/smoke.exs, and the full mix test --include live.
# Requires Neo4j reachable at $NEO4J_URI (defaults baked in for Coolify).
./scripts/smoke.sh
# or isolate phases:
SKIP_PHX=1 ./scripts/smoke.sh # only library
SKIP_MIX_TEST=1 ./scripts/smoke.sh # library checks + phoenix HTTPRoadmap
- Phase 1 (done): scaffolding, primitives + graph/temporal/spatial
types, auto-commit
run/execute. - Phase 2 (done): transactions, streaming,
ResultSummary. - Phase 3 (done): Bolt v5 bookmarks, TLS, impersonation, structured Neo4j errors.
- Phase 4 (done): precompiled NIFs via
rustler_precompiled, Hex metadata, GitHub Actions release pipeline. - Future:
element_idon Nodes/Relationships (requires decoding via the Bolt v5 serde path, not the classictypes/*tree).-
Optional
neo4rsfeatures:json(transparentserde_json::Value),uuid(Ecto.UUID↔String). -
Per-stream
fetch_sizeoverride, streaming within a transaction. :telemetryhooks (query start/stop, pool checkout, tx commit).-
musl (Alpine) precompiled targets once a working
Cross.tomlis in place.
Releasing new versions (maintainers)
.github/workflows/release.yml runs only on tag pushes (v*). It builds
a matrix of 5 targets × 2 NIF versions (10 artifacts) and uploads them to
a draft GitHub Release. After publishing the draft, run:
mix rustler_precompiled.download BoltexNif.Native --all --ignore-unavailable --print
git add checksum-boltex_nif-X.Y.Z.exs
git commit -m "chore(release): checksum for vX.Y.Z"
git push
mix hex.publish
Full step-by-step in RELEASING.md.
License
MIT — see LICENSE.