From c8b368b980e3ef08888e2f22f65502c32c57743b Mon Sep 17 00:00:00 2001 From: Brock Date: Thu, 25 Jun 2026 10:17:34 -0700 Subject: [PATCH] BUG: to_json(orient="table") drops fixed-offset timezone (GH#39537) A column with a fixed-offset stdlib timezone (e.g. timezone(timedelta(hours=1))) was silently serialized without its tz in the table schema, so the offset was lost on round-trip through read_json. Emit the round-trippable "UTC+HH:MM" string instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/source/whatsnew/v3.1.0.rst | 1 + pandas/io/json/_table_schema.py | 5 ++++ .../tests/io/json/test_json_table_schema.py | 30 +++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/doc/source/whatsnew/v3.1.0.rst b/doc/source/whatsnew/v3.1.0.rst index 4ac823e188692..d5ab02bcc81ae 100644 --- a/doc/source/whatsnew/v3.1.0.rst +++ b/doc/source/whatsnew/v3.1.0.rst @@ -360,6 +360,7 @@ I/O - :meth:`HDFStore.put` and :meth:`HDFStore.append` now support storing :class:`Series` and :class:`DataFrame` columns with :class:`PeriodDtype` in both ``"fixed"`` and ``"table"`` formats (:issue:`41978`) - Bug in :meth:`DataFrame.__repr__` raising ``TypeError`` for a column with a NumPy structured dtype (e.g. produced by :meth:`DataFrame.from_records` from a structured ``ndarray``) (:issue:`55011`) - Bug in :meth:`DataFrame.__repr__` where horizontally truncated output could exceed the terminal width by up to 4 characters (:issue:`32461`) +- Bug in :meth:`DataFrame.to_json` and :meth:`Series.to_json` with ``orient="table"`` silently dropping the timezone of columns with a fixed-offset timezone (e.g. ``datetime.timezone(timedelta(hours=1))``), so the offset was lost on round-trip through :func:`read_json` (:issue:`39537`) - Bug in :meth:`DataFrame.to_stata` raising ``KeyError`` when column names require renaming and ``convert_dates`` is specified for a different column (:issue:`60536`) - Bug in :meth:`DataFrame.to_string` where ``formatters`` dict was applied to wrong columns when output was horizontally truncated via ``max_cols`` (:issue:`35410`) - Fixed :func:`read_json` with ``lines=True`` and ``nrows=0`` to return an empty DataFrame (:issue:`64025`) diff --git a/pandas/io/json/_table_schema.py b/pandas/io/json/_table_schema.py index 29142a0139bb1..cac771d929e1b 100644 --- a/pandas/io/json/_table_schema.py +++ b/pandas/io/json/_table_schema.py @@ -6,6 +6,7 @@ from __future__ import annotations +from datetime import timezone from typing import ( TYPE_CHECKING, Any, @@ -149,6 +150,10 @@ def convert_pandas_type_to_json_field(arr) -> dict[str, JSONSerializable]: zone = timezones.get_timezone(dtype.tz) if isinstance(zone, str): field["tz"] = zone + elif isinstance(zone, timezone): + # fixed-offset stdlib timezone, e.g. timezone(timedelta(hours=1)); + # str gives a round-trippable "UTC+HH:MM" (GH#39537) + field["tz"] = str(zone) elif isinstance(dtype, ExtensionDtype): field["extDtype"] = dtype.name return field diff --git a/pandas/tests/io/json/test_json_table_schema.py b/pandas/tests/io/json/test_json_table_schema.py index bec53a0450b68..52e902d8ff09b 100644 --- a/pandas/tests/io/json/test_json_table_schema.py +++ b/pandas/tests/io/json/test_json_table_schema.py @@ -1,6 +1,10 @@ """Tests for Table Schema integration.""" from collections import OrderedDict +from datetime import ( + timedelta, + timezone, +) from io import StringIO import json @@ -499,6 +503,23 @@ def test_convert_pandas_type_to_json_field_datetime( expected.update(extra_exp) assert result == expected + @pytest.mark.parametrize( + "offset,expected_tz", + [ + (timedelta(hours=1), "UTC+01:00"), + (timedelta(hours=-5, minutes=-30), "UTC-05:30"), + ], + ) + def test_convert_pandas_type_to_json_field_fixed_offset(self, offset, expected_tz): + # GH#39537 fixed-offset stdlib timezones were silently dropped from the + # schema instead of being serialized as a round-trippable "UTC+HH:MM" + data = pd.Series( + pd.to_datetime([1.0], utc=True).tz_convert(timezone(offset)), + name="values", + ) + result = convert_pandas_type_to_json_field(data) + assert result == {"name": "values", "type": "datetime", "tz": expected_tz} + def test_convert_pandas_type_to_json_period_range(self): arr = pd.period_range("2016", freq="Y-DEC", periods=4) result = convert_pandas_type_to_json_field(arr) @@ -708,6 +729,15 @@ class TestTableOrientReader: "2016-01-01", freq="D", periods=4, tz="US/Central", unit="ns" ) # added in # GH 35973 }, + { + "fixed_offset": pd.date_range( + "2016-01-01", + freq="D", + periods=4, + tz=timezone(timedelta(hours=1)), + unit="ns", + ) # GH#39537 + }, ], ) def test_read_json_table_orient(self, index_nm, vals):