{"record":{"id":"cf9d5d1fffb49b2a","repo":"windmill-labs/windmill","slug":"invalid-time-value","errorCode":null,"errorMessage":"Invalid time value","messagePattern":"Invalid time value","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"backend/windmill-worker/src/pg_executor.rs","lineNumber":2084,"sourceCode":"    fn accepts(ty: &Type) -> bool {\n        matches!(ty, &Type::INTERVAL)\n    }\n}\n\nstruct TimeTZStr(String);\nimpl<'a> FromSql<'a> for TimeTZStr {\n    fn from_sql(\n        _: &Type,\n        mut raw: &'a [u8],\n    ) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {\n        let microsecond = raw.get_i64();\n        let offset = raw.get_i32();\n        let utc_sec = (microsecond / 1_000_000) + offset as i64;\n        let utc = chrono::NaiveTime::from_num_seconds_from_midnight_opt(\n            ((utc_sec + 3600 * 24) % (3600 * 24)) as u32,\n            ((microsecond % 1_000_000) * 1_000) as u32,\n        )\n        .ok_or_else(|| anyhow::anyhow!(\"Invalid time value\"))?;\n        // ISO-8601: append `+00:00` since TIMETZ is normalised to UTC here.\n        Ok(TimeTZStr(format!(\"{}+00:00\", utc)))\n    }\n\n    fn accepts(ty: &Type) -> bool {\n        matches!(ty, &Type::TIMETZ)\n    }\n}\n\n/// Format a `NaiveDateTime` as ISO-8601 (`YYYY-MM-DDTHH:MM:SS[.fff…]`).\n/// chrono's default `to_string` uses a space separator, which is not parseable\n/// by `new Date(s)` in older JS engines or Python's `datetime.fromisoformat`\n/// before 3.11. Use the explicit format string so output is portable.\nfn format_naive_datetime_iso(dt: &chrono::NaiveDateTime) -> String {\n    if dt.and_utc().timestamp_subsec_nanos() == 0 {\n        dt.format(\"%Y-%m-%dT%H:%M:%S\").to_string()\n    } else {\n        dt.format(\"%Y-%m-%dT%H:%M:%S%.f\").to_string()","sourceCodeStart":2066,"sourceCodeEnd":2102,"githubUrl":"https://github.com/windmill-labs/windmill/blob/e474e8803ce2ff5c2df09a58dab51d45f5c922ca/backend/windmill-worker/src/pg_executor.rs#L2066-L2102","documentation":"PostgreSQL TIMETZ values are decoded manually: microseconds since midnight plus a UTC offset are normalized to a seconds-of-day count, then chrono::NaiveTime::from_num_seconds_from_midnight_opt builds the time. That constructor returns None when the seconds value exceeds 86399 or the nanosecond value exceeds 1_999_999_999, and the code maps that to \"Invalid time value\". This happens only for corrupt/out-of-range wire data, since the modulo (utc_sec + 3600*24) % (3600*24) normally keeps seconds in range.","triggerScenarios":"Decoding a TIMETZ column whose decoded microsecond field is negative or larger than a day's worth of microseconds in a way the normalization doesn't absorb (e.g. microsecond negative enough that (microsecond/1_000_000 + offset) before the modulo is < -86400), producing a negative seconds value that NaiveTime rejects.","commonSituations":"Extreme TIMETZ offsets combined with times near midnight; data written by non-Postgres tools or binary replication that encodes time-of-day unconventionally; a Postgres instance/extension emitting microsecond fields outside the documented range; decoding rows fetched by a Windmill PostgreSQL script.","solutions":["Inspect the offending value in Postgres (SELECT column, column::text FROM ... WHERE ...) and fix/normalize the stored TIMETZ.","Cast the column to text in the query (SELECT tz_col::text) so the raw string is returned and the binary decode path is skipped.","Cast to time (SELECT tz_col::time) to drop the timezone offset, which uses the well-tested NaiveTime path.","If you control ingestion, store TIMESTAMPTZ instead of TIMETZ, which avoids this custom decoder entirely."],"exampleFix":"-- before\nSELECT meeting_at FROM schedules;  -- meeting_at is TIMETZ\n-- after\nSELECT meeting_at::text AS meeting_at FROM schedules;","handlingStrategy":"validation","validationCode":"-- verify stored TIMETZ values are in the valid range before querying them from a job\nSELECT id FROM t WHERE meeting_at < time '00:00:00+00' OR meeting_at > time '23:59:59.999999+00' OR meeting_at IS NOT NULL AND extract(epoch from meeting_at) NOT BETWEEN 0 AND 86399;","typeGuard":null,"tryCatchPattern":"try {\n  return await query('SELECT meeting_at FROM schedules');\n} catch (e) {\n  if (String(e.message).includes('Invalid time value')) {\n    return await query('SELECT meeting_at::text AS meeting_at FROM schedules');\n  }\n  throw e;\n}","preventionTips":["Cast TIMETZ columns to ::text or ::time in job queries","Prefer TIMESTAMPTZ over TIMETZ for new schemas","Validate/normalize time data at ingestion rather than at read time"],"tags":["postgres","datetime-parsing","chrono"],"backgroundTag":"invalid-datetime-value","analyzedSha":"e474e8803ce2ff5c2df09a58dab51d45f5c922ca","analyzedAt":"2026-09-03T12:38:19.024Z","contentChangedAt":"2026-09-03T12:38:19.024Z","schemaVersion":2},"datasetVersion":"2026-09-08T10:18:20.063Z"}