Your file is not broken: json.dump() escapes every non-ASCII character by default, so the word "кофе" lands on disk as \u043a\u043e\u0444\u0435. To keep letters as letters you must turn two separate knobs — ensure_ascii=False inside json.dump() and encoding="utf-8" inside open(); either one alone fails, and each partial fix fails in its own way.
Why does json.dump write Cyrillic as \u0430-style escapes?
Because json.dump() and json.dumps() take an ensure_ascii parameter that defaults to True. In that mode the module guarantees pure ASCII output, so every character outside ASCII is replaced by a \uXXXX escape sequence. Cyrillic falls under that rule entirely: "товар" becomes \u0442\u043e\u0432\u0430\u0440 and "Москва" becomes \u041c\u043e\u0441\u043a\u0432\u0430. This is documented standard-library behaviour, not an encoding failure and not a bug in your code.
Here is the exact situation. Note that encoding="utf-8" is already present — and it does not help.
import json
coffee = {"товар": "кофе 250 г", "цена": 349, "склад": "Москва"}
with open("prices.json", "w", encoding="utf-8") as f:
json.dump(coffee, f)
print(open("prices.json", encoding="utf-8").read())
{"\u0442\u043e\u0432\u0430\u0440": "\u043a\u043e\u0444\u0435 250 \u0433", "\u0446\u0435\u043d\u0430": 349, "\u0441\u043a\u043b\u0430\u0434": "\u041c\u043e\u0441\u043a\u0432\u0430"}
The file is not broken: it round-trips without loss
Before fixing anything, check that there is something to fix. \uXXXX sequences are part of the JSON specification, not garbage — every conforming parser must expand them. Feed the exact file contents to json.loads() and watch.
import json
text = r'{"\u0442\u043e\u0432\u0430\u0440": "\u043a\u043e\u0444\u0435", "\u0446\u0435\u043d\u0430": 349}'
data = json.loads(text)
print(data)
print(data["товар"], data["цена"])
{'товар': 'кофе', 'цена': 349}
кофе 349
Strings came back intact, keys too. If only your own program reads the file, you do not have to change anything. Escaping hurts in exactly two places: when a human opens the file, and when a reviewer reads it in a diff — readability, not correctness.
ensure_ascii controls escaping, not bytes
The first knob lives inside the json module and answers one question: should non-ASCII characters be turned into \uXXXX? It has nothing to do with files or encodings, and the effect is visible on an in-memory string before anything is written.
import json
word = {"город": "Москва"}
print(json.dumps(word))
print(json.dumps(word, ensure_ascii=False))
{"\u0433\u043e\u0440\u043e\u0434": "\u041c\u043e\u0441\u043a\u0432\u0430"}
{"город": "Москва"}
Both are plain str objects, both are valid JSON, and both parse into the same dict. The only difference is how the text looks. The reference handbook collects the serialization basics under JSON serialization.
encoding controls bytes, not escaping
The second knob is not in json at all — it is in open(), and it decides which bytes hit the disk. Look at the file in binary mode: no str, no escape sequences, just bytes.
import json
word = {"город": "Москва"}
with open("city.json", "w", encoding="utf-8") as f:
json.dump(word, f, ensure_ascii=False)
print(open("city.json", "rb").read())
b'{"\xd0\xb3\xd0\xbe\xd1\x80\xd0\xbe\xd0\xb4": "\xd0\x9c\xd0\xbe\xd1\x81\xd0\xba\xd0\xb2\xd0\xb0"}'
Each Cyrillic letter took two bytes — that is UTF-8. Latin letters, quotes and colons stayed single-byte. The characters-versus-bytes split is covered in Text encoding and decoding.
Four combinations of the two settings
Almost every forum answer stops at one of the first three rows. You want the fourth.
ensure_ascii |
encoding in open() |
What lands in the file |
Symptom |
True (default) |
omitted |
\u043a\u043e\u0444\u0435 |
Always works, never crashes, painful to read |
True (default) |
"utf-8" |
\u043a\u043e\u0444\u0435 |
The case that brought you here: encoding set, escapes remain |
False |
omitted |
depends on locale |
UnicodeEncodeError on English Windows; a silent cp1251 file on Russian Windows that then breaks every reader expecting UTF-8 |
False |
"utf-8" |
кофе |
What you want |
Want indentation too? Add a third argument: json.dump(data, f, ensure_ascii=False, indent=2) on a file opened with encoding="utf-8".
Why do I get UnicodeEncodeError: 'charmap' codec on Windows?
Because you turned one knob out of two. When open() gets no encoding, Python takes the encoding from the operating system locale: UTF-8 on Linux and macOS, a single-byte code page on Windows. On English or Western European Windows that page is cp1252, which has no Cyrillic at all, so the codec dies on the first Russian letter. While ensure_ascii was still on, this never happened — escape sequences are pure ASCII and fit into any code page.
You can reproduce it on any OS by naming cp1252 explicitly:
import json
coffee = {"товар": "кофе", "цена": 349}
try:
with open("prices.json", "w", encoding="cp1252") as f:
json.dump(coffee, f, ensure_ascii=False)
except UnicodeEncodeError as e:
print(f"{type(e).__name__}: {e}")
UnicodeEncodeError: 'charmap' codec can't encode characters in position 1-5: character maps to <undefined>
One edit fixes it: encoding="utf-8" in open(). To see what your own machine defaults to, run python -c "import locale; print(locale.getpreferredencoding(False))". The mirror problem shows up while reading: the same encoding mismatch gives either UnicodeDecodeError or silent mojibake, which is covered in the article on cp1251 versus UTF-8 when reading files.
What is the difference between json.dump and json.dumps?
json.dump() writes straight into a file object, while json.dumps() — with an s for string — returns a string and writes nothing. They share the ensure_ascii parameter, but only the first has a second knob, because the encoding comes from the open() call you handed it. If you return JSON in an HTTP response or push it into a queue there is no file at all: only ensure_ascii applies, and the bytes are the transport's problem, declared by Content-Type: application/json; charset=utf-8.
import json
user = {"имя": "Ольга", "город": "Казань"}
as_text = json.dumps(user, ensure_ascii=False)
print(type(as_text).__name__)
print(as_text)
with open("user.json", "w", encoding="utf-8") as f:
json.dump(user, f, ensure_ascii=False)
print(open("user.json", encoding="utf-8").read())
str
{"имя": "Ольга", "город": "Казань"}
{"имя": "Ольга", "город": "Казань"}
How do I read a JSON file with Cyrillic back?
Reading needs no special json settings: ensure_ascii is a write-side parameter only, and json.load() parses escaped sequences and literal Cyrillic exactly the same way. The one thing worth spelling out is encoding in open(), for the same reason as on the write side. One extra case: a file exported from Excel or a vendor system may start with an invisible BOM, and then parsing fails.
import json
with open("from_vendor.json", "wb") as f:
f.write('\ufeff{"склад": "Москва"}'.encode("utf-8"))
try:
with open("from_vendor.json", encoding="utf-8") as f:
json.load(f)
except json.JSONDecodeError as e:
print(e)
with open("from_vendor.json", encoding="utf-8-sig") as f:
print(json.load(f))
Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)
{'склад': 'Москва'}
The message names its own cure: encoding="utf-8-sig". On read it strips a BOM if there is one and changes nothing if there is not, which makes it a safe default for files you did not create. On write it does the exact opposite — it prepends a BOM — so keep plain encoding="utf-8" for files you produce, or the next json.load() will trip over your own byte order mark.
When are escapes actually the right choice?
Sometimes ensure_ascii=True is a requirement, not an annoyance. If the JSON goes into a legacy system, an ASCII-filtered log, a single-byte database column, or any channel that does not promise UTF-8, the escaped form travels untouched: it contains no byte above 127.
import json
order = {"клиент": "Ольга", "сумма": 1290}
ascii_text = json.dumps(order)
utf8_text = json.dumps(order, ensure_ascii=False)
print(ascii_text.isascii(), utf8_text.isascii())
print(len(ascii_text), len(utf8_text))
print(json.loads(ascii_text) == json.loads(utf8_text))
True False
114 34
True
The trade-off is visible in the numbers: the escaped payload is three times longer but pure ASCII. Both parse into the same dict, so the choice is about transport, not about data.
Common mistakes
- Setting
ensure_ascii=False and leaving open() alone. Cyrillic is no longer escaped, so it now has to be encoded — you get UnicodeEncodeError on English Windows and a silent cp1251 file on Russian Windows. Add encoding="utf-8".
- Setting
encoding="utf-8" and forgetting ensure_ascii=False. The bytes are right, the escapes are still there: escaping happens first, so the encoder never sees a Cyrillic character. Add ensure_ascii=False.
- Trying to "decode" the file with
decode("unicode_escape"). It looks like it works on a pure-ASCII string, but as soon as real Cyrillic is present it silently mangles the text, because it reads UTF-8 bytes as latin-1.
import json
user = {"имя": "Ольга", "город": "Казань"}
text = json.dumps(user, ensure_ascii=False)
print(repr(text.encode("utf-8").decode("unicode_escape")))
'{"имÑ\x8f": "Ð\x9eлÑ\x8cга", "гоÑ\x80од": "Ð\x9aазанÑ\x8c"}'
The only correct unpacker is json.loads().
- Writing
str(my_dict) into the file instead of json.dump(). The Cyrillic looks great and the file is not JSON: it uses single quotes.
import json
user = {"имя": "Ольга"}
with open("wrong.json", "w", encoding="utf-8") as f:
f.write(str(user))
print(open("wrong.json", encoding="utf-8").read())
try:
with open("wrong.json", encoding="utf-8") as f:
json.load(f)
except json.JSONDecodeError as e:
print(e)
{'имя': 'Ольга'}
Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
- Passing
ensure_ascii to json.load(). The answer is blunt: TypeError: JSONDecoder.__init__() got an unexpected keyword argument 'ensure_ascii'. The parameter exists on the write side only.
- Opening the file in binary mode
"wb" and handing it to json.dump(). You get TypeError: a bytes-like object is required, not 'str'. json.dump() writes text, so the mode must be "w" with an explicit encoding.
- The file is written correctly but your editor still shows mojibake. That is no longer a json problem — it is the encoding your editor guessed when opening the file.
Practice: save and reload a dict with Cyrillic
The final recipe is both knobs plus indentation, followed by a check that the data came back identical:
import json
catalog = [
{"название": "кофе Арабика", "цена": 349.0},
{"название": "чай Ассам", "цена": 219.5},
]
with open("catalog.json", "w", encoding="utf-8") as f:
json.dump(catalog, f, ensure_ascii=False, indent=2)
with open("catalog.json", encoding="utf-8") as f:
back = json.load(f)
print(back == catalog)
print(back[0]["название"])
True
кофе Арабика
Look at the last line: back is a list of dicts, so you index it as back[0]["название"], not back["название"]. Getting that wrong produces one of the most common errors when working with parsed JSON — list indices must be integers or slices, not str.
To make dict handling automatic rather than theoretical, solve two tasks in the browser: Parse a settings string turns text into a dict, and Merge identical items folds a list of dicts — the shape json exports usually have. Both are graded by a real Python 3.12 runtime, nothing to install.
In short
\u043a\u043e\u0444\u0435 in the file is not damage: it is valid JSON, and json.load() returns the original strings.
ensure_ascii=False in json.dump() and json.dumps() disables escaping and knows nothing about bytes.
encoding="utf-8" in open() sets the bytes and knows nothing about escaping.
- You need both:
json.dump(data, f, ensure_ascii=False, indent=2) with open(path, "w", encoding="utf-8").
UnicodeEncodeError: 'charmap' codec means the second knob is missing and the Windows locale picked the encoding.
- Reading needs only
encoding="utf-8", or encoding="utf-8-sig" for third-party files carrying a BOM.
- If the JSON travels through an ASCII-only channel, keep
ensure_ascii=True: longer, but it passes everywhere.
Your file is not broken:
json.dump()escapes every non-ASCII character by default, so the word "кофе" lands on disk as\u043a\u043e\u0444\u0435. To keep letters as letters you must turn two separate knobs —ensure_ascii=Falseinsidejson.dump()andencoding="utf-8"insideopen(); either one alone fails, and each partial fix fails in its own way.Why does json.dump write Cyrillic as \u0430-style escapes?
Because
json.dump()andjson.dumps()take anensure_asciiparameter that defaults toTrue. In that mode the module guarantees pure ASCII output, so every character outside ASCII is replaced by a\uXXXXescape sequence. Cyrillic falls under that rule entirely: "товар" becomes\u0442\u043e\u0432\u0430\u0440and "Москва" becomes\u041c\u043e\u0441\u043a\u0432\u0430. This is documented standard-library behaviour, not an encoding failure and not a bug in your code.Here is the exact situation. Note that
encoding="utf-8"is already present — and it does not help.import json coffee = {"товар": "кофе 250 г", "цена": 349, "склад": "Москва"} with open("prices.json", "w", encoding="utf-8") as f: json.dump(coffee, f) print(open("prices.json", encoding="utf-8").read())The file is not broken: it round-trips without loss
Before fixing anything, check that there is something to fix.
\uXXXXsequences are part of the JSON specification, not garbage — every conforming parser must expand them. Feed the exact file contents tojson.loads()and watch.import json # exactly what json.dump() wrote to the file without ensure_ascii text = r'{"\u0442\u043e\u0432\u0430\u0440": "\u043a\u043e\u0444\u0435", "\u0446\u0435\u043d\u0430": 349}' data = json.loads(text) print(data) print(data["товар"], data["цена"])Strings came back intact, keys too. If only your own program reads the file, you do not have to change anything. Escaping hurts in exactly two places: when a human opens the file, and when a reviewer reads it in a diff — readability, not correctness.
ensure_ascii controls escaping, not bytes
The first knob lives inside the
jsonmodule and answers one question: should non-ASCII characters be turned into\uXXXX? It has nothing to do with files or encodings, and the effect is visible on an in-memory string before anything is written.import json word = {"город": "Москва"} print(json.dumps(word)) print(json.dumps(word, ensure_ascii=False))Both are plain
strobjects, both are valid JSON, and both parse into the same dict. The only difference is how the text looks. The reference handbook collects the serialization basics under JSON serialization.encoding controls bytes, not escaping
The second knob is not in
jsonat all — it is inopen(), and it decides which bytes hit the disk. Look at the file in binary mode: nostr, no escape sequences, just bytes.import json word = {"город": "Москва"} with open("city.json", "w", encoding="utf-8") as f: json.dump(word, f, ensure_ascii=False) print(open("city.json", "rb").read())Each Cyrillic letter took two bytes — that is UTF-8. Latin letters, quotes and colons stayed single-byte. The characters-versus-bytes split is covered in Text encoding and decoding.
Four combinations of the two settings
Almost every forum answer stops at one of the first three rows. You want the fourth.
ensure_asciiencodinginopen()True(default)\u043a\u043e\u0444\u0435True(default)"utf-8"\u043a\u043e\u0444\u0435FalseUnicodeEncodeErroron English Windows; a silent cp1251 file on Russian Windows that then breaks every reader expecting UTF-8False"utf-8"кофеWant indentation too? Add a third argument:
json.dump(data, f, ensure_ascii=False, indent=2)on a file opened withencoding="utf-8".Why do I get UnicodeEncodeError: 'charmap' codec on Windows?
Because you turned one knob out of two. When
open()gets noencoding, Python takes the encoding from the operating system locale: UTF-8 on Linux and macOS, a single-byte code page on Windows. On English or Western European Windows that page is cp1252, which has no Cyrillic at all, so the codec dies on the first Russian letter. Whileensure_asciiwas still on, this never happened — escape sequences are pure ASCII and fit into any code page.You can reproduce it on any OS by naming cp1252 explicitly:
import json coffee = {"товар": "кофе", "цена": 349} try: with open("prices.json", "w", encoding="cp1252") as f: json.dump(coffee, f, ensure_ascii=False) except UnicodeEncodeError as e: print(f"{type(e).__name__}: {e}")One edit fixes it:
encoding="utf-8"inopen(). To see what your own machine defaults to, runpython -c "import locale; print(locale.getpreferredencoding(False))". The mirror problem shows up while reading: the same encoding mismatch gives eitherUnicodeDecodeErroror silent mojibake, which is covered in the article on cp1251 versus UTF-8 when reading files.What is the difference between json.dump and json.dumps?
json.dump()writes straight into a file object, whilejson.dumps()— with an s for string — returns a string and writes nothing. They share theensure_asciiparameter, but only the first has a second knob, because the encoding comes from theopen()call you handed it. If you return JSON in an HTTP response or push it into a queue there is no file at all: onlyensure_asciiapplies, and the bytes are the transport's problem, declared byContent-Type: application/json; charset=utf-8.import json user = {"имя": "Ольга", "город": "Казань"} as_text = json.dumps(user, ensure_ascii=False) print(type(as_text).__name__) print(as_text) with open("user.json", "w", encoding="utf-8") as f: json.dump(user, f, ensure_ascii=False) print(open("user.json", encoding="utf-8").read())How do I read a JSON file with Cyrillic back?
Reading needs no special json settings:
ensure_asciiis a write-side parameter only, andjson.load()parses escaped sequences and literal Cyrillic exactly the same way. The one thing worth spelling out isencodinginopen(), for the same reason as on the write side. One extra case: a file exported from Excel or a vendor system may start with an invisible BOM, and then parsing fails.import json # a file from another system: an invisible BOM sits before { with open("from_vendor.json", "wb") as f: f.write('\ufeff{"склад": "Москва"}'.encode("utf-8")) try: with open("from_vendor.json", encoding="utf-8") as f: json.load(f) except json.JSONDecodeError as e: print(e) with open("from_vendor.json", encoding="utf-8-sig") as f: print(json.load(f))The message names its own cure:
encoding="utf-8-sig". On read it strips a BOM if there is one and changes nothing if there is not, which makes it a safe default for files you did not create. On write it does the exact opposite — it prepends a BOM — so keep plainencoding="utf-8"for files you produce, or the nextjson.load()will trip over your own byte order mark.When are escapes actually the right choice?
Sometimes
ensure_ascii=Trueis a requirement, not an annoyance. If the JSON goes into a legacy system, an ASCII-filtered log, a single-byte database column, or any channel that does not promise UTF-8, the escaped form travels untouched: it contains no byte above 127.import json order = {"клиент": "Ольга", "сумма": 1290} ascii_text = json.dumps(order) utf8_text = json.dumps(order, ensure_ascii=False) print(ascii_text.isascii(), utf8_text.isascii()) print(len(ascii_text), len(utf8_text)) print(json.loads(ascii_text) == json.loads(utf8_text))The trade-off is visible in the numbers: the escaped payload is three times longer but pure ASCII. Both parse into the same dict, so the choice is about transport, not about data.
Common mistakes
ensure_ascii=Falseand leavingopen()alone. Cyrillic is no longer escaped, so it now has to be encoded — you getUnicodeEncodeErroron English Windows and a silent cp1251 file on Russian Windows. Addencoding="utf-8".encoding="utf-8"and forgettingensure_ascii=False. The bytes are right, the escapes are still there: escaping happens first, so the encoder never sees a Cyrillic character. Addensure_ascii=False.decode("unicode_escape"). It looks like it works on a pure-ASCII string, but as soon as real Cyrillic is present it silently mangles the text, because it reads UTF-8 bytes as latin-1.import json user = {"имя": "Ольга", "город": "Казань"} text = json.dumps(user, ensure_ascii=False) print(repr(text.encode("utf-8").decode("unicode_escape")))The only correct unpacker is
json.loads().str(my_dict)into the file instead ofjson.dump(). The Cyrillic looks great and the file is not JSON: it uses single quotes.import json user = {"имя": "Ольга"} with open("wrong.json", "w", encoding="utf-8") as f: f.write(str(user)) print(open("wrong.json", encoding="utf-8").read()) try: with open("wrong.json", encoding="utf-8") as f: json.load(f) except json.JSONDecodeError as e: print(e)ensure_asciitojson.load(). The answer is blunt:TypeError: JSONDecoder.__init__() got an unexpected keyword argument 'ensure_ascii'. The parameter exists on the write side only."wb"and handing it tojson.dump(). You getTypeError: a bytes-like object is required, not 'str'.json.dump()writes text, so the mode must be"w"with an explicit encoding.Practice: save and reload a dict with Cyrillic
The final recipe is both knobs plus indentation, followed by a check that the data came back identical:
import json catalog = [ {"название": "кофе Арабика", "цена": 349.0}, {"название": "чай Ассам", "цена": 219.5}, ] with open("catalog.json", "w", encoding="utf-8") as f: json.dump(catalog, f, ensure_ascii=False, indent=2) with open("catalog.json", encoding="utf-8") as f: back = json.load(f) print(back == catalog) print(back[0]["название"])Look at the last line:
backis a list of dicts, so you index it asback[0]["название"], notback["название"]. Getting that wrong produces one of the most common errors when working with parsed JSON — list indices must be integers or slices, not str.To make dict handling automatic rather than theoretical, solve two tasks in the browser: Parse a settings string turns text into a dict, and Merge identical items folds a list of dicts — the shape json exports usually have. Both are graded by a real Python 3.12 runtime, nothing to install.
In short
\u043a\u043e\u0444\u0435in the file is not damage: it is valid JSON, andjson.load()returns the original strings.ensure_ascii=Falseinjson.dump()andjson.dumps()disables escaping and knows nothing about bytes.encoding="utf-8"inopen()sets the bytes and knows nothing about escaping.json.dump(data, f, ensure_ascii=False, indent=2)withopen(path, "w", encoding="utf-8").UnicodeEncodeError: 'charmap' codecmeans the second knob is missing and the Windows locale picked the encoding.encoding="utf-8", orencoding="utf-8-sig"for third-party files carrying a BOM.ensure_ascii=True: longer, but it passes everywhere.