An embedded property graph database for Go — Cypher over SQLite.
graingraph was created as a response to poor cell phone signal on U.K trains that prevented download of Neo4j docker container & associated Go driver; I was in midst of learning Go ( still am ) and wanted to try out using Neo4j drivers. I felt, perhaps more in hope, that a smaller download may have made it.
graingraph is a Go package that provides an embedded property graph accessible using openCypher or Cypher 25. It uses SQLite for storage, either in memory or file based. It sets out to be a small, lightweight, and allows moving to full Neo4j with as least friction as possible.
Like this
import "github.com/graingraph/graingraph"
// In-memory — transient, great for tests
db, err := graingraph.Open(":memory:")
result, err := db.RunQuery(ctx, `MATCH (n:Person) RETURN n.name AS name`, nil)
for result.Next(ctx) {
fmt.Println(result.Record().Values()[0])
}graingraph does not implement the Bolt wire protocol. To reach it over the network, run
graingraph-query, an HTTP server compatible with Neo4j's Query API (v2) built on it, which also has a compact output format and a skill for AI agents.
graingraph passes every scenario of the openCypher Technology Compatibility Kit that applies to Cypher 25 (3,895 scenarios; the few that rely on syntax Cypher 25 removed are listed with reasons in compat/testdata/excluded.txt). Queries are parsed by a hand-written Cypher 25 parser, checked by a semantic analysis pass that reports the standard error classes and codes, and run by a Go interpreter over SQLite.
| Feature | Supported |
|---|---|
MATCH / OPTIONAL MATCH — labels, properties, directed and undirected relationships, variable-length paths, named paths |
✅ |
shortestPath() / allShortestPaths(), pattern predicates, EXISTS/COUNT/COLLECT subqueries |
✅ |
WHERE, RETURN, WITH, ORDER BY, SKIP/LIMIT, DISTINCT, UNWIND, UNION [ALL], RETURN * |
✅ |
Aggregation — count, sum, avg, min, max, collect, percentile*, stDev* |
✅ |
CASE, list/pattern comprehensions, quantifiers (all/any/none/single), reduce |
✅ |
| Scalar, string, math, list, conversion and entity functions | ✅ |
Temporal types — date, time, localtime, datetime, localdatetime, duration (constructors, accessors, truncation, arithmetic, duration.between) |
✅ |
CREATE, MERGE (ON CREATE/ON MATCH), SET, REMOVE, DELETE/DETACH DELETE, FOREACH |
✅ |
CALL procedures — built-in db.labels(), db.relationshipTypes(), db.propertyKeys() and your own via DB.RegisterProcedure |
✅ |
CALL { … } subqueries, FILTER, LET, FINISH |
✅ |
Named query parameters ($param) |
✅ |
| Bulk import — JSON, CSV (Neo4j format); bulk export — JSON | ✅ |
Neo4j extensions — label/type expressions, dynamic labels and properties, map projections, EXISTS/COUNT/COLLECT and CALL {} subqueries, FILTER/LET/FINISH, OFFSET, IS :: TYPE predicates, EXPLAIN/PROFILE |
✅ |
Quantified path patterns (((a)-[:R]->(b)){1,3}, -[:R]->+) and path selectors (ANY, ALL, SHORTEST k, SHORTEST k GROUPS) |
✅ |
POINT (cartesian, WGS-84; point.distance, point.withinBBox) and VECTOR (vector(), vector_distance, vector_norm, vector.similarity.*) values; vector indexes with exact nearest-neighbour search (db.index.vector.queryNodes, DB.VectorSearch) |
✅ |
Schema — CREATE/DROP INDEX and CONSTRAINT (unique, node/relationship key, existence, type), SHOW INDEXES/CONSTRAINTS/PROCEDURES |
✅ |
LOAD CSV (opt-in: WithImportDirectory) |
✅ |
Nested quantified path patterns, SHOW FUNCTIONS, USE and user/role/database management commands |
❌ (parsed; execution returns ErrUnsupportedCypher) |
Unsupported features return ErrUnsupportedCypher — they never silently produce wrong results.
Shortest paths and limits. shortestPath(), allShortestPaths(), ANY SHORTEST and ALL SHORTEST over a single relationship pattern use a breadth-first search (bidirectional when both ends are bound; allShortestPaths returns at most 100,000 paths). SHORTEST k, SHORTEST k GROUPS, ANY k and ALL, and selectors over longer patterns, enumerate matches per (start, end) pair: with both ends bound they stop as soon as the selector is satisfied, otherwise they fail with an error beyond 1,000,000 matches. WithMaxPathHops(n) caps unbounded variable-length patterns and rejects larger explicit bounds. Nested quantified path patterns are not supported.
go get github.com/graingraph/graingraphRequires Go 1.26.9+ (earlier 1.26 releases have a net/http vulnerability, GO-2026-6617, that affects graingraph-query). No CGO required. Works on Linux (amd64/arm64), macOS (arm64), and Windows (amd64).
import "github.com/graingraph/graingraph"
// In-memory — transient, great for tests
db, err := graingraph.Open(":memory:")
// File-backed — persists across restarts
db, err := graingraph.Open("/var/data/graph.db")
// With options
db, err := graingraph.Open("graph.db",
graingraph.WithBusyTimeout(5*time.Second),
graingraph.WithReadOnly(),
)
defer db.Close(ctx)In tests, use NewTestDB to open an in-memory database that is closed automatically when the test ends:
db := graingraph.NewTestDB(t)A *DB is safe for concurrent use. On a file-backed database, statements without updating clauses run on a small pool of read-only SQLite connections (WithMaxReadConns, default 2), each in its own snapshot transaction. Readers never wait for the writer and the writer never waits for readers: a read issued while another goroutine holds an open BeginTx transaction returns immediately and sees only committed data. Writes, schema commands and explicit transactions use the single write connection. In-memory databases have one connection and no read pool.
More read connections are not always faster, because the pure-Go SQLite shares allocator and file-lock state between connections. On a 20,000-node graph, two readers run indexed lookups 1.8x faster than one but gain nothing from more, and run a full-label scan 1.3x faster, while four run it slower than one. Raise WithMaxReadConns only after measuring (go test -bench ParallelReads).
Declare them in Cypher or from Go:
CREATE INDEX FOR (p:Person) ON (p.name)
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.email IS UNIQUE
CREATE CONSTRAINT FOR (p:Person) REQUIRE (p.first, p.last) IS NODE KEY
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.name IS NOT NULL
CREATE CONSTRAINT FOR (p:Person) REQUIRE p.age IS :: INTEGER
SHOW INDEXES SHOW CONSTRAINTS DROP CONSTRAINT <name>err := db.CreatePropertyIndex(ctx, "Person", "name")
err = db.CreateUniqueConstraint(ctx, "Person", "email")
schema, err := db.ListSchema(ctx) // []SchemaInfo; db.DropIndex / db.DropConstraint by nameConstraints are checked when a statement finishes, over the nodes and relationships it created or changed, and a violation rolls the whole statement back with a structured error:
var cv *graingraph.ErrConstraintViolation
if errors.As(err, &cv) && cv.Kind == "UNIQUENESS" { /* cv.Label, cv.Properties, cv.Name */ }A constraint cannot be created over data that already breaks it. Uniqueness works for multi-label nodes. Bulk Import is checked too: after the rows are inserted and before the import commits, every imported node and relationship is validated, and a violation fails the whole import with *ErrConstraintViolation. Export (JSON) writes the indexes and constraints in a "schema" section (Cypher CREATE … IF NOT EXISTS statements), and Import replays them after the data, in the same transaction, so a copy of a database keeps its schema. Indexes are SQLite expression indexes, so equality lookups on an indexed property stop scanning: on 100,000 nodes a lookup by property drops from 25 ms to 33 µs (and a relationship lookup, MATCH ()-[r:R {w: $v}]->(), from 28 ms to 40 µs, with an index created by CREATE INDEX FOR ()-[r:R]-() ON (r.w)). graingraph also creates a node index automatically once a property has narrowed a few scans on a graph of 500 or more nodes; WithoutAutomaticIndexes turns that off. The schema is stored in the database file, so it survives reopening and Snapshot.
Store embeddings as VECTOR values (or lists of numbers), declare a vector index, and search it:
CREATE VECTOR INDEX doc_embeddings FOR (d:Doc) ON (d.embedding)
OPTIONS {indexConfig: {`vector.dimensions`: 384, `vector.similarity_function`: 'cosine'}}
CALL db.index.vector.queryNodes('doc_embeddings', 10, $query) YIELD node, score
RETURN node.title, scorematches, err := db.VectorSearch(ctx, "doc_embeddings", queryVector, 10) // []VectorMatch{Node, Score}The index validates the dimension on every write (a wrong-sized vector fails the statement) and supports cosine (score (1 + cos)/2) and euclidean (score 1/(1 + d²)). The search is exact, not approximate: it scans an in-memory copy of the index's vectors (float32, contiguous, split across CPUs). Measured on an Apple M5 Max at 384 dimensions: 10,000 vectors in 0.2 ms, 100,000 in 0.8 ms, 1,000,000 in 7 ms (about 1.5 GB of memory). The copy is built on the first search after the database is opened (about 4 µs per vector: 0.4 s for 100,000, 4 s for a million, on all your cores) and then kept current as you write. Writes inside an explicit transaction are visible to searches in that transaction and to others only after it commits.
Limits: node indexes only (no relationship vector indexes), a write to the same file by another process is not seen until the database is reopened, and the in-memory copy is capped at 512 MiB across all indexes (WithMaxVectorCacheBytes); beyond that a search still works but rebuilds its copy each time. There is no approximate (HNSW) index, which only starts to matter beyond a few million vectors.
RunQuery executes a Cypher statement in auto-commit mode and returns a lazy *Result cursor.
result, err := db.RunQuery(ctx,
`MATCH (p:Person {name: $name})-[:KNOWS*1..3]->(f:Person) RETURN f.name AS name`,
map[string]any{"name": "Alice"},
)
if err != nil {
log.Fatal(err)
}
for result.Next(ctx) {
fmt.Println(result.Record().Values()[0])
}
if _, err := result.Consume(ctx); err != nil {
log.Fatal(err)
}BeginTx starts an explicit transaction. Call Commit or Rollback when done; Close is idempotent and always safe to defer.
tx, err := db.BeginTx(ctx)
if err != nil {
log.Fatal(err)
}
defer tx.Close()
_, err = tx.Run(ctx, `CREATE (n:Person {name: $name})`, map[string]any{"name": "Bob"})
if err != nil {
_ = tx.Rollback()
log.Fatal(err)
}
if err := tx.Commit(); err != nil {
log.Fatal(err)
}Collect drains all records from a *Result into a slice:
result, err := db.RunQuery(ctx, `MATCH (n:Person) RETURN n`, nil)
records, err := result.Collect(ctx)
for _, rec := range records {
node, _ := rec.Get("n")
fmt.Println(node.(*graingraph.Node).Props)
}Single returns the one record in a result, or an error if there are zero or more than one:
result, err := db.RunQuery(ctx, `MATCH (n:Person {name: "Alice"}) RETURN n`, nil)
rec, err := result.Single(ctx)
// err is graingraph.ErrNoRecords or graingraph.ErrMultipleRecords when applicableUse the generic helpers for typed property and record access:
// Extract a typed property from a Node or Relationship
age, err := graingraph.GetProperty[int64](node, "age")
// Extract a typed value from a Record column
name, isNil, err := graingraph.GetRecordValue[string](rec, "name")
// Collect all records as a typed slice using a mapper
result, _ := db.RunQuery(ctx, `MATCH (n:Person) RETURN n.name AS name`, nil)
names, err := graingraph.CollectT(ctx, result, func(rec *graingraph.Record) (string, error) {
return graingraph.GetRecordValue[string](rec, "name")
})
// Single with a mapper
result, _ := db.RunQuery(ctx, `MATCH (n:Person {name: "Alice"}) RETURN n`, nil)
node, err := graingraph.SingleT(ctx, result, func(rec *graingraph.Record) (*graingraph.Node, error) {
n, _, err := graingraph.GetRecordValue[*graingraph.Node](rec, "n")
return n, err
})// Import from JSON
f, _ := os.Open("testdata/graph.json")
if err := db.Import(ctx, f, graingraph.FormatJSON); err != nil {
log.Fatal(err)
}
// Export to JSON
var buf bytes.Buffer
if err := db.Export(ctx, &buf, graingraph.FormatJSON); err != nil {
log.Fatal(err)
}Snapshot writes an atomic, consistent copy of the database to a file using SQLite VACUUM INTO. Works on both file-backed and in-memory databases.
db, _ := graingraph.Open(":memory:")
// ... build or import graph data ...
if err := db.Snapshot("/var/data/graph-checkpoint.db"); err != nil {
log.Fatal(err)
}
// Reopen the snapshot as a normal database.
snap, _ := graingraph.Open("/var/data/graph-checkpoint.db")Runnable programs live in examples/; run them from the repository root.
| Example | What it shows |
|---|---|
examples/basic |
Create nodes and relationships, query them, update, run a transaction, and delete — go run ./examples/basic |
examples/movies |
Build the movies graph (171 nodes, 253 relationships) by reading a file of Cypher statements, then query it — go run ./examples/movies |
| Symbol | Signature | Description |
|---|---|---|
Open |
Open(path string, opts ...Option) (*DB, error) |
Open or create a database. Use ":memory:" for a transient in-memory store. |
NewTestDB |
NewTestDB(t testing.TB) *DB |
Open an in-memory database; registers t.Cleanup to close it. |
| Method | Signature | Description |
|---|---|---|
RunQuery |
(*DB) RunQuery(ctx, cypher string, params map[string]any) (*Result, error) |
Execute a Cypher statement in auto-commit mode. |
BeginTx |
(*DB) BeginTx(ctx) (*Tx, error) |
Start an explicit transaction. |
Import |
(*DB) Import(ctx, r io.Reader, format Format) error |
Bulk-import nodes and relationships from JSON or CSV. |
Export |
(*DB) Export(ctx, w io.Writer, format Format) error |
Bulk-export the graph to JSON. |
Snapshot |
(*DB) Snapshot(path string) error |
Write an atomic consistent copy of the database to a file. |
Close |
(*DB) Close(ctx) error |
Release all resources. |
| Method | Signature | Description |
|---|---|---|
Run |
(*Tx) Run(ctx, cypher string, params map[string]any) (*Result, error) |
Execute a Cypher statement inside the transaction. |
Commit |
(*Tx) Commit() error |
Commit the transaction. |
Rollback |
(*Tx) Rollback() error |
Roll back the transaction. |
Close |
(*Tx) Close() error |
Close the transaction (rolls back if not yet committed). |
| Method | Signature | Description |
|---|---|---|
Next |
(*Result) Next(ctx) bool |
Advance the cursor; returns false when exhausted or on error. |
Record |
(*Result) Record() *Record |
Return the current record (valid after Next returns true). |
Err |
(*Result) Err() error |
Return any iteration error. |
Keys |
(*Result) Keys() []string |
Return the projection column names. |
Collect |
(*Result) Collect(ctx) ([]*Record, error) |
Drain all records into a slice. |
Single |
(*Result) Single(ctx) (*Record, error) |
Return the single record, or ErrNoRecords / ErrMultipleRecords. |
Consume |
(*Result) Consume(ctx) (*ResultSummary, error) |
Drain remaining records and return query counters. |
ResultSummary carries two views of a statement's side effects. Counters() compares the graph before and after
(the openCypher TCK's side effects): a node created and deleted in one statement leaves no trace. Statistics() counts
each change as it happens, the way a Neo4j server reports it: that same node counts as created and as deleted, setting
a property to the value it already has counts, and removing a property counts as a property set. Use Statistics() to
give clients of a Neo4j-compatible service the numbers Neo4j would.
| Function | Description |
|---|---|
GetProperty[T PropertyValue](entity, key) |
Extract a typed property from a *Node or *Relationship. |
GetRecordValue[T RecordValue](rec, key) |
Extract a typed value from a *Record column; second return is true when null. |
CollectT[T any](ctx, result, mapper) |
Collect all records, applying a mapper to each; returns []T. |
SingleT[T any](ctx, result, mapper) |
Like Single, but applies a mapper to the one record; returns T. |
| Error | Meaning |
|---|---|
ErrNoRecords |
Single found zero records. |
ErrMultipleRecords |
Single found more than one record. |
ErrReadOnly |
A write statement was attempted on a read-only database. |
ErrUnsupportedCypher |
The Cypher feature is not yet implemented. |
graingraph/
├── types.go ← Node, Relationship, Record, error types
├── driver.go ← graingraph.Open, DB, RunQuery, BeginTx
├── tx.go ← Tx type (Run, Commit, Rollback, Close)
├── result.go ← Result cursor (Next, Record, Err, Keys, Collect, Single, Consume)
├── helpers.go ← generic helpers (GetProperty, GetRecordValue, CollectT, SingleT)
├── importer.go ← Import / Export (JSON, CSV)
├── options.go ← functional options (WithBusyTimeout, WithReadOnly)
├── engine.go ← parse → analyze → interpret pipeline
├── cypher/
│ ├── syntax/ ← hand-written Cypher 25 lexer + parser + typed AST
│ ├── analyze/ ← semantic analysis (variables, aggregation, types)
│ ├── interp/ ← Go interpreter: executes a statement against SQLite
│ └── proc/ ← procedure signatures and registry (CALL)
├── store/
│ ├── store.go ← Store interface
│ ├── sqlite.go ← modernc.org/sqlite implementation
│ └── schema.go ← DDL: nodes/edges tables + indexes
├── compat/
│ └── tck_test.go ← openCypher TCK harness (opt-in: -tags=tck)
└── bench/
└── *.go ← benchmark suite
Storage uses two tables in SQLite WAL mode. Queries are executed by a Go interpreter that reads nodes and relationships from SQLite (narrowing scans with property equalities and automatic expression indexes) and applies writes in one transaction per statement.
CREATE TABLE nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
labels TEXT NOT NULL DEFAULT '', -- comma-separated: "Person,Employee"
props JSON NOT NULL DEFAULT '{}'
);
CREATE TABLE edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
start_id INTEGER NOT NULL REFERENCES nodes(id),
end_id INTEGER NOT NULL REFERENCES nodes(id),
props JSON NOT NULL DEFAULT '{}'
);graingraph-query is an HTTP server on top of graingraph that is compatible
with Neo4j's Query API (v2): most clients that send Query API requests to Neo4j
work with it, and the differences are listed below. Download a binary from the
Releases page, or
go install github.com/graingraph/graingraph/cmd/graingraph-query@latest.
It is built for one person or agent at a time. There is a single basic-auth credential, and whoever holds it can do anything to the graph.
export GRAINGRAPH_QUERY_PASSWORD=change-me
go run ./cmd/graingraph-query --username admin --db graingraph.db
curl -u admin:change-me -H 'Content-Type: application/json' \
localhost:7474/db/neo4j/query/v2 \
-d '{"statement":"RETURN 1 AS n"}'| Method | Path | |
|---|---|---|
| GET | / |
discovery (no auth) |
| GET | /health |
liveness (no auth) |
| GET | /skills |
list agent skills (no auth) |
| GET | /skills/{name} |
one skill as Markdown (no auth) |
| POST | /db/{db}/query/v2 |
run a statement |
| POST | /db/{db}/query/v2/tx |
open a transaction |
| POST | /db/{db}/query/v2/tx/{id} |
run in a transaction |
| POST | /db/{db}/query/v2/tx/{id}/commit |
commit |
| DELETE | /db/{db}/query/v2/tx/{id} |
roll back |
Responses follow what a Neo4j server returns, checked side by side against Neo4j 2026.07:
- Success is
202. A failed statement is400with{"errors":[{"code":"Neo.ClientError.Statement.SyntaxError","message":"…"}]}. A failure while producing a result (arithmetic, type errors) is202with the error beside an emptydatablock. - A failed statement inside a transaction destroys the transaction (later calls
get
404). Rollback answers200 {}. - Unknown databases answer
404; authentication failures401. - Plain JSON, Typed JSON (
application/vnd.neo4j.query.v1.x) and JSONL are supported viaAccept, including comma-separated lists with q-values. Accept: text/toonis an extension for agents, see below.- CORS is enabled and
OPTIONSanswers preflights (see--cors-origins).
One transaction at a time. graingraph serialises on a single connection, so
while an explicit transaction is open it holds the whole database. Other
queries, and attempts to open a second transaction, wait up to --tx-wait
(default 5s, enough for tools that open several short transactions at once)
and then fail with Neo.TransientError.General.DatabaseUnavailable naming the
open transaction, instead of hanging. Commit or roll it back; idle transactions are rolled back
after --tx-timeout.
Accept: text/toon answers with a TOON table instead
of the Neo4j response document. It is an extension: Neo4j has no equivalent,
so use JSON when you need parity with a real server.
curl -u admin:change-me -H 'Content-Type: application/json' -H 'Accept: text/toon' \
localhost:7474/db/neo4j/query/v2 \
-d '{"statement":"MATCH (p:Person) RETURN p.name AS name, p.age AS age"}'
# rows[2]{name,age}:
# Alice,30
# Bob,null- One flat table,
rows[N]{columns}:. A node or relationship becomes columns (p._id,p._labels,p.name, ... andr._type,r._start,r._end); a path column is a Cypher pattern,(:Person {name:Keanu})-[:ACTED_IN]->(:Movie {title:The Matrix}); other lists and maps are a compact JSON string in one cell. - No bookmarks or timings. Non-zero mutation counters are always reported
(
counters:), withoutincludeCounters. - Every failure is an error status with an
errors[N]{code,message}:table, including one raised while producing rows, which JSON answers with202. - Transactions work as usual: opening returns a
transaction:block with the id, and commit answerscommitted: true.
A statement that returns two nodes and the relationship between them, on the Neo4j movies graph:
MATCH (p:Person {name: $name})-[r:ACTED_IN]->(m:Movie {title: $title})
RETURN p, r, mwith parameters {"name": "Tom Hanks", "title": "Cast Away"}.
JSON (the default, the same document Neo4j returns; reformatted here, the server sends it on one line):
{
"data": {
"fields": ["p", "r", "m"],
"values": [
[
{"elementId": "57", "labels": ["Person"], "properties": {"born": 1956, "name": "Tom Hanks"}},
{"elementId": "97", "startNodeElementId": "57", "endNodeElementId": "165", "type": "ACTED_IN", "properties": {"roles": ["Chuck Noland"]}},
{"elementId": "165", "labels": ["Movie"], "properties": {"released": 2000, "tagline": "At the edge of the world, his journey begins.", "title": "Cast Away"}}
]
]
},
"bookmarks": ["FB:AAAAAAAAAA8vsraGnMfqiQ=="],
"queryType": "r",
"resultAvailableAfter": 1,
"resultConsumedAfter": 1
}TOON (Accept: text/toon):
rows[1]{p._id,p._labels,p.born,p.name,r._id,r._type,r._start,r._end,r.roles,m._id,m._labels,m.released,m.tagline,m.title}:
"57",Person,1956,Tom Hanks,"97",ACTED_IN,"57","165","[\"Chuck Noland\"]","165",Movie,2000,"At the edge of the world, his journey begins.",Cast Away
Each node and relationship is spread over columns: p._id, p._labels,
p.born, r._type, r._start, r._end and so on. A property a row lacks is
null, the roles list is a JSON string in its cell, and "57" is quoted
because it is a string that looks like a number (an element ID), not a number.
This one row is 147 tokens as JSON and 96 as TOON (GPT tokenizer); the header is
written once, so the saving grows with the number of rows. The element IDs
belong to this server and differ from Neo4j's.
Measured on a synthetic graph of 80 people and 12 companies. These are small experiments, not guarantees; your data will differ.
Tokens (GPT o200k tokenizer, so approximate for other models). Percentages
are against plain JSON as the server returns it:
| Result shape | TOON | Typed JSON |
|---|---|---|
| flat table, 25-50 rows | 81-90% | about 320% |
| wide table, 12 columns | 84% | 350% |
| 25 nodes | 51% | 246% |
25 (p)-[r]->(c) rows |
47% | 225% |
collect() list cells |
93% | 300% |
| mixed benchmark, all of the above | 58% | 254% |
Most of the saving comes from flattening nodes and relationships and dropping bookmarks and timings, not from the TOON syntax itself. For a tiny result such as a count, the fixed response metadata is most of the JSON.
On the Neo4j movies graph (49 queries, from counts to nodes, relationships and paths) served TOON was 60% of the tokens of plain JSON; paths were 51%.
Accuracy. Haiku and Sonnet, with no tools, were given the same query
result as JSON or TOON and asked about 10 questions each (lookups, filtered
counts, sums, maximums, nulls, list membership) over 3 seeded datasets of 80
rows, with awkward values: numeric-looking strings such as "007", text with
commas, colons, quotes and newlines, empty strings against nulls. Answers were
graded against the true values (246 questions per format):
| JSON | TOON | |
|---|---|---|
| Haiku | 92.7% | 95.1% |
| Sonnet | 96.7% | 97.6% |
The difference is within noise, so read it as parity, not as TOON being more accurate. Lookups, joins, null handling and the tricky strings were correct in both formats apart from one wrong zip in TOON. Nearly every error was arithmetic over many rows (sums and counts), which models get wrong in either format; an agent with a shell should compute those instead of reading them. Limits: two models, one run per question, 80 rows, and the prompts included a short description of each format (the skill carries the same description for TOON).
An agent can learn how to use the server (and the Cypher it supports) by
fetching a skill: a SKILL.md document in the
Agent Skills format. The skill is embedded in the
binary, so it always matches the version you run, and its examples point at
your server.
# over HTTP (no authentication; also linked as "skills" in discovery)
curl localhost:7474/skills # [{name, description, url}]
curl localhost:7474/skills/graingraph-query # Markdown
# from the command line, no server needed
graingraph-query skill --url https://graph.example.com > SKILL.md
graingraph-query skill --listFor Claude Code, save it as ~/.claude/skills/graingraph-query/SKILL.md. Behind a
proxy, set --public-url so the HTTP version advertises the right address.
The skill holds no credentials; it tells the agent to ask the user for them.
Every flag has an environment variable (--tx-timeout is GRAINGRAPH_QUERY_TX_TIMEOUT).
| Flag | Default | |
|---|---|---|
--listen |
:7474 |
listen address |
--db |
graingraph.db |
database file, or :memory: |
--username, --password |
required | basic-auth credential (prefer the env var for the password) |
--database-name |
neo4j |
name advertised by discovery |
--read-only |
false | open the database read-only |
--tx-timeout |
5m |
idle timeout for explicit transactions |
--tx-wait |
5s |
how long a request waits for an open transaction to end before failing with "transaction X is open" (0 fails at once) |
--import-dir |
enables LOAD CSV below this directory |
|
--tls-cert, --tls-key |
enable HTTPS | |
--cors-origins |
* |
origins allowed by CORS; none disables |
--public-url |
from request | base URL advertised by discovery |
--log-level |
info |
debug, info, warn, error |
--log-format |
text |
text or json |
--log-file |
stderr | append logs to a file |
--log-statements |
true | include Cypher text in request logs |
One line per request: request_id (also returned as X-Request-Id), method,
path, status, duration, response bytes, remote address, user agent, and the
user name presented. Statement requests add statement, query_type,
rows, and tx_id. A failed statement adds error_code and error, and is
logged at WARN (ERROR for database errors) even though it answers 202.
Passwords and query parameters are never logged.
go test ./...internal/server/compat sends identical requests to a Neo4j server and to an
in-process graingraph-query and requires the responses to match (status, content
type and body, ignoring element IDs, bookmarks, timings, transaction IDs and
error wording). It covers every response format, parameters, errors, and
transactions over a small graph. It is skipped unless a server is configured:
GRAINGRAPH_QUERY_COMPAT_NEO4J_URL=http://localhost:7474 \
GRAINGRAPH_QUERY_COMPAT_NEO4J_PASSWORD=secret \
go test ./internal/server/compat -vGRAINGRAPH_QUERY_COMPAT_NEO4J_USER (default neo4j) and
GRAINGRAPH_QUERY_COMPAT_NEO4J_DB (default neo4j) are optional. The reference
database is never modified: writes happen in explicit transactions that are
always rolled back, on private Cq* labels, and no schema is created (Neo4j
does still register the property key names the writes used).
Known differences, each marked in the test with its reason:
- graingraph-query also supports Typed JSON over JSONL (
application/vnd.neo4j.query+jsonl); Neo4j 2026.07 answers 406. - A non-string
statementisRequest.Invalidhere,Statement.SyntaxErrorin Neo4j. - Neo4j adds
notifications(such as deprecation warnings); graingraph-query has none. - A request body needs a JSON
Content-Type(application/jsonor a Typed JSON type); a missing one is415. A body without aContent-Typeis the one shape a web page can send to another origin without a CORS preflight, so refusing it keeps a page from running statements with credentials the browser has cached. For the same reason aPOSTorDELETEwhoseOriginis neither the server's own host nor in--cors-originsis403.
Also seen when comparing 54 queries on the Neo4j movies graph (not covered by the compatibility test; the rest matched, including every temporal and float format):
avgandstDevcan differ in the last digits (graingraph-query computesavgmore exactly;stDevis rounded to 15 significant digits).keys(n)lists the properties sorted; Neo4j lists them in insertion order.- graingraph does not support
datetime({epochSeconds: ...})(Neo4j does). - Functions that return strings, such as
toString(localtime('12:30')), use graingraph's shortest form (12:30); only values returned as temporal types are written the way Neo4j writes them (12:30:00).
internal/server/perf is a measurement harness, not a pass/fail test: it prints
latency tables (p50, p95, mean, speed-up) and requests per second for the
same requests against Neo4j and graingraph-query, and fails only if a request
errors. It covers request overhead and result serialisation for each response
format and size, then queries and writes over a small graph. It is skipped
unless a reference server is configured:
GRAINGRAPH_QUERY_PERF_NEO4J_URL=http://localhost:7474 \
GRAINGRAPH_QUERY_PERF_NEO4J_PASSWORD=secret \
go test ./internal/server/perf -v -timeout 30mGRAINGRAPH_QUERY_PERF_NEO4J_USER and GRAINGRAPH_QUERY_PERF_NEO4J_DB default to
neo4j. As with the compatibility test the reference database is never
modified: data-dependent cases build their graph on private Cq* labels in a
transaction that is always rolled back.
By default graingraph-query runs inside the test process and shares its CPU and
garbage collector. For a fair comparison start it separately and point the
harness at it (same credentials) with GRAINGRAPH_QUERY_PERF_GRAINGRAPH_URL.
# Unit and integration tests
CGO_ENABLED=0 go test -count=1 ./...
# TCK harness (openCypher Technology Compatibility Kit)
CGO_ENABLED=0 go test -tags=tck ./compat/... -v
# Property-based tests
CGO_ENABLED=0 go test -run TestRapid ./...
# Benchmarks
CGO_ENABLED=0 go test -run=^$ -bench=. -benchtime=10s ./bench/...No breaking changes are made to the public API after v1.0 without a major version bump.
This covers the root package and all documented sub-packages. Adding new exported symbols is not a breaking change. See CONTRIBUTING.md for the full definition.
See CONTRIBUTING.md for setup, test suite commands, the 5-step guide for adding a Cypher feature, benchmark baseline process, and PR guidelines.
Apache 2.0 — see LICENSE.
Neo4j is a registered trademark of Neo4j, Inc. graingraph is an independent project and is not affiliated with, endorsed by or sponsored by Neo4j, Inc. References to Neo4j describe compatibility.
