Install
$ agentstack add skill-neo4j-contrib-neo4j-skills-neo4j-driver-dotnet-skill ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
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 instance
await 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 pool
using var driver = GraphDatabase.Driver(uri, auth);
// ✅ Correct
await 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.cs
builder.Services.AddSingleton(_ =>
GraphDatabase.Driver(
builder.Configuration["Neo4j:Uri"],
AuthTokens.Basic(
builder.Configuration["Neo4j:User"],
builder.Configuration["Neo4j:Password"])));
// Shutdown hook — dispose the singleton cleanly
builder.Services.AddHostedService();
// Neo4jShutdownService.cs
public 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 work
public class PersonService(IDriver driver)
{
public async Task> 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("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.
// Read
var (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("name"));
// Use ResultConsumedAfter for wall-clock timing (ResultAvailableAfter = time-to-first-byte only)
Console.WriteLine($"{summary.ResultConsumedAfter.TotalMilliseconds} ms");
// Write
var (_, 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 inline
var names = await driver
.ExecutableQuery("MATCH (p:Person) RETURN p.name AS name")
.WithConfig(new QueryConfig(database: "neo4j"))
.WithMap(r => r["name"].As())
.ExecuteAsync(); // names.Result is IReadOnlyList
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 replicas
var 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("name"));
// Consume cursor INSIDE callback — invalid after callback returns
});
// Write — void, no async needed
await session.ExecuteWriteAsync(tx =>
tx.RunAsync("MERGE (p:Person {name: $name})", new { name = "Carol" }));
// Write — async when needing counters
var 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 returns
var cursor = await session.ExecuteReadAsync(async tx =>
await tx.RunAsync("MATCH (p:Person) RETURN p.name AS name"));
await cursor.FetchAsync(); // throws
// ✅ Consume inside
var 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("name"));
});
Async void trap:
// ❌ CS1998 warning — async with no await; RunAsync Task discarded
await session.ExecuteWriteAsync(async tx =>
tx.RunAsync("MERGE (p:Person {name: $name})", new { name = "Alice" }));
// ✅ No async, return Task directly
await 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("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()
string name = record.Get("name");
int age = record.Get("age");
string name2 = record["name"].As(); // indexer + As
string name3 = record[0].As(); // by column index
// Null safety — .As() on null graph value throws InvalidCastException
string? city = record["city"].As(); // ✅ nullable
int? age2 = record["age"].As(); // ✅ nullable
// Absent key — throws KeyNotFoundException (typo or not in RETURN)
if (record.Keys.Contains("city"))
var city3 = record.Get("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 | | | Map | IReadOnlyDictionary | | | 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 automatically
await 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 overhead
foreach (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 row
MERGE (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.
Object Mapping (Preview API)
using Neo4j.Driver.Preview.Mapping; // REQUIRED — without this, AsObject() is CS1061
public record Person(string Name, int Age); // C# records work well here
var 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(); // RETURN keys map to record properties
// Bulk mapping
var (people, _, _) = await driver
.ExecutableQuery("MATCH (p:Person) RETURN p.name AS name, p.age AS age")
.WithConfig(new QueryConfig(database: "neo4j"))
.AsObjectsAsync();
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() on null graph value | .As() — 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() 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 | | 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](references/transactions.md) — explicit transactions,
BeginTransactionAsync, rollback, commit uncertainty,TransactionConfig(timeout, metadata), causal consistency and bookmarks - [references/performance.md](references/performance.md) — spatial types (Point/WGS-84/Cartesian), connection pool tuning,
WithFetchSize, session config options,CancellationTokenpatterns, large result streaming - [references/object-mapping.md](references/object-mapping.md) —
AsObject, blueprint mapping, lambda mapping,AsObjectsAsync, 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 co
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: neo4j-contrib
- Source: neo4j-contrib/neo4j-skills
- License: MIT
- Homepage: https://neo4j.com/llms.txt
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.