This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Tuesday, 16 December 2025
Native AOT promises to turn your .NET applications into tiny, self-contained executables that start instantly and run anywhere—no runtime installation required. It's magic when it works. But add SQLite to the mix and suddenly you're debugging cryptic DllNotFoundException crashes, fighting with transitive dependencies, and wondering why PublishSingleFile=true doesn't actually bundle everything into a single file. This is the complete, beginner-to-expert guide on getting SQLite working with Native AOT, building for multiple platforms, and automating releases with GitHub Actions. By the end, you'll have 10-12MB binaries that run on Windows, Linux (including Raspberry Pi), and macOS—with zero dependencies.
Let's start with the absolute basics. If you've ever built a .NET application and wondered why you need to install the ".NET Runtime" on servers or why your console app takes a second to start the first time you run it, AOT is the answer to those problems.
When you write C# code and build your application, the compiler doesn't produce machine code that your CPU can directly execute. Instead, it produces something called Intermediate Language (IL)—think of it as halfway between your C# code and actual machine instructions.
When you run your .NET app, here's what happens:
This is like having a translator who reads your recipe (IL) and verbally translates it to a chef (CPU) line by line while they're cooking. It works, but there's overhead:
Native AOT flips this model on its head. Instead of translating your code at runtime, it translates everything at build time. You end up with a single executable file that contains actual machine code your CPU can run directly—no runtime, no translator, no waiting.
Think of it like getting a professionally translated recipe book instead of hiring a live translator. The work is done once, upfront, and the result is ready to use immediately.
Here's what Native AOT gives you:
1. Tiny executables: 10-30MB instead of 150MB+
Your app and everything it needs gets compiled into one small binary. No separate runtime files.
2. Instant startup: 80% faster cold starts
My tests: normal .NET took ~800ms to start, AOT took ~150ms. There's no JIT warm-up time—the machine code is ready to execute immediately.
3. Zero dependencies: No .NET runtime required
You can copy your executable to any machine with the right OS (Windows/Linux/Mac) and it just runs. No "install .NET 10 Runtime" prerequisite.
4. Lower memory usage: About 50% less memory
No JIT compiler sitting in memory. In my gateway application, normal .NET used 85MB idle, AOT used 42MB.
5. Better for restricted environments: Works where JIT can't
Some environments (certain Docker containers, iOS, embedded systems) don't allow runtime code generation. AOT works everywhere.
AOT isn't magic. You're trading runtime flexibility for upfront optimization:
1. No dynamic code generation
Anything that generates code at runtime won't work:
System.Reflection.Emit (creating types dynamically)Most normal .NET code is fine, but some frameworks that rely heavily on reflection need special configuration.
2. Platform-specific builds
JIT compilation produces IL that runs on any platform. AOT produces native machine code for one specific platform. You need to build separately for:
We'll cover automating this with GitHub Actions later.
3. Longer build times
Instead of compiling to IL in seconds, AOT compiles all the way to machine code. Expect 2-5 minutes instead of 10 seconds. It's a one-time cost for permanent benefits.
4. Some features need extra configuration
JSON serialization, Entity Framework, and anything using heavy reflection may need you to explicitly tell the compiler what types to keep. We'll cover this.
Native AOT is perfect for:
Skip AOT for:
For CLI tools, microservices, containers, and edge devices, the improvements are game-changing. But there's a catch when you add databases—specifically SQLite.
Now that you understand what AOT is, let's talk about the single biggest pain point: SQLite. This is the wall most people hit when trying to use AOT with database-backed applications.
Here's the typical journey:
Microsoft.Data.Sqlite to your projectPublishAot=true in your .csprojDllNotFoundException: Unable to load DLL 'e_sqlite3' or one of its dependencies
Your app crashes before it can do anything. What gives?
To understand the problem, you need to know about native libraries.
SQLite isn't written in .NET—it's written in C. It's compiled to platform-specific native code:
e_sqlite3.dll on Windowslibe_sqlite3.so on Linuxlibe_sqlite3.dylib on macOSWhen you use Microsoft.Data.Sqlite in normal .NET, it's just a wrapper around this native SQLite library. At runtime, it tries to dynamically load the appropriate native file for your platform.
This works fine with normal .NET because:
Native AOT has two characteristics that clash with SQLite's approach:
1. Aggressive trimming: AOT removes any code it thinks you're not using. If it can't statically prove you need something, it gets deleted. Dynamic library loading confuses the trimmer—it can't see the connection between your code and the native SQLite DLL.
2. No dynamic loading support: AOT produces a self-contained binary. It expects all native dependencies to be explicitly linked at compile time, not loaded dynamically at runtime.
The result: Microsoft.Data.Sqlite expects to find a native SQLite library at runtime, but AOT has either trimmed it away or doesn't know how to bundle it properly.
Even worse, if you have other NuGet packages that use SQLite (like some ORM libraries or my mostlylucid.ephemeral.complete package), you can end up with multiple incompatible SQLite providers in your dependency tree.
Each provider tries to work differently:
The AOT compiler gets confused about which one to use, and often the result is that it includes none of them—or worse, includes conflicting files that can't work together.
The solution is SQLitePCLRaw.bundle_e_sqlite3—a special NuGet package designed specifically to work with AOT.
Add these two packages to your project:
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
</ItemGroup>
The bundle_e_sqlite3 package is different from normal SQLite providers:
1. It includes pre-compiled native SQLite libraries for every major platform:
2. These native libraries are packaged in a way the AOT compiler understands
The bundle marks its native dependencies explicitly so the AOT compiler knows to include them in the final binary. No dynamic loading, no runtime searching for files.
3. It's designed for cross-platform builds
One package works for all platforms. You don't need platform-specific packages or conditional references.
Remember the two problems we identified?
Problem 1: AOT's trimming removes libraries it can't see being used
Problem 2: AOT doesn't support dynamic loading
The result: when you build for Windows x64, the bundle includes e_sqlite3.dll. When you build for Linux ARM64, it includes the ARM64 libe_sqlite3.so. Everything just works.
Here's the part that trips up 90% of people trying to use SQLite with AOT, including me on my first attempt.
With normal .NET, the SQLite bundle automatically initializes itself the first time you use SQLite. Magic happens behind the scenes—you don't need to do anything.
With Native AOT, this automatic initialization doesn't work. The AOT compiler can't see the automatic startup code (it looks like unused code and gets trimmed), so you must manually initialize the bundle at the very start of your application.
Here's the magic line you need:
using SQLitePCL;
public class Program
{
public static void Main(string[] args)
{
// THIS IS CRITICAL: Initialize SQLite FIRST, before ANYTHING else
SQLitePCL.Batteries.Init();
// Now you can do normal application setup
var builder = WebApplication.CreateBuilder(args);
// This is now safe - SQLite is initialized
builder.Services.AddDbContext<MyDbContext>(options =>
options.UseSqlite("Data Source=app.db"));
var app = builder.Build();
app.Run();
}
}
Batteries.Init() Do?This method tells the SQLite bundle to:
Microsoft.Data.Sqlite and the native libraryIt's called "Batteries" because it's the "batteries included" bundle—everything you need is packaged together.
Put Batteries.Init() as the very first line in your Main method, before:
Think of it like plugging in a device before trying to turn it on. If you try to use SQLite before calling Init(), you'll still get the DllNotFoundException even though the DLL is correctly bundled in your application.
If you forget to call Batteries.Init(), your app will:
DllNotFoundException: Unable to load DLL 'e_sqlite3' or one of its dependencies
This is confusing because the DLL is bundled in your application—it's just not initialized.
Here's a full .csproj configured for multi-platform Native AOT with SQLite:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Native AOT -->
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Single File -->
<PublishSingleFile>true</PublishSingleFile>
<StripSymbols>true</StripSymbols>
<!-- Optimization -->
<OptimizationPreference>Speed</OptimizationPreference>
<!-- Multi-platform targets -->
<RuntimeIdentifiers>
win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64
</RuntimeIdentifiers>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
</ItemGroup>
</Project>
Let me break down what each of these settings does:
PublishAot=true
This is the master switch that enables Native AOT compilation. Without this, you get normal .NET behavior (JIT compilation). With this, you get ahead-of-time compiled native code.
PublishTrimmed=true and TrimMode=full
These tell the compiler to remove any code you're not using. Think of it like cleaning out your garage before moving—why pack things you don't need?
PublishTrimmed=true enables trimmingTrimMode=full means "be aggressive, remove everything you can"Warning: This can break code that uses heavy reflection (like some JSON serializers or ORMs) because the trimmer can't always see what you're using via reflection. We'll cover how to handle this later.
InvariantGlobalization=true
This removes all culture-specific data from your app—date formats, currency symbols, text sorting rules for different languages. Saves 5-10MB.
Only set this to true if your app:
If you're building a CLI tool or API gateway that doesn't care about localization, this is free savings. If you're building something that needs to format dates for French users or sort Turkish text correctly, skip this setting.
PublishSingleFile=true
Bundles everything into one executable file. Instead of having:
myapp.exe
myapp.dll
System.Text.Json.dll
... 50 more files
You get just:
myapp.exe
Much easier to deploy.
StripSymbols=true
Debug symbols help debuggers show you variable names and line numbers when debugging. They're useful during development but add several megabytes to your final binary.
This setting removes them. Your app runs exactly the same, just smaller.
OptimizationPreference=Speed
This tells the compiler what to prioritize when making decisions:
Speed: Make it fast (slightly larger binaries, but better performance)Size: Make it small (slightly slower, but minimal binary size)For most applications, Speed is the right choice. The size difference is usually only 2-3MB, but the performance difference can be noticeable.
RuntimeIdentifiers
This declares which platforms you want to support. It doesn't build them all—it just tells tooling "these are valid targets."
Available identifiers:
win-x64: Windows 64-bit (Intel/AMD)win-arm64: Windows ARM64 (Surface Pro X, etc.)linux-x64: Linux 64-bit (Ubuntu, Debian, RHEL, etc.)linux-arm64: Linux ARM64 (Raspberry Pi 4/5, AWS Graviton)osx-x64: macOS Intel (older Macs)osx-arm64: macOS Apple Silicon (M1/M2/M3 Macs)You build one platform at a time using dotnet publish -r linux-x64, for example.
Remember how I said AOT requires platform-specific builds? You need to compile separately for Windows, Linux x64, Linux ARM64, macOS Intel, and macOS Apple Silicon. Doing this manually would be tedious—but we can automate it.
If you're not familiar, GitHub Actions is a free CI/CD (Continuous Integration/Continuous Deployment) service built into GitHub. It lets you run automated tasks whenever you push code or create a release tag.
Think of it like having a build server that:
v1.0.0All of this runs on GitHub's servers—you don't need to maintain any infrastructure. For open-source projects and small personal projects, it's completely free.
Here's a GitHub Actions workflow that builds for all major platforms automatically:
name: Build Native AOT Binaries
on:
push:
tags:
- 'v*'
jobs:
build-binaries:
name: Build ${{ matrix.runtime }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
runtime: linux-x64
artifact-name: myapp-linux-x64
- os: ubuntu-latest
runtime: linux-arm64
artifact-name: myapp-linux-arm64
- os: windows-latest
runtime: win-x64
artifact-name: myapp-win-x64
- os: macos-latest
runtime: osx-x64
artifact-name: myapp-osx-x64
- os: macos-latest
runtime: osx-arm64
artifact-name: myapp-osx-arm64
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Install ARM64 tools (Linux ARM64 only)
if: matrix.runtime == 'linux-arm64'
run: |
sudo apt-get update
sudo apt-get install -y clang zlib1g-dev gcc-aarch64-linux-gnu
- name: Publish
shell: bash
run: |
# Set objcopy for ARM64 cross-compilation
if [ "${{ matrix.runtime }}" = "linux-arm64" ]; then
OBJCOPY_PARAM="-p:ObjCopyName=aarch64-linux-gnu-objcopy"
else
OBJCOPY_PARAM=""
fi
dotnet publish \
-c Release \
-r ${{ matrix.runtime }} \
--self-contained \
--output ./publish/${{ matrix.runtime }} \
-p:PublishAot=true \
-p:PublishTrimmed=true \
-p:StripSymbols=true \
$OBJCOPY_PARAM
- name: Create distribution package
shell: bash
run: |
mkdir -p ./dist
cd ./publish/${{ matrix.runtime }}
# Copy the main executable
cp myapp${{ matrix.file-ext }} ../../dist/
# CRITICAL: Copy native libraries (SQLite and any other native dependencies)
# PublishSingleFile bundles .NET code, but native DLLs remain separate
cp *.dll ../../dist/ 2>/dev/null || true
cp *.so ../../dist/ 2>/dev/null || true
cp *.dylib ../../dist/ 2>/dev/null || true
# Copy config files if needed
cp ../../appsettings.json ../../dist/ || true
cd ../../dist
# Create archive with all files
if [ "${{ runner.os }}" = "Windows" ]; then
7z a -tzip ../${{ matrix.artifact-name }}.zip *
else
tar czf ../${{ matrix.artifact-name }}.tar.gz *
fi
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact-name }}
path: |
${{ matrix.artifact-name }}.zip
${{ matrix.artifact-name }}.tar.gz
retention-days: 7
if-no-files-found: ignore
If the YAML looks intimidating, here's what it does in plain English:
1. Trigger (on: push: tags)
The workflow runs when you push a Git tag that starts with v (like v1.0.0, v2.3.1). This is the standard way to mark release versions.
2. Matrix Strategy
This is the clever part. Instead of writing five separate workflows, we define a matrix of builds:
os (runner machine) and runtime (target platform)So when you push v1.0.0, GitHub simultaneously:
3. Steps in Each Build
Each platform build does the same steps:
dotnet publish with AOT flags for that specific platform4. The Result
After all builds complete, you have five artifacts (binaries) ready to distribute. You can download them from the Actions run, or use a second job to create a GitHub Release automatically (not shown in this snippet, but easy to add).
Here's something that confused me for hours: PublishSingleFile=true only bundles .NET code. Native libraries like SQLite's e_sqlite3.dll stay separate.
This is why the "Create distribution package" step is so important:
# Copy native libraries - these are NOT included in the main executable
cp *.dll ../../dist/ 2>/dev/null || true # Windows
cp *.so ../../dist/ 2>/dev/null || true # Linux
cp *.dylib ../../dist/ 2>/dev/null || true # macOS
The 2>/dev/null || true part means "if there are no files matching this pattern, don't fail—just continue." This lets the same script work on all platforms.
What you distribute:
myapp.exe + e_sqlite3.dll (bundled together in a ZIP)myapp + libe_sqlite3.so (bundled together in a tar.gz)myapp + libe_sqlite3.dylib (bundled together in a tar.gz)Users extract the archive and run the executable. The native SQLite library sits next to the executable, and the bundle finds it automatically at runtime.
Notice the workflow_dispatch trigger at the top:
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to publish'
required: true
This lets you trigger builds manually from GitHub's web interface without pushing a tag. Useful for testing the workflow or creating beta builds.
The Linux ARM64 build requires special attention. You need cross-compilation tools and must specify the correct objcopy tool:
# Install tools
sudo apt-get install gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
# Build with objcopy specified
dotnet publish \
-r linux-arm64 \
-p:PublishAot=true \
-p:ObjCopyName=aarch64-linux-gnu-objcopy
Without the ObjCopyName parameter, the linker fails with cryptic errors about unrecognized file formats.
Here's what I achieved with my production YARP-based bot detection gateway with middleware and SQLite logging. This is a real project you can download from GitHub.
These are the actual file sizes from my GitHub releases, not theoretical estimates:
| Platform | Executable | SQLite Native DLL | Total Archive Size |
|---|---|---|---|
| Windows x64 | 9.2MB | +1.7MB | 10.9MB (ZIP) |
| Linux x64 | 10.8MB | +1.6MB | 12.4MB (tar.gz) |
| Linux ARM64 | 9.9MB | +1.5MB | 11.4MB (tar.gz) |
| macOS Intel | 11.2MB | +1.8MB | 13.0MB (tar.gz) |
| macOS ARM64 | 9.8MB | +1.7MB | 11.5MB (tar.gz) |
Compare this to self-contained .NET 10 deployments at 130-150MB per platform. We're talking about a 10-12x reduction in size.
The breakdown:
Both files must be distributed together, but they're still dramatically smaller than traditional .NET deployments.
Cold start (first request served) on a modest Linux VPS:
This matters hugely for:
Idle memory (gateway running, no traffic):
Under load (1000 requests/second):
Lower memory means:
Batteries.Init()This is the #1 mistake. Even with the bundle correctly referenced, forgetting to call SQLitePCL.Batteries.Init() at the very start of your Main method will cause runtime crashes.
The fix:
public static void Main(string[] args)
{
SQLitePCL.Batteries.Init(); // FIRST LINE
// ... rest of your code
}
This is mistake #2, and it got me twice. PublishSingleFile=true does NOT bundle native SQLite DLLs into your executable. You must distribute them alongside your executable.
What happens if you forget:
DllNotFoundExceptionThe fix:
When packaging your release, always include:
myapp.exe # Your main executable
e_sqlite3.dll # SQLite native library (Windows)
# or libe_sqlite3.so # SQLite native library (Linux)
# or libe_sqlite3.dylib # SQLite native library (macOS)
In your GitHub Actions or deployment scripts:
# Copy ALL native libraries from the publish directory
cp *.dll ./dist/ 2>/dev/null || true
cp *.so ./dist/ 2>/dev/null || true
cp *.dylib ./dist/ 2>/dev/null || true
Users should extract the archive and run the executable. The native library needs to be in the same directory.
winsqlite3 ProviderYou might see recommendations to use SQLitePCLRaw.provider.winsqlite3 on Windows to use the OS-provided SQLite. Don't. It only works on Windows, requires manual initialization, and breaks cross-platform builds. Stick with bundle_e_sqlite3.
If you're using Entity Framework Core with SQLite, you'll get trim warnings (IL2026, IL3050). These are usually safe to suppress for EF Core's SQLite provider, but test thoroughly:
<PropertyGroup>
<NoWarn>$(NoWarn);IL2026;IL3050</NoWarn>
</PropertyGroup>
Better yet, consider using Dapper or raw ADO.NET with SQLite for AOT applications—they're more AOT-friendly.
On Windows, Native AOT requires Visual Studio's MSVC linker. If you build outside a Developer Command Prompt, you'll get errors about vswhere.exe. GitHub Actions handles this automatically, but for local builds, use:
Or initialize the environment in your build script:
& "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\Launch-VsDevShell.ps1" -Arch amd64
dotnet publish -c Release -r win-x64
Native AOT is perfect for:
Avoid AOT for:
If you've read this whole post and just want a checklist to follow, here you go:
1. Add the packages:
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
2. Configure your .csproj for AOT:
<PublishAot>true</PublishAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishSingleFile>true</PublishSingleFile>
<StripSymbols>true</StripSymbols>
<OptimizationPreference>Speed</OptimizationPreference>
3. Initialize SQLite at the start of your Main method:
SQLitePCL.Batteries.Init();
4. Build for your target platform:
dotnet publish -c Release -r linux-x64 --self-contained
5. Test your binary—it should just run with no dependencies!
Getting SQLite working with Native AOT isn't obvious, but once you know the magic incantation—SQLitePCLRaw.bundle_e_sqlite3 + Batteries.Init()—it's straightforward. The payoff is substantial: tiny binaries, instant startup, and the ability to deploy to any platform without runtime dependencies.
For anyone building CLI tools, gateways, or edge applications with .NET, Native AOT with SQLite is now a realistic option. The GitHub Actions workflow provided here automates the entire multi-platform build process—just tag a release and you're done.
If you're new to AOT, start small: convert a simple CLI tool or utility first. Get comfortable with the build process, learn what warnings to expect, and understand the limitations. Once you've got the basics down, you can tackle more complex applications.
The .NET ecosystem is increasingly AOT-friendly. Most modern libraries either work out of the box or have clear documentation on AOT support. The future is native, and it's faster than you think.
I deploy my AOT-compiled gateway to:
The deployment process is identical everywhere:
No "install .NET Runtime" step. No dependency hell. No version conflicts. Just extract and run.
For Docker, my Dockerfile is embarrassingly simple:
FROM debian:bookworm-slim
# Copy just the two files we need
COPY minigw /app/minigw
COPY libe_sqlite3.so /app/libe_sqlite3.so
WORKDIR /app
RUN chmod +x minigw
EXPOSE 5000
ENTRYPOINT ["./minigw"]
The resulting image is ~130MB (mostly the base Debian image). A traditional .NET container would be 200-250MB.
Everything I've shown here comes from a real, production-ready project. You can:
The project is a minimal YARP reverse proxy with bot detection middleware that logs signatures to SQLite. It demonstrates:
Clone it, study it, use it as a template for your own AOT projects.
Now go build something tiny and fast.
© 2025 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.