-
Notifications
You must be signed in to change notification settings - Fork 2
JSON and JSONB
JSON support is built into every SQLite-provider package. You get two things: type converters that store .NET objects as JSON inside a SQLite column, and method translators that let you call SQLite's built-in JSON functions from LINQ queries. The translators are registered automatically when you build the options.
When you have a .NET type that does not map to a simple SQLite column, you can serialize it to JSON and store the result in the database. The framework provides two converters for this.
SQLiteJsonConverter<T> serializes the value to a JSON string and stores it in a TEXT column. It is the simplest option and works with any SQLite tooling that can read text.
SQLiteJsonbConverter<T> stores the value in a BLOB column using SQLite's built-in jsonb() and json() functions. JSONB is more compact than text and lets SQLite parse it without scanning for quotes or escape sequences, which can make JSON function calls faster.
Platform compatibility. JSONB support was added in SQLite 3.45.0. As of Android 15 (API level 35) the bundled SQLite is 3.44.3, so JSONB requires Android 16 (API level 36) or later. iOS 16 and earlier ship with SQLite 3.39.5 or older, so no listed iOS version includes JSONB support out of the box.
If you are targeting mobile devices and need JSONB, use
SQLite.Framework.BundledorSQLite.Framework.Cipherinstead of the defaultSQLite.Frameworkpackage. Both ship their own SQLite binary and can be updated independently of the OS. With those packages you can useSQLiteJsonbConverter<T>on any supported OS version. If you must use the default package on older devices, useSQLiteJsonConverter<T>for plain TEXT storage instead.When you use
SQLite.Framework(the OS-bundled flavor), the JSONB types carry[SupportedOSPlatform("android36.0")]so the .NET platform compatibility analyzer (CA1416) warns when you target a lower Android API level. In a MAUI or multi-targeted csproj, raise the minimum once your supported floor is high enough:<PropertyGroup> <SupportedOSPlatformVersion Condition="'$(TargetPlatformIdentifier)' == 'android'">36.0</SupportedOSPlatformVersion> </PropertyGroup>The
SQLite.Framework.BundledandSQLite.Framework.Cipherpackages do not carry the attribute and never trigger the warning.
Both converters take a JsonTypeInfo<T> from a source-generated JsonSerializerContext, which keeps them compatible with Native AOT and trimming.
Create a JsonSerializerContext that includes all types you want to store as JSON:
[JsonSerializable(typeof(Address))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(List<Address>))]
public partial class AppJsonContext : JsonSerializerContext;Register the converter on the SQLiteOptionsBuilder before building the options:
SQLiteOptions options = new SQLiteOptionsBuilder("app.db")
// TEXT column
.AddTypeConverter<Address>(new SQLiteJsonConverter<Address>(AppJsonContext.Default.Address))
// or for JSONB binary BLOB column
// .AddTypeConverter<Address>(new SQLiteJsonbConverter<Address>(AppJsonContext.Default.Address))
// collections work the same way
.AddTypeConverter<List<string>>(new SQLiteJsonConverter<List<string>>(AppJsonContext.Default.ListString))
.Build();
using var db = new SQLiteDatabase(options);When a JSON-mapped type holds another complex type, projecting that nested type directly normally requires its own AddTypeConverter registration. Pass your JsonSerializerContext to AddJsonContext and the framework registers a converter for every type declared in it.
SQLiteOptions options = new SQLiteOptionsBuilder("app.db")
.AddJsonContext(AppJsonContext.Default)
.Build();
// Address has no AddTypeConverter call, but the projection works:
List<Address> shipTos = await db.Table<Test>()
.Select(t => t.Order.ShipTo)
.ToListAsync();Both methods skip any type that already has a converter, so anything you registered through AddTypeConverter is left alone, including the root types declared in the context.
After that, any model with an Address property is handled automatically:
public class Contact
{
[PrimaryKey, AutoIncrement]
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public Address HomeAddress { get; set; } = new();
}
public class Address
{
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
}Reading and writing work the same as any other column:
await db.Table<Contact>().AddAsync(new Contact
{
Name = "Alice",
HomeAddress = new Address { Street = "1 Main St", City = "Springfield" }
});
Contact alice = await db.Table<Contact>().FirstAsync(c => c.Name == "Alice");
Console.WriteLine(alice.HomeAddress.City); // SpringfieldSQLite has a set of built-in JSON functions such as json_extract, json_set, and json_valid. The framework exposes these through the SQLiteJsonFunctions static class.
| Method | SQL produced |
|---|---|
Extract<T>(json, path) |
json_extract(json, path) |
Set(json, path, value) |
json_set(json, path, value) |
Insert(json, path, value) |
json_insert(json, path, value) |
Replace(json, path, value) |
json_replace(json, path, value) |
Remove(json, path) |
json_remove(json, path) |
Type(json, path) |
json_type(json, path) |
Valid(json) |
json_valid(json) |
Patch(json, patch) |
json_patch(json, patch) |
ArrayLength(json) |
json_array_length(json) |
ArrayLength(json, path) |
json_array_length(json, path) |
Minify(json) |
json(json) |
ToJsonb(json) |
jsonb(json) |
ExtractJsonb<T>(json, path) |
jsonb_extract(json, path) |
These methods throw InvalidOperationException at runtime. They are only valid inside a LINQ expression tree, where they are translated to SQL before execution.
var errors = await db.Table<Log>()
.Where(l => SQLiteJsonFunctions.Extract<string>(l.Data, "$.level") == "error")
.ToListAsync();var levels = await db.Table<Log>()
.Select(l => SQLiteJsonFunctions.Extract<string>(l.Data, "$.level"))
.ToListAsync();var valid = await db.Table<Log>()
.Where(l => SQLiteJsonFunctions.Valid(l.Data))
.ToListAsync();When you store a List<T> or T[] as JSON, the framework also routes many standard LINQ, List<T>, and Array methods to SQL using json_each() and other SQLite JSON functions. Everything runs on the database, not in memory.
Scalar results (no predicate):
| Method | What it does |
|---|---|
Any() |
True if the array is not empty |
Count() |
Number of elements |
First() / FirstOrDefault()
|
First element |
Last() / LastOrDefault()
|
Last element |
Single() / SingleOrDefault()
|
The only element, or null if there is not exactly one |
ElementAt(i) |
Element at the given index |
Min() / Max()
|
Smallest or largest element |
Sum() / Average()
|
Sum or average of numeric elements |
Scalar results (with predicate):
| Method | What it does |
|---|---|
Any(x => ...) |
True if any element matches |
All(x => ...) |
True if every element matches |
Count(x => ...) |
Number of matching elements |
First(x => ...) / FirstOrDefault(x => ...)
|
First matching element |
Last(x => ...) / LastOrDefault(x => ...)
|
Last matching element |
Single(x => ...) / SingleOrDefault(x => ...)
|
The only matching element |
Aggregate with selector:
| Method | What it does |
|---|---|
Min(x => x.Prop) |
Smallest value of a property |
Max(x => x.Prop) |
Largest value of a property |
Sum(x => x.Prop) |
Sum of a numeric property |
Average(x => x.Prop) |
Average of a numeric property |
Collection results:
| Method | What it does |
|---|---|
Where(x => ...) |
Filter elements |
Select(x => ...) |
Project each element |
SelectMany(x => x.Items) |
Flatten nested collections |
OrderBy(x => ...) |
Sort ascending |
OrderByDescending(x => ...) |
Sort descending |
ThenBy(x => ...) |
Secondary sort ascending (after OrderBy) |
ThenByDescending(x => ...) |
Secondary sort descending (after OrderBy) |
GroupBy(x => ...) |
Group elements by a key |
Distinct() |
Remove duplicates |
Reverse() |
Reverse the order |
Skip(n) |
Skip the first n elements |
Take(n) |
Take the first n elements |
Concat(other) |
Combine two collections |
Union(other) |
Combine two collections, removing duplicates |
Intersect(other) |
Keep only elements that appear in both |
Except(other) |
Remove elements that appear in the other |
| Method | What it does |
|---|---|
Contains(item) |
True if the list contains the item |
IndexOf(item) |
Index of the first occurrence, or -1 |
LastIndexOf(item) |
Index of the last occurrence, or -1 |
GetRange(index, count) |
A sub-list starting at the given index |
Exists(x => ...) |
True if any element matches the predicate |
Find(x => ...) |
First element matching the predicate |
FindAll(x => ...) |
All elements matching the predicate |
FindIndex(x => ...) |
Index of the first match, or -1 |
FindLast(x => ...) |
Last element matching the predicate |
FindLastIndex(x => ...) |
Index of the last match, or -1 |
TrueForAll(x => ...) |
True if every element matches |
| Method | What it does |
|---|---|
Array.IndexOf(arr, item) |
Index of the first occurrence, or -1 |
Array.LastIndexOf(arr, item) |
Index of the last occurrence, or -1 |
Array.Exists(arr, x => ...) |
True if any element matches |
Array.Find(arr, x => ...) |
First matching element |
Array.FindAll(arr, x => ...) |
All matching elements |
Array.FindIndex(arr, x => ...) |
Index of the first match, or -1 |
Array.FindLast(arr, x => ...) |
Last matching element |
Array.FindLastIndex(arr, x => ...) |
Index of the last match, or -1 |
Array.TrueForAll(arr, x => ...) |
True if every element matches |
Array.ConvertAll(arr, x => ...) |
Project each element |
// simple list queries
bool hasTag = db.Table<Product>()
.Where(p => p.Tags.Contains("electronics"))
.Any();
int tagCount = db.Table<Product>()
.Select(p => p.Tags.Count())
.First();
// predicate on simple types
List<Product> filtered = db.Table<Product>()
.Where(p => p.Tags.Any(t => t.StartsWith("elec")))
.ToList();
// predicate on complex types
List<Order> orders = db.Table<Order>()
.Where(o => o.Items.Any(i => i.Price > 100 && i.Category == "Books"))
.ToList();
// nested property access works too
bool hasLocal = db.Table<Company>()
.Select(c => c.Offices.Any(o => o.Address.City == "Springfield"))
.First();
// aggregate with selector
decimal maxPrice = db.Table<Order>()
.Select(o => o.Items.Max(i => i.Price))
.First();
// collection results
List<string> sorted = db.Table<Product>()
.Select(p => p.Tags.OrderBy(t => t).Take(3))
.First();
// chaining works, methods are combined into a single SQL subquery
string firstSorted = db.Table<Product>()
.Select(p => p.Tags.OrderBy(t => t).First())
.First();
// secondary sorting with ThenBy
string result = db.Table<Order>()
.Select(o => o.Items
.OrderBy(i => i.Category)
.ThenByDescending(i => i.Price)
.First().Name)
.First();
// multiple chained operations become one query
int count = db.Table<Product>()
.Select(p => p.Tags
.Where(t => t.Length > 3)
.OrderBy(t => t)
.Count())
.First();
// flatten nested collections with SelectMany
List<string> allTags = db.Table<Company>()
.Select(c => c.Departments.SelectMany(d => d.Tags))
.First();
// group by and count
int distinctGroups = db.Table<Product>()
.Select(p => p.Tags.GroupBy(t => t).Count())
.First();When you access a property on a JSON-stored object, the framework translates it to json_extract. This works in Where, Select, OrderBy, and anywhere else you use a property:
// property access on a single JSON object
string city = db.Table<Contact>()
.Select(c => c.HomeAddress.City)
.First();
// SQL: SELECT json_extract(t0.HomeAddress, '$.City') ...
// property access on the result of a collection method
string street = db.Table<Order>()
.Select(o => o.Items.First(i => i.Price > 50).Name)
.First();When you chain two or more methods on a JSON collection, they are combined into a single SQL subquery instead of nesting multiple subqueries. For example, .Where(...).OrderBy(...).Take(n) produces one SELECT ... FROM json_each(...) WHERE ... ORDER BY ... LIMIT n query.
This also means that combinations like .Where(...).Count(), .OrderBy(...).ThenBy(...).First(), and .GroupBy(...).Count() all work and produce clean SQL.
These patterns are not translated to SQL and will either fall back to client-side evaluation or throw an error:
-
Predicate overloads with start index or count.
FindIndex(int startIndex, Predicate<T>)and similar overloads that take a start index are not supported. Only the single-predicate overloads work. -
OrderBy/OrderByDescendingas the final result in a Select. The C# return type isIOrderedEnumerable<T>, which cannot be deserialized back toList<T>. Chain another method after it instead, like.First()or.Take(n). -
List<T>.Reverse()in a Select. The C# compiler picks the void instance method over the LINQ extension. UseEnumerable.Reverse(list)with the static call syntax instead. -
Zip. This is not supported. -
GroupBywith result selector.GroupBy(x => x.Key, (key, group) => ...)with a result selector is not supported yet. You can useGroupBy(x => x.Key).Count()and similar aggregations. -
Predicate methods that return complex objects directly.
Find(x => ...)andFirst(x => ...)return the raw JSON value from the database. If you access a property on the result (like.Street), it works. If you try to return the whole object, you get the JSON string, not the deserialized object.
SQLiteJsonConverter<T> and SQLiteJsonbConverter<T> both use JsonTypeInfo<T> for serialization, so they are fully compatible with Native AOT and trimming. The framework keeps all public methods on SQLiteJsonFunctions and Enumerable rooted for the trimmer, so those methods are never removed from the output.
You do not need to do anything extra beyond providing a source-generated JsonSerializerContext as shown above.