• [x] I have checked that this issue has not already been reported.

  • [x] I have confirmed this bug exists on the latest version of pandas.

  • [x] (optional) I have confirmed this bug exists on the master branch of pandas.


Code Sample, a copy-pastable example

In [11]: pd.to_datetime('2021-03-28 01:30').tz_localize('Europe/London', nonexistent='shift_forward')
Out[11]: Timestamp('2021-03-28 03:00:00+0100', tz='Europe/London')

In [12]: pd.to_datetime('2021-03-28 01:30').tz_localize('Europe/London', nonexistent='shift_forward') - pd.Timedelta(60, 'm')
Out[12]: Timestamp('2021-03-28 02:00:00+0100', tz='Europe/London')

In [13]: pd.to_datetime('2021-03-28 01:30').tz_localize('Europe/London', nonexistent='shift_forward') - pd.Timedelta(61, 'm')
Out[13]: Timestamp('2021-03-28 00:59:00+0000', tz='Europe/London')

Problem description

When attempting to localize a non-existent time in the Europe/London timezone using the nonexistent='shift_forward', parameter, the resulting time is 03:00 summer time-- even though the time transition skips from 01:00 AM UTC to 02:00 AM summer time.

The issue appears to occur in European timezones that are in UTC when the time is not summer time (i.e. London, Dublin, and Lisbon). Timezones that start at UTC+1 and have their summer time conversions from 2 AM-3 AM work properly.

In [23]: pd.to_datetime('2021-03-28 02:30').tz_localize('Europe/Paris', nonexistent='shift_forward')
Out[23]: Timestamp('2021-03-28 03:00:00+0200', tz='Europe/Paris')

In [24]: pd.to_datetime('2021-03-28 01:30').tz_localize('Europe/Dublin', nonexistent='shift_forward')
Out[24]: Timestamp('2021-03-28 03:00:00+0100', tz='Europe/Dublin')

In [25]: pd.to_datetime('2021-03-28 01:30').tz_localize('Europe/Lisbon', nonexistent='shift_forward')
Out[25]: Timestamp('2021-03-28 03:00:00+0100', tz='Europe/Lisbon')

In [26]: pd.to_datetime('2021-03-28 02:30').tz_localize('Europe/Paris', nonexistent='shift_forward')
Out[26]: Timestamp('2021-03-28 03:00:00+0200', tz='Europe/Paris')

In [27]: pd.to_datetime('2021-03-28 02:30').tz_localize('Europe/Stockholm', nonexistent='shift_forward')
Out[27]: Timestamp('2021-03-28 03:00:00+0200', tz='Europe/Stockholm')

Expected Output

Per the documentation localizing with nonexistent='shift_forward' should "shift the nonexistent time forward to the closest existing time". In this case the closest existing time in Europe/London to 2021-03-28 01:30 would be 2021-03-28 02:00+0100.

In [11]: pd.to_datetime('2021-03-28 01:30').tz_localize('Europe/London', nonexistent='shift_forward')
Out[11]: Timestamp('2021-03-28 02:00:00+0100', tz='Europe/London')

Output of pd.show_versions()

INSTALLED VERSIONS ------------------ commit : 7d4757b4deb851bb44ab6bb20cdc404fa13fffcf python : 3.8.8.final.0 python-bits : 64 OS : Windows OS-release : 10 Version : 10.0.19041 machine : AMD64 processor : Intel64 Family 6 Model 158 Stepping 10, GenuineIntel byteorder : little LC_ALL : None LANG : None LOCALE : English_United States.1252 pandas : 1.3.0.dev0+1297.g7d4757b4de numpy : 1.20.2 pytz : 2021.1 dateutil : 2.8.1 pip : 21.0.1 setuptools : 49.6.0.post20210108 Cython : 0.29.22 pytest : 6.2.2 hypothesis : 6.8.3 sphinx : 3.5.3 blosc : None feather : None xlsxwriter : 1.3.8 lxml.etree : 4.6.3 html5lib : 1.1 pymysql : None psycopg2 : None jinja2 : 2.11.3 IPython : 7.22.0 pandas_datareader: None bs4 : 4.9.3 bottleneck : 1.3.2 fsspec : 0.8.7 fastparquet : 0.5.0 gcsfs : None matplotlib : 3.4.0 numexpr : 2.7.3 odfpy : None openpyxl : 3.0.7 pandas_gbq : None pyarrow : 3.0.0 pyxlsb : None s3fs : 0.5.2 scipy : 1.6.2 sqlalchemy : 1.4.3 tables : 3.6.1 tabulate : 0.8.9 xarray : 0.17.0 xlrd : 2.0.1 xlwt : 1.3.0 numba : 0.53.1

Comment From: mroeschke

Looks like there's Timestamp constructor differences as shown here:

In [2]: ts = pd.to_datetime('2021-03-28 01:30').tz_localize('Europe/London', nonexistent='shift_forward')

In [3]: ts
Out[3]: Timestamp('2021-03-28 03:00:00+0100', tz='Europe/London')

In [4]: pd.Timestamp(ts.value, tz=ts.tz)
Out[4]: Timestamp('2021-03-28 03:00:00+0100', tz='Europe/London')

In [5]: pd.Timestamp(ts.value).tz_localize(ts.tz)
Out[5]: Timestamp('2021-03-28 02:00:00+0100', tz='Europe/London')

In [6]: pd.__version__
Out[6]: '1.3.0.dev0+1297.g7d4757b4de'

Comment From: mroeschke

Tracked down the issue to this line in _localize_tso

https://github.com/pandas-dev/pandas/blob/7d4757b4deb851bb44ab6bb20cdc404fa13fffcf/pandas/_libs/tslibs/conversion.pyx#L737

in which for the 'shift_forward' we actually want the right edge index

# debugging git diff
+            print(obj.dts.hour)
             dt64_to_dtstruct(obj.value + deltas[pos], &obj.dts)
+            print(obj.dts.hour)

In [1]: ts = pd.to_datetime('2021-03-28 01:30').tz_localize('Europe/London', nonexistent='shift_forward')
2
3

Comment From: thomie

This issue seems to be fixed (version 2.3.1).

>>> pd.to_datetime('2021-03-28 01:30').tz_localize('Europe/London', nonexistent='shift_forward')
Timestamp('2021-03-28 02:00:00+0100', tz='Europe/London')

Output of pd.show_versions()

Details INSTALLED VERSIONS ------------------ commit : c888af6d0bb674932007623c0867e1fbd4bdc2c6 python : 3.12.11 python-bits : 64 OS : Linux OS-release : 6.12.41 Version : #1-NixOS SMP PREEMPT_DYNAMIC Fri Aug 1 08:48:47 UTC 2025 machine : x86_64 processor : byteorder : little LC_ALL : None LANG : en_US.UTF-8 LOCALE : en_US.UTF-8 pandas : 2.3.1 numpy : 2.3.1 pytz : 2025.2 dateutil : 2.9.0.post0 pip : None Cython : None sphinx : None IPython : 9.4.0 adbc-driver-postgresql: None adbc-driver-sqlite : None bs4 : None blosc : None bottleneck : None dataframe-api-compat : None fastparquet : None fsspec : None html5lib : None hypothesis : None gcsfs : None jinja2 : None lxml.etree : None matplotlib : None numba : None numexpr : None odfpy : None openpyxl : None pandas_gbq : None psycopg2 : None pymysql : None pyarrow : 20.0.0 pyreadstat : None pytest : 8.4.1 python-calamine : None pyxlsb : None s3fs : None scipy : None sqlalchemy : 2.0.42 tables : None tabulate : None xarray : None xlrd : None xlsxwriter : None zstandard : None tzdata : 2025.2 qtpy : None pyqt5 : None