Skip to content
Written with Claude

Changelog v3.16.0 (2026-05-20)

Version 3.16.0 (2026-05-20)

Full Changelog

Minor release fixing a long-standing class of bugs in the JSON-to-parameter parsers for the timestamp, timestamptz, time, and timetz PostgreSQL types: the parsers were silently shifting incoming values by the host process's UTC offset. Bumped to minor (not patch) because the corrected behavior changes how naive ISO strings (no Z, no offset) are interpreted on non-UTC hosts — see Breaking change below. The shift was invisible on UTC hosts (the default for mcr.microsoft.com/dotnet/aspnet and almost every Linux container) and only surfaced once the same image ran somewhere with TZ set to anything else — a Windows dev box, a Kubernetes pod with TZ overridden, or a non-UTC CI runner — at which point stored values diverged from the JSON the caller sent by the host's offset.

Fix: datetime parsers are now host-TZ-independent

TryParseTimestamp, TryParseTimestampTz, TryParseTime, and TryParseTimeTz in NpgsqlRest/ParameterParsers.cs all relied on the parameter-less DateTime.TryParse(value) overload. That overload's default DateTimeStyles.None converts offset-bearing strings to the host's local TZ and tags the result Kind=Local. The two *Tz parsers then called DateTime.SpecifyKind(v, DateTimeKind.Utc) on the local-shifted value — but SpecifyKind only relabels the kind, it does not convert. The result was a host-local wall-clock value labelled UTC, written to Postgres with a silent shift.

The timestamp and time parsers used the same buggy parse and sent the local-shifted value directly to Npgsql, which transmits the wall-clock verbatim for a without time zone column — the same silent shift, same size as the host's offset.

All four parsers now use:

csharp
csharp
DateTime.TryParse(
    value,
    CultureInfo.InvariantCulture,
    DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
    out var v)
  • AssumeUniversal treats naive ISO strings (no Z, no offset) as UTC — the canonical JSON-over-HTTP convention.
  • AdjustToUniversal converts any Z-bearing or offset-bearing value to UTC.

The result is a DateTime with Kind=Utc carrying the true UTC instant regardless of the host's TZ. The *Tz parsers use it directly. The without time zone parsers strip the kind back to Unspecified so Npgsql sends the UTC clock-time as the naive wall-clock value, matching the column semantics.

Why this was hidden so long

Almost every production container runs TZ=UTC by default. On a UTC host, DateTime.TryParse(...)'s local-conversion is a no-op and the SpecifyKind(Utc) "lie" coincidentally matches reality. The bug only manifests once the same code is deployed where TZ is anything else. The first symptom is usually a downstream report along the lines of "we send 2026-05-20T06:00:00Z and Postgres stored 08:00" — which is exactly the host's UTC offset.

The existing MultiParamsTests2 / MultiParamsQueryStringTests2 test pairs already hinted at this — both used Should().Match(t => t == "12:06:59..." || t == "11:06:59...") style assertions with a comment that read "integration server seems to have a different datetime alltogether". That was the bug, papered over. After this fix both tests assert single deterministic values.

TryParseDate left alone

DateOnly.TryParse rejects Z- and offset-bearing strings outright (verified across UTC, America/Los_Angeles, Europe/Zagreb, Pacific/Auckland) — it does not silently shift, so the date parser was not affected by the host-TZ bug class. It was however a separate papercut: callers sending full ISO timestamps (e.g. "2026-05-20T03:00:00Z") to a date column got a flat parse failure. TryParseDate now falls back to a DateTime parse and extracts the date portion when DateOnly.TryParse rejects the input, honoring the same JsonTimestampsAreUtc semantic as the other datetime parsers (UTC date when the flag is true, host-local date when false).

Breaking change

JSON timestamps are now interpreted as UTC:

  • Z-suffixed and offset-bearing ISO strings are converted to UTC.
  • Naive ISO strings (no offset, no Z) are assumed UTC rather than interpreted as host-local time.

Callers who relied on the previous "JSON is host-local" behavior — usually by accident, because the host happened to be UTC — will see no change. Callers who sent Z strings expecting UTC were silently broken on non-UTC hosts and are now correct.

Opt-out: NpgsqlRestOptions.JsonTimestampsAreUtc

Users whose downstream code genuinely depends on the legacy "naive timestamps are host-local" behavior — and who cannot update those callers to send Z-suffixed values — can restore the pre-3.16.0 behavior by setting JsonTimestampsAreUtc to false:

  • Library: new NpgsqlRestOptions { JsonTimestampsAreUtc = false, ... }.
  • Client (appsettings.json): "NpgsqlRest": { "JsonTimestampsAreUtc": false } (default is true).

When false, the four parsers fall back to the bare DateTime.TryParse(value) overload — Z/offset strings get host-local-converted and tagged Kind=Local, naive strings get parsed as Kind=Unspecified, and the *Tz parsers re-apply SpecifyKind(Utc) on top. That reproduces the exact pre-3.16.0 code path. Note that this is not recommended for new deployments: it puts you back in the bug class the rest of this release fixes. The flag exists purely as a compatibility escape hatch.

Tests

New file NpgsqlRestTests/HostTimeZoneIndependenceTests.cs covers all four parsers via echo functions and json_build_object round-trips:

  • timestamptz with Z suffix, with numeric offset, and naive (assumed UTC)
  • timestamp with Z suffix (stored as naive UTC clock-time)
  • timetz with Z suffix (round-trips as UTC)
  • time with Z suffix (UTC clock-time extracted)

Each assertion is exact — no host-TZ-tolerant ORs. The fixture forces the database to UTC at creation (alter database … set timezone to 'UTC'), so the assertions stay deterministic across runners. To verify host-TZ independence at the parser layer, run the suite under a non-UTC TZ env var (TZ=America/Los_Angeles dotnet test, for example) — the tests must still pass.

The two existing MultiParams* tests had their loose Should().Match(...) assertions for timestamptz and timetz replaced with single-value Should().Be(...) assertions, now that the parsers produce deterministic output.

Files touched

  • NpgsqlRest/ParameterParsers.cs — four parsers switched to AssumeUniversal | AdjustToUniversal.
  • NpgsqlRestTests/HostTimeZoneIndependenceTests.cs — new, six tests covering the four type variants.
  • NpgsqlRestTests/ParamTests/MultiParamsTests2.cs — tightened timestamptz / timetz assertions.
  • NpgsqlRestTests/ParamTests/MultiParamsQueryStringTests2.cs — same.

No config changes, no API surface changes.

Comments