Table of Contents

Value converters and JSON columns

A value converter maps a property between its CLR model type and the representation actually stored in the column. Typical uses are an enum stored as text, a DateTimeOffset stored in a formatted string, a strong-typed value (Money) stored as a decimal, or an arbitrary object stored as JSON.

Declaring a converter

Derive from ValueConverter<TModel, TProvider> and override the two typed conversions. The engine applies them automatically on both the read and the write path, and caches a typed invoker so value types are not boxed per row.

public enum Status { Unknown, Active, Closed }

public sealed class StatusConverter : ValueConverter<Status, string>
{
    public override string? ConvertToProvider(Status model) => model.ToString();
    public override Status ConvertFromProvider(string? provider) => Enum.Parse<Status>(provider!);
}

Attach it with the attribute:

[SqlTable("orders")]
public sealed class Order
{
    public long Id { get; set; }

    [ValueConverter(typeof(StatusConverter))]
    public Status Status { get; set; }
}

or fluently:

ctx.From<Order>(b => b
    .Table("orders")
    .Property(x => x.Status).HasConversion(new StatusConverter()));

HasConversion<TModel, TProvider> accepts either a ValueConverter<TModel, TProvider> instance or a pair of conversion expressions:

b.Property(x => x.Status).HasConversion<Status, string>(
    status => status.ToString(),
    text => Enum.Parse<Status>(text));

Built-in enum converter

The enum-as-string case is common enough to ship as a converter: EnumToStringConverter<TEnum> stores the enum name (Enum.ToString()) and parses it back with Enum.Parse<TEnum>, so it replaces the hand-written StatusConverter above. Attach it with either form:

[ValueConverter(typeof(EnumToStringConverter<Status>))]
public Status Status { get; set; }
b.Property(x => x.Status).HasConversion(new EnumToStringConverter<Status>());

It also works for a nullable property (Status?): SQL NULL still maps to null without calling the converter. Write a custom converter when you need a different provider representation (for example the enum's number) or a case-insensitive parse.

Implementing the interface directly

ValueConverter<TModel, TProvider> implements IPropertyValueConverter, and that interface is what the engine actually consumes. Deriving from the base class is the normal path, but you can implement IPropertyValueConverter directly when the model type is not known at compile time — for example a converter built from runtime metadata — or when one converter instance is shared across several properties of different CLR types:

public sealed class InvariantStringConverter : IPropertyValueConverter
{
    public Type ProviderType => typeof(string);

    public bool ConvertsNulls => false;

    public object? ConvertToProvider(object? model)
        => model is null ? null : Convert.ToString(model, CultureInfo.InvariantCulture);

    public object? ConvertFromProvider(object? provider)
        => provider;
}

ProviderType is the representation the column stores: the engine uses it to select the typed reader accessor and to type the bound write parameter. ConvertsNulls keeps the same meaning as on the base class (see Null handling).

A direct implementation is attached exactly like a typed one, through either form:

ctx.From<Order>(b => b
    .Table("orders")
    .Property(x => x.Payload).HasConversion(new InvariantStringConverter()));
[ValueConverter(typeof(InvariantStringConverter))]
public object? Payload { get; set; }

Two things you give up relative to the base class: values are boxed on every row (there is no cached typed invoker), and the mapping cannot verify that the converter's model type matches the property, because that check reads the ValueConverter<TModel, TProvider> base. A mismatch then surfaces as a cast error on first execution instead of at mapping-build time.

JSON columns

[JsonColumn] stores any serializable CLR object as JSON. The storage is chosen by the dialect:

  • JsonColumnStorage.Auto (default) uses the provider's native JSON representation where the provider has one (PostgreSQL jsonb, ClickHouse object-rooted JSON, SQL Server's json on a context declaring major version 17+ or an Azure/Fabric SQL database engine edition) and a text column otherwise;
  • JsonColumnStorage.Native requires that native representation — not merely a server-side JSON column — and throws a dialect-named NotSupportedException where the provider has none (MySQL, MariaDB, SQLite) or where the declared state does not reach it (SQL Server without a major-version-17 declaration and without a capable engine edition, including the default unversioned construction);
  • JsonColumnStorage.Text forces a text column.

The native provider representation is JsonElement on PostgreSQL, JsonObject on ClickHouse and the plain CLR string on SQL Server 2025+ (json columns are transported as text there: parameters stay nvarchar, no DOM value is bound and nothing is serialized twice). ClickHouse's native JSON is object-rooted, so a model that does not serialize to a JSON object throws NotSupportedException — use Text for array/scalar roots. The legacy ClickHouse Object('json') alias is not supported by this integration; see JSON support.

A JSON column type is not a native representation. MySQL 8 has a real json column and MariaDB's JSON is a LONGTEXT alias with a json_valid() CHECK, but MySqlConnector cannot bind JsonElement/JsonDocument/JsonNode parameters (MySqlDbType.JSON accepts strings only) and always reads a json column back as string. Both engines are therefore supported through the text storage — Auto and Text both produce the JSON text the server parses — and Native is refused before any command runs. SQL Server is the mirror case: 2016–2022 have no native type and keep JSON in nvarchar, while 2025+ do have a distinct json type — reached only when the context declares a major version 17+ or a capable Azure/Fabric engine edition, and transported as text through the same converter, so Auto, Text and Native store identical bytes. A native json column accepts object/array documents only; the scalar roots and the JSON null document are rejected by the server (error 13609) and need Text storage.

public sealed class Address
{
    public string? City { get; set; }
    public List<string> Lines { get; set; } = [];
    public DateTimeOffset UpdatedAt { get; set; }
}

[SqlTable("customers")]
public sealed class Customer
{
    public long Id { get; set; }

    [JsonColumn]
    public Address Address { get; set; } = new();
}

The fluent form adds serializer options:

b.Property(x => x.Address).JsonColumn(o =>
{
    o.Storage = JsonColumnStorage.Text;
    o.Options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
});

The options object is held by reference and must not be mutated after the mapping is built. Source generated JsonTypeInfo<T> (Native AOT/trimming) is out of scope; use reflection-based serialization. See NativeAOT (partial support) for the verified surface.

Inserting a row serializes the mapped object into the document stored in the column. Members such as DateTimeOffset need no converter — System.Text.Json writes them as ISO 8601:

ctx.CreateInsertBuilder<Customer>()
    .Values(new Customer
    {
        Id = 1,
        Address = new Address
        {
            City = "Berlin",
            Lines = ["a", "b"],
            UpdatedAt = new DateTimeOffset(2026, 9, 25, 12, 34, 56, TimeSpan.Zero),
        },
    })
    .Insert();
insert into customers (id, address) values (@p0, @p1)

@p1 carries the serialized document (a JsonElement on PostgreSQL jsonb, a JsonObject on ClickHouse JSON, a string on a text column):

{"City":"Berlin","Lines":["a","b"],"UpdatedAt":"2026-09-25T12:34:56+00:00"}

Property names follow the serializer options; the default is the CLR name (City), so set JsonNamingPolicy.CamelCase if you need camelCase. A JSON value in a SET list is converted the same way.

Reading is the reverse: the column is planned like any other and the document is deserialized back on the client.

var customer = ctx.From<Customer>().Where(x => x.Id == 1).ToList().Single();
// customer.Address.City      == "Berlin"
// customer.Address.Lines     == ["a", "b"]
// customer.Address.UpdatedAt == 2026-09-25T12:34:56+00:00
select id, address
 from customers
 where id = 1

On the client the engine reads the column as the provider value (a JsonElement on PostgreSQL jsonb, a JsonObject on ClickHouse JSON, a string on a text column) and runs the converter's ConvertFromProvider, i.e. JsonElement.Deserialize<Address>(), JsonObject.Deserialize<Address>() or JsonSerializer.Deserialize<Address>(text). System.Text.Json reconstructs the graph and turns "UpdatedAt" from ISO 8601 back into a DateTimeOffset — again with no converter or manual parsing.

JSON in queries

A JSON-mapped property is planned like any other converted column, so it also works in projections and comparisons. A scalar projection materializes the model, not the provider value:

var addresses = ctx.From<Customer>().Select(x => x.Address).ToList();

A constant compared against a JSON column is serialized to its provider representation before it is bound, so the predicate runs against the stored document (document equality on a native jsonb/JSON column, text equality on a text column):

var berlin = new Address { City = "Berlin", Lines = [], UpdatedAt = DateTimeOffset.UnixEpoch };
ctx.From<Customer>().Where(x => x.Address == berlin).ToList();
select id, address
 from customers
 where address = @p0

@p0 carries the serialized Address (a JsonElement on PostgreSQL jsonb, a JsonObject on ClickHouse JSON, the JSON string on a text column). The same conversion applies inside an anonymous projection (Select(x => new { x.Address })). Comparison is by the serialized representation, so it matches when the stored document equals the serialized constant — the serializer options must therefore be stable.

Provider matrix

The converter runs in the application, so it is provider independent; only the provider representation must be readable and bindable.

Provider Native JSON Text storage Enum-as-string
PostgreSQL jsonb (read/written as JsonElement) text text
SQL Server json on 2025+ with a declared major version 17+ or an Azure/Fabric SQL database engine edition (transported as string; object/array roots only), otherwise — nvarchar(max) nvarchar
MySQL / MariaDB — (real json column on MySQL 8 / JSON = LONGTEXT + json_valid() on MariaDB, both reached through text) longtext longtext
SQLite — (TEXT) TEXT TEXT
ClickHouse JSON (read/written as JsonObject, object-rooted) String String
In-memory CLR value CLR value CLR value

Null handling

By default SQL NULL maps to a null model value and the converter is not called. Override ConvertsNulls to true to pass NULL through the converter instead. The policy lives on the converter instance, so it is the single source of truth.

Queries and generated SQL

The converter never changes the shape of the statement: it only converts values on the way in and out, so the column is planned like any other. The examples below use the Order entity from the top of the guide and show PostgreSQL output; other dialects differ only in keyword casing and identifier quoting (see the provider matrix).

Writing. An insert sends the provider representation as a bound parameter:

ctx.CreateInsertBuilder<Order>()
    .Values(new Order { Id = 1, Status = Status.Active })
    .Insert();
insert into orders (id, status) values (@p0, @p1)

@p1 carries the converted value 'Active' (the enum's string form), not the enum number. A constant in a SET list is converted the same way:

ctx.CreateUpdateBuilder<Order>()
    .Set(x => x.Status, Status.Closed)
    .Where(x => x.Id == 1)
    .Update();
update orders set status = @p0 where id = 1

Reading. The engine reads the column as the provider representation and runs ConvertFromProvider in the application:

var orders = ctx.From<Order>()
    .Where(x => x.Id == 1)
    .ToList();
select id, status
 from orders
 where id = 1

status is read as text and converted to Status per row.

Filtering. A constant compared against a converted column is converted as well, so the predicate binds the stored representation:

ctx.From<Order>().Where(x => x.Status == Status.Active).ToList();
select id, status
 from orders
 where status = @p0

@p0 carries 'Active'. A captured variable behaves the same, and a value list is converted element by element into an in list:

var state = Status.Active;
ctx.From<Order>().Where(x => new[] { state, Status.Closed }.Contains(x.Status)).ToList();
select id, status
 from orders
 where status in (@p0, @p1)

The converter is applied only to the value side of the comparison; an operand that references another column keeps its normal SQL, so (int)x.Status == x.Id + 1 is not converted. A compound value is converted as a whole rather than leaf by leaf, so Where(x => x.Code == a + b) binds a single parameter holding ConvertToProvider(a + b) (the same for a captured a ?? b or conditional, and for string concatenation). In a case/switch used as the value side, only the branch values are converted while the test keeps its normal SQL.

Projection. A scalar projection of a converted property materializes the model value, not the provider value:

ctx.From<Order>().Where(x => x.Id == 1).Select(x => x.Status).ToList();
select status
 from orders
 where id = 1

The result is [Status.Active] — the column is read as text and converted back per row, exactly like an entity read. An anonymous or member-init projection (Select(x => new { x.Status })) carries the same conversion.

In-memory context

The in-memory provider stores CLR objects as-is: it does not have a separate provider representation, so converters are not applied there (the read is identity). This is the documented, consistent semantics.

Limitations

  • A JSON column stores a single object graph; navigation properties and cycles are not supported.
  • [JsonColumn] cannot be combined with [ValueConverter] on the same property.