Skip to content

ExecuteUpdateAsync JSON partial updates translate CLR null to SQL NULL instead of JSON null #3850

@equals03

Description

@equals03

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:

  1. Emits 'null'::jsonb for null constants
  2. 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 😄

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No fields configured for Bug.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions