This file provides guidance to coding agents working with code in this repository.
DuckDB.NET is an ADO.NET provider and low-level bindings for DuckDB. The solution (DuckDB.NET.slnx) has five
projects: Bindings (P/Invoke over the DuckDB C API), Data (the ADO.NET provider), Test (xUnit),
Samples and Benchmarks (BenchmarkDotNet). The libraries target net8.0;net10.0; the test project targets
net8.0 only, so dotnet test -f net10.0 silently runs nothing. The SDK is pinned by global.json (10.0.2xx).
User documentation lives in a separate docfx repository (S:\src\DuckDB.NET-Docs, published at https://duckdb.net).
# Build everything the way CI does (Full = download native libraries for all platforms)
dotnet build --configuration Release /p:BuildType=Full
# Run all tests (CI also adds /p:CollectCoverage=true /p:CoverletOutputFormat=lcov)
dotnet test DuckDB.NET.Test/Test.csproj --configuration Release /p:BuildType=Full
# One class, one test, or everything except the slow VARINT parameter tests
dotnet test DuckDB.NET.Test/Test.csproj --filter "FullyQualifiedName~DuckDB.NET.Test.TableFunctionTests"
dotnet test DuckDB.NET.Test/Test.csproj --filter "FullyQualifiedName~DuckDB.NET.Test.TableFunctionTests.TestTableFunction"
dotnet test DuckDB.NET.Test/Test.csproj --filter "FullyQualifiedName!~VarintTest"
# Packages: the plain packages need a separately installed native library; /p:BuildType=Full produces the .Full variants
dotnet pack DuckDB.NET.Data/Data.csproj --configuration Release /p:BuildType=Full
# Benchmarks
dotnet run -c Release --project DuckDB.NET.Benchmarks- The
DownloadNativeLibstarget inBindings.csproj(withDownloadNativeLibs.targets) downloads the DuckDB release zips intoDuckDB.NET.Bindings/obj/runtimes/<rid>/native. The test project always triggers this download, whateverBuildTypeis. - The cache is version-blind: a platform is downloaded only if its folder is missing. After changing the DuckDB
version, delete
DuckDB.NET.Bindings/obj/runtimes, rebuild, and confirm the version withSELECT version(). - Nightly builds:
/p:NightlyBuild=true /p:DuckDbArtifactRoot=https://artifacts.duckdb.org/<branch>downloads DuckDB'sduckdb-shared-libs-<platform>.tar.gzinstead.<branch>is a DuckDB branch name, andlatestmeans DuckDB'smain. CI does this on the schedule: it uses the workflow file fromdevelop, builds the code of thenightly-buildsbranch, and currently tracksv2.0-cyanopterauntil DuckDB 2.0 ships.
- Current target: DuckDB v1.5.6. The version is set in
DuckDB.NET.Bindings/Bindings.csproj(DuckDbArtifactRoot). The "Updated to DuckDB vX" line in thePackageReleaseNotesof bothBindings.csprojandData.csprojalso needs updating. EveryLibraryImportnames itsEntryPoint, so after an update, compare those names with the library's exports to catch removed functions. - Package version: comes from git tags through GitVersion (
GitVersion.yml). DuckDB.NET versions follow the DuckDB version (tag1.5.6). - Release steps:
- Update the release notes in both project files.
- Merge
developintomainand tag. - Wait for the "Build" run on
mainto succeed. - Start the
NuGetworkflow (.github/workflows/NuGet.yml, manual) onmain. It publishes thenugetPackages-mainartifact of the latest successful main build, so starting it earlier publishes an old build. Pushes todeveloppublish to GitHub Packages only.
NativeMethods: a partial class split into one file per C API area underNativeMethods/(Appender, Arrow, DataChunks, PreparedStatements, Query, ScalarFunction, StreamingResult, TableFunction, Value, Vectors, ...). Each declaration is aLibraryImportwith an explicitEntryPointand theDuckDbLibraryconstant; some hot paths use[SuppressGCTransition].- Lifetimes: native objects are
SafeHandles inDuckDBWrapperObjects.cs(database, connection, prepared statement, data chunk, logical type, value, ...). The exception isDuckDBResult, a plain struct inDuckDBNativeObjects.cswith no finalizer. Every result must beClose()d exactly once, or it leaks permanently. - Strings: returned strings use the marshallers in
DuckDBStringMarshallers.cs. UseDuckDBOwnedStringMarshallerwhen DuckDB owns the memory, andDuckDBCallerOwnedStringMarshallerwhen the caller mustduckdb_freeit.
- Pooling:
DuckDBConnectiongets its native connection from the staticConnectionManager. It caches oneFileReference(native database plus a reference count) per file. Returning the last connection disposes the database and removes the cache entry. - Data sources:
:memory:gives every connection its own private database;:memory:?cache=sharedshares one;md:<db>?motherduck_token=...connects to MotherDuck.
- Configuration:
DuckDBConnectionStringBuildernormalizesData Source/DataSourceand exposes DuckDB configuration options as typed properties. - Why not DuckDB's own instance cache:
duckdb_get_or_create_from_cachefails under concurrent opens of the same path (duckdb/duckdb#22277), soConnectionManagerdoes the pooling itself.
- Execution:
DuckDBCommandcallsPreparedStatement.PrepareMultiple, an iterator that extracts the statements and prepares and executes one statement perMoveNext(). Parameters are converted byDuckDBTypeMapandClrToDuckDBConverter(positional and named). - Result modes:
UseStreamingModedefaults tofalse, so results are materialized.trueusesduckdb_execute_prepared_streamingandduckdb_stream_fetch_chunk. - Errors: every native result error goes through
DuckDBResultExtensions.ThrowOnError. An interrupt becomesOperationCanceledException. An exception thrown by a C# UDF is attached asInnerExceptionthroughUdfExceptionStore, which is keyed by connection ID. Connection IDs repeat across databases, so parallel tests on different databases can see each other's stored exception. This is a known flaky test source. DuckDBDataReader: reads one chunk at a time and creates readers throughVectorDataReaderFactory, one per column. Composite readers (struct, list, map, decimal) hold child readers. After the first chunk, readers are reused throughVectorDataReaderBase.Reset(IntPtr vector). A newVectorDataReaderBasesubclass must be registered in the factory, and must overrideResetif it has child readers.- Invariants in
DuckDBDataReader, each guarded by a regression test:- A NULL chunk from a streaming fetch means either the end of the results or an error. Check the result's error before treating it as the end.
- Reset the row counters before fetching, so a failed fetch cannot leave accessors reading the freed chunk.
NextResult()closes the previous result: its chunk first, then the result.Close()releases the reader's own objects and statements beforeCommandBehavior.CloseConnectioncloses the connection. Otherwise a still-alive prepared statement keeps the native database alive afterConnectionManagerdisposes it, and the next open creates a second instance on the same file. That corrupts data on Linux and macOS, and fails with "file in use" on Windows.
- Arrow:
ExecuteArrowStreamandExecuteArrowBatchesAsyncstream results as Apache Arrow record batches (Arrow/DuckDBArrowArrayStream.cs), using DuckDB's Arrow C Data Interface.
- Writers:
VectorDataWriterFactory(DataChunk/Writer) creates the column writers used by the appender and by scalar and table function output.CollectionVectorDataWriterholds the shared collection logic;ListVectorDataWriter(running offsets, growth) andArrayVectorDataWriter(fixed size, row-based positions) are separate because DuckDB stores the two differently. DuckDBAppender: the low-level appender (connection.CreateAppender(table), thenCreateRow().AppendValue(...), or the allocation-freeAppendRow). It buffers rows in a data chunk ofVectorSize(2048) rows. It does no type checking and writes the raw bytes of the .NET type it is given; the docs call this out. A row is written only once every column has a value. Moving on from an incomplete row discards it and faults the appender.DuckDBMappedAppender<T, TMap>: the type-checked alternative (CreateAppender<T, TMap>(table)). ADuckDBAppenderMap<T>subclass declares the columns withMap,DefaultValueandNullValue, and type mismatches are reported when the appender is created. SeeAppenderMap-Usage.md.- UDFs: scalar functions (
DuckDBConnection.ScalarFunction*.cs) and table functions (DuckDBConnection.TableFunction*.cs) are registered through generic overloads for different parameter counts. Callbacks are pinned withGCHandle. A table function creates its data enumerator per scan init, not per bind, because DuckDB re-initializes scans without re-binding, for example in recursive CTEs.
There is no .editorconfig, so match the surrounding code:
- Layout: file-scoped namespaces (
namespace DuckDB.NET.Data;) and 4-space indentation. - Naming:
PascalCasefor types, methods and properties;camelCasefor locals and fields, with no underscore prefix. - Language features:
LangVersionislatest. Usevarwhen the type is obvious. Primary constructors and collection expressions ([]) are already used. - Nullable reference types: enabled in
Bindings,DataandBenchmarks, but disabled in the test project. Don't put?on reference types in tests (string?,List<object?>); it produces CS8632 warnings.int?and other nullable value types are fine. - Files: files are UTF-8, and about half start with a byte-order mark. The checkout uses CRLF line endings
(
core.autocrlf). Edits made with scripts must keep the file's byte-order mark as it is and keep CRLF throughout: no stray LF, no doubled CR, and no lone CR at the end of a file. Otherwise git can treat the file as binary.
Test classes take the per-class DuckDBDatabaseFixture (a shared in-memory connection) through
DuckDBTestBase. Test classes run in parallel, each with its own database. connection.ExecuteNonQuery(sql) is
an extension in Extensions/DbConnectionExtension.cs.
public class MyTests(DuckDBDatabaseFixture db) : DuckDBTestBase(db)
{
[Fact]
public void TestSomething()
{
Connection.ExecuteNonQuery("CREATE TABLE ...");
}
}Tests that close or reconfigure a connection, or need a file database, should create their own DuckDBConnection
instead of using the shared fixture connection.
Rules for every new or changed test (both have caused tests that passed locally and failed CI):
- Match
AppendValueargument types to the column types exactly. A bare integer literal such asAppendValue(1)binds to a 1-byte overload, and so doesAppendValue((int)1), because it is still a constant. Written into an INTEGER column, that stores garbage. Use(int?)1, a typed variable, or the exact CLR type of the column. - To check row order, use
ORDER BY rowidinstead of adding an ordering column. - Run a new test at least 5 times before committing. One pass proves nothing.
Known flaky failure: with DuckDB 1.5.x, a later-chunk error test can fail with OperationCanceledException.
A worker-thread error sets the interrupt flag, and a streaming fetch can report that flag instead of the real error.
DuckDB fixes this in 2.0. Re-run the job; don't work around it.
While debugging code that uses DuckDB.NET, System.AccessViolationException can appear because the debugger
interacts with native memory during marshalling. For the workaround, see
https://youtrack.jetbrains.com/issue/RIDER-114126.
Building DuckDB extensions in C# is a separate project: https://github.com/Giorgi/DuckDB.ExtensionKit