Python JSON Changes Integer Keys to Strings: Preserve IDs Without Collisions
By ON Lab · Sources checked 8 November 2026
Python JSON object keys load as strings, even when the original Python dictionary used integer keys. After saving and loading {7: "ready"}, look up "7" in the decoded dictionary, or explicitly restore integer keys under a known schema. Do not convert every numeric-looking string blindly: mixed integer and string keys can collide, and leading zeros may carry meaning.
Confirm that the key changed, rather than the value
A public Python tracker report describes saving a dictionary with integer keys, loading it, and finding that an integer membership test returned false. That is a useful symptom to reproduce before changing the storage format. JSON objects use string names; a Python dictionary supports more key types. The serialization boundary cannot preserve every Python dictionary unchanged.
import json
original = {7: "ready"}
wire = json.dumps(original)
restored = json.loads(wire)
print(wire)
print(7 in restored, "7" in restored)
print(restored["7"])
assert restored != original
assert restored == {"7": "ready"}The controlled result is {"7": "ready"}, followed by False True and ready. The value remains a string throughout; the changed type belongs to the key. A KeyError from restored[7] would therefore be different from a missing record in the saved JSON. Inspect the decoded mapping before assuming that the file lost its value.
The same key rule applies to dump/load with a file and dumps/loads with text. Changing from a file to a string API does not preserve integer object keys. Formatting options and Unicode escaping do not change that contract either.
Choose a schema before choosing a conversion
If these are identifiers used by other JSON consumers, using string keys consistently is often the simplest contract. The producer, saved data and lookup code should all agree that the ID is a string. A valid ID such as "007" must not silently become 7 unless your application explicitly treats them as the same identifier.
If the application requires integer keys in memory, restore them only at the specific mapping boundary whose schema guarantees integer IDs. Do not run a recursive integer conversion over every object: nested records may contain field names or string IDs with different rules. First reject ambiguous input rather than guessing which record to keep.
import json
def load_integer_id_map(text):
raw = json.loads(text)
if not isinstance(raw, dict):
raise ValueError("Expected a JSON object")
result = {}
for key, value in raw.items():
number = int(key)
if str(number) != key:
raise ValueError("Expected canonical integer ID")
result[number] = value
return result
restored = load_integer_id_map('{"7": "ready", "-2": "waiting"}')
assert restored == {7: "ready", -2: "waiting"}
try:
load_integer_id_map('{"007": "ready"}')
except ValueError as error:
print(error)This deliberately narrow example accepts decimal integer names whose spelling matches Python's str(int(key)). It rejects "007", a leading plus sign, surrounding whitespace and other noncanonical spellings. Whether negative IDs are allowed is still a domain rule; this example allows them. It is a schema example for small controlled input, not a complete validator for arbitrary files or a general recovery tool.
Invalid integer text also raises ValueError. Decide how your application reports that validation failure rather than catching it and substituting an empty mapping. This version assumes unique names in the input object; the duplicate-name check below is needed when that assumption is not guaranteed.
Check for collisions before you serialize
The more serious case is a dictionary containing both the integer 1 and the string "1". They are different Python keys, but both become the JSON name "1". Python's decoder accepts repeated object names by default and retains the last value. A normal load can therefore discard information.
import json
original = {1: "integer record", "1": "string record"}
wire = json.dumps(original)
print(wire)
restored = json.loads(wire)
assert restored == {"1": "string record"}
assert len(restored) == 1
print(restored)Our run emitted two occurrences of the JSON name "1", then decoded only the later value. Converting that result's key back to an integer cannot recover the earlier record. Preserve the original data and fix the producer's schema before using a conversion as a repair. Do not overwrite the only saved copy while investigating.
If all keys must be strings, validate that requirement before dumping. If integer and string identifiers really are distinct, represent identity explicitly, such as an array of records with separate kind, id and value fields. The consumer must validate those fields too. Merely calling str on every key moves the collision earlier; it does not solve it.
Reject repeated JSON names when they are not allowed
The decoder's object_pairs_hook receives each object's ordered name/value pairs before the ordinary dictionary is constructed. That gives a small parser an opportunity to reject repeated names instead of silently keeping the last one.
import json
def reject_duplicate_names(pairs):
result = {}
for name, value in pairs:
if name in result:
raise ValueError("Repeated JSON object name")
result[name] = value
return result
assert json.loads('{"7": "ready"}',
object_pairs_hook=reject_duplicate_names) == {"7": "ready"}
try:
json.loads('{"1": "first", "1": "second"}',
object_pairs_hook=reject_duplicate_names)
except ValueError as error:
print(error)The duplicate example raises the explicit repeated-name error in our fixture. This hook also runs for nested objects, so its policy applies across the parsed document. It detects duplicate JSON spellings, not every possible collision created by a later application-specific conversion. If you combine it with the integer-ID loader, use the hook on the initial load, then apply the schema validation to the intended map. Neither step reconstructs data already discarded upstream.
Why parse_int does not restore object keys
parse_int handles JSON integer number tokens. An object name such as "7" is a string token, so setting parse_int=int leaves that name as a string. It is already the default numeric conversion; it is not a key-conversion option.
Similarly, skipkeys=True is not an integer-key preservation setting. It skips unsupported key types rather than changing JSON's object-name model; ordinary integer keys are among Python's supported basic key types and still serialize as strings. Avoid replacing one silent loss with another setting whose behavior does not match the schema.
For an apparently damaged JSON file, keep this type-identity issue separate from Unicode escapes and double serialization, which concern representation layers, and nonfinite numeric values, which concern number compatibility. A key mismatch is not repaired by changing either option.
Verify the result you actually need
- Keep the original mapping or an untouched source copy.
- Inspect the key types before serialization and after loading.
- Check for distinct keys that would share a JSON name.
- Choose string IDs or an explicit typed-record schema; restore integer keys only where the contract requires them.
- Verify record count, representative identities and values after saving and reopening.
The standard library and official documentation are free alternatives for this task; no paid converter is needed. The historical reports establish a real reader problem, and the later documentation request shows that the rule can be overlooked. They do not measure current search demand, customer spending or the reliability of every external JSON consumer.
Method: we read the full primary tracker discussion and Python 3.13 JSON documentation, then executed the four complete examples with Python 3.9.6 on macOS. We separately checked parse_int and skipkeys behavior. These are synthetic standard-library fixtures, not an interactive application reproduction or a test of a private user file. No package, account or external API was used.