From 08e550ce05e940348f34ff1901ff2eff4418a585 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 5 Sep 2026 17:08:26 +0300 Subject: [PATCH] gh-69643: Fix sorting keys of different types in json json.dumps() and json.dump() with sort_keys=True failed for keys of different basic types (str, int, float, bool and None), and for unsupported keys skipped due to skipkeys. Now keys of mixed types are sorted by groups: strings, numbers (booleans are numbers too) and None. Co-Authored-By: Claude Opus 5 (1M context) --- Doc/library/json.rst | 9 ++ Lib/json/encoder.py | 33 ++++++- Lib/test/test_json/test_dump.py | 37 ++++++++ Lib/test/test_json/test_speedups.py | 4 - ...6-09-05-15-40-00.gh-issue-69643.Rn2Kd7.rst | 4 + Modules/_json.c | 85 ++++++++++++++++++- 6 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-05-15-40-00.gh-issue-69643.Rn2Kd7.rst diff --git a/Doc/library/json.rst b/Doc/library/json.rst index 5e8c452a5ab9a1f..0648517fc015e98 100644 --- a/Doc/library/json.rst +++ b/Doc/library/json.rst @@ -243,6 +243,10 @@ Basic Usage .. versionchanged:: 3.6 All optional parameters are now :ref:`keyword-only `. + .. versionchanged:: next + *sort_keys* no longer fails for keys of different basic types + or for unsupported keys skipped due to *skipkeys*. + .. function:: dumps(obj, *, skipkeys=False, ensure_ascii=True, \ check_circular=True, allow_nan=True, cls=None, \ @@ -536,6 +540,11 @@ Encoders and Decoders If *sort_keys* is true (default: ``False``), then the output of dictionaries will be sorted by key; this is useful for regression tests to ensure that JSON serializations can be compared on a day-to-day basis. + Keys of mixed types are sorted by groups: strings, numbers and ``None``. + + .. versionchanged:: next + *sort_keys* no longer fails for keys of different basic types + or for unsupported keys skipped due to *skipkeys*. If *indent* is a non-negative integer or string, then JSON array elements and object members will be pretty-printed with that indent level. An indent level diff --git a/Lib/json/encoder.py b/Lib/json/encoder.py index 8768b63a3f80417..6f49eac9092d53a 100644 --- a/Lib/json/encoder.py +++ b/Lib/json/encoder.py @@ -261,6 +261,32 @@ def floatstr(o, allow_nan=self.allow_nan, self.skipkeys, _one_shot) return _iterencode(o, 0) +def _sort_items(items, skipkeys): + """Sort (key, value) pairs in separate groups, because keys of + different types are not comparable: strings, numbers and ``None``. + + Unsupported keys are skipped if *skipkeys* is true and reported + otherwise. + """ + strings = [] + nones = [] + numbers = [] + for item in items: + key, value = item + if isinstance(key, str): + strings.append(item) + elif key is None: + nones.append(item) + elif isinstance(key, (int, float)): # includes bool + numbers.append(item) + elif not skipkeys: + raise TypeError(f'keys must be str, int, float, bool or None, ' + f'not {key.__class__.__name__}') + strings.sort() + numbers.sort() + return strings + numbers + nones + + def _make_iterencode(markers, _default, _encoder, _indent, _floatstr, _key_separator, _item_separator, _sort_keys, _skipkeys, _one_shot, ): @@ -343,7 +369,12 @@ def _iterencode_dict(dct, _current_indent_level): item_separator = _item_separator first = True if _sort_keys: - items = sorted(dct.items()) + items = list(dct.items()) + try: + items.sort() + except TypeError: + # Keys of different types are not comparable. + items = _sort_items(items, _skipkeys) else: items = dct.items() for key, value in items: diff --git a/Lib/test/test_json/test_dump.py b/Lib/test/test_json/test_dump.py index 5bc03085e60a3d3..07b46f6205f3146 100644 --- a/Lib/test/test_json/test_dump.py +++ b/Lib/test/test_json/test_dump.py @@ -42,6 +42,43 @@ def test_skipkeys_indent(self): v = {b'invalid_key': False, 'valid_key': True} self.assertEqual(self.json.dumps(v, skipkeys=True, indent=4), '{\n "valid_key": true\n}') + def test_dump_sort_keys_mixed_types(self): + # Keys of different types are sorted in separate groups. + self.assertEqual( + self.dumps({1: 'a', 'z': 'b', 'a': 'c'}, sort_keys=True), + '{"a": "c", "z": "b", "1": "a"}') + self.assertEqual( + self.dumps({None: 0, True: 1, False: 4, 2: 2, 'a': 3}, + sort_keys=True), + '{"a": 3, "false": 4, "true": 1, "2": 2, "null": 0}') + # Numbers are still sorted as numbers, and adding a string key + # does not change their order. + self.assertEqual( + self.dumps({10: 1, 2: 2}, sort_keys=True), + '{"2": 2, "10": 1}') + self.assertEqual( + self.dumps({10: 1, 2: 2, 'a': 3}, sort_keys=True), + '{"a": 3, "2": 2, "10": 1}') + # Unsupported keys are still reported, or skipped. + with self.assertRaises(TypeError): + self.dumps({(1, 2): 'x', 'z': 'b'}, sort_keys=True) + self.assertEqual( + self.dumps({(1, 2): 'x', 'z': 'b'}, skipkeys=True, sort_keys=True), + '{"z": "b"}') + + def test_dump_sort_keys_unsupported(self): + # Unsupported keys are reported or skipped, whether or not they are + # comparable with each other. + for d in ({(2,): 1, (1,): 2}, # comparable + {(2,): 1, (1,): 2, 'z': 3}, + {(1,): 1, ('a',): 2, 'z': 3}): # not comparable + with self.subTest(d=d): + with self.assertRaises(TypeError): + self.dumps(d, sort_keys=True) + self.assertEqual( + self.dumps(d, skipkeys=True, sort_keys=True), + '{"z": 3}' if 'z' in d else '{}') + def test_encode_truefalse(self): self.assertEqual(self.dumps( {True: False, False: True}, sort_keys=True), diff --git a/Lib/test/test_json/test_speedups.py b/Lib/test/test_json/test_speedups.py index 0b22a0bf4b95387..b5c1d3088abafe4 100644 --- a/Lib/test/test_json/test_speedups.py +++ b/Lib/test/test_json/test_speedups.py @@ -78,10 +78,6 @@ def test(name): self.assertRaises(ZeroDivisionError, test, 'allow_nan') self.assertRaises(ZeroDivisionError, test, 'sort_keys') - def test_unsortable_keys(self): - with self.assertRaises(TypeError): - self.json.encoder.JSONEncoder(sort_keys=True).encode({'a': 1, 1: 'a'}) - def test_current_indent_level(self): enc = self.json.encoder.c_make_encoder( markers=None, diff --git a/Misc/NEWS.d/next/Library/2026-09-05-15-40-00.gh-issue-69643.Rn2Kd7.rst b/Misc/NEWS.d/next/Library/2026-09-05-15-40-00.gh-issue-69643.Rn2Kd7.rst new file mode 100644 index 000000000000000..c00dc760d79ca11 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-05-15-40-00.gh-issue-69643.Rn2Kd7.rst @@ -0,0 +1,4 @@ +:func:`json.dump` and :func:`json.dumps` with ``sort_keys=True`` no longer +fail for keys of different basic types or for unsupported keys skipped due +to *skipkeys*. Keys of mixed types are sorted by groups: strings, numbers +and ``None``. diff --git a/Modules/_json.c b/Modules/_json.c index 3a724a3e72b185b..a9a49c765a1642b 100644 --- a/Modules/_json.c +++ b/Modules/_json.c @@ -1793,6 +1793,76 @@ _encoder_iterate_dict_lock_held(PyEncoderObject *s, PyUnicodeWriter *writer, return 0; } +/* Sort the (key, value) pairs in separate groups, because keys of + different types are not comparable: strings, numbers and None. + Unsupported keys are skipped if skipkeys is true and reported otherwise. + Return a new list, or NULL on error. */ +static PyObject * +encoder_sort_items(PyObject *items, int skipkeys) +{ + enum {STRINGS, NUMBERS, NONES, NGROUPS}; + PyObject *groups[NGROUPS] = {NULL}; + PyObject *result = NULL; + + for (int i = 0; i < NGROUPS; i++) { + groups[i] = PyList_New(0); + if (groups[i] == NULL) { + goto done; + } + } + for (Py_ssize_t i = 0; i < PyList_GET_SIZE(items); i++) { + PyObject *item = PyList_GET_ITEM(items, i); + if (!PyTuple_Check(item) || PyTuple_GET_SIZE(item) != 2) { + PyErr_SetString(PyExc_ValueError, "items must return 2-tuples"); + goto done; + } + PyObject *key = PyTuple_GET_ITEM(item, 0); + int group; + if (PyUnicode_Check(key)) { + group = STRINGS; + } + else if (key == Py_None) { + group = NONES; + } + else if (PyLong_Check(key) || PyFloat_Check(key)) { // includes bool + group = NUMBERS; + } + else if (skipkeys) { + continue; + } + else { + PyErr_Format(PyExc_TypeError, + "keys must be str, int, float, bool or None, " + "not %.100s", Py_TYPE(key)->tp_name); + goto done; + } + if (PyList_Append(groups[group], item) < 0) { + goto done; + } + } + /* There is at most one None key. */ + if (PyList_Sort(groups[STRINGS]) < 0 || + PyList_Sort(groups[NUMBERS]) < 0) + { + goto done; + } + result = groups[STRINGS]; + groups[STRINGS] = NULL; + for (int i = STRINGS + 1; i < NGROUPS; i++) { + Py_ssize_t size = PyList_GET_SIZE(result); + if (PyList_SetSlice(result, size, size, groups[i]) < 0) { + Py_CLEAR(result); + goto done; + } + } + +done: + for (int i = 0; i < NGROUPS; i++) { + Py_XDECREF(groups[i]); + } + return result; +} + static int encoder_listencode_dict(PyEncoderObject *s, PyUnicodeWriter *writer, PyObject *dct, @@ -1837,10 +1907,21 @@ encoder_listencode_dict(PyEncoderObject *s, PyUnicodeWriter *writer, if (s->sort_keys || !PyAnyDict_CheckExact(dct)) { PyObject *items = PyMapping_Items(dct); - if (items == NULL || (s->sort_keys && PyList_Sort(items) < 0)) { - Py_XDECREF(items); + if (items == NULL) { goto bail; } + if (s->sort_keys && PyList_Sort(items) < 0) { + if (!PyErr_ExceptionMatches(PyExc_TypeError)) { + Py_DECREF(items); + goto bail; + } + /* Keys of different types are not comparable. */ + PyErr_Clear(); + Py_SETREF(items, encoder_sort_items(items, s->skipkeys)); + if (items == NULL) { + goto bail; + } + } int result; Py_BEGIN_CRITICAL_SECTION_SEQUENCE_FAST(items);