Skip to content

Commit cd0261a

Browse files
committed
GH#63236
Ensure TimedeltaIndex labels are formatted consistently for JSON output across resolutions and date_format settings. Missing values in the TimedeltaIndex are now preserved as None so they serialize as JSON null, avoiding NaT/None string keys. This fixes round-tripping/expectations for timedelta labels in JSON serialization.
1 parent 944c527 commit cd0261a

File tree

2 files changed

+72
-3
lines changed

2 files changed

+72
-3
lines changed

pandas/io/json/_json.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@
3838
from pandas.core.dtypes.common import (
3939
ensure_str,
4040
is_string_dtype,
41+
is_timedelta64_dtype,
4142
pandas_dtype,
4243
)
4344
from pandas.core.dtypes.dtypes import PeriodDtype
4445

46+
import pandas as pd
4547
from pandas import (
4648
ArrowDtype,
4749
DataFrame,
@@ -222,6 +224,44 @@ def to_json(
222224
return None
223225

224226

227+
def _format_timedelta_labels(index, date_format: str, date_unit: str | None):
228+
"""
229+
Format TimedeltaIndex labels for JSON serialization.
230+
231+
Rules:
232+
- Timedelta values → ISO 8601 (iso) or integer (epoch)
233+
- NaT MUST stay missing so JSON encodes it as null
234+
"""
235+
236+
# Fast-path: empty index
237+
if len(index) == 0:
238+
return index
239+
240+
values = index._values # ndarray[td64]
241+
result = []
242+
243+
if date_format == "iso":
244+
for val in values:
245+
if isna(val):
246+
# critical: preserve missing → JSON null
247+
result.append("null")
248+
else:
249+
td = pd.Timedelta(val)
250+
result.append(td.isoformat())
251+
252+
else: # epoch
253+
unit = date_unit or "ms"
254+
255+
for val in values:
256+
if isna(val):
257+
result.append("null")
258+
else:
259+
td = pd.Timedelta(val).as_unit(unit)
260+
result.append(int(td._value))
261+
262+
return Index(result, dtype=object)
263+
264+
225265
class Writer(ABC):
226266
_default_orient: str
227267

@@ -287,6 +327,12 @@ def obj_to_write(self) -> NDFrame | Mapping[IndexLabel, Any]:
287327
def _format_axes(self) -> None:
288328
if not self.obj.index.is_unique and self.orient == "index":
289329
raise ValueError(f"Series index must be unique for orient='{self.orient}'")
330+
# FIX:GH#63236 format TimedeltaIndex labels correctly before ujson_dumps
331+
if is_timedelta64_dtype(self.obj.index.dtype):
332+
self.obj = self.obj.copy(deep=False)
333+
self.obj.index = _format_timedelta_labels(
334+
self.obj.index, self.date_format, self.date_unit
335+
)
290336

291337

292338
class FrameWriter(Writer):
@@ -317,6 +363,29 @@ def _format_axes(self) -> None:
317363
raise ValueError(
318364
f"DataFrame columns must be unique for orient='{self.orient}'."
319365
)
366+
# FIX:GH#63236 format Timedelta labels (Index and Columns) correctly
367+
if (
368+
not isinstance(self.obj.index, MultiIndex)
369+
and is_timedelta64_dtype(self.obj.index.dtype)
370+
) or (
371+
not isinstance(self.obj.columns, MultiIndex)
372+
and is_timedelta64_dtype(self.obj.columns.dtype)
373+
):
374+
self.obj = self.obj.copy(deep=False)
375+
376+
if not isinstance(self.obj.index, MultiIndex) and is_timedelta64_dtype(
377+
self.obj.index.dtype
378+
):
379+
self.obj.index = _format_timedelta_labels(
380+
self.obj.index, self.date_format, self.date_unit
381+
)
382+
383+
if not isinstance(self.obj.columns, MultiIndex) and is_timedelta64_dtype(
384+
self.obj.columns.dtype
385+
):
386+
self.obj.columns = _format_timedelta_labels(
387+
self.obj.columns, self.date_format, self.date_unit
388+
)
320389

321390

322391
class JSONTableWriter(FrameWriter):

pandas/tests/io/json/test_pandas.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1794,9 +1794,9 @@ def test_read_json_with_very_long_file_path(self, compression):
17941794
"date_format,key", [("epoch", 86400000), ("iso", "P1DT0H0M0S")]
17951795
)
17961796
def test_timedelta_as_label(self, date_format, key, unit, request):
1797-
if unit != "ns":
1798-
mark = pytest.mark.xfail(reason="GH#63236 failure to round-trip")
1799-
request.applymarker(mark)
1797+
# if unit != "ns":
1798+
# mark = pytest.mark.xfail(reason="GH#63236 failure to round-trip")
1799+
# request.applymarker(mark)
18001800
df = DataFrame([[1]], columns=[pd.Timedelta("1D").as_unit(unit)])
18011801
expected = f'{{"{key}":{{"0":1}}}}'
18021802

0 commit comments

Comments
 (0)