Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Formats (JSON, YAML, TOML, XML, CSV)  Module 1 – Foundations: choosing formats and handling text

UTF-8, BOMs and line endings in data files

Which encodings JSON, YAML, TOML, XML and CSV allow, removing byte-order marks before parsing, keeping line breaks in CSV fields and refusing bad characters.

  • Beginner
  • 15 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Explain which encodings JSON, YAML, TOML, XML and CSV allow and how each one declares it
  • Detect and remove a byte-order mark before parsing in Python and JavaScript
  • Handle CRLF and LF line endings without corrupting quoted values
  • Validate text for characters a format forbids, such as lone surrogates and XML control characters

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain which encodings JSON, YAML, TOML, XML and CSV allow and how each one declares it
  • Detect and remove a byte-order mark before parsing in Python and JavaScript
  • Handle CRLF and LF line endings without corrupting quoted values
  • Validate text for characters a format forbids, such as lone surrogates and XML control characters

Before you start

On this page

A spreadsheet export that starts with three invisible bytes, EF BB BF, makes Python’s csv.DictReader name its first column '\ufeffname', and row["name"] fails with KeyError although the first line of the file plainly says name. Those bytes are a byte-order mark, one of four ways the text under a data file breaks a parser before the format’s own rules even start.

You will find which encodings each of the five formats allows and how it says which one a file uses, detect and remove a byte-order mark in Python and JavaScript, keep the line breaks inside quoted CSV fields intact, and refuse the characters a format forbids, with the offset of each.

Each format fixes its encoding, and only XML can name it inside the file

A file holds bytes, and the same word has different bytes in different encodings. café is 63 61 66 c3 a9 in UTF-8 and 63 61 66 e9 in Latin-1. Read the UTF-8 bytes as Latin-1 and you get café without any error; read the Latin-1 bytes as UTF-8 and the strict decoder stops at the fourth byte. This program does both, then gives the same bytes to XML and to TOML:

One word, two encodings, three formats Python · decode/which_encoding.py
"""The same word as bytes in two encodings, read with the right and the wrong one, then given to XML and TOML."""
import tomllib
import xml.etree.ElementTree as ET

utf8 = "café".encode("utf-8")      # 63 61 66 c3 a9
latin1 = "café".encode("latin-1")  # 63 61 66 e9
print("UTF-8 bytes:  ", utf8.hex(" "), "| read as Latin-1:", utf8.decode("latin-1"))
try:
    latin1.decode("utf-8")
except UnicodeDecodeError as e:
    print("Latin-1 bytes:", latin1.hex(" "), "   | read as UTF-8:", e.reason, "at byte", e.start)

# XML can say inside the file which encoding it uses; without a declaration it must be UTF-8.
declared = b'<?xml version="1.0" encoding="ISO-8859-1"?><w>' + latin1 + b"</w>"
print("XML, declared ISO-8859-1:", ET.fromstring(declared).text)
try:
    ET.fromstring(b"<w>" + latin1 + b"</w>")
except ET.ParseError as e:
    print("XML, no declaration:     ", e)

# TOML must be UTF-8, and tomllib does not skip a byte-order mark.
try:
    tomllib.loads("\ufeffname = 'café'")
except tomllib.TOMLDecodeError as e:
    print("TOML with a BOM:", e)

Output

UTF-8 bytes:   63 61 66 c3 a9 | read as Latin-1: café
Latin-1 bytes: 63 61 66 e9    | read as UTF-8: unexpected end of data at byte 3
XML, declared ISO-8859-1: café
XML, no declaration:      not well-formed (invalid token): line 1, column 6
TOML with a BOM: Invalid statement (at line 1, column 1)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 which_encoding.py

The character encoding: The rule that turns characters into bytes and back, such as UTF-8 or UTF-16. Bytes read with the wrong encoding become other characters without an error, or stop a strict decoder. is the contract between writer and reader, and each format settles it in its own way. The XML file named its encoding in its first line, so ElementTree read Latin-1 correctly; the same bytes without that line had to be UTF-8, and they were not. TOML allows only UTF-8, so tomllib has nothing to detect, and it refused a file that started with a byte-order mark. The five formats settle it like this:

Format Encodings it allows How a file says which
JSON UTF-8, for JSON exchanged between systems that are not part of a closed ecosystem (RFC 8259, 8.1) It does not: senders must not add a BOM, and parsers may ignore one
YAML UTF-8 and UTF-16 on input, and UTF-32 for JSON compatibility; UTF-8 is the recommended output A BOM may start any document; otherwise the first bytes tell
TOML UTF-8 only; a file that is not valid UTF-8 is not TOML It does not need to
XML UTF-8 and UTF-16 in every parser, others by declaration <?xml version="1.0" encoding="ISO-8859-1"?>, or a BOM, which UTF-16 must have; neither means UTF-8
CSV Whatever the writer chose; RFC 4180 calls US-ASCII the common usage Only outside the file, in the charset parameter of text/csv

CSV is the weak one. Nothing in the file says how to read it, so the reader has to be told, by the media type’s charset, by an agreement, or by a guess. When you write data files yourself, write UTF-8: it is the only encoding all five formats accept.

Letting the platform choose the encoding

Wrong: open("export.csv") with no encoding, which uses the platform’s default and differs between computers. Right: open("export.csv", encoding="utf-8", newline=""), or the encoding the sender documented.

Checkpoint

Check: The encodings each format allows

  1. Question 1 of 2 Which of the five formats can name its own encoding inside the file?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: XML, in its declaration

    Only XML can say inside the file which encoding it uses, in its declaration or with a BOM. JSON between systems and TOML are UTF-8 by rule, YAML is detected from its first bytes, and CSV needs to be told from outside.

  2. Question 2 of 2 A customer's name shows on a page as "café". What most likely happened to it?

    Choose one answer.

    A hint

    Look at what which_encoding.py printed for the UTF-8 bytes.

    Show the answer: Question 2 of 2

    Answer: Its UTF-8 bytes were read as Latin-1

    The same bytes mean different characters in different encodings. c3 a9 is é in UTF-8 and é in Latin-1, and no decoder can tell which was meant: only the agreement between writer and reader can.

A byte-order mark is three bytes that JSON and CSV readers trip over

The byte-order mark: The character U+FEFF at the very start of a text: the bytes EF BB BF in UTF-8, FF FE or FE FF in UTF-16. It names the encoding, and many parsers refuse it unless it is removed first. is the character U+FEFF placed at the very start of a text. In UTF-16 it tells the reader the byte order, FF FE for little-endian and FE FF for big-endian; in UTF-8, where there is no byte order to tell, it is the three bytes EF BB BF. Python’s documentation calls a BOM in UTF-8 discouraged, and records that Microsoft’s Notepad wrote one to make UTF-8 files easier to recognise; some programs still write one at the start of their UTF-8 exports. This program writes a CSV file that way and reads it twice:

A CSV export with a BOM, read two ways Python · csvbom/header_bom.py
"""A CSV file that starts with a BOM, as some spreadsheet exports write it, read two ways."""
import csv

with open("branches.csv", "w", encoding="utf-8-sig", newline="") as f:
    csv.writer(f).writerows([["name", "city"], ["Deccan", "Pune"], ["Ikeja", "Lagos"]])

with open("branches.csv", "rb") as f:
    print("first bytes:", f.read(6).hex(" "))

for encoding in ["utf-8", "utf-8-sig"]:
    with open("branches.csv", encoding=encoding, newline="") as f:
        rows = list(csv.DictReader(f))
    print(f"{encoding:<10} header keys: {list(rows[0])}")
    try:
        print(f"{'':<10} first name: {rows[0]['name']}")
    except KeyError as e:
        print(f"{'':<10} KeyError: {e}")

Output

first bytes: ef bb bf 6e 61 6d
utf-8      header keys: ['\ufeffname', 'city']
           KeyError: 'name'
utf-8-sig  header keys: ['name', 'city']
           first name: Deccan

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 header_bom.py

Read as plain utf-8, the BOM became part of the first header, '\ufeffname', which looks like name when printed and fails like any other missing key. The utf-8-sig codec skips an optional UTF-8 BOM, so the second read is clean. JSON parsers react to the same three bytes in their own way, and the safe reader looks at the first bytes before decoding anything. Step through what it decides for four files:

Interactive · stage by stage From the bytes of a file to text a parser can read Step through four files. Each step says what the reader looks at and what it decides.
From the bytes of a file to text a parser can read, step 7 of 7. Showing Look at the first bytes, EF BB BF? (found: 3 bytes removed), FF FE or FE FF? (found: ff fe, little end), Decode strictly, Check the characters, Parse the clean text. In focus: Parse the clean text.no BOM: UTF-8Look at the first bytesbefore decoding anythingEF BB BF?found: 3 bytes removedFF FE or FE FF?found: ff fe, little endDecode strictlybad bytes stop the readCheck the charactersforbidden ones stop it tooParse the clean textno BOM left in it

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. A plain UTF-8 file. The first file starts 7b 22 63 69, which is already the text itself. No BOM matches, so the reader takes the default, UTF-8, as RFC 8259 asks of JSON between systems.
  2. Decoded and checked. Strict decoding succeeds, every character is allowed, and json.loads gets {"city": "Pune"}.
  3. A UTF-8 BOM. The second file starts ef bb bf 7b. Those three bytes are U+FEFF, the byte-order mark. The reader removes them and decodes the rest as UTF-8.
  4. A UTF-16LE BOM. The third file starts ff fe 7b 00: a UTF-16 BOM with the low byte first, so every character takes two bytes, 7b 00 for {. The reader removes the BOM and decodes as utf-16-le.
  5. Without the reader. Decoded as plain UTF-8 instead, the BOM would stay as the character U+FEFF, and json.loads would stop at char 0 with Unexpected UTF-8 BOM.
  6. A forbidden character. A note pasted from another program holds a vertical tab, U+000B, at offset 4. It decodes fine, but XML 1.0 allows no such character, so the check stops the file there.
  7. Only clean text goes on. A file reaches the parser only after both checks: decoded with the encoding its BOM names, or UTF-8, and without a single character the format forbids.

The program behind that figure tests the three BOMs in turn, removes the one it finds and decodes the rest with the encoding it names, and UTF-8 when there is none:

Find the BOM, decode, then parse Python · bom/bom_sniff.py
"""Find the byte-order mark of a text, decode the text with the encoding it names, and parse the JSON inside."""
import codecs
import json

BOMS = [
    (codecs.BOM_UTF8, "utf-8"),        # EF BB BF
    (codecs.BOM_UTF16_LE, "utf-16-le"),  # FF FE
    (codecs.BOM_UTF16_BE, "utf-16-be"),  # FE FF
]


def sniff(data):
    """The encoding a BOM names and the bytes after it; UTF-8 when there is no BOM."""
    for bom, encoding in BOMS:
        if data.startswith(bom):
            return encoding, data[len(bom):], bom
    return "utf-8", data, b""


text = '{"city": "Pune"}'
files = {
    "UTF-8": text.encode("utf-8"),
    "UTF-8 with BOM": codecs.BOM_UTF8 + text.encode("utf-8"),
    "UTF-16LE with BOM": codecs.BOM_UTF16_LE + text.encode("utf-16-le"),
    "UTF-16BE with BOM": codecs.BOM_UTF16_BE + text.encode("utf-16-be"),
}
for name, data in files.items():
    encoding, body, bom = sniff(data)
    value = json.loads(body.decode(encoding, errors="strict"))
    print(f"{name:<18} first bytes {data[:4].hex(' ')}  BOM {bom.hex(' ') or 'none':<8} {encoding:<9} {value}")

print()
try:
    json.loads(files["UTF-8 with BOM"].decode("utf-8"))
except json.JSONDecodeError as e:
    print("decoded as plain utf-8:", e)
print("decoded as utf-8-sig:", json.loads(files["UTF-8 with BOM"].decode("utf-8-sig")))
print("the bytes themselves:", json.loads(files["UTF-8 with BOM"]))  # json.loads decodes bytes and drops a UTF-8 BOM

Output

UTF-8              first bytes 7b 22 63 69  BOM none     utf-8     {'city': 'Pune'}
UTF-8 with BOM     first bytes ef bb bf 7b  BOM ef bb bf utf-8     {'city': 'Pune'}
UTF-16LE with BOM  first bytes ff fe 7b 00  BOM ff fe    utf-16-le {'city': 'Pune'}
UTF-16BE with BOM  first bytes fe ff 00 7b  BOM fe ff    utf-16-be {'city': 'Pune'}

decoded as plain utf-8: Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)
decoded as utf-8-sig: {'city': 'Pune'}
the bytes themselves: {'city': 'Pune'}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bom_sniff.py

A UTF-16 file without a BOM

Press Edit and add a fifth file, "UTF-16LE, no BOM": text.encode("utf-16-le"), then run it. sniff() finds no BOM and decodes the bytes as UTF-8, which succeeds, and json.loads stops at char 1 with Expecting property name: the zero byte after every character is the only sign of UTF-16 left, and a reader that looks for a BOM alone misses it.

json.loads refused the BOM in text that had already been decoded, with a message that names the fix, decode using utf-8-sig. Given the bytes themselves, as in the last line, it sniffs them for you: it reads UTF-8, UTF-16 and UTF-32 and drops a UTF-8 BOM. In JavaScript the default TextDecoder removes a BOM for you, but Buffer.toString() and ignoreBOM: true keep it, and JSON.parse then fails on it. Decoding UTF-16 bytes as UTF-8 does not fail at once: each bad byte becomes U+FFFD, the replacement character:

The same files in Node.js JavaScript · bom/bom_sniff.mjs
// The same files in Node.js: TextDecoder removes a BOM by default; other ways of decoding keep it.
const json = new TextEncoder().encode('{"city": "Pune"}');
const withBom = new Uint8Array([0xef, 0xbb, 0xbf, ...json]);
const utf16le = new Uint8Array([0xff, 0xfe, ...[...'{"city": "Pune"}'].flatMap((c) => [c.charCodeAt(0), 0])]);

const hex = (bytes) => [...bytes.slice(0, 4)].map((b) => b.toString(16).padStart(2, '0')).join(' ');
// The first characters as code points, so an invisible BOM (U+FEFF) or a NUL (U+0000) shows.
const start = (text) => [...text.slice(0, 3)].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');
const parsed = (text) => {
  try {
    return JSON.stringify(JSON.parse(text));
  } catch (e) {
    return e.name;
  }
};
const show = (label, text) => console.log(`${label.padEnd(32)} starts ${start(text)}  ${parsed(text)}`);

console.log(`UTF-8 with BOM, bytes ${hex(withBom)} ...`);
show('TextDecoder()', new TextDecoder().decode(withBom));
show('TextDecoder({ ignoreBOM: true })', new TextDecoder('utf-8', { ignoreBOM: true }).decode(withBom));
show("Buffer.toString('utf8')", Buffer.from(withBom).toString('utf8'));
console.log(`UTF-16LE with BOM, bytes ${hex(utf16le)} ...`);
show("TextDecoder('utf-16le')", new TextDecoder('utf-16le').decode(utf16le));
show("TextDecoder('utf-8')", new TextDecoder('utf-8').decode(utf16le));

Output

UTF-8 with BOM, bytes ef bb bf 7b ...
TextDecoder()                    starts U+007B U+0022 U+0063  {"city":"Pune"}
TextDecoder({ ignoreBOM: true }) starts U+FEFF U+007B U+0022  SyntaxError
Buffer.toString('utf8')          starts U+FEFF U+007B U+0022  SyntaxError
UTF-16LE with BOM, bytes ff fe 7b 00 ...
TextDecoder('utf-16le')          starts U+007B U+0022 U+0063  {"city":"Pune"}
TextDecoder('utf-8')             starts U+FFFD U+FFFD U+007B  SyntaxError

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node bom_sniff.mjs

Hex Viewer & File Inspector Open a data file as bytes and look for EF BB BF, FF FE or a stray 0B before you parse it.

Key idea

Look at the first bytes before decoding: remove a BOM and use the encoding it names, or UTF-8 when there is none, and never write a BOM into JSON yourself.

Checkpoint

Check: Finding and removing a BOM

  1. Question 1 of 2 With which bytes does a UTF-8 file that has a byte-order mark begin?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: EF BB BF

    The byte-order mark is always the character U+FEFF; its bytes depend on the encoding: EF BB BF in UTF-8, FF FE in UTF-16LE and FE FF in UTF-16BE.

  2. Question 2 of 2 branches.csv starts with EF BB BF. Which call makes csv.DictReader(f) give the header key "name"?

    Read the code, then choose one answer.

    with open("branches.csv", ???) as f:
        rows = list(csv.DictReader(f))
    Show the answer: Question 2 of 2

    Answer: encoding="utf-8-sig", newline=""

    header_bom.py read the same file both ways: utf-8 gave the key '\ufeffname' and a KeyError, utf-8-sig gave "name". Use utf-8-sig for files that may start with a BOM, and newline="" for every CSV file.

CRLF ends CSV records, and quoted fields keep their own line breaks

A line ending: The characters that end a line of text: LF (U+000A) on Linux and macOS, CR LF (U+000D U+000A) on Windows and at the end of every RFC 4180 CSV record. is LF (\n) on Linux and macOS and CR LF (\r\n) on Windows, and RFC 4180 ends every CSV record with CRLF. A field may contain a line break of its own, inside quotes: a postal address over two lines is the usual case. This program writes such a field with Python’s csv module and reads it back twice:

A two-line address in CSV, and in XML Python · newlines/newline_demo.py
"""A CSV field with a line break inside it: written by the csv module, then read back two ways."""
import csv
import xml.etree.ElementTree as ET

with open("addresses.csv", "w", encoding="utf-8", newline="") as f:
    csv.writer(f).writerows([["branch", "address"], ["Deccan", "12 FC Road\r\nPune 411004"]])

with open("addresses.csv", "rb") as f:
    print("bytes on disk:", f.read())

with open("addresses.csv", encoding="utf-8", newline="") as f:
    print("newline='':   ", repr(list(csv.reader(f))[1][1]))
with open("addresses.csv", encoding="utf-8") as f:  # the default: universal newlines
    print("default:      ", repr(list(csv.reader(f))[1][1]))

# XML normalises every line break before the application sees it.
print("XML:          ", repr(ET.fromstring(b"<address>12 FC Road\r\nPune 411004</address>").text))

Output

bytes on disk: b'branch,address\r\nDeccan,"12 FC Road\r\nPune 411004"\r\n'
newline='':    '12 FC Road\r\nPune 411004'
default:       '12 FC Road\nPune 411004'
XML:           '12 FC Road\nPune 411004'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 newline_demo.py

The bytes on disk show both kinds of line break: \r\n after each record, and the \r\n inside the quoted address, because csv.writer uses '\r\n' as its line terminator by default. Opened with newline='', as the csv module’s documentation asks, the field came back exactly. Opened with the default, Python’s universal newlines turned the \r\n inside the field into \n before the csv module ever saw it, so the address changed without an error, and a checksum of the row would no longer match.

The other formats each settle line breaks in their own rules:

Format What happens to a CR LF
CSV It ends a record; inside quotes it is data and must survive, so read with newline=''
XML Every CR LF and every lone CR becomes LF before the application sees the text (XML 1.0, 2.11), as the last line of the output shows
YAML Line breaks inside scalar content are normalised to a single LF (YAML 1.2.2, 5.4)
TOML A newline is LF or CRLF, and both are accepted
JSON Between tokens it is whitespace; inside a string a raw line break is not allowed and must be written \n or \r

Opening CSV files without newline=''

Wrong: open("addresses.csv", encoding="utf-8"), which rewrites the line breaks inside quoted fields. Right: open("addresses.csv", encoding="utf-8", newline=""), for reading and for writing, so the csv module handles every line break itself.

Checkpoint

Check: Line endings and quoted fields

  1. One question A quoted CSV field holds "12 FC Road\r\nPune 411004". Which way of opening the file gives it back unchanged?

    Choose one answer.

    Show the answer: One question

    Answer: open(path, encoding="utf-8", newline="")

    The csv module documentation asks for newline="" because it does its own newline handling. With the default, universal newlines rewrite the field's \r\n before the csv module sees it, so the data changes without an error.

Some characters are not allowed at all: lone surrogates and control codes

Decoding can succeed and still leave characters the format refuses. Text pasted from other programs can bring control characters with it, such as a vertical tab, U+000B, or U+0001, and XML 1.0 allows neither. Its production [2] Char admits tab, line feed and carriage return, then every code point from U+0020 to U+D7FF, from U+E000 to U+FFFD and from U+10000 up, which leaves out the other C0 controls, the surrogates, U+FFFE and U+FFFF. This program finds them with their offsets before any XML is written:

Find the characters XML 1.0 forbids Python · forbidden/forbidden_scan.py
"""Find the characters that XML 1.0 does not allow, with their offsets, before writing text into XML."""
import xml.etree.ElementTree as ET


def xml_char(cp):
    """XML 1.0, production [2] Char: tab, LF, CR and the ranges below, which leave out surrogates, FFFE and FFFF."""
    return cp in (0x9, 0xA, 0xD) or 0x20 <= cp <= 0xD7FF or 0xE000 <= cp <= 0xFFFD or 0x10000 <= cp <= 0x10FFFF


def forbidden(text):
    """(offset, code point) of every character XML 1.0 forbids."""
    return [(i, f"U+{ord(c):04X}") for i, c in enumerate(text) if not xml_char(ord(c))]


note = "Gift\x0bwrap\x01 please\ufffe"  # a vertical tab, a stray control and a noncharacter, as pasted text brings
print(forbidden(note))
print(forbidden("Tab\tand\r\nnew lines are fine"))

try:
    ET.fromstring(f"<note>{note}</note>")
except ET.ParseError as e:
    print("ElementTree:", e)

Output

[(4, 'U+000B'), (9, 'U+0001'), (17, 'U+FFFE')]
[]
ElementTree: not well-formed (invalid token): line 1, column 10

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 forbidden_scan.py

ElementTree stopped at column 10, the vertical tab, because a file holding it is not well-formed: an XML parser must refuse the whole document, not skip the character. The scan names all three offenders, 4, 9 and 17, so they can be removed or replaced before writing.

JSON has a different gap. Its \u escapes can name half of a surrogate pair, such as \ud800, and RFC 8259 calls the behaviour of software that receives one unpredictable. A lone surrogate: A code point from U+D800 to U+DFFF that is not part of a pair. UTF-16 uses two of them together for one character above U+FFFF; on its own, one is not a character and has no UTF-8 form. is not a character, so it has no UTF-8 form:

A lone surrogate in Python Python · surrogates/lone_surrogate.py
"""A JSON escape for half of a surrogate pair: legal JSON text, but not a character."""
import json

pair = json.loads(r'"\ud83d\udcda"')  # a surrogate pair: one character, U+1F4DA (books)
lone = json.loads(r'"\ud800"')  # the first half alone
print("pair:", len(pair), f"U+{ord(pair):X}", pair.encode("utf-8"))
print("lone:", len(lone), f"U+{ord(lone):04X}")
print("dumps, ensure_ascii=True: ", json.dumps(lone))
try:
    lone.encode("utf-8")
except UnicodeEncodeError as e:
    print("encode('utf-8'):", e.reason)
try:
    with open("names.json", "w", encoding="utf-8") as f:
        f.write(json.dumps({"name": lone}, ensure_ascii=False))
except UnicodeEncodeError as e:
    print("writing the file:", e.reason)

Output

pair: 1 U+1F4DA b'\xf0\x9f\x93\x9a'
lone: 1 U+D800
dumps, ensure_ascii=True:  "\ud800"
encode('utf-8'): surrogates not allowed
writing the file: surrogates not allowed

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lone_surrogate.py

Python kept the half in a str and failed only when it had to write UTF-8, possibly far from where the data came in. JavaScript strings are UTF-16 code units, so a lone half is allowed in a string; JSON.stringify escapes it again, as ES2019’s well-formed JSON.stringify requires, but TextEncoder silently writes EF BF BD, the replacement character, so the data changes without a word:

The same escape in Node.js JavaScript · surrogates/lone_surrogate.mjs
// The same escape in JavaScript, whose strings are UTF-16 code units: a lone half is allowed in a string.
const lone = JSON.parse('"\\ud800"');
const hex = (s) => [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');
console.log('lone:', lone.length, hex(lone), 'well formed:', lone.isWellFormed());
console.log('JSON.stringify:', JSON.stringify(lone));
console.log('TextEncoder bytes:', [...new TextEncoder().encode(lone)].map((b) => b.toString(16)).join(' '));
console.log('toWellFormed():', hex(lone.toWellFormed()));

Output

lone: 1 U+D800 well formed: false
JSON.stringify: "\ud800"
TextEncoder bytes: ef bf bd
toWellFormed(): U+FFFD

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node lone_surrogate.mjs

RFC 9839, a Proposed Standard, gives these sets names that a format or an API can require: Unicode Scalars (no surrogates), XML Characters (the Char production above) and Unicode Assignables, which also leaves out every control except tab, LF and CR and every noncharacter. Pick one for your format, check it where text enters, and refuse what falls outside it with the offset, as the scan does.

Check at the boundary

Refuse forbidden characters where data enters your program, with the offset in the message. A check that runs only when a file is written fails later, far from the input that caused it, and can leave half a file behind.

Checkpoint

Check: Characters a format forbids

  1. Question 1 of 2 Which of these characters may appear in the text of an XML 1.0 document?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • U+0009, a tab
    • U+00E9, é

    XML 1.0's production [2] Char is tab, LF, CR, then U+0020 to U+D7FF, U+E000 to U+FFFD and U+10000 upwards. Every other code point makes the document not well-formed.

  2. Question 2 of 2 In Node.js, JSON.parse('"\\ud800"') gives a one-unit string. What does new TextEncoder().encode() make of it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: EF BF BD, the replacement character U+FFFD

    A lone surrogate is not a character, so it has no UTF-8 form. Python refused to encode it, while TextEncoder replaced it with U+FFFD. Check for lone surrogates where text enters, so neither surprise happens later.

Practise: decode a data file safely

The exercise puts the lesson together in one function: it looks for a BOM, decodes strictly with the encoding the BOM names, and refuses every character XML 1.0 forbids, with its code point and offset, as the scan printed them:

Exercise · Easy · Python

Decode a data file safely before parsing it

Write normalise_text(data) in normalise.py. It takes the bytes of a data file and returns the text a parser should see:

  • A UTF-8 BOM (EF BB BF), a UTF-16LE BOM (FF FE) or a UTF-16BE BOM (FE FF) at the very start names the encoding and is removed. Without a BOM the bytes are UTF-8.
  • Decoding is strict: bytes that are not valid in the encoding raise ValueError (a UnicodeDecodeError is one).
  • Every character that XML 1.0 does not allow raises ValueError with the message forbidden character U+000B at offset 4: the code point in four or more hexadecimal capitals and its offset in the returned text. Tab, line feed and carriage return are allowed; the rest of the C0 controls, surrogates, U+FFFE and U+FFFF are not.
  • Nothing else changes: a U+FEFF that is not at the start stays, and so do line endings and characters above U+FFFF, such as U+1F4DA.

Starter code · normalise.py

import codecs


def normalise_text(data):
    """The text of `data` for a parser: BOM removed, decoded strictly, XML-forbidden characters refused."""
    return data.decode("utf-8")
The sample tests · test_normalise.py
import codecs

from normalise import normalise_text

TEXT = '{"city": "Pune"}'


def raises_value_error(data):
    """The message of the ValueError normalise_text(data) raises, or None."""
    try:
        normalise_text(data)
    except ValueError as e:
        return str(e)
    return None


def test_plain_utf8():
    """decodes UTF-8 without a BOM unchanged"""
    assert normalise_text(TEXT.encode("utf-8")) == TEXT
    assert normalise_text("caf\u00e9 \u20b9".encode("utf-8")) == "caf\u00e9 \u20b9"


def test_utf8_bom_removed():
    """removes a UTF-8 BOM"""
    assert normalise_text(codecs.BOM_UTF8 + TEXT.encode("utf-8")) == TEXT


def test_utf16_boms():
    """decodes UTF-16LE and UTF-16BE named by their BOM"""
    assert normalise_text(codecs.BOM_UTF16_LE + TEXT.encode("utf-16-le")) == TEXT
    assert normalise_text(codecs.BOM_UTF16_BE + TEXT.encode("utf-16-be")) == TEXT


def test_empty():
    """gives an empty string for no bytes, or for a BOM alone"""
    assert normalise_text(b"") == ""
    assert normalise_text(codecs.BOM_UTF8) == ""


def test_invalid_utf8():
    """refuses bytes that are not UTF-8"""
    assert raises_value_error(b"caf\xc3(") is not None


def test_forbidden_controls():
    """refuses a C0 control with its code point and offset"""
    assert raises_value_error("Gift\x0bwrap".encode("utf-8")) == "forbidden character U+000B at offset 4"
    assert raises_value_error(b"ok\x01") == "forbidden character U+0001 at offset 2"


def test_noncharacter():
    """refuses U+FFFE, counting the offset after the BOM is removed"""
    data = codecs.BOM_UTF8 + "ab\ufffe".encode("utf-8")
    assert raises_value_error(data) == "forbidden character U+FFFE at offset 2"


def test_allowed_whitespace_and_inner_bom():
    """keeps tab, CR, LF and a U+FEFF that is not at the start"""
    text = "a\tb\r\nc\ufeffd"
    assert normalise_text(text.encode("utf-8")) == text


def test_characters_above_ffff():
    """keeps a character above U+FFFF, in UTF-8 and in UTF-16 with a BOM"""
    text = "books \U0001f4da"
    assert normalise_text(text.encode("utf-8")) == text
    assert normalise_text(codecs.BOM_UTF16_LE + text.encode("utf-16-le")) == text
A hint

Test the three BOMs with data.startswith(codecs.BOM_UTF8) and its two UTF-16 siblings, and decode the rest with data[len(bom):].decode(encoding), whose default is already strict. Then walk the text with for i, ch in enumerate(text) and test ord(ch) against the ranges of the lesson's xml_char().

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then try the questions on encodings, byte-order marks, line endings and forbidden characters. A wrong answer brings back that idea before the others, with the reason spelt out:

Practice that adapts to you

Practise this lesson

13 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 Which encoding may a TOML file use?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: UTF-8 only

    TOML allows exactly one encoding, UTF-8. which_encoding.py also showed that tomllib reads a leading U+FEFF as part of the text and fails at line 1, column 1.

  2. Question 2 of 13 A partner sends CSV files and says nothing about their encoding. Where does RFC 4180 expect the encoding to be stated?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: In the charset parameter of the text/csv media type

    CSV is the one format of the five whose files cannot say how to read them. The charset parameter of text/csv, or a written agreement, has to; otherwise the reader guesses.

  3. Question 3 of 13 An XML file has no BOM and no encoding declaration, and it holds the single byte E9 for é. What must a conforming parser do?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: Report a fatal error, because the file must then be UTF-8 and E9 alone is not

    XML 1.0 makes UTF-8 the default when neither a BOM nor a declaration names an encoding, and an entity in another encoding without either is a fatal error. Add encoding="ISO-8859-1" to the declaration, or convert the file to UTF-8.

  4. Question 4 of 13 What does Python's json.loads report for a text that starts with U+FEFF?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: Unexpected UTF-8 BOM (decode using utf-8-sig)

    bom_sniff.py recorded this message for text decoded with the BOM kept. Decode with utf-8-sig, or hand json.loads the bytes themselves: it decodes them and drops a UTF-8 BOM, as the last line of the run showed.

  5. Question 5 of 13 In Node.js, which ways of turning the bytes EF BB BF 7B … into a string keep the BOM, so that JSON.parse fails?

    Choose every answer that is right.

    Show the answer: Question 5 of 13

    Answer:

    • Buffer.from(bytes).toString('utf8')
    • new TextDecoder('utf-8', { ignoreBOM: true }).decode(bytes)

    bom_sniff.mjs printed U+FEFF as the first character for Buffer and for ignoreBOM: true, and a clean start for the default TextDecoder, the one to use for data files.

  6. Question 6 of 13 A file starts FF FE 7B 00, but a program decodes it with TextDecoder("utf-8"). What does it get?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: Replacement characters U+FFFD for the BOM, then the text with a NUL after every character

    The run printed U+FFFD U+FFFD U+007B as the first characters, and JSON.parse failed. Sniffing the BOM first, as bom_sniff.py does, picks utf-16-le and reads the object.

  7. Question 7 of 13 How does RFC 4180 end each CSV record?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: With CRLF

    RFC 4180 records end with CRLF, and Python's csv.writer writes '\r\n' by default, as the bytes on disk in newline_demo.py showed.

  8. Question 8 of 13 In newline_demo.py, what did the default open() give back for the address field?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: '12 FC Road\nPune 411004'

    The field kept its two lines but lost its CR. A checksum or a comparison with the source would report the row as changed, although nobody edited it.

  9. Question 9 of 13 What does an XML parser hand the application for a CR LF inside an element's text?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: A single LF

    XML's end-of-line handling makes line breaks the same on every system. To keep a CR in XML text, write it as the character reference &#13;.

  10. Question 10 of 13 A JSON string value must hold a two-line address. How must the line break be written inside the string?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: As the escape \n (or \r\n), never as a raw line break

    Inside a JSON string, LF and CR are control characters that must be escaped. That is why line ending conversion never changes JSON data: the line breaks inside values are escapes, not raw bytes.

  11. Question 11 of 13 Why did ElementTree refuse the note with the vertical tab instead of skipping the character?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: A forbidden character makes the document not well-formed, a fatal error

    Well-formedness errors are fatal in XML, so the reader refuses the document and reports the position, column 10 here. Remove or replace such characters before writing XML.

  12. Question 12 of 13 In Python, json.loads(r'"\ud800"') succeeds. When does the lone surrogate make the program fail?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: When the string is encoded as UTF-8, for example while writing a file

    The failure comes late, wherever the text is first written as UTF-8, far from the input that caused it. Refuse lone surrogates where data enters, as the I-JSON rules and RFC 9839's subsets suggest.

  13. Question 13 of 13 An API stores names in JSON now and in XML documents later. Which RFC 9839 subset is not enough for its checks?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Unicode Scalars

    RFC 9839 names three subsets, from least to most restrictive: Unicode Scalars, XML Characters and Unicode Assignables. Data that will end up in XML needs at least the XML Characters check where it enters.

Interview questions

Warm-up (fresher to 2–5 years): what is a byte-order mark, and why does JSON care?

A byte-order mark is the character U+FEFF at the very start of a text. In UTF-16 it says which byte comes first, FF FE for little-endian and FE FF for big-endian; in UTF-8 it is the three bytes EF BB BF, which can only mark the text as UTF-8, because UTF-8 has no byte order. RFC 8259 says JSON sent between systems must be UTF-8 and that implementations must not add a BOM, though parsers may ignore one. Given text that starts with U+FEFF, Python’s json.loads refuses it with “Unexpected UTF-8 BOM (decode using utf-8-sig)”, and JSON.parse refuses it too. The fix is to remove it while decoding, with the utf-8-sig codec in Python or the default TextDecoder in JavaScript, and never to write one.

Two harder questions follow, about an import that broke when the exporter changed and about addresses that changed in a round trip. Decide what you would check first, then open each first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A nightly import reads a CSV export with csv.DictReader. Since the export moved to a new tool, it fails with KeyError: 'name', although the first line of the file still reads name,city. What happened, and how do you fix the import and protect it from the next change of exporter?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A job reads a CSV file, changes one column and writes it back. A checksum comparison then reports every row with a two-line address as changed, although nobody edited the addresses. Where did the change happen, and how would you make the round trip exact?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Which encoding must JSON use when it travels between systems?

    UTF-8, by RFC 8259. A sender must not add a byte-order mark; a parser may ignore one.

  2. How does each format say which encoding a file uses?

    XML can name it in its declaration, YAML detects it from the first bytes, TOML is always UTF-8, JSON between systems is UTF-8, and CSV says nothing inside the file: only the charset parameter of text/csv can.

  3. What bytes does a byte-order mark take in UTF-8 and in UTF-16?

    U+FEFF is EF BB BF in UTF-8. In UTF-16 it is FF FE for little-endian and FE FF for big-endian.

  4. How do you get rid of a BOM before parsing JSON or CSV?

    Decode with the utf-8-sig codec in Python, or with JavaScript's TextDecoder, which drops it by default. json.loads refuses text that still begins with U+FEFF.

  5. Why open a CSV file with newline='' in Python?

    So the csv module sees the line endings itself: CR LF ends a record, and the line breaks of a quoted value are data that must come through unchanged.

  6. What do XML and YAML parsers do with CR LF?

    They normalise line breaks, so a CR LF, or in XML a lone CR, reaches your program as a single LF.

  7. How can a JSON string hold a character that UTF-8 cannot encode?

    A \u escape can name a lone surrogate such as \uD800, which has no UTF-8 form. XML 1.0 also forbids most control characters, U+FFFE and U+FFFF, so check characters where data enters.

Key takeaways

  • The same characters have different bytes in different encodings; JSON between systems and TOML must be UTF-8, XML can name its encoding in its declaration, YAML detects it from the first bytes, and CSV says nothing inside the file.
  • A byte-order mark is U+FEFF at the start of a text, EF BB BF in UTF-8; remove it before parsing with utf-8-sig or TextDecoder, and never write one into JSON.
  • CSV ends records with CRLF and keeps line breaks inside quoted fields, so open CSV files with newline=''; XML and YAML normalise line breaks to LF themselves.
  • XML 1.0 forbids most control characters, the surrogates, U+FFFE and U+FFFF; a JSON escape can name a lone surrogate, which has no UTF-8 form, so check characters where data enters and report the offset.

Terms in this lesson

byte-order mark
The character U+FEFF at the very start of a text: the bytes EF BB BF in UTF-8, FF FE or FE FF in UTF-16. It names the encoding, and many parsers refuse it unless it is removed first.
character encoding
The rule that turns characters into bytes and back, such as UTF-8 or UTF-16. Bytes read with the wrong encoding become other characters without an error, or stop a strict decoder.
line ending
The characters that end a line of text: LF (U+000A) on Linux and macOS, CR LF (U+000D U+000A) on Windows and at the end of every RFC 4180 CSV record.
lone surrogate
A code point from U+D800 to U+DFFF that is not part of a pair. UTF-16 uses two of them together for one character above U+FFFF; on its own, one is not a character and has no UTF-8 form.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.