diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml
index 52550e0c..d1ef781f 100644
--- a/.github/workflows/build-test-publish.yml
+++ b/.github/workflows/build-test-publish.yml
@@ -8,7 +8,7 @@ on:
workflow_dispatch:
env:
- RELEASE_VERSION: v3.15.2
+ RELEASE_VERSION: v3.16.0
# Add workflow-level permissions
permissions:
diff --git a/NpgsqlRest/NpgsqlRest.csproj b/NpgsqlRest/NpgsqlRest.csproj
index 31db29a7..533cf80c 100644
--- a/NpgsqlRest/NpgsqlRest.csproj
+++ b/NpgsqlRest/NpgsqlRest.csproj
@@ -29,10 +29,10 @@
true
README.MD
bin\$(Configuration)\$(AssemblyName).xml
- 3.15.0
- 3.15.0
- 3.15.0
- 3.15.0
+ 3.16.0
+ 3.16.0
+ 3.16.0
+ 3.16.0
true
@@ -41,7 +41,7 @@
-
+
diff --git a/NpgsqlRest/NpgsqlRestBuilder.cs b/NpgsqlRest/NpgsqlRestBuilder.cs
index 8ec6b9bd..a7b87d30 100644
--- a/NpgsqlRest/NpgsqlRestBuilder.cs
+++ b/NpgsqlRest/NpgsqlRestBuilder.cs
@@ -40,6 +40,7 @@ public static IApplicationBuilder UseNpgsqlRest(this WebApplication builder, Npg
}
Options = options;
+ ParameterParsers.JsonTimestampsAreUtc = options.JsonTimestampsAreUtc;
var factory = builder.Services.GetRequiredService();
Logger = factory.CreateLogger(options.LoggerName ?? typeof(NpgsqlRestBuilder).Namespace ?? "NpgsqlRest");
diff --git a/NpgsqlRest/Options/NpgsqlRestOptions.cs b/NpgsqlRest/Options/NpgsqlRestOptions.cs
index 7b2e812c..4c98d280 100644
--- a/NpgsqlRest/Options/NpgsqlRestOptions.cs
+++ b/NpgsqlRest/Options/NpgsqlRestOptions.cs
@@ -262,6 +262,17 @@ public NpgsqlRestOptions(NpgsqlDataSource dataSource)
///
public bool WrapInTransaction { get; set; } = false;
+ ///
+ /// Controls how JSON datetime strings are interpreted when bound to `timestamp`, `timestamptz`, `time`, or `timetz`
+ /// parameters. When true (the default, since 3.16.0), `Z`- and offset-bearing strings are converted to UTC and naive
+ /// strings (no offset, no `Z`) are assumed UTC — making stored values identical regardless of the host process's
+ /// `TZ` environment. When false, the legacy pre-3.16.0 behavior is restored: `DateTime.TryParse` interprets the
+ /// string in the host's local time zone, which silently shifts stored values by the host's UTC offset on non-UTC
+ /// hosts. Only set to false if you have callers that depend on the host-local interpretation of naive datetime
+ /// strings and you cannot update them to send `Z`-suffixed values.
+ ///
+ public bool JsonTimestampsAreUtc { get; set; } = true;
+
///
/// SQL commands executed after any context is set but before the main routine call. They run in the same batch as the
/// context `set_config` calls, so no extra round-trip. Each command can be either a raw SQL string (no parameters) or
diff --git a/NpgsqlRest/ParameterParsers.cs b/NpgsqlRest/ParameterParsers.cs
index e62540db..2d7b352d 100644
--- a/NpgsqlRest/ParameterParsers.cs
+++ b/NpgsqlRest/ParameterParsers.cs
@@ -151,9 +151,34 @@ private static bool TryParseBoolean(string? value, out object? result)
return false;
}
+ // AssumeUniversal: naive ISO strings (no offset, no Z) are treated as UTC -
+ // the canonical JSON-over-HTTP convention. AdjustToUniversal: Z- and offset-
+ // bearing values are converted to UTC. Together they yield Kind=Utc holding
+ // the true UTC instant regardless of the host's TZ. Without these styles,
+ // DateTime.TryParse silently shifts to host-local time and tags Kind=Local.
+ private const DateTimeStyles UtcStyles =
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal;
+
+ ///
+ /// Process-wide toggle wired from . When true (default), the
+ /// datetime parsers convert any input to UTC and assume naive strings are UTC. When false, the legacy pre-3.16.0
+ /// host-local-interpretation behavior is restored.
+ ///
+ public static bool JsonTimestampsAreUtc = true;
+
private static bool TryParseTimestamp(string? value, out object? result)
{
- if (DateTime.TryParse(value, out var v))
+ if (JsonTimestampsAreUtc)
+ {
+ if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v))
+ {
+ // Strip Kind so Npgsql sends the UTC clock-time verbatim as the
+ // naive wall-clock value for `timestamp without time zone`.
+ result = DateTime.SpecifyKind(v, DateTimeKind.Unspecified);
+ return true;
+ }
+ }
+ else if (DateTime.TryParse(value, out var v))
{
result = v;
return true;
@@ -164,7 +189,15 @@ private static bool TryParseTimestamp(string? value, out object? result)
private static bool TryParseTimestampTz(string? value, out object? result)
{
- if (DateTime.TryParse(value, out var v))
+ if (JsonTimestampsAreUtc)
+ {
+ if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v))
+ {
+ result = v;
+ return true;
+ }
+ }
+ else if (DateTime.TryParse(value, out var v))
{
result = DateTime.SpecifyKind(v, DateTimeKind.Utc);
return true;
@@ -175,18 +208,50 @@ private static bool TryParseTimestampTz(string? value, out object? result)
private static bool TryParseDate(string? value, out object? result)
{
+ // Fast path: bare date or naive date-time string. DateOnly.TryParse handles
+ // "2026-05-20" and "2026-05-20T06:00:00" but rejects any Z- or offset-bearing
+ // input outright.
if (DateOnly.TryParse(value, out var v))
{
result = v;
return true;
}
+
+ // Fallback for Z- and offset-bearing strings (e.g. "2026-05-20T03:00:00Z"). The
+ // legacy parser returned false here, forcing callers to strip the time portion
+ // client-side. The fallback parses as DateTime and extracts the date portion.
+ // The UTC-vs-host-local semantic mirrors JsonTimestampsAreUtc so that the
+ // chosen date is deterministic on UTC hosts (true) or matches the host's
+ // calendar day (false).
+ if (JsonTimestampsAreUtc)
+ {
+ if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var dt))
+ {
+ result = DateOnly.FromDateTime(dt);
+ return true;
+ }
+ }
+ else if (DateTime.TryParse(value, out var dt))
+ {
+ result = DateOnly.FromDateTime(dt);
+ return true;
+ }
+
result = null;
return false;
}
private static bool TryParseTime(string? value, out object? result)
{
- if (DateTime.TryParse(value, out var v))
+ if (JsonTimestampsAreUtc)
+ {
+ if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v))
+ {
+ result = TimeOnly.FromDateTime(v);
+ return true;
+ }
+ }
+ else if (DateTime.TryParse(value, out var v))
{
result = TimeOnly.FromDateTime(v);
return true;
@@ -197,7 +262,15 @@ private static bool TryParseTime(string? value, out object? result)
private static bool TryParseTimeTz(string? value, out object? result)
{
- if (DateTime.TryParse(value, out var v))
+ if (JsonTimestampsAreUtc)
+ {
+ if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v))
+ {
+ result = new DateTimeOffset(v, TimeSpan.Zero);
+ return true;
+ }
+ }
+ else if (DateTime.TryParse(value, out var v))
{
result = new DateTimeOffset(DateTime.SpecifyKind(v, DateTimeKind.Utc));
return true;
diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs
index 66b59809..af70ebfa 100644
--- a/NpgsqlRestClient/ConfigDefaults.cs
+++ b/NpgsqlRestClient/ConfigDefaults.cs
@@ -760,6 +760,7 @@ private static JsonObject GetNpgsqlRestDefaults()
["RequestHeadersContextKey"] = "request.headers",
["RequestHeadersParameterName"] = "_headers",
["WrapInTransaction"] = false,
+ ["JsonTimestampsAreUtc"] = true,
["BeforeRoutineCommands"] = new JsonArray(
new JsonObject
{
diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs
index 6ce1b824..15866fe2 100644
--- a/NpgsqlRestClient/ConfigSchemaGenerator.cs
+++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs
@@ -323,6 +323,7 @@ public static partial class ConfigSchemaGenerator
["NpgsqlRest:RequestHeadersContextKey"] = "Name of the context variable that will receive the request headers when RequestHeadersMode is set to Context.",
["NpgsqlRest:RequestHeadersParameterName"] = "Sets a parameter name that will receive a request headers JSON when the `Parameter` value is used in `RequestHeadersMode` options. A parameter with this name must exist, must be one of the JSON or text types and must have the default value defined. This option for individual endpoints can be changed with the `EndpointCreated` function callback, or by using comment annotations.",
["NpgsqlRest:WrapInTransaction"] = "When true, **every request** is wrapped in an explicit BEGIN/COMMIT, and all `set_config` calls switch to the transaction-local form (`is_local=true`).\n**Required when using a connection pooler in transaction mode** (PgBouncer transaction-pool, AWS RDS Proxy in transaction mode, Supabase Pooler) — without this, the backend can be reused across unrelated requests, allowing GUC state (and the routine call itself) to leak or split mid-request.\nDefault is false to preserve existing behavior; safe to leave off when using Npgsql's native pool only.",
+ ["NpgsqlRest:JsonTimestampsAreUtc"] = "Controls how JSON datetime strings are interpreted when bound to `timestamp`, `timestamptz`, `time`, or `timetz` parameters.\nWhen true (default since 3.16.0), `Z`- and offset-bearing strings are converted to UTC and naive strings (no offset, no `Z`) are assumed UTC — making stored values identical regardless of the host process's `TZ` environment.\nWhen false, the legacy pre-3.16.0 behavior is restored: `DateTime.TryParse` interprets the string in the host's local time zone, which silently shifts stored values by the host's UTC offset on non-UTC hosts. Only set to false if you have callers that depend on the host-local interpretation of naive datetime strings and you cannot update them to send `Z`-suffixed values.",
["NpgsqlRest:BeforeRoutineCommands"] = "SQL commands executed after any context is set but before the main routine call. They run in the same batch as the context `set_config` calls (no extra round-trip). Each array entry is either:\n- A raw SQL string (always runs, no parameters), or\n- An object with `Enabled`, `Sql`, and optional `Parameters`. Object entries are gated by `Enabled` (default false) — set `\"Enabled\": true` to activate.\nEach parameter has a `Source` (`Claim`, `RequestHeader`, or `IpAddress`) and an optional `Name` (claim type or header name; ignored for IpAddress). Values are bound at request time via parameterized SQL.\nCommon use case: setting `search_path` from a tenant claim for multi-tenant deployments. Combine with `WrapInTransaction = true` for transaction-local scoping.",
["NpgsqlRest:BeforeRoutineCommands:Enabled"] = "When false (default), this command is ignored at startup. Set to true to register the command. Logged at startup whether enabled or skipped.",
["NpgsqlRest:BeforeRoutineCommands:Sql"] = "The SQL text to execute. May contain positional parameters ($1, $2, ...) bound from the Parameters list at request time.",
diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs
index 6b55812d..3861ccdd 100644
--- a/NpgsqlRestClient/ConfigTemplate.cs
+++ b/NpgsqlRestClient/ConfigTemplate.cs
@@ -1940,6 +1940,15 @@ public static partial class ConfigSchemaGenerator
//
"WrapInTransaction": false,
//
+ // Controls how JSON datetime strings are interpreted when bound to timestamp / timestamptz / time / timetz parameters.
+ // When true (default since 3.16.0), Z- and offset-bearing strings are converted to UTC, and naive strings (no offset, no Z)
+ // are assumed UTC. Stored values are then identical regardless of the host process's TZ environment.
+ // When false, the legacy pre-3.16.0 behavior is restored: DateTime.TryParse interprets the string in the host's local time
+ // zone, which silently shifts stored values by the host's UTC offset on non-UTC hosts. Only set to false if you have callers
+ // that depend on host-local interpretation of naive datetime strings and you cannot update them to send Z-suffixed values.
+ //
+ "JsonTimestampsAreUtc": true,
+ //
// SQL commands executed after any context is set but before the main routine call. Run in the same batch as the
// context `set_config` calls (no extra round-trip). Each entry can be either:
// - A raw SQL string (always runs, no parameters), or
diff --git a/NpgsqlRestClient/NpgsqlRestClient.csproj b/NpgsqlRestClient/NpgsqlRestClient.csproj
index 6d1f1a01..ef6ac5c3 100644
--- a/NpgsqlRestClient/NpgsqlRestClient.csproj
+++ b/NpgsqlRestClient/NpgsqlRestClient.csproj
@@ -10,19 +10,19 @@
true
true
full
- 3.15.2
+ 3.16.0
-
+
-
-
+
+
-
+
diff --git a/NpgsqlRestClient/Program.cs b/NpgsqlRestClient/Program.cs
index fc723091..0d141a06 100644
--- a/NpgsqlRestClient/Program.cs
+++ b/NpgsqlRestClient/Program.cs
@@ -455,6 +455,7 @@
RequestHeadersContextKey = config.GetConfigStr("RequestHeadersContextKey", config.NpgsqlRestCfg) ?? "request.headers",
RequestHeadersParameterName = config.GetConfigStr("RequestHeadersParameterName", config.NpgsqlRestCfg) ?? "_headers",
WrapInTransaction = config.GetConfigBool("WrapInTransaction", config.NpgsqlRestCfg, false),
+ JsonTimestampsAreUtc = config.GetConfigBool("JsonTimestampsAreUtc", config.NpgsqlRestCfg, true),
BeforeRoutineCommands = builder.BuildBeforeRoutineCommands(),
EndpointCreated = appInstance.CreateEndpointCreatedHandler(authCfg),
diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json
index 4a0343ab..d222ecf8 100644
--- a/NpgsqlRestClient/appsettings.json
+++ b/NpgsqlRestClient/appsettings.json
@@ -1931,6 +1931,15 @@
//
"WrapInTransaction": false,
//
+ // Controls how JSON datetime strings are interpreted when bound to timestamp / timestamptz / time / timetz parameters.
+ // When true (default since 3.16.0), Z- and offset-bearing strings are converted to UTC, and naive strings (no offset, no Z)
+ // are assumed UTC. Stored values are then identical regardless of the host process's TZ environment.
+ // When false, the legacy pre-3.16.0 behavior is restored: DateTime.TryParse interprets the string in the host's local time
+ // zone, which silently shifts stored values by the host's UTC offset on non-UTC hosts. Only set to false if you have callers
+ // that depend on host-local interpretation of naive datetime strings and you cannot update them to send Z-suffixed values.
+ //
+ "JsonTimestampsAreUtc": true,
+ //
// SQL commands executed after any context is set but before the main routine call. Run in the same batch as the
// context `set_config` calls (no extra round-trip). Each entry can be either:
// - A raw SQL string (always runs, no parameters), or
diff --git a/NpgsqlRestTests/HostTimeZoneIndependenceTests.cs b/NpgsqlRestTests/HostTimeZoneIndependenceTests.cs
new file mode 100644
index 00000000..85599b94
--- /dev/null
+++ b/NpgsqlRestTests/HostTimeZoneIndependenceTests.cs
@@ -0,0 +1,151 @@
+#pragma warning disable CS8602 // Dereference of a possibly null reference.
+namespace NpgsqlRestTests;
+
+public static partial class Database
+{
+ public static void HostTimeZoneIndependenceTests()
+ {
+ script.Append(@"
+create function host_tz_echo_timestamp(_ts timestamp)
+returns json
+language sql
+as $$ select json_build_object('v', _ts) $$;
+
+create function host_tz_echo_timestamptz(_ts timestamptz)
+returns json
+language sql
+as $$ select json_build_object('v', _ts) $$;
+
+create function host_tz_echo_time(_t time)
+returns json
+language sql
+as $$ select json_build_object('v', _t) $$;
+
+create function host_tz_echo_timetz(_t timetz)
+returns json
+language sql
+as $$ select json_build_object('v', _t) $$;
+
+create function host_tz_echo_date(_d date)
+returns json
+language sql
+as $$ select json_build_object('v', _d) $$;
+");
+ }
+}
+
+[Collection("TestFixture")]
+public class HostTimeZoneIndependenceTests(TestFixture test)
+{
+ private static async Task EchoAsync(HttpClient client, string endpoint, string body)
+ {
+ using var content = new StringContent(body, Encoding.UTF8, "application/json");
+ using var response = await client.PostAsync(endpoint, content);
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var raw = await response.Content.ReadAsStringAsync();
+ var node = JsonNode.Parse(raw);
+ return node["v"].GetValue();
+ }
+
+ [Fact]
+ public async Task TimestampTz_ZSuffix_RoundTripsAsUtc()
+ {
+ var v = await EchoAsync(test.Client, "/api/host-tz-echo-timestamptz/",
+ """{"ts":"2026-05-20T06:00:00Z"}""");
+ v.Should().Be("2026-05-20T06:00:00+00:00");
+ }
+
+ [Fact]
+ public async Task TimestampTz_NumericOffset_ConvertedToUtc()
+ {
+ // 06:00+02:00 == 04:00 UTC
+ var v = await EchoAsync(test.Client, "/api/host-tz-echo-timestamptz/",
+ """{"ts":"2026-05-20T06:00:00+02:00"}""");
+ v.Should().Be("2026-05-20T04:00:00+00:00");
+ }
+
+ [Fact]
+ public async Task TimestampTz_NaiveIso_AssumedUtc()
+ {
+ // No offset, no Z. AssumeUniversal treats as UTC, not host-local.
+ var v = await EchoAsync(test.Client, "/api/host-tz-echo-timestamptz/",
+ """{"ts":"2026-05-20T06:00:00"}""");
+ v.Should().Be("2026-05-20T06:00:00+00:00");
+ }
+
+ [Fact]
+ public async Task Timestamp_ZSuffix_StoredAsUtcClockTimeNaively()
+ {
+ // `timestamp without time zone` has no offset semantics. Convention here:
+ // the digits inside the Z-marked string become the naive wall-clock value.
+ var v = await EchoAsync(test.Client, "/api/host-tz-echo-timestamp/",
+ """{"ts":"2026-05-20T06:00:00Z"}""");
+ v.Should().Be("2026-05-20T06:00:00");
+ }
+
+ [Fact]
+ public async Task TimeTz_ZSuffix_RoundTripsAsUtc()
+ {
+ var v = await EchoAsync(test.Client, "/api/host-tz-echo-timetz/",
+ """{"t":"06:00:00Z"}""");
+ v.Should().Be("06:00:00+00");
+ }
+
+ [Fact]
+ public async Task Time_ZSuffix_StoredAsUtcClockTime()
+ {
+ var v = await EchoAsync(test.Client, "/api/host-tz-echo-time/",
+ """{"t":"06:00:00Z"}""");
+ v.Should().Be("06:00:00");
+ }
+
+ // The legacy `TryParseDate` rejected Z- and offset-bearing strings outright. The
+ // 3.16.0 enhancement falls back to a UTC DateTime parse and extracts the date
+ // portion, matching the JsonTimestampsAreUtc semantic.
+ [Fact]
+ public async Task Date_BareDateString_StillRoundTrips()
+ {
+ var v = await EchoAsync(test.Client, "/api/host-tz-echo-date/",
+ """{"d":"2026-05-20"}""");
+ v.Should().Be("2026-05-20");
+ }
+
+ [Fact]
+ public async Task Date_NaiveDateTimeString_TakesDatePortion()
+ {
+ var v = await EchoAsync(test.Client, "/api/host-tz-echo-date/",
+ """{"d":"2026-05-20T06:00:00"}""");
+ v.Should().Be("2026-05-20");
+ }
+
+ [Fact]
+ public async Task Date_ZSuffix_TakesUtcDate()
+ {
+ // 2026-05-20T03:00:00Z is still May 20 in UTC.
+ var v = await EchoAsync(test.Client, "/api/host-tz-echo-date/",
+ """{"d":"2026-05-20T03:00:00Z"}""");
+ v.Should().Be("2026-05-20");
+ }
+
+ [Fact]
+ public async Task Date_NumericOffset_ConvertsToUtcDateFirst()
+ {
+ // 2026-05-21T01:30:00+02:00 == 2026-05-20T23:30:00 UTC → date is 2026-05-20.
+ // On a non-UTC host the legacy mode would shift this differently; here we
+ // assert the UTC interpretation that the default mode produces.
+ var v = await EchoAsync(test.Client, "/api/host-tz-echo-date/",
+ """{"d":"2026-05-21T01:30:00+02:00"}""");
+ v.Should().Be("2026-05-20");
+ }
+
+ [Fact]
+ public async Task Date_LateUtcEvening_DoesNotRollToNextLocalDay()
+ {
+ // 2026-05-20T23:00:00Z is May 20 in UTC. The legacy `DateOnly.TryParse`
+ // would have rejected this entirely; the new fallback resolves it as
+ // 2026-05-20 regardless of the host's local TZ.
+ var v = await EchoAsync(test.Client, "/api/host-tz-echo-date/",
+ """{"d":"2026-05-20T23:00:00Z"}""");
+ v.Should().Be("2026-05-20");
+ }
+}
diff --git a/NpgsqlRestTests/JsonTimestampsAreUtcOptOutTests.cs b/NpgsqlRestTests/JsonTimestampsAreUtcOptOutTests.cs
new file mode 100644
index 00000000..5ddd70f9
--- /dev/null
+++ b/NpgsqlRestTests/JsonTimestampsAreUtcOptOutTests.cs
@@ -0,0 +1,124 @@
+#pragma warning disable CS8602 // Dereference of a possibly null reference.
+using NpgsqlTypes;
+
+namespace NpgsqlRestTests;
+
+// Unit-level coverage for the `JsonTimestampsAreUtc` opt-out toggle on ParameterParsers.
+// Visible behavioral differences between the two modes only manifest on non-UTC hosts
+// (on a UTC host, the host-local-conversion of legacy mode is a no-op and the wire-level
+// output is identical to the new mode). These tests therefore verify the *plumbing*: the
+// flag is settable, restores correctly, and the parsers continue to produce valid results
+// in both modes. The genuine non-UTC behavioral coverage belongs in a CI matrix step that
+// re-runs the suite under `TZ=America/Los_Angeles` (or similar) — there, the legacy mode
+// produces a host-offset-shifted value while the default mode produces the correct UTC
+// instant.
+// [Collection("TestFixture")] serializes against the integration-test collection so the
+// static `ParameterParsers.JsonTimestampsAreUtc` flag mutations below cannot race with
+// HTTP tests that exercise the parsers in another collection.
+[Collection("TestFixture")]
+public class JsonTimestampsAreUtcOptOutTests
+{
+ [Fact]
+ public void FlagDefaultsToTrue()
+ {
+ ParameterParsers.JsonTimestampsAreUtc.Should().BeTrue();
+ }
+
+ [Fact]
+ public void FlagOff_TimestampTz_NaiveString_ParsesSuccessfully()
+ {
+ var parser = ParameterParsers.GetParser(NpgsqlDbType.TimestampTz);
+ parser.Should().NotBeNull();
+
+ var previous = ParameterParsers.JsonTimestampsAreUtc;
+ try
+ {
+ ParameterParsers.JsonTimestampsAreUtc = false;
+ var ok = parser!("2026-05-20T06:00:00", out var v);
+ ok.Should().BeTrue();
+ v.Should().BeOfType();
+ ((DateTime)v!).Kind.Should().Be(DateTimeKind.Utc);
+ }
+ finally
+ {
+ ParameterParsers.JsonTimestampsAreUtc = previous;
+ }
+ }
+
+ [Fact]
+ public void FlagOff_Timestamp_NaiveString_ReturnsUnspecifiedKind()
+ {
+ var parser = ParameterParsers.GetParser(NpgsqlDbType.Timestamp);
+
+ var previous = ParameterParsers.JsonTimestampsAreUtc;
+ try
+ {
+ ParameterParsers.JsonTimestampsAreUtc = false;
+ var ok = parser!("2026-05-20T06:00:00", out var v);
+ ok.Should().BeTrue();
+ ((DateTime)v!).Kind.Should().Be(DateTimeKind.Unspecified);
+ }
+ finally
+ {
+ ParameterParsers.JsonTimestampsAreUtc = previous;
+ }
+ }
+
+ [Fact]
+ public void FlagOff_TimeTz_NaiveString_ReturnsDateTimeOffset()
+ {
+ var parser = ParameterParsers.GetParser(NpgsqlDbType.TimeTz);
+
+ var previous = ParameterParsers.JsonTimestampsAreUtc;
+ try
+ {
+ ParameterParsers.JsonTimestampsAreUtc = false;
+ var ok = parser!("06:00:00", out var v);
+ ok.Should().BeTrue();
+ v.Should().BeOfType();
+ }
+ finally
+ {
+ ParameterParsers.JsonTimestampsAreUtc = previous;
+ }
+ }
+
+ [Fact]
+ public void FlagOff_Time_NaiveString_ReturnsTimeOnly()
+ {
+ var parser = ParameterParsers.GetParser(NpgsqlDbType.Time);
+
+ var previous = ParameterParsers.JsonTimestampsAreUtc;
+ try
+ {
+ ParameterParsers.JsonTimestampsAreUtc = false;
+ var ok = parser!("06:00:00", out var v);
+ ok.Should().BeTrue();
+ v.Should().BeOfType();
+ ((TimeOnly)v!).Hour.Should().Be(6);
+ }
+ finally
+ {
+ ParameterParsers.JsonTimestampsAreUtc = previous;
+ }
+ }
+
+ [Fact]
+ public void FlagOff_InvalidInput_ReturnsFalse()
+ {
+ var parser = ParameterParsers.GetParser(NpgsqlDbType.TimestampTz);
+
+ var previous = ParameterParsers.JsonTimestampsAreUtc;
+ try
+ {
+ ParameterParsers.JsonTimestampsAreUtc = false;
+ var ok = parser!("not-a-date", out var v);
+ ok.Should().BeFalse();
+ v.Should().BeNull();
+ }
+ finally
+ {
+ ParameterParsers.JsonTimestampsAreUtc = previous;
+ }
+ }
+}
diff --git a/NpgsqlRestTests/NpgsqlRestTests.csproj b/NpgsqlRestTests/NpgsqlRestTests.csproj
index e1aa68c8..cd723e71 100644
--- a/NpgsqlRestTests/NpgsqlRestTests.csproj
+++ b/NpgsqlRestTests/NpgsqlRestTests.csproj
@@ -15,16 +15,16 @@
-
-
-
-
+
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
diff --git a/NpgsqlRestTests/ParamTests/MultiParamsQueryStringTests2.cs b/NpgsqlRestTests/ParamTests/MultiParamsQueryStringTests2.cs
index 974d6b5e..e33c4066 100644
--- a/NpgsqlRestTests/ParamTests/MultiParamsQueryStringTests2.cs
+++ b/NpgsqlRestTests/ParamTests/MultiParamsQueryStringTests2.cs
@@ -90,17 +90,10 @@ public async Task Test_case_get_multi_params2()
node["double"].ToJsonString().Should().Be("2.2");
node["jsonpath"].GetValue().Should().Be("$.\"user\".\"addresses\"[0].\"city\"");
node["timestamp"].GetValue().Should().Be("2024-01-12T11:59:17.811872");
-
- // integration server seems to have a different datetime alltogether
- node["timestamptz"].GetValue().Should()
- .Match(t => t == "2024-01-12T12:06:59.334476+00:00" || t == "2024-01-12T11:06:59.334476+00:00");
-
+ node["timestamptz"].GetValue().Should().Be("2024-01-12T11:06:59.334476+00:00");
node["date"].GetValue().Should().Be("2024-01-12");
node["time"].GetValue().Should().Be("12:07:26.933545");
-
- // integration server seems to have a different datetime alltogether
- node["timetz"].GetValue().Should()
- .Match(t => t == "12:07:44.422546+00" || t == "11:07:44.422546+00" || t == "13:07:44.422546+00");
+ node["timetz"].GetValue().Should().Be("11:07:44.422546+00");
node["interval"].GetValue().Should().Be("03:20:00");
node["bool"].ToJsonString().Should().Be("true");
@@ -146,17 +139,10 @@ public async Task Test_case_get_multi_params2_reverse_order()
node["double"].ToJsonString().Should().Be("2.2");
node["jsonpath"].GetValue().Should().Be("$.\"user\".\"addresses\"[0].\"city\"");
node["timestamp"].GetValue().Should().Be("2024-01-12T11:59:17.811872");
-
- // integration server seems to have a different datetime alltogether
- node["timestamptz"].GetValue().Should()
- .Match(t => t == "2024-01-12T12:06:59.334476+00:00" || t == "2024-01-12T11:06:59.334476+00:00");
-
+ node["timestamptz"].GetValue().Should().Be("2024-01-12T11:06:59.334476+00:00");
node["date"].GetValue().Should().Be("2024-01-12");
node["time"].GetValue().Should().Be("12:07:26.933545");
-
- // integration server seems to have a different datetime alltogether
- node["timetz"].GetValue().Should()
- .Match(t => t == "12:07:44.422546+00" || t == "11:07:44.422546+00" || t == "13:07:44.422546+00");
+ node["timetz"].GetValue().Should().Be("11:07:44.422546+00");
node["interval"].GetValue().Should().Be("03:20:00");
node["bool"].ToJsonString().Should().Be("true");
diff --git a/NpgsqlRestTests/ParamTests/MultiParamsTests2.cs b/NpgsqlRestTests/ParamTests/MultiParamsTests2.cs
index baace59a..61626462 100644
--- a/NpgsqlRestTests/ParamTests/MultiParamsTests2.cs
+++ b/NpgsqlRestTests/ParamTests/MultiParamsTests2.cs
@@ -91,15 +91,10 @@ public async Task Test_case_multi_params2()
node["double"].ToJsonString().Should().Be("2.2");
node["jsonpath"].GetValue().Should().Be("$.\"user\".\"addresses\"[0].\"city\"");
node["timestamp"].GetValue().Should().Be("2024-01-12T11:59:17.811872");
- // integration server seems to have a different datetime alltogether
- node["timestamptz"].GetValue().Should()
- .Match(t => t == "2024-01-12T12:06:59.334476+00:00" || t == "2024-01-12T11:06:59.334476+00:00");
+ node["timestamptz"].GetValue().Should().Be("2024-01-12T11:06:59.334476+00:00");
node["date"].GetValue().Should().Be("2024-01-12");
node["time"].GetValue().Should().Be("12:07:26.933545");
-
- // integration server seems to have a different datetime alltogether
- node["timetz"].GetValue().Should()
- .Match(t => t == "12:07:44.422546+00" || t == "11:07:44.422546+00" || t == "13:07:44.422546+00");
+ node["timetz"].GetValue().Should().Be("11:07:44.422546+00");
node["interval"].GetValue().Should().Be("03:20:00");
node["bool"].ToJsonString().Should().Be("true");
@@ -146,15 +141,10 @@ public async Task Test_case_multi_params2_reverse_order()
node["double"].ToJsonString().Should().Be("2.2");
node["jsonpath"].GetValue().Should().Be("$.\"user\".\"addresses\"[0].\"city\"");
node["timestamp"].GetValue().Should().Be("2024-01-12T11:59:17.811872");
- // integration server seems to have a different datetime alltogether
- node["timestamptz"].GetValue().Should()
- .Match(t => t == "2024-01-12T12:06:59.334476+00:00" || t == "2024-01-12T11:06:59.334476+00:00");
+ node["timestamptz"].GetValue().Should().Be("2024-01-12T11:06:59.334476+00:00");
node["date"].GetValue().Should().Be("2024-01-12");
node["time"].GetValue().Should().Be("12:07:26.933545");
-
- // integration server seems to have a different datetime alltogether
- node["timetz"].GetValue().Should()
- .Match(t => t == "12:07:44.422546+00" || t == "11:07:44.422546+00" || t == "13:07:44.422546+00");
+ node["timetz"].GetValue().Should().Be("11:07:44.422546+00");
node["interval"].GetValue().Should().Be("03:20:00");
node["bool"].ToJsonString().Should().Be("true");
diff --git a/changelog/v3.16.0.md b/changelog/v3.16.0.md
new file mode 100644
index 00000000..625a9b2c
--- /dev/null
+++ b/changelog/v3.16.0.md
@@ -0,0 +1,78 @@
+# Changelog v3.16.0 (2026-05-20)
+
+## Version [3.16.0](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.16.0) (2026-05-20)
+
+[Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.15.2...3.16.0)
+
+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
+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.
diff --git a/npm/package.json b/npm/package.json
index 173e5595..dad0b104 100644
--- a/npm/package.json
+++ b/npm/package.json
@@ -1,6 +1,6 @@
{
"name": "npgsqlrest",
- "version": "3.15.2",
+ "version": "3.16.0",
"description": "Automatic REST API for PostgreSQL Databases Client Build",
"scripts": {
"postinstall": "node postinstall.js",
diff --git a/npm/postinstall.js b/npm/postinstall.js
index 099638a0..89e0ed9d 100644
--- a/npm/postinstall.js
+++ b/npm/postinstall.js
@@ -5,7 +5,7 @@ const path = require("path");
const os = require("os");
const https = require("https");
-const downloadFrom = "https://github.com/NpgsqlRest/NpgsqlRest/releases/download/v3.15.2/";
+const downloadFrom = "https://github.com/NpgsqlRest/NpgsqlRest/releases/download/v3.16.0/";
// Download binary next to this script, not to ../.bin/
const binDir = path.join(__dirname, "bin");
diff --git a/plugins/NpgsqlRest.CrudSource/NpgsqlRest.CrudSource.csproj b/plugins/NpgsqlRest.CrudSource/NpgsqlRest.CrudSource.csproj
index f1fca289..c25f6c67 100644
--- a/plugins/NpgsqlRest.CrudSource/NpgsqlRest.CrudSource.csproj
+++ b/plugins/NpgsqlRest.CrudSource/NpgsqlRest.CrudSource.csproj
@@ -40,7 +40,7 @@
-
+
diff --git a/plugins/NpgsqlRest.HttpFiles/NpgsqlRest.HttpFiles.csproj b/plugins/NpgsqlRest.HttpFiles/NpgsqlRest.HttpFiles.csproj
index 815eebe9..953ad6b6 100644
--- a/plugins/NpgsqlRest.HttpFiles/NpgsqlRest.HttpFiles.csproj
+++ b/plugins/NpgsqlRest.HttpFiles/NpgsqlRest.HttpFiles.csproj
@@ -40,7 +40,7 @@
-
+
diff --git a/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj b/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj
index 3d1e7004..52957486 100644
--- a/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj
+++ b/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj
@@ -40,7 +40,7 @@
-
+
diff --git a/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj b/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj
index e8f6ea10..7b6d7cb3 100644
--- a/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj
+++ b/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj
@@ -38,7 +38,7 @@
-
+
diff --git a/plugins/NpgsqlRest.TsClient/NpgsqlRest.TsClient.csproj b/plugins/NpgsqlRest.TsClient/NpgsqlRest.TsClient.csproj
index 2652eee3..8b7d95bb 100644
--- a/plugins/NpgsqlRest.TsClient/NpgsqlRest.TsClient.csproj
+++ b/plugins/NpgsqlRest.TsClient/NpgsqlRest.TsClient.csproj
@@ -38,7 +38,7 @@
-
+