Native Haskell driver for Neo4j using the BOLT protocol (versions 4.4 through 5.4).
- Connection pooling with configurable idle timeout, health checks, and validation strategies
- Cluster routing — automatic server discovery and read/write splitting for Neo4j causal clusters
- Sessions with bookmark-based causal consistency across transactions
- Transactions with automatic retry on transient errors (exponential backoff)
- Type-safe record decoding — composable
Decode/RowDecodercombinators, or derive viaFromBolt - All Neo4j types — nodes, relationships, paths, temporal (date, time, datetime, duration), spatial (point2D, point3D)
- Query metadata — server timing, query statistics, notifications, EXPLAIN/PROFILE plans
- TLS support via
crypton-connection - Multi-auth — basic, bearer token, Kerberos, custom schemes, plus LOGON/LOGOFF for Bolt 5.1+
import qualified Database.Bolty as Bolt
import Data.Default (def)
main :: IO ()
main = do
let cfg = def{ Bolt.scheme = Bolt.Basic "neo4j" "password", Bolt.use_tls = False }
case Bolt.validateConfig cfg of
Failure errs -> mapM_ putStrLn errs
Success vc -> do
conn <- Bolt.connect vc
result <- Bolt.runBolt conn $
Bolt.queryWith (Bolt.field "greeting" Bolt.text) "RETURN 'hello' AS greeting" mempty
print result -- Right (Vector ["hello"])
Bolt.close connUse Data.Default.def for sensible defaults and override what you need. The config must be validated before use:
import Data.Default (def)
cfg = def
{ host = "db.example.com"
, port = 7687
, scheme = Basic "neo4j" "s3cret"
, use_tls = True
, timeout = 5000 -- ms
}
case validateConfig cfg of
Failure errs -> error (show errs)
Success vc -> connect vc >>= ...Default config: 127.0.0.1:7687, no auth, TLS on, 10s timeout, BOLT 5.4 down to 4.4.
None -- no auth
Basic "user" "pass" -- username/password
Bearer "jwt-token" -- SSO / JWT token
Kerberos "base64-ticket" -- Kerberos
Custom "scheme" credentials -- custom auth providerQueries run in BoltM, a thin ReaderT Connection IO wrapper:
runBolt :: Connection -> BoltM a -> IO a-- Auto-decode via FromBolt (returns Either DecodeError (Vector a))
query :: FromBolt a => Text -> HashMap Text Ps -> BoltM (Either DecodeError (Vector a))
-- Explicit decoder
queryWith :: RowDecoder a -> Text -> HashMap Text Ps -> BoltM (Either DecodeError (Vector a))
-- Raw result set (field names + records)
queryResult :: Text -> HashMap Text Ps -> BoltM ResultSet
-- Side-effects only, discard results
execute :: Text -> HashMap Text Ps -> BoltM ()Pass mempty for no parameters.
import Data.PackStream.Ps (Ps(..))
import qualified Data.HashMap.Lazy as H
queryWith decoder "MATCH (p:Person) WHERE p.age > $minAge RETURN p.name AS name"
(H.singleton "minAge" (PsInteger 21))bolty provides composable, type-safe decoders. A Decode a extracts a single value from a Bolt cell; a RowDecoder a maps column names to decoders for a full row.
bool :: Decode Bool
int :: Decode Int -- may lose precision from Int64
int64 :: Decode Int64
float :: Decode Double
text :: Decode Text
bytes :: Decode ByteStringdata Person = Person { pName :: Text, pAge :: Int64 }
personDecoder :: RowDecoder Person
personDecoder = Person
<$> field "name" text
<*> field "age" int64
result <- runBolt conn $
queryWith personDecoder "MATCH (p:Person) RETURN p.name AS name, p.age AS age" memptyWhen a query returns full nodes, decode properties from within:
data Person = Person { name :: Text, age :: Int64 }
personDecoder :: RowDecoder Person
personDecoder = do
n <- field "p" node
pure $ Person
<$> nodeProperty "name" text n
<*> nodeProperty "age" int64 nnullable :: Decode a -> Decode (Maybe a) -- NULL-safe
list :: Decode a -> Decode (Vector a) -- list values
dict :: Decode (HashMap Text Bolt) -- raw dictionary
node :: Decode Node -- graph node
relationship :: Decode Relationship -- graph relationship
path :: Decode Path -- graph path
uuid :: Decode UUID -- UUID from string
utcTime :: Decode UTCTime -- DateTime → UTCTime
day :: Decode Day -- Date → Day
timeOfDay :: Decode TimeOfDay -- LocalTime → TimeOfDay
aesonValue :: Decode Aeson.Value -- Bolt → aeson ValueFor multi-pass decoding (e.g. denormalized OPTIONAL MATCH results):
rs <- runBolt conn $ queryResult "MATCH (p:Person) RETURN p.name AS name, p.age AS age" mempty
-- Decode all rows
people <- either throwIO pure $ decodeResultSet personDecoder rs
-- Decode just the first row
first <- either throwIO pure $ decodeHead personDecoder rs
-- Group by a key field (consecutive grouping)
groups <- either throwIO pure $ groupByField (field "dept" (nullable text)) rswithTransaction conn $ \txConn -> do
runBolt txConn $ execute "CREATE (p:Person {name: 'Alice'})" mempty
runBolt txConn $ execute "CREATE (p:Person {name: 'Bob'})" mempty
-- auto-commits on success, rolls back on exceptionwithRetryTransaction defaultRetryConfig conn $ \txConn ->
runBolt txConn $ execute "CREATE (p:Person {name: 'Alice'})" mempty
-- retries up to 5 times with exponential backoff on transient Neo4j errorsRetryConfig controls maxRetries (default 5), initialDelay (200ms), and maxDelay (5s).
pool <- createPool validatedConfig defaultPoolConfig
-- defaultPoolConfig: 10 max connections, 60s idle timeout, PingIfIdle 30s
withConnection pool $ \conn ->
runBolt conn $ query @Person "MATCH (p:Person) RETURN p" mempty
-- Convenience: pool + retry transaction in one call
withTransaction' pool $ \conn ->
runBolt conn $ execute "CREATE (n:Test)" mempty
destroyPool poolControl how connections are health-checked on checkout:
AlwaysPing -- send RESET before every use (safest)
PingIfIdle 30 -- only ping if idle > 30 seconds (default, good balance)
NeverPing -- skip health check (fastest, use in trusted environments)For Neo4j causal clusters with multiple servers:
let cfg = def{ scheme = Basic "neo4j" "pass", routing = EnableRouting Nothing }
routingPool <- createRoutingPool validatedConfig defaultRoutingPoolConfig
-- Writes go to a writer server, reads to a reader
withRoutingTransaction routingPool WriteAccess $ \conn ->
runBolt conn $ execute "CREATE (n:Test)" mempty
withRoutingTransaction routingPool ReadAccess $ \conn ->
runBolt conn $ queryWith decoder "MATCH (n) RETURN n" mempty
destroyRoutingPool routingPoolThe routing pool automatically discovers servers via the ROUTE message, caches routing tables with TTL, and retries on different servers when a routing error occurs.
Sessions track bookmarks for causal consistency across transactions:
session <- createSession pool defaultSessionConfig
-- Each transaction's bookmark is automatically passed to the next
writeTransaction session $ \conn ->
runBolt conn $ execute "CREATE (p:Person {name: 'Alice'})" mempty
readTransaction session $ \conn ->
runBolt conn $ queryWith decoder "MATCH (p:Person) RETURN p" mempty
-- ↑ guaranteed to see Alice because of bookmark chaining
bookmarks <- getLastBookmarks session(result, meta) <- runBolt conn $ queryMetaWith decoder cypher params
-- meta :: QueryMeta contains:
-- parsedNotifications :: [Notification] -- warnings, deprecations
-- parsedStats :: Maybe QueryStats -- nodes/rels created/deleted
-- parsedPlan :: Maybe PlanNode -- EXPLAIN plan
-- parsedProfile :: Maybe ProfileNode -- PROFILE with execution stats
-- bookmark, db, tFirst, tLast -- timing and metadata
-- EXPLAIN a query without executing it
plan <- runBolt conn $ queryExplain "MATCH (n) RETURN n" mempty
-- PROFILE a query with actual execution statistics
(rows, profile) <- runBolt conn $ queryProfile "MATCH (n) RETURN n" memptylet cfg = def{ queryLogger = Just $ \ql meta -> do
putStrLn $ "Query: " <> show (qlCypher ql)
putStrLn $ "Rows: " <> show (qlRowCount ql)
putStrLn $ "Time: " <> show (qlClientTime ql) <> "ns"
}-- Check if an error is transient (safe to retry)
isTransient :: Error -> Bool
-- Check if an error is a routing error (server unreachable, etc.)
isRoutingError :: Error -> BoolEvery cell in a query result is a Bolt value:
| Neo4j type | Bolt constructor | Haskell type inside |
|---|---|---|
| null | BoltNull |
— |
| boolean | BoltBoolean |
Bool |
| integer | BoltInteger |
PSInteger |
| float | BoltFloat |
Double |
| bytes | BoltBytes |
ByteString |
| string | BoltString |
Text |
| list | BoltList |
Vector Bolt |
| map | BoltDictionary |
HashMap Text Bolt |
| node | BoltNode |
Node (id, labels, properties) |
| relationship | BoltRelationship |
Relationship (id, start, end, type, properties) |
| path | BoltPath |
Path (nodes, rels, indices) |
| date | BoltDate |
Date (days since epoch) |
| time | BoltTime |
Time (nanos, tz offset) |
| local time | BoltLocalTime |
LocalTime (nanos since midnight) |
| datetime | BoltDateTime |
DateTime (seconds, nanos) |
| datetime (zoned) | BoltDateTimeZoneId |
DateTimeZoneId (seconds, nanos, tz name) |
| local datetime | BoltLocalDateTime |
LocalDateTime (seconds, nanos) |
| duration | BoltDuration |
Duration (months, days, seconds, nanos) |
| point (2d) | BoltPoint2D |
Point2D (srid, x, y) |
| point (3d) | BoltPoint3D |
Point3D (srid, x, y, z) |
Public API — import these:
| Module | Purpose |
|---|---|
Database.Bolty |
Main entry point, re-exports everything |
Database.Bolty.Decode |
Record decoders (Decode, RowDecoder, FromBolt) |
Database.Bolty.Pool |
Connection pooling |
Database.Bolty.Routing |
Cluster routing |
Database.Bolty.Session |
Sessions with bookmark management |
Database.Bolty.ResultSet |
Multi-pass result set decoding |
Database.Bolty.Logging |
Query log types |
Database.Bolty.Notification |
Server notifications |
Database.Bolty.Plan |
EXPLAIN/PROFILE plan types |
Database.Bolty.Stats |
Query statistics types |
Database.Bolty.Record |
Record type alias |
All other modules under Database.Bolty.Connection.*, Database.Bolty.Message.*, and Database.Bolty.Value.* are internal — exposed for bolty-streamly but not part of the stable API.
9.6.7, 9.8.4, 9.10.3, 9.12.3
Apache-2.0