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.

Web Scraping & Browser Automation (Python and JavaScript)  Module 2 – HTTP, sessions, pagination and APIs

Encodings: getting Hindi text and ₹ right

Find a page's encoding from its BOM, header or meta tag, repair mojibake from client defaults, write UTF-8 files and normalise text before comparing.

  • Beginner
  • 15 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • 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

  • Find a page's character encoding from its byte-order mark, its Content-Type header or its meta tag, in that order
  • Repair mojibake caused by a client's wrong default encoding, and decode legacy pages correctly
  • Write text files and CSVs with an explicit UTF-8 encoding that spreadsheet apps read correctly
  • Normalise text to NFC and clean invisible characters before comparing or storing it

Before you start

What you’ll learn · Before you start

What you will learn

  • Find a page's character encoding from its byte-order mark, its Content-Type header or its meta tag, in that order
  • Repair mojibake caused by a client's wrong default encoding, and decode legacy pages correctly
  • Write text files and CSVs with an explicit UTF-8 encoding that spreadsheet apps read correctly
  • Normalise text to NFC and clean invisible characters before comparing or storing it

Before you start

On this page

The 20 characters of a Hindi product title travel as 43 bytes, and read with the wrong table they arrive as बासमती, mojibake: Text decoded with another encoding than the one it was written in, so that characters turn into wrong ones, such as é for é. No error is raised, which is why it spreads unnoticed., with no error to warn you. A collector that stores that text has lost the names of its products until someone notices.

Here you see how text becomes bytes and back, find a page’s encoding in the order the HTML standard uses, see which client defaults produce mojibake and how to repair it, write CSV files that spreadsheet apps open correctly, and normalise text so that two names that look the same also compare the same.

Text travels as bytes, and an encoding turns them back into characters

Every character has a number, its code point: A is U+0041, é U+00E9, the Devanagari letter ब U+092C and the rupee sign ₹ U+20B9. A character encoding: A table that turns characters into bytes and back, such as UTF-8 or windows-1252. Bytes carry no note of their encoding, so it must be declared or guessed. is the table that turns those numbers into bytes and back. UTF-8, which the Encoding Standard calls “the most appropriate encoding for interchange of Unicode”, uses 1 byte for ASCII, 2 for many Latin and other letters, 3 for Devanagari, the rupee sign and every other character up to U+FFFF, and 4 for those beyond it, such as emoji. Step through one title on its way out and back:

Interactive · step by step The same 43 bytes, read with two different tables Step through one title on its way from text to bytes and back, once with the right table and once with the wrong one.
The same 43 bytes, read with two different tables, step 5 of 5. Showing The shop's text, On the wire (e0 a4 ac e0 a4 be …), Read as UTF-8 (the title, unchanged), Read as windows-1252 (बा… : 43 characters). In focus: Read as windows-1252, Read as UTF-8.encodedecodedecoderepairThe shop's text20 characters, HindiOn the wiree0 a4 ac e0 a4 be …Read as UTF-8the title, unchangedRead as windows-1252बा… : 43 charactersencode back, then UTF-8

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

Every step as text
  1. Characters to bytes. The title has 20 characters. UTF-8 writes the Latin letters and digits as 1 byte each, the middle dot as 2 and each Devanagari letter and the rupee sign as 3: 43 bytes in all.
  2. Only bytes travel. The network and the file carry only the bytes. Nothing in e0 a4 ac says which table turns it back into a character: the encoding has to be declared, or guessed.
  3. The right table. Decoded with UTF-8, the table it was written with, e0 a4 ac becomes one character, BA, and the title comes back exactly: 20 characters.
  4. The wrong table. windows-1252 gives almost every byte a character of its own, so e0 a4 ac becomes three: à, ¤ and ¬. Nothing fails, and 43 characters of mojibake replace the title.
  5. Undoing it. No byte was lost: encoding the mojibake back with windows-1252 restores the 43 bytes, and reading those as UTF-8 restores the title. That repair works only when every byte survived.

The program below prints the code point and UTF-8 bytes of six characters, then what those bytes become when they are read as windows-1252, the table of many old Western pages, which gives almost every byte a character of its own:

Code points, UTF-8 bytes, and the same bytes read wrongly Python · text/bytes_and_text.py
"""Characters, their code points and their UTF-8 bytes, and what the bytes become when read with the wrong table."""
import unicodedata

print(f"{'char':4} {'code point':10} {'UTF-8 bytes':12} {'read as windows-1252':20} name")
for char in ["A", "é", "–", "₹", "ब", "च"]:
    data = char.encode("utf-8")
    print(f"{char:4} U+{ord(char):04X}     {data.hex(' '):12} {data.decode('cp1252'):20} {unicodedata.name(char)}")

title = "बासमती चावल · ₹25.00"
data = title.encode("utf-8")
print()
print(len(title), "characters became", len(data), "bytes")
wrong = data.decode("cp1252")
print("read as windows-1252:", wrong)
print("encoded back the same wrong way and read as UTF-8:", wrong.encode("cp1252").decode("utf-8"))

Output

char code point UTF-8 bytes  read as windows-1252 name
A    U+0041     41           A                    LATIN CAPITAL LETTER A
é    U+00E9     c3 a9        é                   LATIN SMALL LETTER E WITH ACUTE
–    U+2013     e2 80 93     –                  EN DASH
₹    U+20B9     e2 82 b9     ₹                  INDIAN RUPEE SIGN
ब    U+092C     e0 a4 ac     ब                  DEVANAGARI LETTER BA
च    U+091A     e0 a4 9a     च                  DEVANAGARI LETTER CA

20 characters became 43 bytes
read as windows-1252: बासमती चावल · ₹25.00
encoded back the same wrong way and read as UTF-8: बासमती चावल · ₹25.00

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

The ASCII letter came through unchanged, which is why mojibake hides in English text and shows in everything else. Each 3-byte character became three characters, so the 20-character title became 43 characters of noise, beginning with ब. The last line is the repair: encode the mojibake back with the table that was wrongly used, and decode the bytes with the right one. It works only when no byte was lost or replaced on the way.

Repairing text that has already been replaced

The repair undoes a wrong decode, not a lossy one. Once a decoder has replaced bytes with ? or � (U+FFFD, the replacement character), the original bytes are gone and no table brings them back. Fix the decode at the source, where you still have the bytes, instead of repairing stored text.

Three places declare the encoding, and a BOM beats them all

A page can say what its encoding is in three places. A byte-order mark: The character U+FEFF written at the very start of a text to identify its encoding: the bytes EF BB BF mean UTF-8, FE FF and FF FE the two byte orders of UTF-16., the bytes EF BB BF at the very start, says UTF-8. The charset parameter of the Content-Type header, as in text/html; charset=utf-8, is the HTTP way; RFC 9110 compares charset names in any case. And the page itself can carry <meta charset="utf-8"> near the top. The HTML standard’s sniffing algorithm checks them in a fixed order: the byte-order mark first, then the header, then a prescan of the start of the page, which it encourages user agents to keep to the first 1024 bytes, and only then a default. The Encoding Standard explains why the BOM goes first: so that pages already published keep working, it treats the mark as “more authoritative than anything else”. Follow three of the shop’s pages through the checks:

Interactive · stage by stage Who decides a page's encoding, in order Follow three of the shop's pages through the checks and see which declaration decides each one.
Who decides a page's encoding, in order, step 4 of 4. Showing A byte-order mark?, Charset in the header? (legacy: windows-1252), A meta tag up front? (meta-only: UTF-8), Valid UTF-8? (undeclared: valid UTF-8), Decode with it (bom: UTF-8). In focus: Valid UTF-8?.yes: it winsyesyesA byte-order mark?EF BB BF, FE FF or FF FECharset in the header?legacy: windows-1252A meta tag up front?meta-only: UTF-8Valid UTF-8?undeclared: valid UTF-8Decode with itbom: UTF-8

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

Every step as text
  1. A BOM against the header. /encoding/bom says windows-1252 in its header, but its body starts with EF BB BF, the UTF-8 byte-order mark. The mark wins: the Encoding Standard treats it as more authoritative than anything else.
  2. An old page, honestly labelled. /encoding/legacy has no BOM, and its header says windows-1252. That is true of its bytes, so its curly quotes and en dash come out right.
  3. The header is silent. /encoding/meta-only sends text/html with no charset. The page itself declares meta charset utf-8 in its first bytes, so the meta tag decides.
  4. Nothing declared. A page with no BOM, no charset and no meta tag gets a guess. Bytes that match UTF-8's pattern are very likely UTF-8, as the HTML standard notes; anything else is read as windows-1252, the usual default for old Western pages.

The shop serves the same product page five ways: with a correct header, with only a meta tag, with a BOM that contradicts its header, as an old windows-1252 page, and with nothing declared at all. The program decodes each in the standard’s order and prints the rule that decided:

Five copies of one page, decoded in the standard's order

text/where_declared.py

"""Five copies of one page, each declaring its encoding differently, decoded in the order the HTML standard uses."""
import codecs
import re

import nimbu_lab as lab

shop = lab.opener()
BOMS = [(codecs.BOM_UTF8, "utf-8"), (codecs.BOM_UTF16_BE, "utf-16-be"), (codecs.BOM_UTF16_LE, "utf-16-le")]


def decode_body(content_type, body):
    """The text of an HTML body and the rule that chose its encoding: BOM, header, meta tag, then a guess."""
    for bom, name in BOMS:
        if body.startswith(bom):
            return body[len(bom):].decode(name), f"BOM ({name})"
    declared = re.search(r"charset=([\w-]+)", content_type or "", re.I)
    if declared:
        return body.decode(declared.group(1)), f"header ({declared.group(1)})"
    meta = re.search(rb"<meta[^>]*charset=[\"']?([\w-]+)", body[:1024], re.I)
    if meta:
        return body.decode(meta.group(1).decode()), f"meta tag ({meta.group(1).decode()})"
    try:
        return body.decode("utf-8"), "guess: valid UTF-8"
    except UnicodeDecodeError:
        return body.decode("cp1252"), "guess: windows-1252"


def title(html):
    return re.search(r"<title>(.*?)</title>", html).group(1)


for path in ["/encoding/declared", "/encoding/meta-only", "/encoding/bom", "/encoding/legacy", "/encoding/undeclared"]:
    with shop.open(lab.SITE + path, timeout=10) as response:
        kind, body = response.headers["Content-Type"], response.read()
    html, rule = decode_body(kind, body)
    print(f"{path:21} {kind:31} -> {rule}")
    print(f"{'':21} {title(html)}")
    if "charset" not in kind:
        # The ISO-8859-1 rule some clients apply to text/* without a charset, ignoring the meta tag:
        print(f"{'':21} as ISO-8859-1: {title(body.decode('iso-8859-1'))!r}")

Output

/encoding/declared    text/html; charset=utf-8        -> header (utf-8)
                      बासमती चावल 250 g · ₹25.00
/encoding/meta-only   text/html                       -> meta tag (utf-8)
                      बासमती चावल 250 g · ₹25.00
                      as ISO-8859-1: 'बासमतà¥\x80 à¤\x9aावल 250 g · â\x82¹25.00'
/encoding/bom         text/html; charset=windows-1252 -> BOM (utf-8)
                      बासमती चावल 250 g · ₹25.00
/encoding/legacy      text/html; charset=windows-1252 -> header (windows-1252)
                      Kashmiri ‘Mongra’ saffron – 1 g · Rs. 310
/encoding/undeclared  text/html                       -> guess: valid UTF-8
                      बासमती चावल 250 g · ₹25.00
                      as ISO-8859-1: 'बासमतà¥\x80 à¤\x9aावल 250 g · â\x82¹25.00'

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

nimbu_lab.py (the shop) · text/nimbu_lab.py

"""Nimbu Bazaar, the practice shop of this track. Every product, price and person in it is invented.

The shop is a WSGI application (PEP 3333): app() answers one request. opener() builds Python's own HTTP client,
urllib.request, with its usual handlers for redirects, errors and cookies, and one change: the last step hands each
request to app() instead of opening a socket. The client code is real, nothing leaves your program, and any host but
the shop's gets 421 Misdirected Request.
"""
import fnmatch
import http.client
import http.cookies
import io
import json
import socket
import urllib.request
import urllib.response
from urllib.parse import parse_qs, urlsplit

HOST = "bazaar.nimbu.test"
SITE = f"https://{HOST}"
KINDS = ["Basmati rice", "Toor dal", "Green tea", "Jaggery", "Turmeric", "Mustard oil", "Poha", "Rock salt",
         "Cashews", "Cardamom", "Ragi flour", "Ghee", "Honey", "Peanuts", "Coffee", "Raisins", "Sesame seeds",
         "Tamarind", "Cumin", "Black pepper", "Rolled oats", "Cloves", "Fennel", "Moong dal", "Chana dal",
         "Wheat flour", "Semolina", "Coriander", "Red chilli", "Almonds", "Dates", "Coconut oil", "Brown rice",
         "Millet", "Flax seeds", "Cinnamon", "Ginger powder", "Vermicelli", "Masoor dal", "Sago"]
SIZES = [250, 500, 1000, 2000, 5000, 10000]  # grams
LOG = []  # what the shop saw: one entry per request


class Clock:
    """The shop's clock, in seconds. A slow page moves it on instead of waiting, so every example runs at once."""

    def __init__(self):
        self.seconds = 0.0

    def now(self):
        return self.seconds

    def sleep(self, seconds):
        self.seconds += seconds


clock = Clock()


def products():
    """The 240 products: 40 kinds in 6 pack sizes, each price in paise (100 paise = 1 rupee)."""
    out = []
    for k, kind in enumerate(KINDS):
        per_kg = 60 + (k * 37) % 900  # rupees a kilogram
        for grams in SIZES:
            size = f"{grams // 1000} kg" if grams >= 1000 else f"{grams} g"
            out.append({"sku": f"NB-{len(out) + 1:04d}", "name": f"{kind} {size}",
                        "paise": per_kg * grams // 10 + 1000})
    return out


def rupees(paise):
    return f"₹{paise // 100:,}.{paise % 100:02d}"


class Asked:
    """One request as the shop's pages see it."""

    def __init__(self, environ):
        self.method, self.path = environ["REQUEST_METHOD"], environ["PATH_INFO"]
        self.query = {k: v[-1] for k, v in parse_qs(environ.get("QUERY_STRING", "")).items()}
        self.headers = {k[5:].replace("_", "-").title(): v for k, v in environ.items() if k.startswith("HTTP_")}
        size = int(environ.get("CONTENT_LENGTH") or 0)
        self.form = {k: v[-1] for k, v in parse_qs(environ["wsgi.input"].read(size).decode()).items()}
        self.cookies = {k: m.value for k, m in http.cookies.SimpleCookie(environ.get("HTTP_COOKIE", "")).items()}
        self.wait = environ.get("lab.wait", clock.sleep)  # the shop takes this long before its next bytes


PAGES = {}  # path pattern ("/products/NB-*/" matches every product) -> function(asked) -> (status, headers, body)


def page(pattern):
    def register(fn):
        PAGES[pattern] = fn
        return fn
    return register


def find_page(path):
    exact = PAGES.get(path)
    return exact or next((fn for p, fn in PAGES.items() if "*" in p and fnmatch.fnmatchcase(path, p)), None)


def text(status, body, kind="text/plain; charset=utf-8", extra=()):
    data = body.encode() if isinstance(body, str) else body
    return status, [("Content-Type", kind), ("Content-Length", str(len(data))), *extra], data


def not_found(asked):
    return text("404 Not Found", "no such page\n")


def app(environ, start_response):
    """The shop: answers one request, and writes it into LOG first."""
    asked = Asked(environ)
    if environ.get("HTTP_HOST") != HOST:
        status, headers, body = text("421 Misdirected Request", "not a Nimbu Bazaar address\n")
    else:
        status, headers, body = (find_page(asked.path) or not_found)(asked)
    target = asked.path + ("?" + environ["QUERY_STRING"] if environ.get("QUERY_STRING") else "")
    LOG.append({"line": f"{asked.method} {target} HTTP/1.1", "headers": environ.get("lab.sent", []),
                "status": int(status[:3])})
    start_response(status, headers)
    return [body] if isinstance(body, bytes) else body


class Stream(io.RawIOBase):
    """A response body read chunk by chunk, as from a socket: a slow chunk can time out in the middle of read()."""

    def __init__(self, chunks):
        self.chunks, self.rest = iter(chunks), b""

    def readable(self):
        return True

    def readinto(self, buffer):
        while not self.rest:
            self.rest = next(self.chunks, None)
            if self.rest is None:
                self.rest = b""
                return 0
        n = min(len(buffer), len(self.rest))
        buffer[:n], self.rest = self.rest[:n], self.rest[n:]
        return n


class ShopTransport(urllib.request.AbstractHTTPHandler):
    """urllib's last step, with app() where the socket would be. Everything before it is urllib's own code."""

    handler_order = 100  # runs before any handler that would open a real connection
    http_request = https_request = urllib.request.AbstractHTTPHandler.do_request_

    def https_open(self, req):
        # The headers urllib's do_open() writes to the socket, and the one http.client adds by itself.
        headers = dict(req.unredirected_hdrs)
        headers.update({k: v for k, v in req.headers.items() if k not in headers})
        headers["Connection"] = "close"
        sent = [(k.title(), v) for k, v in headers.items()]
        if "Accept-encoding" not in headers:
            sent.insert(0, ("Accept-Encoding", "identity"))
        timeout = socket.getdefaulttimeout() if req.timeout is socket._GLOBAL_DEFAULT_TIMEOUT else req.timeout

        def wait(seconds):
            """A socket in timeout mode gives up when one wait for bytes lasts longer than the timeout."""
            if timeout is not None and seconds > timeout:
                clock.sleep(timeout)
                raise TimeoutError("timed out")
            clock.sleep(seconds)

        parts, data = urlsplit(req.full_url), req.data or b""
        environ = {"REQUEST_METHOD": req.get_method(), "PATH_INFO": parts.path or "/", "QUERY_STRING": parts.query,
                   "SERVER_NAME": parts.hostname or "", "SERVER_PORT": str(parts.port or 443),
                   "SERVER_PROTOCOL": "HTTP/1.1", "wsgi.url_scheme": parts.scheme, "wsgi.input": io.BytesIO(data),
                   "CONTENT_TYPE": headers.get("Content-type", ""), "CONTENT_LENGTH": str(len(data)),
                   "lab.wait": wait, "lab.sent": sent}
        environ.update({"HTTP_" + k.upper().replace("-", "_"): v for k, v in sent
                        if k not in ("Content-Type", "Content-Length")})
        answer = {}
        body = app(environ, lambda status, hdrs, exc_info=None: answer.update(status=status, headers=hdrs))
        raw = "".join(f"{k}: {v}\r\n" for k, v in answer["headers"]) + "\r\n"
        message = http.client.parse_headers(io.BytesIO(raw.encode("iso-8859-1")))
        response = urllib.response.addinfourl(io.BufferedReader(Stream(body)), message, req.full_url,
                                              int(answer["status"][:3]))
        response.reason = response.msg = answer["status"][4:]  # as urllib's own responses carry them
        return response

    http_open = https_open


DEFAULTS = [urllib.request.HTTPDefaultErrorHandler, urllib.request.HTTPRedirectHandler,
            urllib.request.HTTPErrorProcessor, urllib.request.UnknownHandler]


def opener(*handlers):
    """Like urllib.request.build_opener(*handlers), with the shop in place of the network: urllib's usual handlers,
    except those a handler you pass replaces (a subclass of HTTPRedirectHandler, say)."""
    director = urllib.request.OpenerDirector()
    kept = [d for d in DEFAULTS if not any(isinstance(h, d) for h in handlers)]
    for handler in [ShopTransport(), *(d() for d in kept), *handlers]:
        director.add_handler(handler)
    return director


# ---- The pages this lesson's examples ask for ----------------------------------------------------------------------
# One product page, five ways of declaring (or not declaring) its encoding. The bytes are what the shop sends.
TITLE = "बासमती चावल 250 g · ₹25.00"
UTF8_PAGE = f'<!doctype html>\n<html lang="hi"><head><meta charset="utf-8"><title>{TITLE}</title></head></html>\n'
NO_META_PAGE = f"<!doctype html>\n<html lang=\"hi\"><head><title>{TITLE}</title></head></html>\n"
LEGACY_PAGE = "<!doctype html>\n<title>Kashmiri ‘Mongra’ saffron – 1 g · Rs. 310</title>\n"  # an old supplier's page

ENCODING_PAGES = {
    "/encoding/declared": ("text/html; charset=utf-8", UTF8_PAGE.encode("utf-8")),
    "/encoding/meta-only": ("text/html", UTF8_PAGE.encode("utf-8")),
    "/encoding/bom": ("text/html; charset=windows-1252", b"\xef\xbb\xbf" + NO_META_PAGE.encode("utf-8")),
    "/encoding/legacy": ("text/html; charset=windows-1252", LEGACY_PAGE.encode("cp1252")),
    "/encoding/undeclared": ("text/html", NO_META_PAGE.encode("utf-8")),
}


@page("/encoding/*")
def encoding_page(asked):
    if asked.path not in ENCODING_PAGES:
        return not_found(asked)
    kind, body = ENCODING_PAGES[asked.path]
    return text("200 OK", body, kind)

Every page came out right, the legacy one with its curly quotes and en dash. The BOM overruled a header that said windows-1252; the meta tag decided where the header was silent; the undeclared page was valid UTF-8, and the HTML standard notes that bytes matching UTF-8’s distinctive pattern are very likely UTF-8. For the two pages without a charset, the program also printed what the ISO-8859-1 rule gives: the same mojibake as before, with invisible control characters such as \x9a where windows-1252 would have shown symbols.

Key idea

Decode a page’s bytes in the standard’s order, the byte-order mark, then the header’s charset, then a meta tag in the first 1024 bytes, then a guess, and keep the bytes until you have.

Checkpoint

Check: Characters, bytes and the table between them · Where a page declares its encoding

  1. Question 1 of 2 A stored product name reads éclair au chocolat. What most likely happened to the word éclair?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Its UTF-8 bytes were decoded as windows-1252, so the 2 bytes of é became the 2 characters à and ©

    Multi-byte UTF-8 characters read with a one-byte table turn into several characters each, which is why the 20-character title became 43. Encoding back with windows-1252 and decoding as UTF-8 repairs it.

  2. Question 2 of 2 A page's header says Content-Type: text/html; charset=windows-1252, but its body starts with EF BB BF. How should it be decoded?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: As UTF-8, because a byte-order mark is checked first and outranks the header

    BOM, then header, then meta tag, then a guess. A server that labels a UTF-8 file wrongly is common, and the BOM rule is how browsers still show it correctly.

Client defaults cause most mojibake

Most mojibake in collected data comes from a client that decided the encoding before your code could. Requests documents its rule plainly: it guesses from the headers and, when the Content-Type contains text but no charset, follows the old RFC 2616 default of ISO-8859-1, so response.text ignores a page’s <meta charset="utf-8">. Its quickstart’s advice is to read response.content, find the encoding, and set response.encoding. HTTPX assumes UTF-8 when nothing is declared and replaces any bytes that are not valid UTF-8; it guesses only when you give its client a guessing function as default_encoding.

Client A text page with no charset becomes What to do
urllib bytes from read(); get_content_charset() returns None decode it yourself, in the standard’s order
Requests response.text decoded as ISO-8859-1 use response.content, or set response.encoding first
HTTPX response.text decoded as UTF-8, with � for invalid bytes set response.encoding from a declaration you trust
Node’s fetch response.text() always decodes UTF-8 TextDecoder(charset) on arrayBuffer()

The last row is the Fetch standard’s own definition: text() consumes the body with UTF-8 decode, whatever the charset says. Against the JavaScript version of the shop, the old windows-1252 page lost every non-ASCII character to �, and a TextDecoder with the declared charset got them all back. The run also showed the Encoding Standard’s label table at work: a decoder asked for iso-8859-1 is a windows-1252 decoder.

text() against TextDecoder on an old windows-1252 page

node/text_vs_decoder.mjs

// response.text() always decodes UTF-8, whatever the charset says; TextDecoder decodes what the page declares.
import { SITE, shop } from './nimbu_lab.mjs';

const lab = { dispatcher: shop, signal: AbortSignal.timeout(10_000) }; // a real request leaves dispatcher out
const title = (html) => html.match(/<title>(.*?)<\/title>/)[1];

const legacy = await fetch(`${SITE}/encoding/legacy`, lab);
const viaText = await legacy.clone().text();
const bytes = new Uint8Array(await legacy.arrayBuffer());
const charset = /charset=([\w-]+)/i.exec(legacy.headers.get('content-type'))?.[1] ?? 'utf-8';
console.log('Content-Type:', legacy.headers.get('content-type'));
console.log('response.text():', title(viaText));
console.log(`with TextDecoder('${charset}'):`, title(new TextDecoder(charset).decode(bytes)));
console.log("TextDecoder('iso-8859-1') is", new TextDecoder('iso-8859-1').encoding, 'in the Encoding Standard');

const modern = await fetch(`${SITE}/encoding/meta-only`, lab);
console.log('meta-only page, text():', title(await modern.text()));

Output

Content-Type: text/html; charset=windows-1252
response.text(): Kashmiri �Mongra� saffron � 1 g � Rs. 310
with TextDecoder('windows-1252'): Kashmiri ‘Mongra’ saffron – 1 g · Rs. 310
TextDecoder('iso-8859-1') is windows-1252 in the Encoding Standard
meta-only page, text(): बासमती चावल 250 g · ₹25.00

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

nimbu_lab.mjs (the shop for fetch) · node/nimbu_lab.mjs

// Two of Nimbu Bazaar's product pages for Node's fetch: one in UTF-8, one in windows-1252, as the shop sends them.
//
// `shop` is a dispatcher, the object Node's fetch hands each request to (its `dispatcher` option, which Node's
// documentation describes). It answers with the bytes below instead of opening a socket. Nothing leaves the program.
export const SITE = 'https://bazaar.nimbu.test';

// windows-1252 bytes for the few characters of the old page that are not ASCII.
const CP1252 = { '‘': 0x91, '’': 0x92, '–': 0x96, '·': 0xb7 };
const cp1252 = (s) => Buffer.from([...s].map((c) => CP1252[c] ?? c.charCodeAt(0)));

const PAGES = {
  '/encoding/meta-only': ['text/html', Buffer.from('<!doctype html>\n<html lang="hi"><head><meta charset="utf-8"><title>बासमती चावल 250 g · ₹25.00</title></head></html>\n')],
  '/encoding/legacy': ['text/html; charset=windows-1252', cp1252('<!doctype html>\n<title>Kashmiri ‘Mongra’ saffron – 1 g · Rs. 310</title>\n')],
};

export const shop = {
  dispatch(options, handler) {
    const [kind, body] = PAGES[new URL(options.path, options.origin).pathname] ?? ['text/plain', Buffer.from('no such page\n')];
    handler.onConnect(() => {});
    queueMicrotask(() => {
      handler.onHeaders(200, [Buffer.from('content-type'), Buffer.from(kind), Buffer.from('content-length'), Buffer.from(String(body.length))], () => {}, '');
      handler.onData(body);
      handler.onComplete([]);
    });
    return true;
  },
  close() {},
  destroy() {},
};

The rule for every client is the same: give the parser bytes, or decode them yourself from a declaration you trust, and use a guess only when nothing is declared. Parsing libraries that receive bytes can read the meta tag themselves; handing them a string that a client has already decoded wrongly removes that chance.

Unicode Character Inspector Paste a piece of mojibake such as ब and see each character's code point and name, and which bytes it came from.

Write every file with its encoding named

The other half of the problem is writing. Python’s open() without an encoding uses the locale’s encoding, which on many Windows systems is not UTF-8, so the same script writes different bytes on different machines. The defaults have changed over the releases:

Timeline · 4 events in order Python's default encoding for files, release by release Walk through the releases and see when writing encoding="utf-8" stopped mattering, and why to write it anyway.
  1. 3.7

    UTF-8 Mode, off by default opt in

    PEP 540 adds a UTF-8 Mode, switched on with -X utf8 or PYTHONUTF8=1, in which open() uses UTF-8 instead of the locale's encoding. Without it, a file opened with no encoding is read and written in the locale's encoding.

  2. 3.10

    A warning you can ask for find the gaps

    PEP 597 adds EncodingWarning, shown with -X warn_default_encoding when open() is called without an encoding, and encoding="locale" for code that really means the locale.

  3. 3.14

    The version this track pins the pin

    Python 3.14.8 still uses the locale's encoding by default. On most Linux and macOS machines that is UTF-8; on Windows it often is not, so a script without encoding="utf-8" can write different bytes there.

  4. 3.15

    UTF-8 by default new default

    PEP 686 turns UTF-8 Mode on by default from Python 3.15. Code that names its encoding behaves the same on every version and every system, which is why the examples here always do.

The timeline has 4 events, from 3.7 to 3.15.

For CSV files, the csv module’s documentation adds a second rule: open the file with newline="", because the module does its own newline handling, and without it quoted fields with line breaks are read wrongly and Windows gains an extra \r on write. Spreadsheet apps add a third: many detect UTF-8 in a CSV file only by a byte-order mark, which Python’s utf-8-sig codec writes before the first byte and skips on reading. The program writes the same three rows both ways and reads each file back with each codec:

UTF-8 and UTF-8 with a BOM, written and read back Python · text/write_csv.py
"""Write the same rows as UTF-8 and as UTF-8 with a BOM, and read each back two ways."""
import csv
from pathlib import Path

ROWS = [["sku", "name_hi", "price_inr"], ["NB-0001", "बासमती चावल 250 g", "25.00"], ["NB-0019", "जीरा 100 g", "48.00"]]

for encoding in ["utf-8", "utf-8-sig"]:
    path = Path(f"products-{encoding}.csv")
    with path.open("w", encoding=encoding, newline="") as f:  # newline="": the csv module writes its own line ends
        csv.writer(f).writerows(ROWS)
    print(f"{path.name}: {path.stat().st_size} bytes, starting {path.read_bytes()[:6].hex(' ')}")
    for reading in ["utf-8", "utf-8-sig"]:
        with path.open(encoding=reading, newline="") as f:
            first = next(csv.DictReader(f))
        print(f"  read as {reading:9} columns {list(first)}  sku={first.get('sku')!r}")
    path.unlink()

Output

products-utf-8.csv: 110 bytes, starting 73 6b 75 2c 6e 61
  read as utf-8     columns ['sku', 'name_hi', 'price_inr']  sku='NB-0001'
  read as utf-8-sig columns ['sku', 'name_hi', 'price_inr']  sku='NB-0001'
products-utf-8-sig.csv: 113 bytes, starting ef bb bf 73 6b 75
  read as utf-8     columns ['\ufeffsku', 'name_hi', 'price_inr']  sku=None
  read as utf-8-sig columns ['sku', 'name_hi', 'price_inr']  sku='NB-0001'

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

The BOM costs 3 bytes, ef bb bf, and those 3 bytes are what a reader using plain utf-8 turns into \ufeff at the start of the first column’s name. That is why first.get("sku") returned None there: the column was called "\ufeffsku". Reading with utf-8-sig works for both files, so it is the safe choice for reading CSVs you did not write.

Name the encoding everywhere

Write encoding="utf-8" (or "utf-8-sig" for a CSV meant for spreadsheet apps) on every open(), every read_text() and every write_text(). Running with -X warn_default_encoding makes Python report each call that leaves it out.

Checkpoint

Check: Client defaults that cause mojibake · Writing files with a named encoding

  1. Question 1 of 2 A page is served as Content-Type: text/html with no charset and declares <meta charset="utf-8">. Its Hindi title comes out as mojibake in response.text with Requests. Why, and what is the fix?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Requests decodes text without a charset as ISO-8859-1, ignoring the meta tag; decode response.content yourself or set response.encoding first

    Give the decoder the bytes, or a declaration you trust. A client's default chosen before your code sees the page is the commonest source of mojibake.

  2. Question 2 of 2 A CSV written with utf-8-sig is read with open(path, encoding="utf-8") and csv.DictReader. Why does row.get("sku") return None?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The BOM became the character \ufeff at the start of the first column's name, so the column is "\ufeffsku"

    Write utf-8-sig for spreadsheet apps that need the mark, and read CSVs you did not write with utf-8-sig, which accepts files with and without it.

Normalise text before you compare it

Two strings can look identical and still differ. Unicode can write è as one character, U+00E8, or as e followed by a combining accent, U+0300. The two are canonically equivalent, and Unicode normalization: Turning text into one standard form so that canonically equivalent strings compare equal. NFC composes characters such as e plus a combining accent into é wherever Unicode allows it. turns either into one form: NFC composes characters where it can, NFD takes them apart. UAX #15 notes that the W3C recommends NFC for all content, and warns against applying the compatibility forms, NFKC and NFKD, “blindly” to any text you happen to have. Pages also hide characters you cannot see: a no-break space between a currency sign and its number, or a zero-width space inside a word.

Equal-looking names, a hidden space and a cleaning step Python · text/normalise.py
"""Two names that look the same and compare unequal, and the cleaning that makes them comparable."""
import unicodedata

composed = "Crème brûlée mix"  # è and û as single characters
decomposed = unicodedata.normalize("NFD", composed)  # e and u, each followed by a combining accent
print("look the same:", composed, "|", decomposed)
print("equal?", composed == decomposed, "| lengths", len(composed), len(decomposed))
print("equal after NFC?", unicodedata.normalize("NFC", composed) == unicodedata.normalize("NFC", decomposed))
print()

qa_one, qa_two = "\u0958", "\u0915\u093c"  # DEVANAGARI LETTER QA, and KA followed by the sign NUKTA
for label, s in [("one code point", qa_one), ("two code points", qa_two)]:
    nfc = unicodedata.normalize("NFC", s)
    print(f"{label:16} {' '.join(f'U+{ord(c):04X}' for c in s):14} NFC: {' '.join(f'U+{ord(c):04X}' for c in nfc)}")
print()

price = "\u20b9\u00a01,250"  # a price with a no-break space between the sign and the number, as many pages write it
print(repr(price), "->", [unicodedata.name(c) for c in price[:2]])
print("replace(' ', '') leaves", repr(price.replace(" ", "")))


def clean(text):
    """NFC, every kind of space made a plain space, zero-width spaces dropped; joiners kept, Indic text needs them."""
    text = unicodedata.normalize("NFC", text)
    text = "".join(" " if unicodedata.category(c) == "Zs" else c for c in text)
    return text.replace("\u200b", "").replace("\ufeff", "")


print("cleaned:", repr(clean(price)), "| digits:", "".join(c for c in clean(price) if c.isdigit()))
print("joiner kept:", [unicodedata.name(c) for c in clean("\u0915\u094d\u200d\u0937")][-2:])

Output

look the same: Crème brûlée mix | Crème brûlée mix
equal? False | lengths 16 19
equal after NFC? True

one code point   U+0958         NFC: U+0915 U+093C
two code points  U+0915 U+093C  NFC: U+0915 U+093C

'₹\xa01,250' -> ['INDIAN RUPEE SIGN', 'NO-BREAK SPACE']
replace(' ', '') leaves '₹\xa01,250'
cleaned: '₹ 1,250' | digits: 1250
joiner kept: ['ZERO WIDTH JOINER', 'DEVANAGARI LETTER SSA']

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

The two spellings of “Crème brûlée mix” had 16 and 19 characters and compared unequal until both were normalised to NFC. NFC does not always compose: the Devanagari letter क़, U+0958, came out as two code points, U+0915 U+093C, because Unicode excludes that letter from composition, so both spellings meet in the same two-character form. The price held a no-break space, U+00A0, which replace(" ", "") does not touch; clean() turns every space character into a plain one.

Keep the joiners in Indic text

The zero width joiner, U+200D, and non-joiner, U+200C, look like nothing, but the Unicode Standard’s chapter on Devanagari describes how they change the way consonant clusters are drawn: half forms instead of conjuncts, or a visible virama. Remove zero-width spaces, never the joiners, as clean() does.

Checkpoint

Check: Normalising before comparing

  1. Question 1 of 2 Two product names display as Crème brûlée mix, but a == b is False and len() gives 16 and 19. What makes them compare equal?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: unicodedata.normalize("NFC", ...) on both, which composes e plus a combining accent into è

    Canonically equivalent strings can differ code point by code point. Normalise to NFC before comparing, storing or using text as a key.

  2. Question 2 of 2 A price arrives as a currency sign, a space and 1,250, and price.replace(" ", "") leaves the space in. What is that space?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A no-break space, U+00A0, which looks like a space but is a different character

    Pages use no-break spaces to keep a sign with its number. Turn every space character (category Zs) into a plain space before parsing numbers, as clean() did.

Practise: decode a page in the standard’s order

The exercise asks for the decoder of this lesson with the details real pages need: a quoted charset, a byte-order mark for UTF-16, the http-equiv form of the meta tag, and the Encoding Standard’s labels, in which iso-8859-1 and latin1 mean windows-1252. All its tests are sample tests:

Exercise · Medium · Python

Decode a page in the order the HTML standard uses

Write decode_body(content_type, body) in charset.py. It gets the response's Content-Type header (a string, or None when the response had none) and the body as bytes, and returns the body as text, choosing the encoding in this order:

  1. A byte-order mark at the start of the body: EF BB BF is UTF-8, FE FF UTF-16 big-endian, FF FE UTF-16 little-endian (the list BOMS holds them). The mark is not part of the text.
  2. The charset parameter of Content-Type. Its name and value compare in any case, and the value may be in double quotes, as in charset="UTF-8".
  3. A <meta charset="...">, or a <meta http-equiv="Content-Type" content="...; charset=...">, in the first 1024 bytes of the body.
  4. If nothing declared an encoding: UTF-8 when the bytes are valid UTF-8, otherwise windows-1252.

Map the labels as the Encoding Standard does: utf-8 and utf8 are UTF-8, and windows-1252, cp1252, iso-8859-1, latin1, ascii and us-ascii are all windows-1252 (Python's codec cp1252). A label you do not know counts as no declaration, so the next rule decides. The sample tests use the lesson's product pages in UTF-8, UTF-16 and windows-1252.

Starter code · charset.py

import codecs
import re

BOMS = [(codecs.BOM_UTF8, "utf-8"), (codecs.BOM_UTF16_BE, "utf-16-be"), (codecs.BOM_UTF16_LE, "utf-16-le")]


def decode_body(content_type, body):
    """The text of an HTML body: a BOM first, then the Content-Type charset, then a meta tag in the first 1024 bytes,
    then UTF-8 if the bytes are valid UTF-8, else windows-1252."""
    # Replace this: decoding everything as UTF-8 turns every legacy page into replacement characters.
    return body.decode("utf-8", errors="replace")
The sample tests · test_charset.py
import codecs

from charset import decode_body

HINDI = "बासमती चावल 250 g · ₹25.00"
OLD = "Kashmiri ‘Mongra’ saffron – 1 g"


def page(title, head=""):
    return f"<!doctype html>\n<html><head>{head}<title>{title}</title></head></html>\n"


def test_the_header_charset():
    """a charset in Content-Type decides, in any case and quoted or not"""
    assert decode_body("text/html; charset=utf-8", page(HINDI).encode("utf-8")) == page(HINDI)
    assert decode_body('text/html; Charset="UTF-8"', page(HINDI).encode("utf-8")) == page(HINDI), "names and values compare in any case; quotes are not part of the value"
    assert decode_body("text/html; charset=windows-1252", page(OLD).encode("cp1252")) == page(OLD)


def test_legacy_labels_mean_windows_1252():
    """iso-8859-1 and latin1 are labels for windows-1252 in the Encoding Standard"""
    data = page(OLD).encode("cp1252")
    assert decode_body("text/html; charset=iso-8859-1", data) == page(OLD), "0x96 is an en dash in windows-1252, a control character in true ISO-8859-1"
    assert decode_body("text/html; charset=latin1", data) == page(OLD)


def test_a_bom_beats_the_header():
    """a byte-order mark decides even when the header says something else"""
    assert decode_body("text/html; charset=windows-1252", codecs.BOM_UTF8 + page(HINDI).encode("utf-8")) == page(HINDI), "the UTF-8 BOM wins, and is not part of the text"
    assert decode_body("text/html", codecs.BOM_UTF16_LE + page(HINDI).encode("utf-16-le")) == page(HINDI), "FF FE starts UTF-16 little-endian"


def test_a_meta_tag_when_the_header_is_silent():
    """<meta charset> or http-equiv Content-Type in the first 1024 bytes"""
    html = page(HINDI, '<meta charset="utf-8">')
    assert decode_body("text/html", html.encode("utf-8")) == html
    old = page(OLD, '<meta http-equiv="Content-Type" content="text/html; charset=windows-1252">')
    assert decode_body("text/html", old.encode("cp1252")) == old, "the http-equiv form carries the charset in content"


def test_the_header_beats_the_meta_tag():
    """when the header and the page disagree, the header decides"""
    old = page(OLD, '<meta charset="utf-8">')
    assert decode_body("text/html; charset=windows-1252", old.encode("cp1252")) == old, "the meta tag is read only when the header is silent"


def test_a_meta_tag_too_far_down_is_not_read():
    """only the first 1024 bytes are prescanned"""
    late = "<!--" + "x" * 1100 + "-->" + page(OLD, '<meta charset="utf-8">')
    assert decode_body("text/html", late.encode("cp1252")) == late, "the meta tag is past byte 1024 and the bytes are not valid UTF-8: windows-1252"


def test_a_guess_when_nothing_is_declared():
    """valid UTF-8 is read as UTF-8; anything else as windows-1252"""
    assert decode_body("text/html", page(HINDI).encode("utf-8")) == page(HINDI)
    assert decode_body(None, page(OLD).encode("cp1252")) == page(OLD), "no header at all, and the bytes are not valid UTF-8"
A hint

Write a small helper that turns a label into a codec name: strip spaces and quotes, lower-case it, then look it up in a dictionary of the labels above, returning None for anything else. For the header, re.search(r'charset\s*=\s*("[^"]*"|[^;\s]+)', content_type, re.I) finds the value; for the meta tag, search body[:1024] with a bytes pattern such as rb'<meta[^>]*?charset\s*=\s*["\']?\s*([\w-]+)'.

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 check what stayed with you, bytes, declarations, defaults, files and normalisation, with your weakest idea first:

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 10 How many bytes does UTF-8 use for the text "₹25 जीरा"? The rupee sign and each Devanagari character take 3 bytes; the digits and the space take 1.

    Type a number.

    A hint

    Count the characters first: one rupee sign, two digits, one space and four Devanagari characters (ज, ी, र and ा).

    Show the answer: Question 1 of 10

    Answer: 18 bytes

    3 bytes for the rupee sign, 1 each for 2, 5 and the space, and 3 each for the four Devanagari characters: 3 + 3 + 12 = 18 bytes for 8 characters, the same kind of growth as the title's 20 characters and 43 bytes.

  2. Question 2 of 10 Why does mojibake so often go unnoticed in English text?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: ASCII letters, digits and punctuation are the same bytes in UTF-8, windows-1252 and ISO-8859-1, so only other characters change

    A wrong table shows only where the text leaves ASCII, which is why testing with names in Hindi, or with é and ₹, finds encoding bugs that English test data hides.

  3. Question 3 of 10 Put the sources of a page's encoding in the order the HTML standard checks them, the first one first.

    Give each item its position, from 1 (first).

    Show the answer: Question 3 of 10

    Answer:

    1. A byte-order mark at the start of the body
    2. The charset parameter of the Content-Type header
    3. A meta tag in the first 1024 bytes
    4. A guess from the bytes themselves

    BOM, transport layer, prescan, then a default or a guess: decode_body() follows the same order and printed which rule decided each page.

  4. Question 4 of 10 A page has no BOM and no charset in its header. Its meta charset tag sits 3,000 bytes into the page, after a long comment. What does the standard's order do?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: It does not see the meta tag, because the prescan reads only the start of the page, and falls back to a guess

    Encoding declarations belong in the first 1024 bytes. The exercise's test with a comment of 1,100 bytes checks exactly this.

  5. Question 5 of 10 What does response.text() do with a page whose Content-Type says charset=windows-1252, in Node's fetch?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: It decodes the body as UTF-8 anyway, so the page's curly quotes and en dash become replacement characters

    Read the bytes with arrayBuffer() and decode them with new TextDecoder(charset), using the charset from the header or the page.

  6. Question 6 of 10 Your database holds 10,000 names stored as बा… mojibake, read with windows-1252 from UTF-8 pages. You still have no copy of the pages. What can you do?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Encode each stored name back with windows-1252 and decode the bytes as UTF-8, checking first that no name holds a replacement character

    Repair reverses the mistake: wrong table back to bytes, right table forward. Then fix the collector, so that the next run decodes correctly at the source.

  7. Question 7 of 10 On Python 3.14, what encoding does open("out.txt", "w") use when no encoding is given?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: The locale's encoding, which is often UTF-8 on Linux and macOS and often something else on Windows

    Name the encoding on every open() and you get the same bytes everywhere; -X warn_default_encoding finds the calls that do not.

  8. Question 8 of 10 You export product names in Hindi to a CSV for colleagues who open it in a spreadsheet app, and some see mojibake. What do you change?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: Write it with encoding="utf-8-sig", so the file starts with the UTF-8 byte-order mark those apps look for

    utf-8-sig for files meant for spreadsheet apps, plain utf-8 elsewhere, and utf-8-sig when reading CSVs whose origin you do not control.

  9. Question 9 of 10 You normalise names to NFC and expect the Devanagari letter QA, U+0958, to stay one code point. normalize("NFC", "क़") returns two. Is your code wrong?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: No; Unicode excludes U+0958 from composition, so NFC writes it as U+0915 U+093C, and both spellings meet in that form

    What matters is that equivalent strings normalise to the same form, not that the form is short. Use NFC for comparison and storage, and keep NFKC for specific purposes.

  10. Question 10 of 10 A cleaning step deletes every zero-width character, U+200B, U+200C and U+200D included. What can go wrong in Hindi text?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: Removing the joiner U+200D or the non-joiner U+200C can change how consonant clusters are drawn, so a word may display differently

    Clean what is noise, the zero-width space and stray BOMs, and keep what carries meaning, the joiners, as clean() does.

Interview questions

Warm-up (fresher): What is mojibake, and what causes it?

Mojibake is text that was encoded with one character encoding and decoded with another, so that each character turns into one or more wrong ones, such as ब for the Devanagari letter ब or é for é. Nothing raises an error, because tables such as windows-1252 give almost every byte some character. The usual cause in collected data is a client default: a library decoding as ISO-8859-1 because the header had no charset, or a program reading UTF-8 bytes with the computer’s locale encoding. The fix is to decode the original bytes with the right encoding, found from the byte-order mark, the header or the meta tag; if only the mojibake is left and no byte was replaced, encoding it back with the wrong table and decoding as UTF-8 repairs it.

From here, two interview questions go further: product names that show as mojibake in a database but fine in a browser, and a CSV export that some people’s spreadsheet app opens garbled. Open either on the question page of this module for its first step and its follow-ups:

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

  1. Fresher to mid

    Product names in Hindi look right on the site but are stored in your database as text such as बा. Walk through how you find the cause, fix it, and repair the rows already stored.

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

  2. Fresher to mid

    Your collector's CSV export of Hindi product names looks right in your editor, but some colleagues see garbled text when they open it in their spreadsheet app. What is going on, and what do you change?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Text travels as bytes, and the encoding is the table that turns them back into characters; read with the wrong table, every multi-byte character becomes mojibake without any error.
  • Decode a page in the HTML standard’s order, the byte-order mark, the header’s charset, a meta tag in the first 1024 bytes, then a guess, and keep the original bytes until you have.
  • Client defaults cause most mojibake: Requests applies ISO-8859-1 to text without a charset and fetch’s text() always decodes UTF-8, so take the bytes and decode them yourself.
  • Name the encoding on every file you open, use newline="" for CSV, write utf-8-sig for spreadsheet apps and read CSVs you did not write with utf-8-sig.
  • Normalise text to NFC before comparing, turn no-break and other space characters into plain spaces, drop zero-width spaces, and keep the joiners Indic scripts need.

Terms in this lesson

byte-order mark
The character U+FEFF written at the very start of a text to identify its encoding: the bytes EF BB BF mean UTF-8, FE FF and FF FE the two byte orders of UTF-16.
character encoding
A table that turns characters into bytes and back, such as UTF-8 or windows-1252. Bytes carry no note of their encoding, so it must be declared or guessed.
mojibake
Text decoded with another encoding than the one it was written in, so that characters turn into wrong ones, such as é for é. No error is raised, which is why it spreads unnoticed.
Unicode normalization
Turning text into one standard form so that canonically equivalent strings compare equal. NFC composes characters such as e plus a combining accent into é wherever Unicode allows it.

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.