https://github.com/python/cpython/commit/d0e7c6acc936a171d05ba239497ba82d741ac8dd
commit: d0e7c6acc936a171d05ba239497ba82d741ac8dd
branch: main
author: Stan Ulbrych <[email protected]>
committer: gpshead <[email protected]>
date: 2026-04-14T17:15:27-07:00
summary:

GH-70647: Remove support for `%d` (and deprecate for `%e`) without year in 
`strptime()` (GH-144570)

* Add deprecation for %e with no year
* schedule `%e` for 3.17, and remove `%d` now

files:
A Misc/NEWS.d/next/Library/2026-02-07-12-54-20.gh-issue-70647.Bja_Lk.rst
M Doc/deprecations/pending-removal-in-3.17.rst
M Doc/library/datetime.rst
M Doc/whatsnew/3.15.rst
M Lib/_strptime.py
M Lib/test/datetimetester.py
M Lib/test/test_strptime.py
M Lib/test/test_time.py

diff --git a/Doc/deprecations/pending-removal-in-3.17.rst 
b/Doc/deprecations/pending-removal-in-3.17.rst
index e769c9d371e133..ea9fb93ddd8c84 100644
--- a/Doc/deprecations/pending-removal-in-3.17.rst
+++ b/Doc/deprecations/pending-removal-in-3.17.rst
@@ -1,6 +1,14 @@
 Pending removal in Python 3.17
 ------------------------------
 
+* :mod:`datetime`:
+
+  * :meth:`~datetime.datetime.strptime` calls using a format string containing
+    ``%e`` (day of month) without a year.
+    This has been deprecated since Python 3.15.
+    (Contributed by Stan Ulbrych in :gh:`70647`.)
+
+
 * :mod:`collections.abc`:
 
   - :class:`collections.abc.ByteString` is scheduled for removal in Python 
3.17.
diff --git a/Doc/library/datetime.rst b/Doc/library/datetime.rst
index 8993049a720b1c..f3c4ef9199075c 100644
--- a/Doc/library/datetime.rst
+++ b/Doc/library/datetime.rst
@@ -606,12 +606,11 @@ Other constructors, all class methods:
 
    .. note::
 
-      If *format* specifies a day of month without a year a
-      :exc:`DeprecationWarning` is emitted.  This is to avoid a quadrennial
+      If *format* specifies a day of month (``%d``) without a year,
+      :exc:`ValueError` is raised.  This is to avoid a quadrennial
       leap year bug in code seeking to parse only a month and day as the
       default year used in absence of one in the format is not a leap year.
-      Such *format* values may raise an error as of Python 3.15.  The
-      workaround is to always include a year in your *format*.  If parsing
+      The workaround is to always include a year in your *format*.  If parsing
       *date_string* values that do not have a year, explicitly add a year that
       is a leap year before parsing:
 
@@ -1180,14 +1179,13 @@ Other constructors, all class methods:
    time tuple.  See also :ref:`strftime-strptime-behavior` and
    :meth:`datetime.fromisoformat`.
 
-   .. versionchanged:: 3.13
+   .. versionchanged:: 3.15
 
-      If *format* specifies a day of month without a year a
-      :exc:`DeprecationWarning` is now emitted.  This is to avoid a quadrennial
+      If *format* specifies a day of month (``%d``) without a year,
+      :exc:`ValueError` is raised.  This is to avoid a quadrennial
       leap year bug in code seeking to parse only a month and day as the
       default year used in absence of one in the format is not a leap year.
-      Such *format* values may raise an error as of Python 3.15.  The
-      workaround is to always include a year in your *format*.  If parsing
+      The workaround is to always include a year in your *format*.  If parsing
       *date_string* values that do not have a year, explicitly add a year that
       is a leap year before parsing:
 
@@ -2572,13 +2570,13 @@ requires, and these work on all supported platforms.
 |           | truncated to an integer as a   |                        |       |
 |           | zero-padded decimal number.    |                        |       |
 +-----------+--------------------------------+------------------------+-------+
-|  ``%d``   | Day of the month as a          | 01, 02, ..., 31        | \(9)  |
-|           | zero-padded decimal number.    |                        |       |
+|  ``%d``   | Day of the month as a          | 01, 02, ..., 31        | \(9), |
+|           | zero-padded decimal number.    |                        | \(10) |
 +-----------+--------------------------------+------------------------+-------+
 |  ``%D``   | Equivalent to ``%m/%d/%y``.    | 11/28/25               | \(9)  |
 |           |                                |                        |       |
 +-----------+--------------------------------+------------------------+-------+
-|  ``%e``   | The day of the month as a      | ␣1, ␣2, ..., 31        |       |
+|  ``%e``   | The day of the month as a      | ␣1, ␣2, ..., 31        | \(10) |
 |           | space-padded decimal number.   |                        |       |
 +-----------+--------------------------------+------------------------+-------+
 |  ``%F``   | Equivalent to ``%Y-%m-%d``,    | 2025-10-11,            |       |
@@ -2919,11 +2917,12 @@ Notes:
       >>> dt.datetime.strptime(f"{month_day};1984", "%m/%d;%Y")  # No leap 
year bug.
       datetime.datetime(1984, 2, 29, 0, 0)
 
-   .. deprecated-removed:: 3.13 3.15
+   .. versionchanged:: 3.15
+      Using ``%d`` without a year now raises :exc:`ValueError`.
+
+   .. deprecated-removed:: 3.15 3.17
       :meth:`~.datetime.strptime` calls using a format string containing
-      a day of month without a year now emit a
-      :exc:`DeprecationWarning`. In 3.15 or later we may change this into
-      an error or change the default year to a leap year. See :gh:`70647`.
+      ``%e`` without a year now emit a :exc:`DeprecationWarning`.
 
 .. rubric:: Footnotes
 
diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst
index fe2ddfdcd0e917..0253bb6cb717f3 100644
--- a/Doc/whatsnew/3.15.rst
+++ b/Doc/whatsnew/3.15.rst
@@ -1494,6 +1494,15 @@ collections.abc
   deprecated since Python 3.12, and is scheduled for removal in Python 3.17.
 
 
+datetime
+--------
+
+* :meth:`~datetime.datetime.strptime` now raises :exc:`ValueError` when the
+  format string contains ``%d`` (day of month) without a year directive.
+  This has been deprecated since Python 3.13.
+  (Contributed by Stan Ulbrych and Gregory P. Smith in :gh:`70647`.)
+
+
 ctypes
 ------
 
diff --git a/Lib/_strptime.py b/Lib/_strptime.py
index 3367ac485a590c..746b0907c1d9f4 100644
--- a/Lib/_strptime.py
+++ b/Lib/_strptime.py
@@ -464,7 +464,8 @@ def pattern(self, format):
         format = re_sub(r'\s+', r'\\s+', format)
         format = re_sub(r"'", "['\u02bc]", format)  # needed for br_FR
         year_in_format = False
-        day_of_month_in_format = False
+        day_d_in_format = False
+        day_e_in_format = False
         def repl(m):
             directive = m.group()[1:] # exclude `%` symbol
             match directive:
@@ -472,20 +473,30 @@ def repl(m):
                     nonlocal year_in_format
                     year_in_format = True
                 case 'd':
-                    nonlocal day_of_month_in_format
-                    day_of_month_in_format = True
+                    nonlocal day_d_in_format
+                    day_d_in_format = True
+                case 'e':
+                    nonlocal day_e_in_format
+                    day_e_in_format = True
             return self[directive]
         format = re_sub(r'%[-_0^#]*[0-9]*([OE]?[:\\]?.?)', repl, format)
-        if day_of_month_in_format and not year_in_format:
-            import warnings
-            warnings.warn("""\
+        if not year_in_format:
+            if day_d_in_format:
+                raise ValueError(
+                    "Day of month directive '%d' may not be used without "
+                    "a year directive. Parsing dates involving a day of "
+                    "month without a year is ambiguous and fails to parse "
+                    "leap day. Add a year to the input and format. "
+                    "See https://github.com/python/cpython/issues/70647.";)
+            if day_e_in_format:
+                import warnings
+                warnings.warn("""\
 Parsing dates involving a day of month without a year specified is ambiguous
-and fails to parse leap day. The default behavior will change in Python 3.15
-to either always raise an exception or to use a different default year (TBD).
-To avoid trouble, add a specific year to the input & format.
+and fails to parse leap day. '%e' without a year will become an error in 
Python 3.17.
+To avoid trouble, add a specific year to the input and format.
 See https://github.com/python/cpython/issues/70647.""";,
-                          DeprecationWarning,
-                          skip_file_prefixes=(os.path.dirname(__file__),))
+                              DeprecationWarning,
+                              skip_file_prefixes=(os.path.dirname(__file__),))
         return format
 
     def compile(self, format):
diff --git a/Lib/test/datetimetester.py b/Lib/test/datetimetester.py
index bb8695541ac81d..5d5b8e415f3cd2 100644
--- a/Lib/test/datetimetester.py
+++ b/Lib/test/datetimetester.py
@@ -22,7 +22,7 @@
 
 from test import support
 from test.support import is_resource_enabled, ALWAYS_EQ, LARGEST, SMALLEST
-from test.support import os_helper, script_helper, warnings_helper
+from test.support import os_helper, script_helper
 
 import datetime as datetime_module
 from datetime import MINYEAR, MAXYEAR
@@ -1206,15 +1206,20 @@ def test_strptime_single_digit(self):
                 newdate = strptime(string, format)
                 self.assertEqual(newdate, target, msg=reason)
 
-    @warnings_helper.ignore_warnings(category=DeprecationWarning)
     def test_strptime_leap_year(self):
-        # GH-70647: warns if parsing a format with a day and no year.
+        # GH-70647: %d errors if parsing a format with a day and no year.
         with self.assertRaises(ValueError):
             # The existing behavior that GH-70647 seeks to change.
             date.strptime('02-29', '%m-%d')
+        # %e without a year is deprecated, scheduled for removal in 3.17.
+        _strptime._regex_cache.clear()
+        with self.assertWarnsRegex(DeprecationWarning,
+                                   r'.*day of month without a year.*'):
+            date.strptime('02-01', '%m-%e')
         with self._assertNotWarns(DeprecationWarning):
             date.strptime('20-03-14', '%y-%m-%d')
             date.strptime('02-29,2024', '%m-%d,%Y')
+            date.strptime('02-29,2024', '%m-%e,%Y')
 
 class SubclassDate(date):
     sub_var = 1
@@ -3119,19 +3124,24 @@ def test_strptime_single_digit(self):
                 newdate = strptime(string, format)
                 self.assertEqual(newdate, target, msg=reason)
 
-    @warnings_helper.ignore_warnings(category=DeprecationWarning)
     def test_strptime_leap_year(self):
-        # GH-70647: warns if parsing a format with a day and no year.
+        # GH-70647: %d errors if parsing a format with a day and no year.
         with self.assertRaises(ValueError):
             # The existing behavior that GH-70647 seeks to change.
             self.theclass.strptime('02-29', '%m-%d')
+        with self.assertRaises(ValueError):
+            self.theclass.strptime('03-14.159265', '%m-%d.%f')
+        # %e without a year is deprecated, scheduled for removal in 3.17.
+        _strptime._regex_cache.clear()
         with self.assertWarnsRegex(DeprecationWarning,
                                    r'.*day of month without a year.*'):
-            self.theclass.strptime('03-14.159265', '%m-%d.%f')
+            self.theclass.strptime('03-14.159265', '%m-%e.%f')
         with self._assertNotWarns(DeprecationWarning):
             self.theclass.strptime('20-03-14.159265', '%y-%m-%d.%f')
         with self._assertNotWarns(DeprecationWarning):
             self.theclass.strptime('02-29,2024', '%m-%d,%Y')
+        with self._assertNotWarns(DeprecationWarning):
+            self.theclass.strptime('02-29,2024', '%m-%e,%Y')
 
     def test_strptime_z_empty(self):
         for directive in ('z', ':z'):
diff --git a/Lib/test/test_strptime.py b/Lib/test/test_strptime.py
index dfc8ef6d2c5b7e..5ac28870455f4d 100644
--- a/Lib/test/test_strptime.py
+++ b/Lib/test/test_strptime.py
@@ -8,7 +8,6 @@
 import platform
 import sys
 from test import support
-from test.support import warnings_helper
 from test.support import skip_if_buggy_ucrt_strfptime, run_with_locales
 from datetime import date as datetime_date
 
@@ -639,15 +638,11 @@ def test_escaping(self):
         need_escaping = r".^$*+?{}\[]|)("
         self.assertTrue(_strptime._strptime_time(need_escaping, need_escaping))
 
-    @warnings_helper.ignore_warnings(category=DeprecationWarning)  # gh-70647
     def test_feb29_on_leap_year_without_year(self):
-        time.strptime("Feb 29", "%b %d")
-
-    @warnings_helper.ignore_warnings(category=DeprecationWarning)  # gh-70647
-    def test_mar1_comes_after_feb29_even_when_omitting_the_year(self):
-        self.assertLess(
-                time.strptime("Feb 29", "%b %d"),
-                time.strptime("Mar 1", "%b %d"))
+        with self.assertRaises(ValueError):
+            time.strptime("Feb 29", "%b %d")
+        with self.assertRaises(ValueError):
+            time.strptime("Mar 1", "%b %d")
 
     def test_strptime_F_format(self):
         test_date = "2025-10-26"
diff --git a/Lib/test/test_time.py b/Lib/test/test_time.py
index be8f6b057654c2..1850f053aaffd6 100644
--- a/Lib/test/test_time.py
+++ b/Lib/test/test_time.py
@@ -358,11 +358,11 @@ def test_strptime(self):
         # Should be able to go round-trip from strftime to strptime without
         # raising an exception.
         tt = time.gmtime(self.t)
-        for directive in ('a', 'A', 'b', 'B', 'c', 'd', 'D', 'F', 'H', 'I',
+        for directive in ('a', 'A', 'b', 'B', 'c', 'd', 'D', 'e', 'F', 'H', 
'I',
                           'j', 'm', 'M', 'n', 'p', 'S', 't', 'T',
                           'U', 'w', 'W', 'x', 'X', 'y', 'Y', 'Z', '%'):
             format = '%' + directive
-            if directive == 'd':
+            if directive in ('d', 'e'):
                 format += ',%Y'  # Avoid GH-70647.
             strf_output = time.strftime(format, tt)
             try:
@@ -387,10 +387,13 @@ def test_strptime_exception_context(self):
         self.assertTrue(e.exception.__suppress_context__)
 
     def test_strptime_leap_year(self):
-        # GH-70647: warns if parsing a format with a day and no year.
+        # GH-70647: %d errors if parsing a format with a day and no year.
+        with self.assertRaises(ValueError):
+            time.strptime('02-07 18:28', '%m-%d %H:%M')
+        # %e without a year is deprecated, scheduled for removal in 3.17.
         with self.assertWarnsRegex(DeprecationWarning,
                                    r'.*day of month without a year.*'):
-            time.strptime('02-07 18:28', '%m-%d %H:%M')
+            time.strptime('02-07 18:28', '%m-%e %H:%M')
 
     def test_asctime(self):
         time.asctime(time.gmtime(self.t))
diff --git 
a/Misc/NEWS.d/next/Library/2026-02-07-12-54-20.gh-issue-70647.Bja_Lk.rst 
b/Misc/NEWS.d/next/Library/2026-02-07-12-54-20.gh-issue-70647.Bja_Lk.rst
new file mode 100644
index 00000000000000..9fd39743ca58bf
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-02-07-12-54-20.gh-issue-70647.Bja_Lk.rst
@@ -0,0 +1,3 @@
+:meth:`~datetime.datetime.strptime` now raises :exc:`ValueError` when the
+format string contains ``%d`` without a year directive.
+Using ``%e`` without a year now emits a :exc:`DeprecationWarning`.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to