Transactions
Explicit transactions, managed write closures, retry semantics, and isolation.
Auto-commit transactions
db.Run, db.Query, and db.Exec each run inside an implicit auto-commit transaction.
The transaction commits if the statement succeeds and rolls back if it returns an error.
For most single-statement writes, auto-commit is enough.
Explicit transactions
Open an explicit transaction when you need multiple statements to succeed or fail together:
tx, err := db.BeginTx(ctx, gr.TxOptions{})
if err != nil {
return err
}
defer tx.Rollback() // no-op if already committed
_, err = tx.Exec(ctx, `CREATE (:Person {name:$name})`, map[string]any{"name": "Alice"})
if err != nil {
return err
}
_, err = tx.Exec(ctx, `CREATE (:Person {name:$name})`, map[string]any{"name": "Bob"})
if err != nil {
return err
}
return tx.Commit()
defer tx.Rollback() is safe to call after a commit — it is a no-op.
tx.Run, tx.Query, and tx.Exec have the same signatures as their db counterparts.
Managed write transactions
For write transactions that may hit write-write conflicts, use db.ExecuteWrite.
gr automatically retries the closure on *gr.ConflictError up to Options.MaxRetries times:
_, err = db.ExecuteWrite(ctx, func(tx gr.ManagedTx) (any, error) {
summary, err := tx.Exec(ctx, `
MATCH (p:Person {name:$name})
SET p.loginCount = p.loginCount + 1
`, map[string]any{"name": "Alice"})
return summary, err
})
The closure must be re-runnable with no external side effects. Do not send emails or call external APIs inside it — the retry may call it more than once.
db.ExecuteWrite returns the value your closure returns (typed as any) and any error.
Managed read transactions
db.ExecuteRead is the read-only counterpart:
name, err := db.ExecuteRead(ctx, func(tx gr.ManagedTx) (any, error) {
res, err := tx.Query(ctx, `MATCH (p:Person {id:$id}) RETURN p.name`, map[string]any{"id": id})
if err != nil {
return nil, err
}
defer res.Close()
if res.Next() {
return res.Record().Get("p.name"), nil
}
return nil, res.Err()
})
Read transactions run under snapshot isolation: they see a consistent view of the graph as of the moment the transaction opened. Concurrent writes do not affect the read view.
Read-your-writes
Inside a write transaction, reads see the uncommitted writes from the same transaction:
tx, _ := db.BeginTx(ctx, gr.TxOptions{})
tx.Exec(ctx, `CREATE (:Temp {x:1})`, nil)
res, _ := tx.Query(ctx, `MATCH (t:Temp) RETURN t.x`, nil)
// res sees the newly created node, even though tx has not committed yet
res.Close()
tx.Rollback()
Isolation
gr uses snapshot isolation. Every transaction sees the graph as it was when the transaction opened. A concurrent write transaction that commits later is invisible to ongoing read transactions.
Write transactions serialize through a single writer slot by default.
If two write transactions try to modify overlapping data, the later one gets a *gr.ConflictError and the ExecuteWrite retry loop handles it automatically.
TxOptions
tx, err := db.BeginTx(ctx, gr.TxOptions{
ReadOnly: false,
MaxRetries: 5,
BusyTimeout: 2 * time.Second,
})
| Field | Default | Description |
|---|---|---|
ReadOnly |
false |
Open a read-only transaction (fails on writes) |
MaxRetries |
from Options | Override the retry count for this transaction |
BusyTimeout |
from Options | Override the busy-wait timeout |
Bulk writes
For large imports through the library API, use one large explicit transaction instead of many small ones. The WAL commit overhead scales with the number of transactions, not the number of statements.
tx, err := db.BeginTx(ctx, gr.TxOptions{})
if err != nil {
return err
}
defer tx.Rollback()
for _, row := range rows {
if _, err := tx.Exec(ctx, `CREATE (:Node {id:$id, name:$name})`,
map[string]any{"id": row.ID, "name": row.Name}); err != nil {
return err
}
}
return tx.Commit()
For very large datasets, the bulk importer (gr import) is faster — it bypasses the WAL entirely.
See the bulk import guide.