Pythonjsonencodingunicode

Python JSON: Cyrillic Saved as Unicode Escapes — Fix

Your JSON file stores the word for coffee as \u043a\u043e\u0444\u0435, which is default json.dump behaviour rather than damage. Here is what ensure_ascii controls, what encoding controls, and why you need both at once.

7 min readReferencePython · json · encoding · unicode · files

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

# 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["цена"])
{'товар': 'кофе', 'цена': 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

# 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))
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

  1. 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".
  2. 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.
  3. 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().

  1. 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)
  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.
  2. 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.
  3. 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.

Practice on real tasks

Solve tasks in the Python trainer with instant grading and hints.

Open trainer