When using ExecuteUpdateAsync to clear nullable properties inside a JSON/jsonb-mapped complex type, EF Core + Npgsql generate SQL that passes SQL NULL into jsonb_set / to_jsonb, instead of the JSON literal 'null'::jsonb.
That leads to failures such as:
42804: could not determine polymorphic type because input has type unknown
23502: null value in column "<jsonb column>" violates not-null constraint - because jsonb_set(..., NULL) returns SQL NULL and can wipe the entire JSON document
So atomic bulk updates cannot reliably null-clear JSON leaves or subtrees.
Where we hit it
We use ExecuteUpdateAsync for partial updates against a JSON-mapped configuration object on a PostgreSQL jsonb column - for example clearing optional scalar fields like rateLimitPerMinute / expiresAt, or nullable nested objects like basic / oauth.
Typical pattern:
await query.ExecuteUpdateAsync(setters => setters
.SetProperty(e => e.Configuration.RateLimitPerMinute, _ => (int?)null));
Environment
Npgsql.EntityFrameworkCore.PostgreSQL 10.0.1
- EF Core 10.x
- PostgreSQL 16
- JSON mapping via
ComplexProperty(...).ToJson(...)
Cause
The issue appears to be in Npgsql’s JSON partial-update translation, mainly:
NpgsqlQueryableMethodTranslatingExpressionVisitor.TrySerializeScalarToJson
NpgsqlQueryableMethodTranslatingExpressionVisitor.GenerateJsonPartialUpdateSetter
For null values, generated SQL looks like:
-- scalar clear
jsonb_set(configuration, '{rateLimitPerMinute}', to_jsonb(NULL::int))
-- structural clear (parameterised null)
jsonb_set(configuration, '{basic}', NULL)
In PostgreSQL, to_jsonb(NULL::int) is SQL NULL, not JSON null. Likewise, jsonb_set's third argument must be jsonb; SQL NULL there nulls the whole expression.
Expected shape for a null-clear is effectively:
jsonb_set(configuration, '{rateLimitPerMinute}', 'null'::jsonb)
Experimental Workaround
We replaced IQueryableMethodTranslatingExpressionVisitorFactory with a subclass of NpgsqlQueryableMethodTranslatingExpressionVisitor that, for null JSON update values:
- Emits
'null'::jsonb for null constants
- For parameters, casts to the target type and wraps with
COALESCE(..., 'null'::jsonb) so runtime null becomes JSON null, not SQL NULL
As a "proof of concept" this seems to work but it uses internal API's and is therefore nowhere near to a sustainable solution. It did, however, serve to illustrate the point and fix this particular problem.
DISCLAIMER: I couldn't seem to find anyone else with a similar issue and it is entirely possible that I have missed something obvious and have gone and boiled the ocean on this one 😄
When using
ExecuteUpdateAsyncto clear nullable properties inside a JSON/jsonb-mapped complex type, EF Core + Npgsql generate SQL that passes SQLNULLintojsonb_set/to_jsonb, instead of the JSON literal'null'::jsonb.That leads to failures such as:
42804: could not determine polymorphic type because input has type unknown23502: null value in column "<jsonb column>" violates not-null constraint- becausejsonb_set(..., NULL)returns SQL NULL and can wipe the entire JSON documentSo atomic bulk updates cannot reliably null-clear JSON leaves or subtrees.
Where we hit it
We use
ExecuteUpdateAsyncfor partial updates against a JSON-mapped configuration object on a PostgreSQLjsonbcolumn - for example clearing optional scalar fields likerateLimitPerMinute/expiresAt, or nullable nested objects likebasic/oauth.Typical pattern:
Environment
Npgsql.EntityFrameworkCore.PostgreSQL10.0.1ComplexProperty(...).ToJson(...)Cause
The issue appears to be in Npgsql’s JSON partial-update translation, mainly:
NpgsqlQueryableMethodTranslatingExpressionVisitor.TrySerializeScalarToJsonNpgsqlQueryableMethodTranslatingExpressionVisitor.GenerateJsonPartialUpdateSetterFor null values, generated SQL looks like:
In PostgreSQL,
to_jsonb(NULL::int)is SQL NULL, not JSON null. Likewise,jsonb_set's third argument must bejsonb; SQL NULL there nulls the whole expression.Expected shape for a null-clear is effectively:
Experimental Workaround
We replaced
IQueryableMethodTranslatingExpressionVisitorFactorywith a subclass ofNpgsqlQueryableMethodTranslatingExpressionVisitorthat, for null JSON update values:'null'::jsonbfor null constantsCOALESCE(..., 'null'::jsonb)so runtime null becomes JSON null, not SQL NULLAs a "proof of concept" this seems to work but it uses internal API's and is therefore nowhere near to a sustainable solution. It did, however, serve to illustrate the point and fix this particular problem.
DISCLAIMER: I couldn't seem to find anyone else with a similar issue and it is entirely possible that I have missed something obvious and have gone and boiled the ocean on this one 😄