Skill v1.0.1
currentLLM-judged scan95/100+5 new
name: neo4j-driver-dotnet-skill description: Neo4j .NET Driver v6 — IDriver lifecycle, DI registration (singleton), ExecutableQuery fluent API, ExecuteReadAsync/ExecuteWriteAsync managed transactions, IResultCursor (FetchAsync/ ToListAsync), record value access (.Get<T>/As<T>), null safety, UNWIND batching, temporal types, await using, EagerResult, object mapping, CancellationToken, error handling, and common traps. Use when writing C# or .NET code connecting to Neo4j. Also triggers on Neo4j.Driver, IDriver, ExecutableQuery, ExecuteReadAsync, ExecuteWriteAsync, IResultCursor, IAsyncSession, or any Bolt/Aura work in .NET/C#. Does NOT handle Cypher authoring — use neo4j-cypher-skill. Does NOT cover driver version upgrades — use neo4j-migration-skill. compatibility: .NET 8, 9, 10; Neo4j.Driver v6 allowed-tools: Bash WebFetch version: 1.0.1
When to Use
- Writing C# or .NET code connecting to Neo4j
- Setting up
IDriver, DI registration, or session/transaction lifecycle - Questions about
ExecutableQuery,IResultCursor, async patterns, result mapping - Debugging sessions, type mapping, null safety, or error handling in .NET
When NOT to Use
- Writing/optimizing Cypher queries →
neo4j-cypher-skill - Upgrading from older driver version →
neo4j-migration-skill
Install
dotnet add package Neo4j.Driver
| Package | Use | |
|---|---|---|
Neo4j.Driver | Async API — use this | |
Neo4j.Driver.Simple | Synchronous wrapper | |
Neo4j.Driver.Reactive | System.Reactive streams |
Driver Lifecycle
IDriver — thread-safe, connection-pooled, expensive to create. Create one per application.
using Neo4j.Driver;// URI schemes:// neo4j+s://xxx.databases.neo4j.io — TLS + cluster routing (Aura)// neo4j://localhost — unencrypted + cluster routing// bolt+s://localhost:7687 — TLS + single instance// bolt://localhost:7687 — unencrypted + single instanceawait using var driver = GraphDatabase.Driver("neo4j+s://xxx.databases.neo4j.io",AuthTokens.Basic("neo4j", "password"));await driver.VerifyConnectivityAsync(); // fail fast on startup
IDriver and IAsyncSession implement IAsyncDisposable — always await using, never plain using.
// ❌ Wrong — synchronous Dispose() may block thread poolusing var driver = GraphDatabase.Driver(uri, auth);// ✅ Correctawait using var driver = GraphDatabase.Driver(uri, auth);
Auth options: AuthTokens.Basic(u, p) / AuthTokens.Bearer(token) / AuthTokens.Kerberos(ticket) / AuthTokens.None
Environment Variables
Load connection config from environment / appsettings.json — never hardcode credentials.
// appsettings.json{"Neo4j": {"Uri": "neo4j+s://xxx.databases.neo4j.io","User": "neo4j","Password": "secret","Database": "neo4j"}}
// Access via IConfiguration (injected in Program.cs)var uri = builder.Configuration["Neo4j:Uri"];var user = builder.Configuration["Neo4j:User"];var password = builder.Configuration["Neo4j:Password"];var database = builder.Configuration["Neo4j:Database"] ?? "neo4j";
Override with environment variables (standard .NET behavior): Neo4j__Uri=neo4j+s://... (double underscore = colon separator). Never commit appsettings.json with real credentials — use appsettings.Development.json (gitignored) or env vars in CI/production.
DI Registration (ASP.NET Core)
Register IDriver as singleton — never Scoped or Transient. Never register IAsyncSession in DI.
// Program.csbuilder.Services.AddSingleton<IDriver>(_ =>GraphDatabase.Driver(builder.Configuration["Neo4j:Uri"],AuthTokens.Basic(builder.Configuration["Neo4j:User"],builder.Configuration["Neo4j:Password"])));// Shutdown hook — dispose the singleton cleanlybuilder.Services.AddHostedService<Neo4jShutdownService>();// Neo4jShutdownService.cspublic class Neo4jShutdownService(IDriver driver, IHostApplicationLifetime lifetime): IHostedService{public Task StartAsync(CancellationToken _){lifetime.ApplicationStopping.Register(() =>driver.DisposeAsync().AsTask().GetAwaiter().GetResult());return Task.CompletedTask;}public Task StopAsync(CancellationToken _) => Task.CompletedTask;}// Inject into services — sessions opened per unit of workpublic class PersonService(IDriver driver){public async Task<List<string>> GetNamesAsync(CancellationToken ct = default){var (records, _, _) = await driver.ExecutableQuery("MATCH (p:Person) RETURN p.name AS name").WithConfig(new QueryConfig(database: "neo4j")).ExecuteAsync(ct);return records.Select(r => r.Get<string>("name")).ToList();}}
Choose the Right API
| API | When | Auto-retry | Streaming | |
|---|---|---|---|---|
driver.ExecutableQuery() | Most queries — simple default | ✅ | ❌ eager | |
session.ExecuteReadAsync/WriteAsync() | Large results, multi-query tx | ✅ | ✅ | |
session.RunAsync() | LOAD CSV, CALL {} IN TRANSACTIONS | ❌ | ✅ | |
session.BeginTransactionAsync() | Multi-function, external coordination | ❌ | ✅ |
ExecutableQuery — Recommended Default
Fluent builder; manages session, transaction, retries, and bookmarks automatically.
// Readvar (records, summary, keys) = await driver.ExecutableQuery("MATCH (p:Person {name: $name})-[:KNOWS]->(f) RETURN f.name AS name").WithParameters(new { name = "Alice" }).WithConfig(new QueryConfig(database: "neo4j",routing: RoutingControl.Readers)) // route reads to replicas.ExecuteAsync(cancellationToken);foreach (var r in records)Console.WriteLine(r.Get<string>("name"));// Use ResultConsumedAfter for wall-clock timing (ResultAvailableAfter = time-to-first-byte only)Console.WriteLine($"{summary.ResultConsumedAfter.TotalMilliseconds} ms");// Writevar (_, writeSummary, _) = await driver.ExecutableQuery("CREATE (p:Person {name: $name, age: $age})").WithParameters(new { name = "Bob", age = 30 }).WithConfig(new QueryConfig(database: "neo4j")).ExecuteAsync();Console.WriteLine($"Created {writeSummary.Counters.NodesCreated} nodes");// WithMap — project inlinevar names = await driver.ExecutableQuery("MATCH (p:Person) RETURN p.name AS name").WithConfig(new QueryConfig(database: "neo4j")).WithMap(r => r["name"].As<string>()).ExecuteAsync(); // names.Result is IReadOnlyList<string>
Never await omitted: ExecuteAsync() returns Task — missing await compiles silently but query never runs.
Never string-interpolate Cypher. Always WithParameters() — prevents injection, enables plan caching.
Managed Transactions
Use for large result sets (lazy streaming) or multiple queries per transaction. Callback auto-retried on transient failure — keep it idempotent, no side effects inside.
await using var session = driver.AsyncSession(conf => conf.WithDatabase("neo4j"));// Read — routes to replicasvar names = await session.ExecuteReadAsync(async tx =>{var cursor = await tx.RunAsync("MATCH (p:Person) WHERE p.name STARTS WITH $prefix RETURN p.name AS name",new { prefix = "Al" });return await cursor.ToListAsync(r => r.Get<string>("name"));// Consume cursor INSIDE callback — invalid after callback returns});// Write — void, no async neededawait session.ExecuteWriteAsync(tx =>tx.RunAsync("MERGE (p:Person {name: $name})", new { name = "Carol" }));// Write — async when needing countersvar summary = await session.ExecuteWriteAsync(async tx =>{var cursor = await tx.RunAsync("CREATE (p:Person {name: $name})", new { name = "Alice" });return await cursor.ConsumeAsync(); // drains cursor, returns IResultSummary});Console.WriteLine($"Created {summary.Counters.NodesCreated} nodes");
Cursor rules:
- Consume with
ToListAsync()orFetchAsync()loop inside the callback - Returning a cursor from the callback → transaction closes → cursor invalid → exception
// ❌ Returns cursor — tx closes immediately after lambda returnsvar cursor = await session.ExecuteReadAsync(async tx =>await tx.RunAsync("MATCH (p:Person) RETURN p.name AS name"));await cursor.FetchAsync(); // throws// ✅ Consume insidevar names = await session.ExecuteReadAsync(async tx =>{var cursor = await tx.RunAsync("MATCH (p:Person) RETURN p.name AS name");return await cursor.ToListAsync(r => r.Get<string>("name"));});
Async void trap:
// ❌ CS1998 warning — async with no await; RunAsync Task discardedawait session.ExecuteWriteAsync(async tx =>tx.RunAsync("MERGE (p:Person {name: $name})", new { name = "Alice" }));// ✅ No async, return Task directlyawait session.ExecuteWriteAsync(tx =>tx.RunAsync("MERGE (p:Person {name: $name})", new { name = "Alice" }));
FetchAsync Loop
var cursor = await tx.RunAsync("MATCH (p:Person) RETURN p.name AS name");while (await cursor.FetchAsync()) // true while records remain{Process(cursor.Current.Get<string>("name"));}// Do NOT use cursor.Current after the loop — it holds the last record, not null// Do NOT call FetchAsync() again after it returned false — throws InvalidOperationException
Cursor consumption methods:
| Method | Records | Summary | Use | |
|---|---|---|---|---|
ToListAsync() | ✅ all | ❌ | Need records | |
ToListAsync(mapper) | ✅ mapped | ❌ | Need mapped records | |
FetchAsync() loop | ✅ one/time | ❌ until ConsumeAsync | Large/lazy | |
ConsumeAsync() | ❌ discards | ✅ | Need counters | |
SingleAsync() | ✅ exactly 1 | ❌ | Expect one row |
Record Value Access
// Two equivalent patterns — prefer .Get<T>()string name = record.Get<string>("name");int age = record.Get<int>("age");string name2 = record["name"].As<string>(); // indexer + As<T>string name3 = record[0].As<string>(); // by column index// Null safety — .As<T>() on null graph value throws InvalidCastExceptionstring? city = record["city"].As<string?>(); // ✅ nullableint? age2 = record["age"].As<int?>(); // ✅ nullable// Absent key — throws KeyNotFoundException (typo or not in RETURN)if (record.Keys.Contains("city"))var city3 = record.Get<string?>("city");
Type Mapping
| Cypher | .NET default | Notes | |
|---|---|---|---|
Integer | long | safe: int, long?, int? | |
Float | double | safe: float, double? | |
String | string | use string? if nullable | |
Boolean | bool | ||
List | IReadOnlyList<object> | ||
Map | IReadOnlyDictionary<string,object> | ||
Node | INode | .Labels, .Properties, .ElementId | |
Relationship | IRelationship | .Type, .StartNodeElementId | |
Date | LocalDate | .ToDateOnly() (.NET 6+) | |
DateTime | ZonedDateTime | .ToDateTimeOffset() (ms precision) | |
LocalDateTime | LocalDateTime | ||
Duration | Duration | .ToTimeSpan() throws if has months/days | |
null | null | use nullable types |
ElementId stable within one transaction only — do not use to MATCH across separate transactions.
// Pass CLR types as params — driver converts automaticallyawait driver.ExecutableQuery("CREATE (e:Event {at: $ts})").WithParameters(new { ts = DateTimeOffset.UtcNow }).WithConfig(new QueryConfig(database: "neo4j")).ExecuteAsync();
UNWIND Batching
// ❌ One transaction per record — high overheadforeach (var item in items)await driver.ExecutableQuery("MERGE (n:Node {id: $id})").WithParameters(new { id = item.Id }).WithConfig(new QueryConfig(database: "neo4j")).ExecuteAsync();// ✅ Single transaction via UNWIND — anonymous types only (custom classes don't serialize)var rows = items.Select(i => new { id = i.Id, name = i.Name }).ToArray();await driver.ExecutableQuery(@"UNWIND $rows AS rowMERGE (n:Node {id: row.id})SET n.name = row.name").WithParameters(new { rows }).WithConfig(new QueryConfig(database: "neo4j")).ExecuteAsync();
Custom class instances passed to WithParameters for UNWIND do not serialize — use new object[] { new { ... } } or Dictionary<string, object>.
Object Mapping (Preview API)
using Neo4j.Driver.Preview.Mapping; // REQUIRED — without this, AsObject<T>() is CS1061public record Person(string Name, int Age); // C# records work well herevar result = await driver.ExecutableQuery("MATCH (p:Person) RETURN p.name AS name, p.age AS age").WithConfig(new QueryConfig(database: "neo4j")).ExecuteAsync();var person = result.Result[0].AsObject<Person>(); // RETURN keys map to record properties// Bulk mappingvar (people, _, _) = await driver.ExecutableQuery("MATCH (p:Person) RETURN p.name AS name, p.age AS age").WithConfig(new QueryConfig(database: "neo4j")).AsObjectsAsync<Person>();
Error Handling
try{await driver.ExecutableQuery("...").WithConfig(new QueryConfig(database: "neo4j")).ExecuteAsync();}catch (AuthenticationException ex) { /* bad credentials */ }catch (ServiceUnavailableException ex) { /* database unreachable */ }catch (ClientException ex)when (ex.Code == "Neo.ClientError.Schema.ConstraintValidationFailed"){// Unique/existence constraint violation — catch BEFORE Neo4jException}catch (Neo4jException ex) { /* all other server errors */ }
Catch ClientException before Neo4jException — it's a subclass; generic handler swallows it.
ex.GqlStatus — stable GQL status codes; prefer over string-matching ex.Code.
Explicit transaction rollback can itself throw — isolate it:
catch (Exception original){try { await tx.RollbackAsync(); }catch (Exception ex) { logger.LogError(ex, "Rollback failed"); }throw;}
If CommitAsync() throws a network error, commit may or may not have succeeded — design writes idempotent with MERGE + unique constraints.
Common Mistakes
| Mistake | Fix | |
|---|---|---|
using var driver | await using var driver — IDriver is IAsyncDisposable | |
using var session | await using var session | |
IDriver as Scoped/Transient in DI | Register as Singleton | |
IAsyncSession in DI | Never — open per unit of work | |
Missing await on ExecuteAsync() | Task silently never runs | |
async tx => tx.RunAsync(...) no inner await | Remove async, return Task directly | |
Omit database in QueryConfig/AsyncSession | Always specify — saves a round-trip | |
No CancellationToken in web apps | Propagate HttpContext.RequestAborted | |
.As<string>() on null graph value | .As<string?>() — non-nullable throws | |
record["key"] absent key | Check record.Keys.Contains() first | |
cursor.Current after FetchAsync loop | Last record, not null — don't use after loop | |
FetchAsync() after false return | Throws — stop loop, don't call again | |
| Return cursor from managed tx callback | Consume with ToListAsync() inside callback | |
| Need counters from session write | await cursor.ConsumeAsync() | |
AsObject<T>() CS1061 compile error | Add using Neo4j.Driver.Preview.Mapping; | |
ResultAvailableAfter for total timing | Use ResultConsumedAfter (full wall-clock) | |
Custom class in WithParameters for UNWIND | Use anonymous types or Dictionary<string,object> | |
Rename C# param but not Cypher $param | Anonymous property names must match $param names | |
ExecuteWriteAsync for reads | Use ExecuteReadAsync — routes to replicas | |
| Side effects inside managed tx callback | Move outside — callback retried on failure | |
Duration.ToTimeSpan() with months/days | Only safe for pure second/nanosecond durations | |
Catch Neo4jException before ClientException | ClientException is subclass — catch it first |
References
Load on demand:
- references/transactions.md — explicit transactions,
BeginTransactionAsync, rollback, commit uncertainty,TransactionConfig(timeout, metadata), causal consistency and bookmarks - references/performance.md — spatial types (Point/WGS-84/Cartesian), connection pool tuning,
WithFetchSize, session config options,CancellationTokenpatterns, large result streaming - references/object-mapping.md —
AsObject<T>, blueprint mapping, lambda mapping,AsObjectsAsync<T>, repository pattern example
Checklist
- [ ]
IDriverregistered as singleton in DI (orawait usingfor short-lived apps) - [ ]
await usingon driver and sessions (not plainusing) - [ ]
databasespecified inQueryConfig/AsyncSessionconfig - [ ]
ExecutableQueryused for simple queries;ExecuteReadAsync/ExecuteWriteAsyncfor streaming/multi-query - [ ] Cursor consumed inside managed tx callback (not returned)
- [ ] Nullable types (
string?,int?) on any graph value that can be null - [ ]
WithParameters()used (no string interpolation) - [ ] UNWIND batching with anonymous types (not custom class instances)
- [ ]
CancellationTokenpropagated in web app handlers - [ ]
ClientExceptioncaught beforeNeo4jException - [ ] Writes idempotent (
MERGE+ constraints) for retry safety - [ ] No side effects inside
ExecuteReadAsync/ExecuteWriteAsynccallbacks