Chapter 20 · SQLite Provider Deep Dive and Embedded-Database Constraints

SQLite Type Affinity vs CLR Types: Mappings, Conversions, Precision, and Unsupported Operations

Expose SQLite storage classes and type affinity beneath EF Core mappings so ServiceHub treats DateTimeOffset, decimal, TimeSpan, ulong, GUID, precision, and conversion choices as database semantics rather than portable CLR guarantees.

Advanced160–210 minutestype-affinity + conversion labEF Core 10.0.11 · SQLite provider 10.0.11 · Microsoft.Data.Sqlite 10.0.11 · .NET 10.0.11 · SDK 10.0.400Free local SQLite file · native engine version captured at runtimeProvider deep-dive reviewed: August 2026

Learning outcomes

01

Explain SQLite storage classes and column affinity without projecting SQL Server-style rigid typing onto the engine.

02

Connect Microsoft.Data.Sqlite CLR mappings to the actual INTEGER/REAL/TEXT/BLOB values stored in a SQLite file.

03

Handle DateTimeOffset, decimal, TimeSpan, ulong, and Guid deliberately when filtering, ordering, arithmetic, or interoperability matters.

04

Distinguish EF Core value converters from database constraints and quantify the precision/range tradeoff of each representation.

05

Inspect PRAGMA metadata, typeof(), generated SQL, and EF model metadata before claiming a mapping is correct.

06

Keep provider probes disposable so Chapter 01–19 ServiceHub migrations and domain semantics remain reproducible.

1. The problem: a C# type is not a SQLite storage contract

ServiceHub has deliberately used SQLite as its free local baseline, but earlier chapters also exposed several places where the provider cannot behave like SQL Server. The most visible example is WorkOrder.OpenedUtc : DateTimeOffset: EF can persist and read it, yet SQLite has no native DateTimeOffset type and the provider cannot treat every comparison/order operation as a normal database date operation. Chapter 20 makes those leaks the subject instead of hiding them.

Do not infer engine semantics from a migration type name

SQLite associates a storage class with each value and derives affinity from a declared column type. A declaration such as decimal(18,2) does not create the same rigid precision/scale contract as SQL Server. Length, precision, and scale text can appear in DDL without being enforced by the ordinary SQLite dynamic type system.

Layer Example What it controls
CLR/domain DateTimeOffset, decimal, Guid Application representation and invariants
EF model converter, comparer, column type/facets Provider mapping and change/query behavior
Microsoft.Data.Sqlite parameter/read conversions How .NET values become SQLite primitives
SQLite INTEGER / REAL / TEXT / BLOB / NULL values + affinity Storage, comparison, functions and indexes

2. Start by asking SQLite what it actually stored

For this chapter create a disposable database such as servicehub-sqlite-deepdive.db. The EF provider package is Microsoft.EntityFrameworkCore.Sqlite 10.0.11. Do not hard-code an SQLite native-engine version from the NuGet version: record the engine actually loaded at runtime with sqlite_version().

bash · pin the stable provider/toolchain
dotnet add src/ServiceHub.EfLab package Microsoft.EntityFrameworkCore.Sqlite --version 10.0.11dotnet add src/ServiceHub.EfLab package Microsoft.EntityFrameworkCore.Design --version 10.0.11dotnet tool update --local dotnet-ef --version 10.0.11dotnet --infodotnet ef --version
csharp · database facts worth printing at lab start
await using var connection = new SqliteConnection(    "Data Source=servicehub-sqlite-deepdive.db;Foreign Keys=True");await connection.OpenAsync(ct);await using var command = connection.CreateCommand();command.CommandText = """SELECT sqlite_version();PRAGMA journal_mode;PRAGMA foreign_keys;""";// Execute these as separate commands/readers in production code; the SQL is grouped// here only as a checklist of facts the lab must capture.

The native SQLite version, journal mode, and foreign-key setting are runtime facts. Record them beside EF/.NET package versions whenever a behavior depends on the engine rather than only on EF.

3. Microsoft.Data.Sqlite ultimately binds four primitive non-null types

The driver maps many .NET types to SQLite, but the resulting database values are still INTEGER, REAL, TEXT, or BLOB. The default mappings are intentionally readable and portable inside SQLite, not identical to server-engine native types.

.NET value Default SQLite representation Important consequence
bool, signed integers INTEGER Boolean is 0/1; integer range is signed 64-bit storage
double/float REAL IEEE-754 floating-point semantics
decimal TEXT Preserves decimal text representation; not a native numeric storage type
DateTime TEXT Sortable ISO-like text representation; provider translates many DateTime members
DateTimeOffset TEXT Read/write and equality are possible, but general ordering/comparison is a provider limitation
TimeSpan TEXT Useful for persistence, but not a native duration type
Guid TEXT Canonical GUID string by default; BLOB is an alternative driver representation
ulong INTEGER Values above signed 64-bit range overflow
sql · observe both declared type and stored value type
SELECT    name,    type,    "notnull",    dflt_value,    pkFROM pragma_table_info('sqlite_type_probes');SELECT    typeof(estimated_cost) AS cost_storage,    typeof(scheduled_at) AS time_storage,    typeof(correlation_id) AS guid_storage,    quote(estimated_cost) AS cost_literalFROM sqlite_type_probesLIMIT 5;

4. Use an isolated type-probe entity instead of mutating WorkOrder just to experiment

The course domain already has production-relevant mappings. Create one disposable probe table so type experiments do not silently become ServiceHub schema decisions.

csharp · disposable provider probe
public sealed class SqliteTypeProbe{    public int Id { get; set; }    public decimal EstimatedCost { get; set; }    public DateTimeOffset ScheduledAt { get; set; }    public TimeSpan SlaWindow { get; set; }    public ulong ExternalCounter { get; set; }    public Guid CorrelationId { get; set; }}modelBuilder.Entity<SqliteTypeProbe>(b =>{    b.ToTable("sqlite_type_probes");    b.HasKey(x => x.Id);});

After inserting deterministic edge values, inspect typeof(), quote(), generated parameters, and round-trip values. The goal is not “prove SQLite can store everything”; it is to identify which operations remain safe and which need a different database representation.

5. DateTimeOffset: persist if needed, but do not build SQLite queue ordering around it

The EF SQLite limitation documentation explicitly calls out DateTimeOffset. Equality can be supported, but ordinary comparison/ordering is not a native provider strength. This is why Chapter 04 added the UTC DateTime shadow property CreatedUtc for mandatory server-side ordering.

csharp · portable SQLite queue order from the established shadow property
var queue = await db.WorkOrders    .OrderBy(w => EF.Property<DateTime>(w, "CreatedUtc"))    .ThenBy(w => w.Id)    .Select(w => new    {        w.Id,        w.WorkOrderNumber,        CreatedUtc = EF.Property<DateTime>(w, "CreatedUtc")    })    .Take(50)    .ToListAsync(ct);
csharp · deliberately problematic SQLite shape
// Keep this as a translation test, not a production queue query.var unsupported = db.WorkOrders.OrderBy(w => w.OpenedUtc);Console.WriteLine(unsupported.ToQueryString());

Modern EF does not silently move a non-translatable ordering predicate to the client. Treat a translation failure as evidence that the representation/query shape is wrong for this provider. If instants are the domain concern, normalize to UTC DateTime or another deliberately ordered representation.

6. decimal: choose precision semantics before choosing storage

Microsoft.Data.Sqlite stores decimal as TEXT by default because REAL is lossy. EF Core 9+ also registers helper functions such as ef_compare, ef_add, ef_sum, and ef_avg so many decimal expressions can execute server-side. Those helpers improve EF translation; they do not create a native SQLite fixed-precision numeric type.

Domain need Possible representation Tradeoff
Exact arbitrary decimal text round-trip default decimal→TEXT Readable/exact text; verify sorting/index/function behavior
Fixed-scale money scaled long minor units Exact and index-friendly if scale/range is a domain invariant
Scientific/approximate value double/REAL Fast native numeric operations; accepts binary floating-point error
csharp · fixed-scale money converter only when the invariant is truly cents
var money = new ValueConverter<decimal, long>(    value => checked((long)decimal.Round(value * 100m, 0,        MidpointRounding.ToEven)),    stored => stored / 100m);builder.Property(x => x.EstimatedCost)    .HasConversion(money);
A converter is a schema decision

Changing from decimal TEXT to scaled INTEGER changes query semantics, migration data conversion, range, interoperability and indexes. Review the migration and backfill rather than calling this a “performance tweak.”

7. TimeSpan and ulong need equally explicit range/query decisions

TimeSpan defaults to TEXT and ulong is bound through INTEGER even though SQLite integer storage is signed. If the application must order or perform arithmetic on durations, storing ticks as long can be clearer. If a domain counter may exceed long.MaxValue, do not pretend SQLite INTEGER can hold every ulong.

csharp · duration-as-ticks mapping for a duration field
builder.Property(x => x.SlaWindow)    .HasConversion(        value => value.Ticks,        stored => TimeSpan.FromTicks(stored));

For unsigned counters, either enforce a signed-64-bit range, use another representation with explicit ordering rules, or choose a database whose numeric type matches the domain. The right answer is driven by operations and constraints, not by whether SaveChanges succeeds for a sample value.

8. Facets such as MaxLength and precision do not become rigid SQLite enforcement automatically

EF model facets still matter for metadata, migrations, validation libraries, and portability. But ordinary SQLite does not enforce declared string length or decimal precision/scale the way SQL Server does. If the database itself must reject invalid values, add an actual CHECK constraint or use another suitable engine feature and test it.

csharp · database-level length invariant
builder.ToTable("work_orders", table =>{    table.HasCheckConstraint(        "CK_work_orders_customer_name_length",        "length(customer_name) BETWEEN 1 AND 160");});

Adding the check constraint itself becomes a SQLite migration/rebuild concern, which is the subject of Lesson 2.

9. Failure case: declaring a numeric-looking type and assuming SQLite will enforce it

A developer sees decimal(18,2) in a migration and assumes the database will reject an arbitrary text value. A manual integration/import path inserts incompatible data, and later EF queries behave unpredictably because the row does not match the application assumption.

sql · diagnostic SQL for suspicious rows
SELECT id,       estimated_cost,       typeof(estimated_cost)FROM sqlite_type_probesWHERE typeof(estimated_cost) <> 'text';

Repair: define the actual database invariant. If the column must have numeric behavior, choose an appropriate representation and add constraints/tests. Validate every non-EF ingestion path as well; an ORM mapping cannot police arbitrary SQL written by other tools.

10. Mandatory lab: map, store, query, and inspect type evidence

  1. Copy the disposable ServiceHub SQLite lab file before experimenting; do not use the main course database.
  2. Create SqliteTypeProbe with decimal, DateTimeOffset, TimeSpan, ulong and Guid values near meaningful boundaries.
  3. Record sqlite_version(), PRAGMA table_info, typeof(), and quote() results.
  4. Call ToQueryString() for equality, range, arithmetic and ordering expressions. Record which translate and the helper functions that appear.
  5. Keep WorkOrder.OpenedUtc as DateTimeOffset, but prove the established CreatedUtc : DateTime shadow property is the SQLite server-ordering path.
  6. Implement one alternate representation (scaled integer money or ticks duration) in the probe model, create/review the migration, and compare SQL/index behavior.
  7. Run PRAGMA integrity_check, verify row counts, and delete the disposable database afterward.

11. Production judgment and bridge

SQLite is not “weakly typed EF Core”; it is a distinct database with a dynamic type system and provider-specific translations. Keep the convenient CLR model only where its database representation supports the required predicates, ordering, precision and interoperability. Prefer explicit UTC instants, scaled integers, constraints or provider boundaries where they make domain semantics more honest. Lesson 2 now shows what happens when those mapping decisions evolve and SQLite cannot perform the required ALTER operation directly.

Check your understanding

  1. What are SQLite’s four primitive non-null storage types exposed by Microsoft.Data.Sqlite?
  2. Why is DateTimeOffset a risky SQLite ordering key?
  3. Does decimal-to-TEXT mean EF Core 10 can never perform decimal math server-side?
  4. Why does HasPrecision not guarantee SQLite will reject excess scale?
  5. What is a safe fixed-scale representation for money when the domain is explicitly minor units?
  6. What should accompany every type-mapping claim?
Review the answers

1. INTEGER, REAL, TEXT and BLOB; NULL is a separate storage class for null values.

2. SQLite has no native DateTimeOffset type and the EF provider does not support every comparison/ordering operation over its default representation.

3. No. The provider registers ef_* helper functions for many decimal operations, but the underlying storage still is not a native fixed-precision numeric type.

4. Ordinary SQLite type declarations/facets are not rigid server-style precision constraints; use real CHECK/domain constraints when required.

5. A checked scaled signed integer such as cents can be appropriate if scale and range are fixed business invariants.

6. EF model metadata, generated SQL/parameters, SQLite typeof/PRAGMA evidence, edge-value tests, and the actual native SQLite version.

Authoritative references

SQLite type behavior spans EF, Microsoft.Data.Sqlite and the engine itself; keep all three layers visible.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n