Bolty is an Elixir driver for Neo4j/Bolt Protocol, forked from Boltx and now developed independently.
- Supports Neo4j 5.26.28 LTS and Neo4j 2026.05
- Supports Bolt versions: 5.0/5.1/5.2/5.3/5.4/5.6/5.7/5.8/6.0
- Supports transactions, prepared queries, streaming, pooling and more via DBConnection
- Automatic decoding and encoding of Elixir values
Documentation: https://hexdocs.pm/bolty
Features
| Feature | Implemented |
|---|---|
| Queries | YES |
| Transactions | YES |
| Streaming | YES — Bolty.stream/4 (lazy, server-side backpressure) |
| Routing | Partial — server-side routing (SSR) for neo4j:// schemes; no autodiscovery/failover |
Usage
Add :bolty to your dependencies:
def deps() do
[
{:bolty, "~> 0.3.0"}
]
endUsing the latest version.
opts = [
hostname: "127.0.0.1",
auth: [username: "neo4j", password: "password"],
user_agent: "boltyTest/1",
pool_size: 15,
max_overflow: 3,
prefix: :default
]
# Pin to a specific Bolt version (versions are strings; floats are deprecated):
opts = [versions: ["5.4"]] ++ opts
# Offer multiple versions as ranges (handshake has 4 slots — ranges cover more):
opts = [versions: [{5, 6..8}, {5, 0..4}]] ++ opts
iex> {:ok, conn} = Bolty.start_link(opts)
{:ok, #PID<0.237.0>}
iex> Bolty.query!(conn, "return 1 as n") |> Bolty.Response.first()
%{"n" => 1}
# Commit is performed automatically if everything went fine
Bolty.transaction(conn, fn conn ->
result = Bolty.query!(conn, "CREATE (m:Movie {title: 'Matrix'}) RETURN m")
end)
# Lazily stream a large result in batches (server-side backpressure).
# Must be enumerated inside a transaction; :fetch_size sets the batch size.
Bolty.transaction(conn, fn conn ->
conn
|> Bolty.stream("MATCH (n) RETURN n", %{}, fetch_size: 500)
|> Stream.flat_map(& &1.results)
|> Enum.each(&IO.inspect/1)
end)
Set it up in an app
Add the configuration to the corresponding files for each environment or to your config/config.ex.
Name of process
The process name must be defined in your configuration
import Config
config :bolty, Bolt,
uri: "bolt://localhost:7687",
auth: [username: "neo4j", password: "password"],
user_agent: "boltyTest/1",
pool_size: 15,
max_overflow: 3,
prefix: :default,
name: BoltAdd Bolty to the application's main monitoring tree and let OTP manage it.
# lib/n4_d/application.ex
defmodule N4D.Application do
@moduledoc false
use Application
def start(_type, _args) do
children = [
%{
id: Bolty,
start: {Bolty, :start_link, [Application.get_env(:bolty, Bolt)] },
}
]
opts = [strategy: :one_for_one, name: N4D.Supervisor]
Supervisor.start_link(children, opts)
end
endOr
children = [
{Bolty, Application.get_env(:bolty, Bolt)}
]Now you can run query with the name you set
iex> Bolty.query!(Bolt, "return 1 as n") |> Bolty.Response.first()
%{"n" => 1}URI schemes
By default the scheme is bolt+s, which performs full certificate verification.
| URI | Description | Default TLS behaviour |
|---|---|---|
| neo4j | Unsecured | no TLS |
| neo4j+s | Secured, full certificate verification | verify: :verify_peer against the system CA store, with SNI + hostname verification |
| neo4j+ssc | Secured, self-signed / trust-all | verify: :verify_none (encryption only, no verification) |
| bolt | Unsecured | no TLS |
| bolt+s | Secured, full certificate verification | verify: :verify_peer against the system CA store, with SNI + hostname verification |
| bolt+ssc | Secured, self-signed / trust-all | verify: :verify_none (encryption only, no verification) |
The +s defaults use the OS trust store via :public_key.cacerts_get/0, which
verifies Neo4j Aura and other public-CA certificates out of the box. In a minimal
container without an OS trust store, add castore
and pass ssl_opts: [cacertfile: CAStore.file_path()].
Any :ssl_opts you pass are merged over these defaults, so you can override
verification (e.g. ssl_opts: [verify: :verify_none] for local development) or
point at a private CA (ssl_opts: [cacertfile: "..."]).
# Default TLS: bolt+s / neo4j+s verify the server cert against the OS trust
# store — works out of the box for Neo4j Aura and other public CAs.
{:ok, conn} =
Bolty.start_link(
scheme: "neo4j+s",
hostname: "xxxx.databases.neo4j.io",
auth: [username: "neo4j", password: System.fetch_env!("NEO4J_PASSWORD")]
)
# Self-signed / internal certificate: use +ssc (encrypted, no verification).
Bolty.start_link(scheme: "bolt+ssc", hostname: "neo4j.internal", auth: [username: "neo4j", password: "..."])
# Verify against your own private CA instead of the OS trust store.
Bolty.start_link(
scheme: "bolt+s",
hostname: "neo4j.internal",
ssl_opts: [cacertfile: "/etc/ssl/my-ca.pem"],
auth: [username: "neo4j", password: "..."]
)Generating and installing the server's certificates is Neo4j-side configuration —
see Neo4j's SSL framework
docs. Neo4j Aura and other managed offerings present public-CA certificates, so
+s needs no setup.
Self-signed certificates and
+s: Erlang's:ssl(which bolty uses) rejects a self-signed server certificate under full verification — the peer cert is flagged:selfsigned_peer— even if you pass that same cert as the CA viassl_opts: [cacertfile: ...], and regardless of itsbasicConstraints. (OpenSSL is more lenient, soopenssl verifysucceeding does not mean+swill.)+sneeds a genuine chain: a CA certificate that signed a distinct server leaf — the server presents the leaf (which isn't self-signed), and you trust the CA viacacertfile. For a single self-signed cert (a typical local / dev box) use+sscinstead, which encrypts without verifying.To use
+swith your own PKI, create a root CA and a server cert signed by it:# 1. root CA (self-signed, CA:TRUE) openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.crt -days 3650 \ -subj "/CN=My Neo4j CA" -addext "basicConstraints=critical,CA:TRUE" # 2. server key + CSR, then sign the leaf with the CA (CA:FALSE, serverAuth, SAN) openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr -subj "/CN=neo4j.internal" openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 825 \ -out server.crt -extfile <(printf "basicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\nsubjectAltName=IP:192.168.4.50") # Neo4j serves server.crt + server.key; bolty trusts ca.crt: # scheme: "bolt+s", ssl_opts: [cacertfile: "ca.crt"]
Changed in 0.3.0:
+s/+sscverification was previously inverted, and+sdid no server authentication.+snow verifies by default, and explicit:ssl_optsare no longer silently overridden. Pin0.2.1if you depend on the old behaviour.
Named-zone datetimes
A Neo4j datetime() carrying a named zone (e.g. "Europe/Berlin") is resolved
into an Elixir DateTime when decoded, which requires a configured
:time_zone_database. Bolty deliberately bundles none — that global is the host
application's to own — so configure one:
# add {:tz, "~> 0.28"} (or {:tzdata, "~> 1.1"}) to your deps, then in config:
config :elixir, :time_zone_database, Tz.TimeZoneDatabaseWithout a database, decoding such a value does not crash — the query returns a
clear {:error, %Bolty.Error{code: :time_zone_database_not_configured}}.
Integer-offset datetimes (DateTimeWithTZOffset) need no database.
Negotiated capabilities
Bolty negotiates the highest mutually-supported Bolt version during connection. The outcome determines which protocol behaviours are active for the lifetime of that connection. Call Bolty.connection_info/1 to inspect what was negotiated:
iex> Bolty.connection_info(conn)
%{
bolt_version: "5.8",
server_version: "Neo4j/5.26.28",
policy: %Bolty.Policy{
datetime: :evolved,
notifications_field: :notifications_disabled_classifications,
gql_errors: true,
vectors: false,
cypher_5: true,
cypher_25: false,
dynamic_labels: true
}
}Capability table
| Capability | Bolt 5.0 – 5.5 | Bolt 5.6 | Bolt 5.7 – 5.8 | Bolt 6.0+ |
|---|---|---|---|---|
| DateTime encoding | evolved (UTC-aware) | evolved | evolved | evolved |
| Notification filter field | notifications_disabled_categories | notifications_disabled_classifications | notifications_disabled_classifications | notifications_disabled_classifications |
| GQL-compliant errors | No — code/message keys | No | Yes — neo4j_code/description keys | Yes |
| Auth handshake | In HELLO (Bolt 5.0 only) | LOGON | LOGON | LOGON |
| Vector type | No | No | No | Yes |
The policy struct is the single source of truth for version-driven behaviour inside the driver. User code should not need to branch on bolt_version directly — check connection_info/1 if you need to gate application-level features on negotiated capabilities.
Server capability flags
cypher_5, cypher_25 and dynamic_labels are derived from the server version reported at HELLO (not the negotiated Bolt version), so they capture Cypher-language capabilities that vary by Neo4j release rather than by wire protocol:
| Flag | true when | Example feature |
|---|---|---|
cypher_5 | server speaks CYPHER 5 (Neo4j ≥ 5.0) — every currently supported server | prefix queries with CYPHER 5 |
cypher_25 | server supports the CYPHER 25 selector (Neo4j ≥ 2025.06) | CYPHER 25 syntax |
dynamic_labels | dynamic labels/types in pattern position (Neo4j ≥ 5.26) — a Cypher 5 feature; superset of cypher_25 | MATCH (n:$($label)), CREATE (n:$($label)) |
So a 5.26.x server resolves to dynamic_labels: true, cypher_25: false, while a 2026.05 server has both true. These flags are only meaningful in the policy resolved after HELLO; they default to false beforehand.
Scope of
dynamic_labels: it covers the pattern-position form only — node labels and relationship types inMATCH/CREATE/MERGE. TheWHERE n:$(expr)label-predicate form is a separate Cypher 25 feature: gate it oncypher_25, notdynamic_labels. (It errors underCYPHER 5even on a2026.xserver, and is unsupported on5.26.x.)
Restricting the negotiated version
By default Bolty offers all supported Bolt versions to the server and the highest common version wins. Use the :versions option to constrain the offer if your application requires specific capabilities:
# Require GQL-compliant errors (Bolt 5.7+)
opts = [versions: [{5, 7..8}]] ++ opts
# Require the renamed notification field (Bolt 5.6+)
opts = [versions: [{5, 6..8}]] ++ opts
# Target a single known version (a string; a float like `5.4` is deprecated)
opts = [versions: ["5.4"]] ++ opts
# Offer two disjoint ranges when you want broad compatibility but must skip 5.5
opts = [versions: [{5, 6..8}, {5, 0..4}]] ++ optsThe handshake has four slots; range tuples let you cover a span of minor versions in a single slot. If the server cannot satisfy the offered range(s) the connection will fail with a version-negotiation error rather than silently falling back to an unsupported version.
Vector embeddings (Bolt 6.0+)
Bolty.Types.Vector represents a typed list of floating-point values for embedding and similarity search. It is available on connections negotiated at Bolt 6.0 (Neo4j 2026.05+). Attempting to send a Vector over an older connection raises Bolty.Error with code :vector_requires_bolt_6.
alias Bolty.Types.Vector
# Ensure a Bolt 6.0 connection
{:ok, conn} = Bolty.start_link([versions: [6.0]] ++ opts)
embedding = Vector.new(:float32, [0.1, 0.2, 0.3])
# Pass as a parameter — round-trips the value over the wire:
[%{"v" => result}] = Bolty.query!(conn, "RETURN $v AS v", %{v: embedding})
# Storing vectors as node properties requires Neo4j Enterprise Edition.Supported element types:
:float32— IEEE-754 single precision (4 bytes per element):float64— IEEE-754 double precision (8 bytes per element)
Telemetry
Bolty emits :telemetry events for the query
([:bolty, :query, :start | :stop | :exception]), streaming
([:bolty, :stream, :start | :fetch | :stop]), and connection
([:bolty, :connect | :disconnect]) lifecycle, so you can attach query latency,
pool health, and error-rate metrics without wrapping every call site. See the
Telemetry guide for the full event/measurement/metadata
reference.
Contributing
Getting Started
Neo4j uses the Bolt protocol for communication and query execution. You can find the official documentation for Bolt here: Bolt Documentation.
It is crucial to grasp various concepts before getting started, with the most important ones being:
- PackStream: The syntax layer for the Bolt messaging protocol.
- Bolt Protocol: The application protocol for database queries via a database query language.
- Bolt Protocol handshake specification
- Bolt Protocol message specification
- Structure Semantics
It is advisable to use the specific terminology from the official documentation and official drivers to ensure consistency with this implementation.
Test
The suite is split into unit and integration tests:
- Plain
mix testruns the unit suite only — no Neo4j required — so a contributor without Docker can run and add tests. This includes the PackStream round-trip property tests. - DB-dependent tests are tagged
:integrationand are excluded by default. They run automatically when a server is configured (anyBOLT_TCP_PORTorBOLT_VERSIONS, as CI andmix test.matrixset), or explicitly withmix test --include integration. For a local server:BOLT_TCP_PORT=7687 mix test.
As certain versions of Bolt may be compatible with specific functionalities while others can undergo significant changes, tags are employed to facilitate version-specific testing. Some of these tags include:
:core(a version-agnostic integration test — runs at whatever version is negotiated).:bolt_version_{{specific version}}(Tag to run the test on a specific version, for example, for 5.2::bolt_version_5_2, for version 1::bolt_version_1_0).bolt_{major version}_x(Tag to run on all minor versions of a major version, for example, for 5::bolt_5_x, for all minor versions of 4:::bolt_4_x).:last_version(Tag to run the test only on the latest version).
When a server is configured, :core tests run and the version tags are disabled by default. To enable specific version tags, configure the following environment variables:
BOLT_VERSIONS: selects the Bolt version the test suite negotiates and which version tags run (e.g.BOLT_VERSIONS=5.4 mix test). This is a test-suite convenience only — configure the driver itself with the:versionsconnection option.BOLT_TCP_PORT: the port the test suite connects to (default 7687). Configure the driver itself with the:portconnection option.
Version matrix
To run the suite against every supported Bolt version, use mix test.matrix (see mix help test.matrix). It reads BOLT_TCP_PORT for Bolt 5.x servers and BOLT_6_TCP_PORT for Bolt 6.x.
Acknowledgments
Thanks to Florin Patrascu for bolt_sips and Luis Sagastume for boltx.