Dapper.AOT: Dapper with Source Generators, Interceptors and Native AOT
Why does vanilla Dapper blow up even under dotnet run once PublishAot is on? Generating the code at build time with Dapper.AOT 1.1.0: [module: DapperAot], InterceptorsNamespaces, the generated interceptor code, Native AOT publish and a startup time going from 49.8 ms to 4.3 ms, type handlers as attributes, the DAP analyzers and limitations such as QueryMultiple.
Version note: The code in this post targets Dapper 2.1 and .NET 10.
Guess First
One of the plainest programs in the series: one table, four books, a single parameterized query. The only difference is that we added one line for Native AOT to the project file:
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
</PropertyGroup>
using Dapper;
using Microsoft.Data.Sqlite;
using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
conn.Execute("""
create table books (id integer primary key, title text not null, price real not null);
insert into books (title, price) values
('Dune', 12.5), ('Dune Messiah', 10.0),
('The Left Hand of Darkness', 11.0), ('The Dispossessed', 13.0);
""");
var cheap = conn.Query<Book>(
"select id, title, price from books where price < @maxPrice order by price",
new { maxPrice = 12.0 });
foreach (var b in cheap)
Console.WriteLine($"{b.Id} | {b.Title} | {b.Price}");
public class Book
{
public long Id { get; set; }
public string Title { get; set; } = "";
public double Price { get; set; }
}
We are not publishing yet, just running dotnet run as usual. What happens?
Unhandled exception. System.PlatformNotSupportedException: Dynamic code generation is not supported on this platform.
at System.Reflection.Emit.AssemblyBuilder.ThrowDynamicCodeNotSupported()
...
at Dapper.SqlMapper.CreateParamInfoGenerator(Identity identity, Boolean checkForDuplicates, Boolean removeUnused, IList`1 literals) in /_/Dapper/SqlMapper.cs:line 2577
It blew up even though it runs on the JIT. When PublishAot is on, the SDK writes IsDynamicCodeSupported: false into the application's runtimeconfig.json, so you see the problems Native AOT will have while you develop. The place it fails is familiar too: the method where Dapper generates parameter code as IL at runtime. The last post of the series is about Dapper.AOT, which moves that code to build time.
What Does Dapper Generate at Runtime?
We saw it in post 15: when Dapper sees a query for the first time, it generates the code that writes parameters into the command and the deserializer that fills objects from rows using System.Reflection.Emit, then caches them. A flexible and fast design, but with two costs. The first is cold start: reflection and IL generation take time on the first call. The second is Native AOT and trimming: the compiler cannot see code that will be generated at runtime, and generating IL is simply not possible under Native AOT.
When I actually published the same program with dotnet publish -r osx-arm64 -c Release, the compiler warned and the resulting native binary failed in the same place:
warning IL2104: Assembly 'Dapper' produced trim warnings. For more information see https://aka.ms/il2104
warning IL3053: Assembly 'Dapper' produced AOT analysis warnings.
Unhandled exception. System.PlatformNotSupportedException: Dynamic code generation is not supported on this platform.
at System.Reflection.Emit.ReflectionEmitThrower.ThrowPlatformNotSupportedException() + 0x30
at Dapper.SqlMapper.CreateParamInfoGenerator(SqlMapper.Identity, Boolean, Boolean, IList`1) + 0x148
Dapper.AOT: Generating the Code at Build Time
Dapper.AOT generates the same code at build time with a source generator. To put the generated methods in place of your conn.Query<Book>(...) calls, it uses interceptors, which arrived as experimental in the .NET 8 SDK and became stable in later SDKs: the compiler redirects the call at a given line and column of a given file to another method the generator wrote. Your source code does not change; what changes is the target of the compiled call. The examples in this post ran with Dapper.AOT 1.1.0 (September 2026). The package is C#-only; for those who want the analyzers without code generation there is a sister package called Dapper.Advisor.
Setup takes three steps, and each step has its own warning. First we add the package:
dotnet add package Dapper.AOT --version 1.1.0
Program.cs(6,6): warning DAP005: 2 candidate Dapper methods detected, but none have Dapper.AOT enabled (https://aot.dapperlib.dev/rules/DAP005)
The package changes nothing on its own; it only says it found two calls it could help with. The second step is to opt in explicitly. [module: DapperAot] enables the whole project; if you want, you can narrow it at the type or method level with [DapperAot] and [DapperAot(false)]. The attribute must come after the usings:
using Dapper;
using Microsoft.Data.Sqlite;
[module: DapperAot]
obj/Debug/net10.0/Dapper.AOT.Analyzers/Dapper.CodeAnalysis.DapperInterceptorGenerator/Bookstore.Aot.generated.cs(8,10): error CS9137: The 'interceptors' feature is not enabled in this namespace. Add '<InterceptorsNamespaces>$(InterceptorsNamespaces);Dapper.AOT</InterceptorsNamespaces>' to your project.
This error comes from the compiler, not from Dapper.AOT: interceptors need permission per namespace. The third step is to do what the message says:
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<InterceptorsNamespaces>$(InterceptorsNamespaces);Dapper.AOT</InterceptorsNamespaces>
<PublishAot>true</PublishAot>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
The .NET 8 SDK does not know this property; its name there is InterceptorsPreviewNamespaces. If your project still builds with the .NET 8 SDK, add that line as well; writing both is harmless. Dapper.AOT needs the .NET 8 SDK or later to build, but the target framework can be anything: the package targets everything from net48 and netstandard2.0 up to net10.0. EmitCompilerGeneratedFiles is not required; I added it to see the generated code on disk.
Looking at the Generated Code
After the build, the file the generator wrote sits under obj/. From the 217-line file, here is the part that corresponds to our query, shortened (I cut the file path and the assertion lines in the body with ...):
[global::System.Runtime.CompilerServices.InterceptsLocationAttribute(".../Program.cs", 18, 18)]
internal static global::System.Collections.Generic.IEnumerable<global::Book> Query1(this global::System.Data.IDbConnection cnn, string sql, object? param, global::System.Data.IDbTransaction? transaction, bool buffered, int? commandTimeout, global::System.Data.CommandType? commandType)
{
...
return global::Dapper.DapperAotExtensions.Command(cnn, transaction, sql, global::System.Data.CommandType.Text, commandTimeout.GetValueOrDefault(), CommandFactory0.Instance).QueryBuffered(param, RowFactory0.Instance);
}
InterceptsLocation binds the Query call at line 18, column 18 of Program.cs to this method. CommandFactory0 is the class that writes maxPrice from the anonymous object into a parameter, and RowFactory0 is the class that fills Book from the id, title and price columns. The two pieces we said were generated at runtime in post 15 now sit in the file as plain C#; they can be read, debugged and seen by the trimmer.
Native AOT Publish
From here on it is a single command:
dotnet publish -r osx-arm64 -c Release -o out
This time neither IL2104 nor IL3053 appeared: because every call goes to an interceptor, Dapper's reflection code is not used and the trimmer can safely drop it. The out/ folder holds a 3.2 MB Mach-O binary and SQLite's native library libe_sqlite3.dylib; it runs on a machine without the .NET runtime installed. Microsoft.Data.Sqlite worked with Native AOT without problems in this test; verify your own provider's AOT compatibility, because Dapper.AOT cannot guarantee that for you.
Let's look at startup time. I ran three variants of the same program 31 times each and took the median of the total process time:
vanilla jit 49.8
aot jit 34.0
aot native 4.3
The values are in milliseconds. 49.8 ms with vanilla Dapper on the JIT; 34.0 ms for the same code with Dapper.AOT on the JIT, because there is no IL generation on the first query; and 4.3 ms for the native binary. For serverless functions, CLI tools and containers that scale often, this difference is the cold start itself.
Type Handlers Are Now Attributes
Remember the type handlers from post 11: they were registered at runtime, process-wide, with SqlMapper.AddTypeHandler. The generator runs at build time, so it cannot see that call, and it says so explicitly:
warning DAP053: SqlMapper.AddTypeHandler for 'Guid' is invisible to Dapper.AOT, so generated code binds the value without it; declare it instead with [module: TypeHandler(typeof(Guid), typeof(GuidHandler))] (https://aot.dapperlib.dev/rules/DAP053)
The fix is to turn the registration into a declaration. In 1.1.0 you can pass a vanilla SqlMapper.TypeHandler<T> handler as is; the generated code adapts it to Dapper.AOT's IDbValueHandler<T> interface:
using System.Data;
using Dapper;
using Microsoft.Data.Sqlite;
[module: DapperAot]
[module: TypeHandler(typeof(Guid), typeof(GuidHandler))]
using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
conn.Execute("""
create table books (id integer primary key, isbn_id text not null, title text not null, price real not null);
insert into books (isbn_id, title, price) values
('3f2b8c1e-7d4a-4e9b-9c61-2a5f0e8d7b13', 'Dune', 12.5),
('9a4e1f20-55c3-4b8e-a0d7-6c2e9b1f3a48', 'Dune Messiah', 10.0),
('c71d2e8b-0f4a-4c39-b6e5-8d1a7f2c9e60', 'The Left Hand of Darkness', 11.0);
""");
var cheap = conn.Query<Book>(
"select id, isbn_id, title, price from books where price < @maxPrice order by price",
new { maxPrice = 12.0 });
foreach (var b in cheap)
Console.WriteLine($"{b.Id} | {b.IsbnId} | {b.Title} | {b.Price}");
public class Book
{
public long Id { get; set; }
public Guid IsbnId { get; set; }
public string Title { get; set; } = "";
public double Price { get; set; }
}
public sealed class GuidHandler : SqlMapper.TypeHandler<Guid>
{
public override Guid Parse(object value) => Guid.Parse((string)value);
public override void SetValue(IDbDataParameter parameter, Guid value) => parameter.Value = value;
}
2 | 9a4e1f20-55c3-4b8e-a0d7-6c2e9b1f3a48 | Dune Messiah | 10
3 | c71d2e8b-0f4a-4c39-b6e5-8d1a7f2c9e60 | The Left Hand of Darkness | 11
This output is from the native binary. The declarative registration applies to one assembly; it is not process-wide, does not depend on startup order and is visible in code review. The "registering the handler after the first query" trap from post 11 cannot exist here.
Watch out for one behavioral difference: the isbn_id column mapped to IsbnId even though MatchNamesWithUnderscores was never turned on. The mapping code Dapper.AOT generates compares names through the StringHashing helper in its source, dropping underscores and whitespace and lower-casing. In vanilla Dapper, with that setting off, the same column would have stayed empty. During a migration, some properties may silently start being filled; let your tests catch that.
Analyzers: The DAP Rules
The second face of Dapper.AOT is its analyzers with the DAP prefix. Some are about AOT, some directly about your SQL. SQL analysis only goes deep when it knows the dialect; right now advanced analysis exists only for SQL Server and kicks in automatically when the connection type is SqlConnection. In post 14 we frowned at the select * Dommel generated; write the same pattern in a SQL Server query and you get:
public static Book? Find(SqlConnection conn, int id) =>
conn.QueryFirstOrDefault<Book>("select * from dbo.books where id = @id", new { id });
BookQueries.cs(9,48): warning DAP219: SELECT columns should be specified explicitly (https://aot.dapperlib.dev/rules/DAP219)
There are rules for unsupported patterns too. QueryMultiple and the overloads that take a CommandDefinition are not intercepted at the moment. In a normal JIT project these are reported only at information level and don't even show up in the build output. With PublishAot on, they are raised to warnings, because now they carry a real risk of crashing:
warning DAP001: The Dapper method 'SqlMapper.QueryMultiple(IDbConnection, string, object?, IDbTransaction?, int?, CommandType?)' is not currently supported by Dapper.AOT (https://aot.dapperlib.dev/rules/DAP001)
warning DAP057: 'QuerySingle' passes its SQL inside a CommandDefinition, which Dapper.AOT cannot read at build time; use the overload that takes the SQL directly. This call-site is left on vanilla Dapper, which will not work under native AOT (https://aot.dapperlib.dev/rules/DAP057)
The reason behind DAP057 makes sense: the generator decides by reading the SQL at build time, while a CommandDefinition carries the SQL inside a struct created at runtime. In post 06 we used CommandDefinition for the CancellationToken; for code moving to AOT that means a trade-off.
Limitations: What Is Not There Yet?
I tried what happens if you ignore the DAP001 warning. I added a QueryMultiple to the same program and published it natively:
using var grid = conn.QueryMultiple("select count(*) from books; select id, isbn_id, title, price from books");
Console.WriteLine(grid.ReadSingle<long>());
Console.WriteLine(grid.Read<Book>().Count());
2 | 9a4e1f20-55c3-4b8e-a0d7-6c2e9b1f3a48 | Dune Messiah | 10
3 | c71d2e8b-0f4a-4c39-b6e5-8d1a7f2c9e60 | The Left Hand of Darkness | 11
3
Unhandled exception. System.PlatformNotSupportedException: Dynamic code generation is not supported on this platform.
The intercepted query worked; QueryMultiple fell back to vanilla Dapper. The first grid is a simple long, so it needed no IL and was read; the second grid blew up when it tried to generate a deserializer for Book. The publish also brought back the IL2104 and IL3053 warnings, because Dapper's reflection code is in use again.
According to the documentation and the source, the other limitations are these. Runtime settings such as SqlMapper.Settings and AddTypeHandler are ignored. APIs that take the type at runtime, like Query(Type, ...), are not supported (DAP056). Private types are not supported. Only direct, inline calls are intercepted; a call inside your own helper method that wraps Dapper falls back to vanilla if it is not somewhere the generator can see the SQL. The documentation's FAQ still answers the full-coverage question with "Not yet - not by a long way" and asks you, in capital letters, to test your code carefully. I see Dapper.AOT the same way: not a drop-in replacement, but a production-capable add-on with gaps.
Common Pitfalls
Adding the package and thinking you are done. Without [module: DapperAot] and InterceptorsNamespaces nothing changes; that is exactly what the DAP005 warning says.
Assuming it works under AOT because it works on the JIT. With PublishAot off, every call that falls back to vanilla works fine; the problem only shows up in the native binary. Turn PublishAot on early during the migration: dotnet run then runs with AOT restrictions, and DAP001 and DAP057 are raised to warnings.
Suppressing the warnings. IL2104, IL3053, DAP001, DAP053 and DAP057 are not "we'll look later" warnings in an AOT project; each one says a specific call may blow up in the native binary. If you can, turn these codes into errors with TreatWarningsAsErrors.
Missing the difference in name matching. Dapper.AOT matches while ignoring underscores; vanilla Dapper depends on the setting. The same query can fill different properties in the two modes.
A Small Experiment
★☆☆ Change the [module: DapperAot] line to [module: DapperAot(false)] and mark only the method that reads Book with [DapperAot]. Count how many interceptors remain in the generated file under obj/.
★★☆ Split the QueryMultiple example from the limitations section into two separate Query calls and confirm that the native binary now works and the IL2104/IL3053 warnings are gone. See how the binary size changes.
★★★ Rewrite the DateTimeOffsetHandler from post 11 by deriving from Dapper.AOT's DbValueHandler<T> class and register it with [module: TypeHandler(...)]. Test both the read direction and parameter writing in the native binary; does the answer to post 11's "why was SetValue never called" question change here?
With this post the first round of the Dapper series is complete: we started with a thin layer over ADO.NET and went all the way to type handlers, provider-specific topics, performance and code generated at build time. The series does not end here; as new versions of Dapper and Dapper.AOT come out, I will keep adding posts about what changed, on the same Bookstore example.