Skip to content

Where std stops

Level: 201 · for anyone who has typed cargo add for a text job

One line: std validates UTF-8, converts UTF-16, maps case and reads a char's own properties — and stops at nine jobs, each with a crate standing where it stopped: a legacy encoding, a guessed one, normalization, what a person calls one character, terminal columns, bytes that are mostly text, validation at memory bandwidth, a pattern, and the locale. Every one is measured here against what bare rustc can do, so you can see what a cargo add buys.

Nothing in this page's answer key came from a crate. The examples here compile with bare rustc and no Cargo (CONTRIBUTING.md), so the one machine-checked block is the baseline — how far std gets on each job — and every crate result is in a fence dated and labelled with the version it ran under, built in a scratch Cargo project on one machine. The sibling Rust library keeps the reading list of string crates ↗: fifteen crates, described and deliberately not run. This page is the ones that sit on the encoding boundary, run.

The same page exists for Python — Where the standard library stops — and the two gaps are in different places: Python's standard library has the legacy encodings and the normalization that are crates here, and the crates here carry a Unicode table a year newer than that interpreter's.

First, the baseline

Nine jobs, std only. Two of them std finishes; on the rest it gets exactly as far as the type system reaches, which is the pattern of the whole chapter.

Verified output of where_std_stops_rs.rs — regenerated by tools/run_examples.py, never hand-typed.

1. LEGACY ENCODINGS -- std decodes UTF-8 and UTF-16, plus one table by accident
   b as char over 63 61 66 e9         -> "café"    Latin-1 IS the first 256 code points
   the same cast over cp1252 bytes  -> "Preis\u{85} 100\u{80}"
   0x80 became U+0080, a C1 control, not the euro sign. windows-1252 needs a
   32-entry table for 80..9f, windows-1250 a 128-entry one, Shift_JIS a state
   machine. std ships none of them: from_utf8, from_utf16, and that is all.
   from_utf8(5a 61 bf f3 b3 e6)      -> Err((2, Some(1)))

2. DETECTION -- validation is the only test std has, and it only names UTF-8
   utf8    63 61 66 c3 a9       valid UTF-8: true
   latin1  63 61 66 e9          valid UTF-8: false
   cp1250  5a 61 bf f3 b3 e6    valid UTF-8: false
   ascii   70 6c 61 69 6e       valid UTF-8: true
   'Not UTF-8' is where std stops. Which 8-bit table the bytes are in instead
   is a guess from letter statistics, and the page measures Firefox's guesser.

3. NORMALIZATION -- two spellings of one word, and == has one opinion
   "café" == "cafe\u{301}" -> false
   bytes 5 vs 6, chars 4 vs 5
   std has no nfc(). Python's unicodedata.normalize is a standard-library
   call; in Rust it is a crate, and the page measures it.

4. WHAT A PERSON CALLS ONE CHARACTER -- three rulers in std, and none is that one
   e + acute          bytes  3  chars  2  utf16  2   a person sees 1
   flag PL            bytes  8  chars  2  utf16  4   a person sees 1
   family             bytes 25  chars  7  utf16 11   a person sees 1
   Devanagari kshi    bytes 12  chars  4  utf16  4   a person sees 1
   thumbs up + tone   bytes  8  chars  2  utf16  4   a person sees 1
   split_whitespace: ["can't", "stop,", "won't", "stop;", "3.14", "isn't", "3,14.", "東京都"]
   Whitespace is the only word boundary std knows. UAX #29's boundaries, which
   keep can't and 3.14 whole and drop the punctuation, are a crate.

5. COLUMNS -- {:<8} pads by chars().count(), which is the wrong ruler
   |café    |  chars 4
   |日本語     |  chars 3
   |👨‍👩‍👧‍👦 |  chars 7
   |🇵🇱      |  chars 2
   The bars are meant to line up. A terminal draws 日本語 six columns wide and
   the family two. std has no width function and no East_Asian_Width property;
   both are a crate.

6. BYTES THAT ARE MOSTLY TEXT -- std has the pieces, and each one is a line
   lines:           3
   find "au":       Some(5)
   utf8_chunks():   [("caf", "e9"), (" au lait\nZa", "bf"), ("", "f3 b3"), ("", "e6"), (" g", "ea 9c"), ("l", "b9"), ("\r\nplain", "")]
   from_utf8_lossy: "caf� au lait\nZa��� g�l�\r\nplain"
   No lines(), no find(), no chars() on a [u8] -- but since Rust 1.79
   utf8_chunks() is std, and it is the loop that bstr and from_utf8_lossy share.

7. VALIDATION -- std checks every byte, and what that costs is not in this key
   from_utf8 over 28 MB of Polish text -> Ok
   How long it took, and how long simdutf8 takes over the same bytes, is on
   the page in a dated fence: a speed is a fact about one machine.

8. REGEX -- there is none in std; char predicates cover the small cases
   split on !is_alphabetic: ["Zażółć", "gęślą", "jaźń", "can", "t", "stop"]
   is_alphabetic('ż') = true   is_numeric('٣') = true
   That is \p{L}+ and \p{N} by hand. Anything with structure -- alternation,
   repetition, a class named by script -- is the regex crate, measured on the page.

9. SORTING -- sort() is code point order, and std has no locale to ask
   sort():                  Zawadzki Zebra cmentarz lód zebra ćma Łukasiewicz łódź żaba
   sort_by_key(lowercase):  cmentarz lód Zawadzki Zebra zebra ćma Łukasiewicz łódź żaba
   ł, ć and ż land after z in both. A Polish dictionary puts ć after c and ł
   after l, and the rules that know that are locale data -- which is why std
   does not carry them and ICU4X does. Measured on the page.

Three things in that block matter more than any crate below. Section 1's castb as char — is a one-line Latin-1 decoder, and it is correct for the same reason char is four bytes warned that char as u8 is wrong: Latin-1 is the first 256 code points, so the cast is the table. Run it over Windows-1252 bytes and 0x80 becomes a control character rather than a euro sign, which is the whole difference between the two tables and a page of its own. Section 6's utf8_chunks() is std closing a gap: since Rust 1.79 the loop that From UTF-8, and lossy hand-rolls from valid_up_to() is a method on [u8], and it is what bstr and from_utf8_lossy both run. And section 4's split_whitespace is the only word boundary std has; the sentence it splits keeps every comma and semicolon glued to its word, which is what the crate for the job is for.

1. A legacy encoding — encoding_rs

std decodes UTF-8 and UTF-16 and nothing else. encoding_rs is Firefox's implementation of the WHATWG Encoding Standard ↗ — the document that says what a browser must do with charset=latin1 — and that pedigree decides everything below, starting with what a label means:

Measured 2026-09-13 — encoding_rs 0.8.41, rustc 1.98.0, x86_64 macOS 26.6.2
Encoding::for_label(b"latin1")           -> windows-1252     every Latin-1 label means Windows-1252: the Standard's rule
Encoding::for_label(b"iso-8859-1")       -> windows-1252
Encoding::for_label(b"ascii")            -> windows-1252     and so does "us-ascii"
Encoding::for_label(b"utf-16")           -> UTF-16LE         and "unicode"
Encoding::for_label(b"gb2312")           -> GBK
Encoding::for_label(b"sjis")             -> Shift_JIS        and "ms932", "windows-31j"
Encoding::for_label(b"utf-7")            -> None             not in the Standard, on purpose
Encoding::for_label(b"utf-32")           -> None
Encoding::for_label(b"cp437")            -> None             no DOS code pages, no EBCDIC, and of the Mac tables only macintosh

WINDOWS_1250.encode("Zażółć gęślą jaźń") -> 5a 61 bf f3 b3 e6 20 67 ea 9c 6c b9 20 6a 61 9f f1   had_errors false
WINDOWS_1250.decode(those bytes)         -> "Zażółć gęślą jaźń"          had_errors false
WINDOWS_1252.decode(the same bytes)      -> "Za¿ó³æ gêœl¹ jaŸñ"          had_errors false     mojibake, and no error: every byte is defined
ISO_8859_2.decode(the same bytes)        -> "Zażółć gę\u{9c}lš ja\u{9f}ń"   had_errors false     two C1 controls where cp1250 had ś and ź

ISO_8859_2.encode("café 1€ 😀")          -> "café 1&#8364; &#128512;"     had_errors true      an HTML character reference, not a ?
WINDOWS_1252.decode(b"\x81")             -> "\u{81}"                      had_errors false     the five unassigned bytes decode to C1 controls

WINDOWS_1252.decode(ef bb bf 63 61 66 c3 a9)   -> used UTF-8,    "café"     decode() sniffs the BOM whatever encoding you called it on
WINDOWS_1252.decode(ff fe 63 00 61 00)         -> used UTF-16LE, "ca"
WINDOWS_1252.decode(ff fe 00 00 63 00 00 00)   -> used UTF-16LE, "\0c\0"    a UTF-32LE BOM is a UTF-16LE BOM and two NULs, to a Standard with no UTF-32
UTF_8.decode_without_bom_handling(ef bb bf 78) -> "\u{feff}x"   2 chars

UTF_8.new_decoder()   chunk "caf" f0 9f   last=false -> "caf"      had_errors false     two bytes held back
                      chunk 98 80 "!"     last=true  -> "caf😀!"   had_errors false
                      the first chunk alone, last=true           -> "caf�"     had_errors true

Read it as a browser would. A label is a lookup in a closed table with the web's history baked in: latin1 means Windows-1252 because that is what every page labelled Latin-1 actually was; utf-7 is refused because a browser that accepted it was an XSS vector and utf-32 because no page used it; cp437 was never a web encoding at all. Off the web that is a limitation — there is no DOS or EBCDIC table here — and on it, it is the specification. Encoding what the table cannot hold writes &#8364;, the HTML form-submission rule, rather than a ? or a refusal, and had_errors is the flag that says it happened. The five unassigned Windows-1252 bytes decode to C1 controls, without an error — the Standard chose that over failure, which is ftfy's sloppy codec on the Python page written into a standard. And decode() reads the byte-order mark before anything else, so the encoding you named is a default rather than an instruction; decode_without_bom_handling is the spelling that means what it says. Byte order and the BOM has the marks; the UTF-32 row is the one that looks like a bug and is the specification.

The last three lines are the reason the crate is worth having even for UTF-8. new_decoder() is an incremental decoder: handed f0 9f at the end of a chunk with last=false it holds the two bytes and waits, and the same bytes with last=true are an error — the distinction From UTF-8, and lossy found in error_len()'s None, as an API that keeps the state for you.

cargo add encoding_rs

2. A guessed one — chardetng

The baseline's section 2 is all std can say: not UTF-8. chardetng is the other half of Firefox — the detector it runs on a page with no label at all — and its API is shaped by that job:

Measured 2026-09-13 — chardetng 1.0.0 (released 2026-03-30; the 0.1 API took a bool where 1.0 takes Utf8Detection::Allow or Deny), rustc 1.98.0. Same files as the Python page.
file             guess(None, Allow)   guess(Some(b"pl"), Allow)   guess(None, Deny)
utf8.txt         UTF-8                UTF-8                       windows-1252
latin1.txt       windows-1252         ISO-8859-2                  windows-1252
cp1252.txt       windows-1252         windows-1250                windows-1252
latin2_short     ISO-8859-2           ISO-8859-2                  ISO-8859-2
latin2_long      ISO-8859-2           ISO-8859-2                  ISO-8859-2
cp1250_long      windows-1250         windows-1250                windows-1250
utf8_pl_long     UTF-8                UTF-8                       windows-1252
sjis             Shift_JIS            Shift_JIS                   Shift_JIS
utf16le_nobom    windows-1252         ISO-8859-2                  windows-1252
one_byte         windows-1252         ISO-8859-2                  windows-1252

Three columns, three findings. It tells ISO-8859-2 from Windows-1250 on the Polish sample, in both directions, with or without a hint — the six letters the Python baseline found are enough, and charset-normalizer could not do it. The second argument is a top-level domain, and it is what a browser has when it has nothing else: .pl turns every ambiguous row Polish, including a Windows-1252 file that becomes Windows-1250 and one byte e9 that becomes ISO-8859-2. And the Deny column is what Firefox actually passes: the crate's own documentation says a browser must not allow UTF-8 as a result, to avoid creating a situation where Web content starts depending on unlabeled detection of UTF-8 — so a valid UTF-8 page with no label is decoded as Windows-1252 by design, and the mojibake is the cost of not rewarding pages that omit the label. Off the web, pass Allow.

Two things it does not do, also by design. There is no confidence number — the guess is an &'static Encoding and nothing else — and there is no UTF-16 detection, because a UTF-16 page carries a BOM or a label, so the utf16le_nobom row comes out as Windows-1252 garbage in all three columns. chardet in Python finds it; a browser never needed to.

cargo add chardetng

3. Normalization — unicode-normalization

"café" == "cafe" + U+0301 is false in std, will stay false, and there is no method that makes the two equal. In Python this is unicodedata.normalize, a standard-library call; here it is the crate that The check that ran too early found every Rust codebase reaching for:

Measured 2026-09-13 — unicode-normalization 0.1.25 (UNICODE_VERSION 17.0.0), rustc 1.98.0
"café" == "cafe\u{301}"                                -> false
"cafe\u{301}".nfc().collect::<String>() == "café"      -> true       is_nfc("café") true, is_nfc("cafe\u{301}") false
"Łódź".nfd()      -> "Ło\u{301}dz\u{301}"     drop the combining marks -> "Łodz"     ł has no decomposition
"fi".nfkc()        -> "fi"      "①".nfkc() -> "1"      "10²".nfkc() -> "102"      "ア".nfkc() -> "ア"
"Å".nfd()         -> "A\u{30a}"

The API is an iterator adaptor — s.nfc() yields chars — so a normalization costs one allocation when you collect it and none when you compare as you go, and is_nfc is the cheap check to run before either. The four forms and what each is for are on Normalization, and the Łodz row is that page's finding: the recipe for stripping accents is a normalization, and normalization does not know that ł is an l.

cargo add unicode-normalization

4. What a person calls one character — unicode-segmentation

Baseline section 4: bytes, code points and UTF-16 units are the three rulers std has, and none of them is the one a cursor moves by. A code point is not a character has all five; this crate is UAX #29 ↗, the rules for the fourth, and for words and sentences besides:

Measured 2026-09-13 — unicode-segmentation 1.13.3 (UNICODE_VERSION 17.0.0), rustc 1.98.0
string                              bytes   chars   graphemes(true)
e + U+0301                            3       2         1
🇵🇱  two regional indicators           8       2         1
family: four people, three ZWJ       25       7         1
🧑‍🧑‍🧒  family, Emoji 15.1 (2023)        18       5         1
👍🏽  thumbs up + skin tone             8       2         1
क्षि  Devanagari kshi                  12       4         1
ก + U+0E33 Thai sara am               6       2         1
ab CR LF c                            5       5         4

"Zażółć gęślą jaźń — can't stop, won't stop; 3.14 isn't 3,14. 東京都"
split_whitespace   ["Zażółć", "gęślą", "jaźń", "—", "can't", "stop,", "won't", "stop;", "3.14", "isn't", "3,14.", "東京都"]
unicode_words      ["Zażółć", "gęślą", "jaźń", "can't", "stop", "won't", "stop", "3.14", "isn't", "3,14", "東", "京", "都"]

Every grapheme row is 1, including the Devanagari conjunct that a 2020 Python library gets wrong, because this crate's table is current: UNICODE_VERSION is a public constant, and it read 17.0.0 on a day Python 3.14 read 16.0. The (true) is extended grapheme clusters, the ones every modern platform means; false is the legacy definition and exists for compatibility.

unicode_words is the other half, and its last three entries are the honest edge of the algorithm: it keeps can't and 3.14 whole and drops the punctuation, then splits 東京都 into three, because UAX #29 without a dictionary has no way to know where a Japanese word ends. Section 9's segmenter has the dictionary.

cargo add unicode-segmentation

5. Columns — unicode-width

Baseline section 5 is the table whose bars do not line up. unicode-width is UAX #11 ↗ with the emoji sequences added, as two traits — one on char, one on str — and the two do not always agree, on purpose:

Measured 2026-09-13 — unicode-width 0.2.2, rustc 1.98.0
string                          chars   str::width()   width_cjk()   char::width() per code point
café                              4         4              4         1 1 1 1
cafe + U+0301                     5         4              4         1 1 1 1 0
日本語                             3         6              6         2 2 2
family: four people, three ZWJ    7         2              2         2 0 2 0 2 0 2
🇵🇱                                2         2              2         1 1
❤  and  ❤ + U+FE0F               1 / 2     1 / 2          1 / 2      1  and  1 0
①                                 1         1              2         1
≈                                 1         1              2         1
a TAB b                           3         3              3         1 None 1
ESC [ 3 1 m r e d ESC [ 0 m      12        12             12         None 1 1 1 1 1 1 1 None 1 1 1
NUL                               1         1              1         None

The str width is the one to call: it knows that a ZWJ sequence draws as one glyph and that two regional indicators make one flag, where summing char::width gives 8 and 2. width_cjk is the other reading of East Asian ambiguous width — and are one column in a Western terminal and two in a CJK one — and the crate makes you choose rather than choosing for you.

The control rows are the decision to know about. char::width says None for a tab, an escape and a NUL; str::width counts each as one column, so an ANSI-coloured string measures twelve. Python's wcwidth returns -1 for the same input. Neither is wrong — there is no width for a byte that moves the cursor — but a program that pads by this number without stripping the escapes first gets a different wrong answer in each language.

cargo add unicode-width

6. Bytes that are mostly text — bstr

Baseline section 6: std can do everything bstr does, one line at a time — split for lines, windows().position() for a search, utf8_chunks() for the decode. bstr is those lines as methods on [u8], for the file that is text apart from the bytes that are not:

Measured 2026-09-13 — bstr 1.13.1, rustc 1.98.0. raw is caf e9 au lait, a newline, Windows-1250 Polish, CR LF, plain.
BStr::new(raw)         -> "caf\xe9 au lait\nZa\xbf\xf3\xb3\xe6 g\xea\x9cl\xb9\r\nplain"    Debug prints the text and escapes the rest
raw.lines()            -> "caf\xe9 au lait" · "Za\xbf\xf3\xb3\xe6 g\xea\x9cl\xb9" · "plain"
line 1 .words()        -> ["Za", "g", "l"]                 a word ends at a byte that is not text
raw.to_str()           -> Err: valid_up_to 3, error_len Some(1)                 the same two numbers as std
raw.find("au")         -> Some(5)      raw.find_byte(0xe6) -> Some(18)     raw.is_utf8() -> false
raw.chars()            -> 'c' 'a' 'f' U+FFFD ' ' 'a' 'u' ' ' ...            one U+FFFD per maximal subpart, as from_utf8_lossy
raw.graphemes()        -> the same count as chars() on every line here

std, for comparison:
raw.utf8_chunks()      -> ("caf", e9) (" au lait\nZa", bf) ("", f3 b3) ("", e6) (" g", ea 9c) ("l", b9) ("\r\nplain", -)

The Debug line is worth the dependency on its own: a Vec<u8> prints as a list of numbers, and a BStr prints as the text it mostly is with the bytes it is not shown as escapes — which is what you want in a log line and what OsStr, Path, and WTF-8 wanted for a filename. Everything else is std's own semantics reached through a &[u8]: chars() spends one U+FFFD per maximal subpart, exactly as from_utf8_lossy does, and words() stops at a bad byte rather than guessing through it. The type does not promise UTF-8, and nothing on it pretends otherwise.

cargo add bstr

7. Validation at memory bandwidth — simdutf8

Baseline section 7 validates twenty-eight megabytes and declines to say how long it took. simdutf8 is std::str::from_utf8 with the loop rewritten for SIMD, in two flavours, and the question is whether the drop-in is really a drop-in:

Measured 2026-09-13 — simdutf8 0.1.5, rustc 1.98.0, x86_64 (an Intel Mac; the crate selects AVX2 at run time). Release build, 268 MB buffers, the mean of 8 runs.
bytes                    std::str::from_utf8     simdutf8::basic         simdutf8::compat
63 61 66 c3 a9           Ok                      Ok                      Ok
63 61 66 e9 20 61 75     Err(3, Some(1))         Err, no position        Err(3, Some(1))
e0 b2                    Err(0, None)            Err                     Err(0, None)
ed a0 80                 Err(0, Some(1))         Err                     Err(0, Some(1))
f4 90 80 80              Err(0, Some(1))         Err                     Err(0, Some(1))

input            std          basic        compat       basic / std
ascii          14.3 GB/s    17.6 GB/s    17.7 GB/s      1.2×
polish 2-byte   1.35 GB/s    9.6 GB/s    11.1 GB/s      7.1×
cjk 3-byte      1.97 GB/s   11.4 GB/s    11.0 GB/s      5.8×
emoji 4-byte    1.93 GB/s   11.2 GB/s    10.9 GB/s      5.8×

The verdicts agree on every row, including the three Validation is a boundary uses to catch a lax decoder — a surrogate, a truncated sequence and a code point past U+10FFFF — so the crate is not a looser validator, which is the first thing to check of anything faster. compat returns std's own two numbers and can replace it inside a match; basic returns a verdict with no position and is the one to use when the answer is only yes or no.

The numbers say when it matters. On ASCII, std already runs a fast path and the crate gains twenty percent; the moment the text is not ASCII, std drops to under two gigabytes a second and the crate does not — a factor of six or seven, on a laptop, on the text this library is about. That is the answer to does validation cost anything: for a Polish log file, it costs the difference between those two columns, once per read.

cargo add simdutf8

8. A pattern — regex

There is no regular expression engine in std, and baseline section 8 shows how far a closure over char::is_alphabetic gets: \p{L}+ by hand, and nothing with structure. The regex crate is the engine under ripgrep, it is Unicode by default, and it names its own UTS #18 level on the first screen of its documentation:

Measured 2026-09-13 — regex 1.13.1, rustc 1.98.0
pattern                        string           is_match
^\p{L}+$                       żółw             true
^\p{Cyrillic}+$                Москва           true       \p{Script=Cyrillic} too
^\w+$                          Zażółć_gęślą     true
^\d+$                          ٣٤               true       (?-u)^\d+$ -> false: ASCII is a flag
^[[:alpha:]]+$                 żółw             false      POSIX classes are ASCII even in Unicode mode
(?i)^straße$                   STRASSE          false      simple case folding, one char to one
(?i)^straße$                   STRAẞE           true
^\p{Emoji}$                    🪾  U+1FABE       true       assigned in Unicode 16
^\p{Cn}$                       🫩  U+1FAE9       false      the table knows it is assigned

Regex::new(r"\X")              -> error: unrecognized escape sequence
Regex::new(r"(a)\1")           -> error: backreferences are not supported
Regex::new(r"(?<=a)b")         -> error: look-around, including look-ahead and look-behind, is not supported
Regex::new(r"(?-u)caf\xe9")    -> error: pattern can match invalid UTF-8
regex::bytes::Regex::new(r"(?-u)caf\xe9").find(b"caf\xe9 au")   -> Some((0, 4))

Level 1 of "Supports Unicode" is a level, not a yes, by its own account, and the rows bear it out: properties and scripts, Unicode \w and \d, simple case folding — and no \X, because graphemes are Level 2 and this engine leaves them to section 4's crate. The two refusals below the table are the design: no backreferences and no look-around, in exchange for a guarantee of linear time on any input, which is the property rg is built on.

The last two lines are the type system reaching into the pattern. A Regex searches a &str, so a pattern that could match a lone e9 is refused at compile time; regex::bytes::Regex searches a &[u8] and takes it. That second engine is how rg searches a file it never decoded, and it is the one to run over bstr's bytes.

cargo add regex

9. The locale — icu

Baseline section 9: sort() is code point order, and std has nothing to ask about a language. ICU4X — the icu crate — is the Unicode Consortium's own rewrite of ICU in Rust, and it is the one dependency on this page that brings locale data rather than a table:

Measured 2026-09-13 — icu 2.3.1 (ICU4X with compiled_data; the build pulled 71 crates and took 26 s), rustc 1.98.0. Twenty-one words.
std sort()             Olsztyn Strasse Straße Zawadzki Zebra cmentarz file lza lód resume résumé zabawa zebra Ölmütz ćma Łukasiewicz łza łódź źdźbło żaba file
Collator, locale pl    cmentarz ćma file file lód lza łódź Łukasiewicz łza Ölmütz Olsztyn resume résumé Strasse Straße zabawa Zawadzki zebra Zebra źdźbło żaba
Collator, locale en    ćma cmentarz file file lód łódź Łukasiewicz lza łza Ölmütz Olsztyn resume résumé Strasse Straße żaba zabawa Zawadzki źdźbło zebra Zebra
Collator, und (root)   the same as en
Collator, locale sv    ... zebra Zebra Ölmütz               ö is a letter after z in Swedish
Collator, locale tr    ... Olsztyn Ölmütz ...               and a letter after o in Turkish
pl, Strength::Primary  lód vs łódź -> Less     resume vs résumé -> Equal     Strasse vs Straße -> Equal     zebra vs Zebra -> Equal

the rest of the same crate:
normalizer   ComposingNormalizer::new_nfc().normalize("cafe" + U+0301) == "café"   -> true
segmenter    graphemes: family 1, 🇵🇱 1, क्षि 1, e + U+0301 1
             words, Thai "สวัสดีครับ" (no spaces): 3 pieces     Japanese "東京都渋谷区": 東京 · 都 · 渋谷 · 区     a dictionary, not a rule
casemap      fold("Straße") -> "strasse"     uppercase("istanbul", tr) -> "İSTANBUL"     lowercase("İSTANBUL", tr) -> "istanbul"
             std: "İSTANBUL".to_lowercase() -> "i" + U+0307 + "stanbul", 9 chars      the locale std refuses to take

The Polish row is the one Sorting and collation could only describe: ć after every c word, ł after every l word, ź and ż after z, and it is the same order — word for word — that macOS's pl_PL locale files gave the Python page from a different copy of the same CLDR data. The en row is the untailored algorithm, and it is what pyuca gives in Python; sv and tr are two languages that each put ö somewhere the other does not. Strength::Primary is the knob under ignore case and accents: at that strength résumé equals resume and Straße equals Strasse, but łódź still does not equal lód, because in Polish ł is a primary difference.

The rest of the block is the elephant Case is not a per-character operation pointed at: a normalizer, a segmenter whose word breaker knows where a Thai or Japanese word ends because it carries a dictionary, and a case mapper that takes the language std refuses to — the Turkish İ that to_lowercase turns into two code points comes back as one. The cost is the cost of the data: seventy-odd crates in the build and a binary that carries CLDR. That is the trade the whole page is about, made in full by the one crate that makes it in full.

cargo add icu

Four small ones

Crate Version, released What it adds Measured 2026-09-13
unicode-bom 2.0.3, 2023-11-13 a Bom enum from the first bytes, including the marks the Encoding Standard leaves out ff fe 00 00 …Utf32Le, length 4, where encoding_rs::Encoding::for_bom says UTF-16LE and 2; 2b 2f 76 38Utf7, where encoding_rs says None
widestring 1.2.1, 2025-10-09 owned u16 and u32 strings for Windows and other FFI, where std has only the conversion U16String::from_str("café 😀").len() → 7 units for 6 chars, as encode_utf16().count(); U16CString::from_str("a\0b")Err, an interior NUL at 1; a lone surrogate is lossy on both sides
unicode-script 0.5.8, 2025-12-03 the Script property — the one Confusables and scripts needs and char does not carry 'а'Cyrillic, 'a'Latin, '1'Common, ZWJ → Inherited
deunicode · any_ascii 1.6.2, 2025-04-27 · 0.3.3, 2025-06-29 transliteration, the job the Python page gives three libraries Zażółć gęślą jaźńZazolc gesla jazn from both; 北京Bei Jing and BeiJing; 😀grinning and :grinning:; ÆrøskøbingAEroskobing and Aeroskobing

The nine, one row each

# The job Crate Version, released Licence What std has instead
1 Decode a legacy encoding encoding_rs 0.8.41, 2026-09-09 (Apache-2.0 OR MIT) AND BSD-3-Clause UTF-8, UTF-16, and b as char for Latin-1
2 Guess the encoding chardetng 1.0.0, 2026-03-30 Apache-2.0 OR MIT from_utf8: yes or no
3 Normalize unicode-normalization 0.1.25, 2025-10-30 MIT OR Apache-2.0 nothing
4 Count what a person sees unicode-segmentation 1.13.3, 2026-06-01 MIT OR Apache-2.0 chars().count(), split_whitespace()
5 Measure columns unicode-width 0.2.2, 2025-10-06 MIT OR Apache-2.0 nothing
6 Bytes that are mostly text bstr 1.13.1, 2026-08-10 MIT OR Apache-2.0 utf8_chunks(), from_utf8_lossy, split, windows
7 Validate at memory bandwidth simdutf8 0.1.5, 2024-09-22 MIT OR Apache-2.0 from_utf8, at a sixth of the speed off ASCII
8 A pattern regex 1.13.1, 2026-07-15 MIT OR Apache-2.0 char predicates in a closure
9 The locale icu 2.3.1, 2026-08-20 Unicode-3.0 nothing, deliberately

Left out, and why. The encoding crate was the legacy-encoding answer before encoding_rs and has not published since 2016; simdutf (no 8) wraps the C++ library of the same name and adds transcoding, at the price of a C++ build. And textwrap, unicode-linebreak and unicode-bidi are real text crates that belong to layout and direction rather than to the boundary this page is about.

The same jobs, in Python

Where the standard library stops has the table that lines the two languages up, job by job. The short version: Python's standard library covers rows 1 and 3 of the table above, and Rust's covers nothing on Python's list that Python's does not — but every crate here carried a Unicode 17 table on the day the Python interpreter carried 16, and chardetng told two Polish code pages apart where the library requests runs did not.

Try it

  1. Take a file file --mime-encoding calls unknown-8bit and run it through chardetng twice: guess(None, Allow), then guess(Some(b"xx"), Allow) with the country code of whoever wrote it. If the two answers differ, the file is short of the letters that separate its candidate tables — which is what a hint is for, and what a confidence number would have told you.
  2. Call encoding_rs::Encoding::for_label on every charset= value in a mailbox or a log of HTTP headers you have, and count the Nones. Then look at what the senders of those were actually using.
  3. Print a table with {:<20} and a Japanese name in it; then pad by 20 - s.width() spaces instead. Then colour one cell with an ANSI escape and watch width() count it.
  4. Validate the largest text file you own with std::str::from_utf8 and with simdutf8::basic::from_utf8, timing each; then strip every non-ASCII byte out of a copy and time both again. The ratio is the answer to whether your data would notice.

Practice

Six jobs, and which of them std finishes. For each of the six, say yes if std can finish the job alone or no if it needs a crate on this page — and name what std returns.

  1. b"caf\xe9" is Latin-1; decode it.
  2. The same four bytes are Windows-1250; decode them.
  3. Count what a user sees in the four-person family emoji.
  4. Is "café" the same word as "cafe" followed by U+0301?
  5. Find the first byte of a file that is not UTF-8.
  6. Sort ["łódź", "lód", "zebra"] the way a Polish dictionary does.
Answers

Verified output of where_std_stops_kata_rs.rs — regenerated by tools/run_examples.py, never hand-typed.

1. b"caf\xe9" is Latin-1; decode it
   bytes.iter().map(|&b| b as char).collect() -> "café"
   YES, std, one line -- because Latin-1 is the first 256 code points.

2. The same four bytes are windows-1250; decode them
   the same line gives "café", and it is right only because e9 is é in both tables.
   on b"\xbf" it gives "¿"; windows-1250 says ż
   NO -- a 128-entry table you do not have. encoding_rs.

3. Count what a user sees in the family emoji
   len() 25   chars().count() 7   encode_utf16().count() 11   a user sees 1
   NO -- three rulers, and none of them is that one. unicode-segmentation.

4. Is "café" the same word as "cafe" + U+0301?
   == -> false
   NO -- std has no normalization. unicode-normalization, or icu.

5. Find the first byte of a file that is not UTF-8
   from_utf8(b).unwrap_err().valid_up_to() -> 3
   YES, std -- and it is the same number simdutf8::compat reports, byte for byte.

6. Sort ["łódź", "lód", "zebra"] the way a Polish dictionary does
   sort() -> ["lód", "zebra", "łódź"]
   NO -- code point order puts ł after z. icu::collator with locale pl: lód, łódź, zebra.

Score: 2 of 6 std finishes, 4 need a crate -- and both YES rows are the ones
where the answer is arithmetic on bytes rather than a table.

See also