You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
dumps(sort_keys=True) only sorts the top level of a parsed document (nested tables / inline tables / AoT keep original order) #614
For a document produced by tomlkit.parse() / loads(), dumps(doc, sort_keys=True) sorts only the top-level keys. Nested tables, inline tables, and arrays-of-tables keep their original (insertion) order. The same option applied to a plain dict sorts recursively. So the output depends on whether the input was parsed or built from scratch, which is surprising for a documented, explicit option.
Reproduction (copy-paste)
importtomlkitdoc=tomlkit.parse("""[zeta]b = 2a = 1[alpha]b = 2a = 1top = 0""")
print(tomlkit.dumps(doc, sort_keys=True))
# [alpha]# b = 2# a = 1## top = 0## [zeta]# b = 2# a = 1# -> top level IS sorted (alpha before zeta), but inside each table# `b` still comes before `a`.print(tomlkit.dumps({"zeta": {"b": 2, "a": 1}, "alpha": {"b": 2, "a": 1}, "top": 0}, sort_keys=True))
# top = 0## [alpha]# a = 1# b = 2## [zeta]# a = 1# b = 2# -> fully sorted, including nested keys.
Same split for inline tables and arrays of tables:
print(tomlkit.dumps(tomlkit.parse("[t]\ninl = { z = 1, a = 2 }\n"), sort_keys=True))
# inl = { z = 1, a = 2 } <- NOT sortedprint(tomlkit.dumps({"t": {"inl": {"z": 1, "a": 2}}}, sort_keys=True))
# inl = { a = 2, z = 1 } <- sortedprint(tomlkit.dumps(tomlkit.parse("[[t]]\nz = 1\na = 2\n"), sort_keys=True))
# z = 1# a = 2 <- NOT sortedprint(tomlkit.dumps({"t": [{"z": 1, "a": 2}]}, sort_keys=True))
# a = 2# z = 1 <- sorted
Expected
sort_keys=True sorts recursively, consistent with the plain-dict path and with the documented "sort the keys in alphabetic order".
Actual
Only the top level of a parsed document is sorted; nested structures keep their original order.
Root cause
dumps() (api.py:60-64) routes parsed documents through item(data, _sort_keys=True). In item() (items.py:114), the top-level TOMLDocument/Container is a dict (not an Item), so it is rebuilt in the isinstance(value, dict) branch (items.py:139-150), which sorts. But every nested value in a parsed document is already a Table / InlineTable / AoT, and item() returns it unchanged at the early guard if isinstance(value, Item): return value (items.py:130-131). Those items are never rebuilt, so their keys keep their original order. The plain-dict path has no such guard (nested plain dicts are rebuilt recursively), which is why it sorts fully.
This is the residual of #466 ("sort_keys does not (always?) work"), which fixed the top level; the nested levels were not covered.
Scope note / open question
A fix would need to rebuild nested Table / InlineTable / AoT items when _sort_keys is set. Because tomlkit's whole purpose is lossless round-tripping (preserving comments, blank lines, key order, formatting), rebuilding nested tables would drop that formatting. So this may be a deliberate trade-off rather than a plain bug — filing here to get a decision:
(a) sort recursively and accept loss of nested formatting, or
(b) document that sort_keys applies only to the top level of parsed documents.
I looked into this. The root cause is in item() (tomlkit/items.py): it early-returns an already-parsed Table/InlineTable/AoT unchanged, so under sort_keys only the top-level container (which reaches the dict-rebuild branch) is sorted, while nested items keep their original order.
I have a fix that sorts a parsed document's containers recursively: it preserves each key's leading comments, keeps inline tables inline, orders a value that renders as its own section after the inline values (so a key never folds under a preceding section header), and does not mutate the input document. PR incoming.
Thanks for the quick turnaround. I pulled #617 and ran it locally: the full suite passes, and I checked the edge cases the PR calls out plus a few more (comments following keys into nested tables, out-of-order fragments, no mutation of the input document, deep nesting, top-level AoT). The new test does fail on master as claimed.
On the open question, my preference is (a) — sort_keys=True should mean the same thing on a parsed document and a plain dict. Since the fix keeps each key's leading comments/whitespace with it, the lossless concern is mostly moot: only key order changes, which is what the option asks for.
Two minor observations, neither a blocker:
Top-level leading comments are still dropped under sort_keys (dumps(parse('# head\nb = 2\na = 1\n'), sort_keys=True) → 'a = 1\nb = 2\n') — but that's pre-existing on master via the dict-rebuild branch, so it can be a separate follow-up.
Inline tables nested inside arrays are still not sorted on the parsed path (and the plain-dict path renders them as AoT — a pre-existing inconsistency).
I defer to the maintainers on the (a)/(b) call; happy to re-test if anything changes.
Activity
Sreekant13 commentedon Sep 29, 2026
I looked into this. The root cause is in
item()(tomlkit/items.py): it early-returns an already-parsedTable/InlineTable/AoTunchanged, so undersort_keysonly the top-level container (which reaches the dict-rebuild branch) is sorted, while nested items keep their original order.I have a fix that sorts a parsed document's containers recursively: it preserves each key's leading comments, keeps inline tables inline, orders a value that renders as its own section after the inline values (so a key never folds under a preceding section header), and does not mutate the input document. PR incoming.
Master-Norna commentedon Sep 29, 2026
Thanks for the quick turnaround. I pulled #617 and ran it locally: the full suite passes, and I checked the edge cases the PR calls out plus a few more (comments following keys into nested tables, out-of-order fragments, no mutation of the input document, deep nesting, top-level AoT). The new test does fail on master as claimed.
On the open question, my preference is (a) —
sort_keys=Trueshould mean the same thing on a parsed document and a plain dict. Since the fix keeps each key's leading comments/whitespace with it, the lossless concern is mostly moot: only key order changes, which is what the option asks for.Two minor observations, neither a blocker:
sort_keys(dumps(parse('# head\nb = 2\na = 1\n'), sort_keys=True)→'a = 1\nb = 2\n') — but that's pre-existing on master via the dict-rebuild branch, so it can be a separate follow-up.I defer to the maintainers on the (a)/(b) call; happy to re-test if anything changes.