sqlc-gen-csharp is a sqlc plugin (written in C#) that turns a sqlc
GenerateRequest (protobuf: catalog + queries + options) into C# data access code for
PostgreSQL (Npgsql), MySQL (MySqlConnector) and SQLite (Microsoft.Data.Sqlite), optionally wrapped with Dapper.
More specific instructions live next to the code - read them before working in those areas:
Drivers/AGENTS.md- type mappings, driver hooks, annotation generators, enums, per-DB quirksend2end/AGENTS.md- end-to-end test scaffoldingdocs/AGENTS.md- documentation and README generation
GeneratedProtobuf (sqlc protobuf, generated) refs: -
Extensions (generic string/collection/Roslyn helpers) refs: -
PluginOptions (RawOptions -> Options, enums) refs: GeneratedProtobuf
Drivers (DB knowledge + method-body emission) refs: Extensions, PluginOptions
CodeGenerator (orchestration + file assembly) refs: PluginOptions, GeneratedProtobuf, Drivers
SqlcGenCsharp (PluginRunner: stdin/stdout I/O) refs: CodeGenerator
LocalRunner (process plugin host, 1-liner) refs: SqlcGenCsharp
WasmRunner (WASM plugin host, 1-liner) refs: SqlcGenCsharp
Never add a ProjectReference that is not in this graph. If a new edge is truly needed, update the
architecture test in the same change and explain why.
Naming trap:
Drivers/is not only "DB drivers". It also owns all generated method bodies (Drivers/Generators/*DeclareGen.cs).CodeGenerator/Generators/only assembles files (namespace, usings, header, which members go in which file).
| You are changing... | Put it in | Never in |
|---|---|---|
| Reading the request / writing the response | SqlcGenCsharp/PluginRunner.cs |
the runners, CodeGenerator |
| Which files are generated, their names, namespace style, usings order, header | CodeGenerator/CodeGenerator.cs, CodeGenerator/Generators/*Gen.cs |
Drivers |
Shape of Row / Args / Model types (record vs class, required/init) |
CodeGenerator/Generators/DataClassesGen.cs |
Drivers |
| Method body flow shared by all DBs (tx / no-tx, Dapper / driver) | Drivers/Generators/<Annotation>DeclareGen.cs, shared bits in CommonGen.cs |
a concrete driver, CodeGenerator |
| A DB-specific code fragment | an override of a DbDriver hook in that driver (see Drivers/AGENTS.md) |
*DeclareGen, CodeGenerator, DbDriver |
| DB type -> C# type mapping | that driver's ColumnMappings |
DbDriver base |
| Enum generation / reading / writing | Drivers/EnumDbDriver.cs (shared) or the driver's override |
CommonGen, CodeGenerator |
CancellationToken in generated code |
only via dbDriver.Cancellation (Drivers/Generators/CancellationGen.cs) |
hand-written cancellationToken strings |
| Names of generated locals/fields/properties | the Variable enum (Drivers/Variable.cs) + AsVarName/AsPropertyName/AsFieldName |
string literals like "reader" |
| Row/Args/Sql member naming | ClassMember (CodeGenerator/ClassMember.cs) |
ad-hoc $"{name}Row" |
| A new plugin option | PluginOptions/RawOptions.cs + PluginOptions/Options.cs (see recipe below) |
code that parses raw JSON |
| Pure helpers (strings, sets, dictionaries, Roslyn trivia) | Extensions/ - must stay free of Query/Column/driver knowledge |
- |
| Protobuf types | regenerate GeneratedProtobuf/ (make generate-protobuf) |
hand edits |
- CodeGenerator -> Drivers: CodeGenerator may only use the public surface of
DbDriver, the annotation interfaces inDrivers/QueryAnnotations.cs(IOne,IMany,IExec,IExecRows,IExecLastId,ICopyFrom) andEnumDbDriver. It must not reference concrete drivers (NpgsqlDriver, ...) - the only exception isCodeGenerator.InstantiateDriver. The same applies toDrivers/Generators/. Enforced byArchitectureTests.TestConcreteDriversAreNotReferenced. - Annotation dispatch is a hard cast in
QueriesGen.AddMethodDeclaration(((IExec)dbDriver).ExecDeclare(...)). A driver that doesn't implement an interface fails at runtime with a wrappedSystemException, not at compile time. - Drivers -> generated code: drivers return C# source as strings /
MemberDeclarationSyntax. Hooks return fragments (ConnectionGenCommands,CommandGenCommands,GenCommand.WrapBlock), never whole files. - Opt-in contract: every option that is off by default (e.g.
useCancellationToken,useProperSingularization) must leave generated output byte-identical when off.sqlc diffon the examples is the check.
PluginRunner.Run read GenerateRequest from stdin
CodeGenerator.Generate
InitGenerators Options(request); debugRequest -> dump request.json/request.message and stop
InstantiateDriver engine (postgresql|mysql|sqlite) -> NpgsqlDriver | MySqlConnectorDriver | SqliteDriver
group queries by query *file name* -> one QueriesGen file per group
ModelsGen (Models.cs), UtilsGen (Utils.cs, only if it has members), CsprojGen (if generateCsproj)
PluginRunner write GenerateResponse to stdout
public class QuerySql : IDisposable // Npgsql/MySQL: IDisposable (own a DataSource); SQLite: no base type
{
public QuerySql(string connectionString) : this() { ... }
public static QuerySql WithTransaction(NpgsqlTransaction transaction) { ... }
private const string GetAuthorSql = @"SELECT id, name, bio FROM authors WHERE name = @name LIMIT 1";
public readonly record struct GetAuthorRow(long Id, string Name, string? Bio); // nested in the query class
public readonly record struct GetAuthorArgs(string Name);
public async Task<GetAuthorRow?> GetAuthorAsync(GetAuthorArgs args) { ... } // :many -> Task<List<Row>>
}net8.0/net9.0/net10.0without Dapper: Row/Args/Models arereadonly record struct(so:onereturnsRow?).- With Dapper, or on
netstandard*: classes.net*->{ get; init; }+requiredon not-null; legacy ->{ get; set; }. - Every method has a no-transaction branch and a
this.Transactionbranch.
| Artifact | Source of truth | Regenerate with |
|---|---|---|
examples/** (code, .csproj, request.json, request.message) |
plugin code + examples/config/** + sqlc.ci.yaml |
make sqlc-generate |
sqlc.local.generated.yaml, sqlc.request.generated.yaml |
sqlc.ci.yaml (.sql section) |
make sync-sqlc-options (needs yq; part of make sqlc-generate) |
end2end/EndToEndTests*/**/*.generated.cs |
end2end/EndToEndScaffold/ templates + Config.cs |
make generate-end2end-tests |
unit-tests/CodegenTests/test-requests/*/request.* |
unit-tests/sqlc.yaml + sibling schema.sql/query.sql |
cd unit-tests && sqlc generate (part of make unit-tests) |
GeneratedProtobuf/Codegen.cs |
sqlc's plugin/codegen.proto |
make generate-protobuf |
docs/09_Examples.md |
sqlc.ci.yaml |
dotnet run --project docs/ExamplesDocGen/ExampleDocGen.csproj > docs/09_Examples.md |
README.md, docs/02_Quickstart.md |
docs/0*.md, latest release |
release flow only - see docs/AGENTS.md |
sqlc.ci.yaml wasm.sha256 |
dist/plugin.wasm |
make setup-wasm-plugin (CI recomputes it) |
sqlc.ci.yaml is the single source of truth for example configurations. Edit it, never the *.generated.yaml files.
CI fails if any of the generated artifacts above is stale (sqlc diff, git diff --quiet).
Naming
- The query class name comes from the query file name including the extension:
query.sql->QuerySql. Query files with the same basename in different directories are merged into one class (all examples rely on this:authors/query.sql+types/query.sql-> oneQuerySql). - Model names:
ToModelNamedefaults toTrimEnd('s'), which strips all trailings(address->Addre).useProperSingularizationfixes it but is opt-in because it renames classes. Tables in a non-default schema get the schema as a prefix (DummySchemaDummyTable). - The default schema is stored under the key
""inDbDriver.TablesandEnumDbDriver.Enums.
Options and overrides
- Overrides match
"<QueryName>:<column>"or"*:<column>"; the first matching entry in list order wins, andcsharp_type.notNullreplaces the column's nullability. - Overrides apply only where a query is in context: Row/Args types and query method bodies. They never affect
Models.cs(table models are generated withquery == null). - An override
typemust be a key in the driver'sColumnMappings, otherwise reader generation throws. debugRequest: truemakes the plugin dump the request instead of generating code; it is rejected for the WASM plugin.targetFrameworknet9.0/net10.0generate exactly the same code asnet8.0; only<TargetFramework>differs.netstandard2.0/2.1("legacy") output: block-scoped namespace, no nullable reference types, different multi-line SQL constant indentation (QueriesGen.GetQueryTextConstant),System.Text.Jsonpackage reference forJsonElement.
Code emission
- Generated code is built from interpolated strings, parsed with
ParseMemberDeclaration(...)!and formatted withNormalizeWhitespace(). Template indentation does not matter (except inside SQL string literals). A malformed template does not throw - it produces broken C#. The only real check is building the examples (dotnet build) and running the end-to-end tests. - Generated files mix
\n(JoinByNewLine) and CRLF trivia. Do not normalize line endings inexamples/-sqlc diffwill fail. Plugin sources are CRLF (.editorconfigend_of_line = crlf). - The
:one/:manygenerators silently fall back from Dapper to the raw driver when the query usessqlc.embed. - Dapper mode mutates global static state in the generated constructor:
Utils.ConfigureSqlMapper()andDefaultTypeMap.MatchNamesWithUnderscores = true.
WASM
- The WASM build is trimmed; only
PluginOptionsis rooted (System.Text.Json reflection). New reflection-based code can work in the process plugin and break only in WASM. WasmRunner.csprojforces awasip1core module because sqlc's wazero cannot load the wasip2 component the .NET 10 WASI SDK produces. The override relies on private SDK item names - re-verify after any SDK upgrade.
Repository
.envis shared by docker-compose, the end-to-end tests and CI;RepositoryTestsasserts it is consistent withbenchmark/.env.- Cloud sessions:
.claude/hooks/session-start.sh(only whenCLAUDE_CODE_REMOTE=true) installs dotnet (version fromglobal.json),sqlcand mikefarahyq, and installs the WASI SDK +wasi-experimentalworkload in the background (.claude/hooks/ensure-wasm-toolchain.shwaits for / finishes it; run it beforemake setup-wasm-plugin). It also starts PostgreSQL + MySQL natively (.claude/hooks/setup-test-databases.sh, no Docker daemon in cloud sessions), sodotnet test ./end2end/EndToEndTests/EndToEndTests.csprojworks without docker-compose. Tool versions live inscripts/tool-versions.env, shared with the CI workflows. make unit-testsneeds the process plugin published tounit-tests/dist/(unit-tests/sqlc.yamluses./dist/LocalRunner); CI doesdotnet publish ../LocalRunner -c release --output dist/fromunit-tests/.benchmark/has its ownsqlc.yamland committed*SqlcImplgenerated projects; it is excluded from format/lint.
Attribution: the repository is public. Never put Claude session links (claude.ai/code/session_..., Claude-Session: trailers) in commit messages, PR descriptions or comments. Other attribution lines are fine.
Every change
make sqlc-generate(process plugin -> examples + requests) and commit the regenerated files.make unit-tests.make test-plugin(needs Docker) - unit tests, regeneration,dotnet build, end-to-end tests.- For changes that can affect the WASM build:
make setup-wasm-plugin && sqlc -f sqlc.ci.yaml diff. make dotnet-format(CI lints the same scope: everything exceptGeneratedProtobuf,examples,benchmark/*SqlcImpl).- PR label:
release:major/release:minor/release:patch, orskip-releasefor non-release changes.
Add support for a DB type - see Drivers/AGENTS.md ("Adding a type mapping").
Add a plugin option
RawOptionsproperty with[JsonPropertyName("camelCase")]and a default that preserves current output.- Matching read-only property in
Options(constructor maps it); validate there. - Consume it via
dbDriver.Options/Options- never re-read the raw JSON. - Unit test in
unit-tests/CodegenTests/OptionsTests.cs(and a codegen test if it changes output). - Enable it in at least one example in
sqlc.ci.yamlso it is covered end-to-end, thenmake sqlc-generate. - Document it in the options table in
docs/03_Usage.mdand in section 5 below.
Add a query annotation
- New
I<Annotation>interface inDrivers/QueryAnnotations.cs. - New
Drivers/Generators/<Annotation>DeclareGen.csfollowing the existing ones (tx / no-tx, Dapper / driver). - New case in the
QueriesGen.AddMethodDeclarationswitch. - Implement the interface in every driver (or throw
NotSupportedExceptionwith a clear message). - Queries in
examples/config/<db>/authors/query.sql, templates inend2end/EndToEndScaffold/Templates/AnnotationTests.cs. - Update the annotation tables in
docs/03_Usage.mdand this file.
Add a database driver
Drivers/<Name>Driver.csderiving fromDbDriver(orEnumDbDriverif the DB has enums), implementing everyI*interface.- Enum value in
PluginOptions/DriverName.cs, engine mapping inOptions.EngineToDriverMapping, case inCodeGenerator.InstantiateDriver. - Examples (normal / Dapper / Legacy / DapperLegacy) in
sqlc.ci.yaml,examples/config/<db>/, e2e testers, docker-compose service,docs/0X_<Db>.md.
| Option | Default | Notes |
|---|---|---|
targetFramework |
net8.0 |
netstandard2.0, netstandard2.1, net8.0, net9.0, net10.0 |
driverVersion |
2.4.0 MySqlConnector, 8.0.6 Npgsql, 10.0.13 Microsoft.Data.Sqlite |
constants in each driver |
generateCsproj |
true |
|
namespaceName |
output directory name | |
useDapper |
false |
|
dapperVersion |
2.1.66 |
DbDriver.DefaultDapperVersion |
useCentralPackageManagement |
false |
omits Version= in the generated csproj |
useAsyncSuffix |
true |
|
useCancellationToken |
false |
must be byte-identical when off |
useAsyncEnumerable |
false |
:many returns IAsyncEnumerable<Row>; not supported on netstandard2.0 |
useProperSingularization |
false |
renames models when on |
overrides |
- | Query:column / *:column, see section 3 |
debugRequest |
false |
internal: dumps the request; not allowed with WASM |
Renamed options: overrideDriverVersion -> driverVersion, overrideDapperVersion -> dapperVersion, withAsyncSuffix -> useAsyncSuffix, withCancellationToken -> useCancellationToken. The old names are deprecated but still accepted; setting both spellings to different values is an error.
| Annotation / macro | PostgreSQL | MySQL | SQLite | Implementation |
|---|---|---|---|---|
:one, :many, :exec, :execrows |
✅ | ✅ | ✅ | shared *DeclareGen |
:execlastid |
✅ | ✅ | ✅ | PG/SQLite: user writes RETURNING; MySQL: plugin appends ; SELECT LAST_INSERT_ID() |
:copyfrom |
✅ | ✅ | ✅ | PG: binary COPY; MySQL: CSV + MySqlBulkLoader; SQLite: multi-row VALUES |
sqlc.arg, sqlc.narg, sqlc.embed |
✅ | ✅ | ✅ | |
sqlc.slice |
🚫 (use arrays) | ✅ | ✅ | runtime Utils.TransformQueryForSliceArgs |
- The plugin projects (section 1 graph) and the tooling/test projects target
net10.0; SDK pinned inglobal.json. Exceptions:end2end/EndToEndCommon(netstandard2.0) andend2end/EndToEndTestsLegacy(net472). - Core examples are spread over net8.0 / net9.0 / net10.0 so every framework is built and tested.
- Legacy examples (
netstandard2.0) are tested byend2end/EndToEndTestsLegacy(net472, Windows,.github/workflows/legacy-tests.yml).
make unit-tests # RepositoryTests + CodegenTests
make sqlc-generate # sync yaml, publish LocalRunner, regenerate requests + examples
make generate-end2end-tests
make test-plugin # everything above + build + end-to-end tests (Docker)
make setup-wasm-plugin # WASM plugin -> dist/plugin.wasm (needs wasi-experimental workload + WASI SDK 25 in WASI_SDK_PATH)
sqlc -f sqlc.ci.yaml diff # WASM output must equal the committed examples
make dotnet-formatrelease-assistant.yml- every PR tomainneedsrelease:major/release:minor/release:patchorskip-release.gen-release-pr.yml- after Build onmain, aggregates labeled PRs, computes the version, regenerates README/quickstart with the wasm sha, opens arelease-prepPR.release.yml- on merge ofrelease-prep, tags, builds release notes from PR titles, uploads the wasm.
Do not copy these patterns; fix them only in a dedicated change (they may change generated output).
DbDriver.NullableTypeshardcodes Npgsql types (NpgsqlPoint,NpgsqlCidr, ...) - should be added byNpgsqlDriver.NpgsqlDriver.ColumnMappingsstring/byte[]include MySQL type names (longtext,tinyblob, ...).JsonElement/objectreader lambdas hardcodereaderinstead ofVariable.Reader.AsVarName().EnumDbDriveris declared in the global namespace.CommonGen.ConstructDapperParamsDictenum branch is dead:Enumsis keyed by schema, not enum name, and theToEnumString()it would emit does not exist.- SQLite
:copyfromignores overrides and writer functions (usesp.Column.NotNulldirectly). - MySQL
:copyfromwrites a fixedinput.csvin the process working directory (not concurrency-safe). GetIdColumnType(:execlastidreturn type) is a heuristic: columnid, else first column containingid, else the first column.- Not supported: PostgreSQL
time with time zone; MySQL geometry types; array columns get noNpgsqlDbTypeoverride (TODO inDbDriver.GetColumnDbTypeOverride).