Skip to content

Rust strings in practice

Level: 201 → 301 · for anyone starting from zero

One line: Take &str, return String, never invent a byte index, ask for the length you actually meant, and let &[u8] mean "not checked yet" — Rust's strings are not hard, they are the UTF-8 everywhere rules with the compiler holding them for you.

Why Rust feels strict here, and why it is not

Rust's string types answer a question other languages leave open: have these bytes been checked?

  • &[u8] / Vec<u8> — bytes. Could be anything. A PNG, a Latin-1 file, half a packet.
  • &str / String — bytes that are guaranteed valid UTF-8. Not "probably", not "we hope": there is no safe way to construct one that is not.
  • char — one Unicode scalar value, four bytes wide in memory, and not what a person calls a character.
  • OsStr / Path — the operating system's idea of a string, which is not guaranteed to be UTF-8 at all.

Every complaint about Rust strings is really one of two things: reaching for a length without saying which length, or trying to index into text by a number you made up. Both are questions the other languages let you get wrong quietly. The rules below are short because the type system is doing the remembering.

The checklist

Take &str, return String. A &str can be borrowed from a String, a literal, or a slice of either, so one signature serves every caller with no allocation. &String narrows your function for nothing. (impl AsRef<str> when you want to be generous; Cow<str> when you might not need to allocate.)

len() is bytes. It is the shortest name and the one you mean least often. .chars().count() is code points. Grapheme clusters — what a cursor moves over — need the unicode-segmentation crate; there is no std substitute and there is not going to be one, because the tables move with every Unicode release.

Never invent a byte index. Indices that came out of std — find, char_indices, split, match_indices — are always character boundaries. An index you computed by counting letters is not, and &s[0..1] panics rather than handing you half a letter. Use s.get(range), which returns Option<&str>, anywhere the range is not obviously yours.

The ascii_ methods are a different tool, not a faster one. to_ascii_lowercase leaves every non-ASCII byte alone — correct and branch-free for a protocol token, an HTTP header name, a hex digit; wrong for anything a person typed. to_lowercase is the Unicode one. eq_ignore_ascii_case has the same split. And note that case conversion can change the length: "ß".to_uppercase() is "SS".

Decode once, at the boundary. std::str::from_utf8(bytes) returns Result and its error carries valid_up_to(), which is how a tool reports where a file stopped making sense. from_utf8_lossy replaces bad bytes with U+FFFD — fine for a message a human will read, never for data you will write back out, because the original bytes are gone. unsafe { from_utf8_unchecked } is for a hot path where something else already checked, and nowhere else: a &str that is not valid UTF-8 is undefined behaviour, not just a wrong answer.

Path is not a str. A Unix filename is any bytes except NUL and /; a Windows one is UTF-16 units that may include unpaired surrogates. So Path::to_str() returns Option<&str> and to_string_lossy() returns a Cow. Handle the None rather than unwrapping it, in anything that will one day be pointed at somebody else's disk. (std::os::unix::ffi::OsStrExt is the escape hatch when you truly need the bytes.) The third door, into_string(), is the one to reach for when you need a String and might not get one: its Err hands the name back.

When it is not text, say so. Binary is &[u8] and Vec<u8>; a byte-string literal is b"…". Do not launder binary through String on its way past. The type is the documentation: &[u8] says "unknown bytes", &str says "already checked", and a function signature that says the right one is a comment that cannot go stale.

Two crates worth knowing about, because their absence from std is deliberate: unicode-segmentation (grapheme clusters, word boundaries) and unicode-normalization (NFC/NFD/NFKC). bstr is the third, for when you want string-ish operations on bytes that are mostly UTF-8 — log processing, grep-like tools.

In Rust

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

1. TAKE &str, RETURN String, AND STOP THINKING ABOUT IT
------------------------------------------------------------------------
   shout("literal")   = "LITERAL"
   shout(&owned)      = "PŁYTA"
   shout(&owned[0..3])= "PŁ"
   One function, three callers, no allocation to call it. A &str
   is a borrowed window onto bytes that are already valid UTF-8;
   String owns them. That is the whole distinction.

2. THREE LENGTHS. THE COMPILER WILL NOT PICK ONE FOR YOU.
------------------------------------------------------------------------
   bytes  5   chars  5   "hello"
   bytes  7   chars  4   "Łódź"
   bytes  5   chars  4   "café"
   bytes  6   chars  5   "cafe\u{301}"
   bytes  9   chars  3   "日本語"
   Rows 3 and 4 look identical and are not: 'café' composed is 4
   chars, decomposed is 5. `len()` is BYTES — it is the one people
   mean least often and reach for most, because it is the short name.

3. NEVER INVENT A BYTE INDEX. ASK FOR ONE.
------------------------------------------------------------------------
   s.find('d')            = Some(4)   <- a real boundary, from std
   s.get(0..2)            = Some("Ł")
   s.get(0..1)            = None   <- inside a character: None
   s.char_indices()       = [(0, 'Ł'), (2, 'ó'), (4, 'd'), (5, 'ź')]
   s.chars().next()       = Some('Ł')
   Indices that came from std (`find`, `char_indices`, `split`) are
   always boundaries. An index you computed by counting letters is
   not, and `&s[0..1]` panics rather than returning half a letter.

4. ASCII METHODS ARE FAST AND WRONG; UNICODE ONES ARE RIGHT AND SLOWER
------------------------------------------------------------------------
   "Straße"
      to_lowercase()       "straße"
      to_ascii_lowercase() "straße"   <- non-ASCII left alone
   "ŁÓDŹ"
      to_lowercase()       "łódź"
      to_ascii_lowercase() "ŁÓdŹ"   <- non-ASCII left alone
   Use the ascii_ methods when the data really is ASCII (a protocol
   token, a hex digit, an HTTP header name) — they are branch-free.
   Use the Unicode ones for anything a person typed.

   And note the length change, which is why case is not per-char:
   "ß".to_uppercase() = "SS", 2 chars from 1

5. REVERSING BY chars() IS NOT REVERSING A WORD
------------------------------------------------------------------------
   composed   "café" reversed -> "éfac"
   decomposed "cafe\u{301}" reversed -> "\u{301}efac"
   The accent came off the 'e' and landed on the front. `chars()`
   yields Unicode SCALAR VALUES, and a user-perceived character can
   be several of them — that is what the `unicode-segmentation`
   crate is for, and there is no std substitute. Same reason emoji
   families and flags cannot be counted with `chars().count()`.

6. AT THE BOUNDARY: BYTES IN, CHECKED TEXT OUT
------------------------------------------------------------------------
   from_utf8(good) -> Ok("order,café,2")
   from_utf8(latin-1) -> Err: invalid utf-8 sequence of 1 bytes from index 9
   lossy              -> "order,caf�,2"
   Decode once, at the edge, and carry &str inwards. `from_utf8` for
   data you will act on; `from_utf8_lossy` only for something a human
   reads, because it destroys the bytes it could not understand.
   `unsafe { from_utf8_unchecked }` is for a checked-elsewhere hot
   path and nothing else: a &str that lies is undefined behaviour.

7. PATHS AND ARGUMENTS ARE NOT &str, AND THAT IS NOT PEDANTRY
------------------------------------------------------------------------
   OsStr::to_str()          = Some("report.csv")
   OsStr::to_string_lossy() = "report.csv"
   A Unix filename is any bytes except NUL and '/', and a Windows
   one is any UTF-16 units including unpaired surrogates. Neither
   is guaranteed to be UTF-8, so `Path` is not a `str` — handle the
   `None` from `to_str()` rather than unwrapping it in a tool that
   will one day be pointed at somebody else's disk.

8. WHEN THE DATA IS NOT TEXT, SAY SO IN THE TYPE
------------------------------------------------------------------------
   b"\x89PNG..."          = [89, 50, 4e, 47, 0d, 0a, 1a, 0a]
   is it UTF-8?            false
   Binary is `&[u8]`/`Vec<u8>`, and it should never be forced through
   String on the way past. The type IS the documentation: `&[u8]`
   says 'unknown bytes', `&str` says 'already checked'.

If you are coming from Python or ABAP

Python. String is a bytearray that has promised to stay valid UTF-8; &str is a memoryview of one. The mapping is almost exact: str&str/String, bytes&[u8]/Vec<u8>, and bytes.decode('utf-8')str::from_utf8. Two real differences to hold on to. Python's s[0] is a character and Rust has no such operator at all — because Python pays for it with a variable-width internal representation (PEP 393) that Rust will not, so chars().nth(0) is a walk and Rust makes the cost visible. And UnicodeDecodeError becomes a Result you must handle: the same information, moved from runtime to the type signature, exactly as sy-subrc becomes Result.

ABAP. The three-way split is one you already live with: string&str/String, xstringVec<u8>, and cl_abap_codepage=>convert_fromstr::from_utf8. What Rust adds is that the conversion cannot be skipped — there is no path from xstring to string that does not go through a check, so the class of bug where raw bytes end up in a character field is not expressible. The strlen / xstrlen distinction you already make by hand is chars().count() / len(), with the difference that Rust's short name is the byte one, which is the opposite of ABAP's instinct and worth a second look in review. (Not machine-checked — CI cannot run ABAP.)

Try it

cd 10_Best_Practices/rust_strings_in_practice/examples
rustc --edition 2024 rust_strings_in_practice_rs.rs -o /tmp/rsprac && /tmp/rsprac

Without the machine: you are writing a function that truncates a name to fit a 20-byte database column without cutting a character in half. Which of the three lengths do you check, which std method finds the cut point, and what does it return when the string is already short enough?

Practice

Five signatures. Write the parameter and return types for a function that counts characters and one that uppercases — and say what taking String instead of &str costs every caller.

Then: give the correct way to take the first three characters without inventing a byte index; say which length method answers which question; explain what &[u8] means in a signature that &str does not; and name the one-line function the compiler will simply not let you write.

Answers

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

1. TAKE &str, RETURN String
   count_chars(&owned)  4
   count_chars("café")  4
   shout(&owned)        "CAFÉ"
   A &str parameter accepts a String, a literal and a slice, so
   it costs the caller nothing. Taking String forces every caller
   to give up ownership or clone -- the single most common
   avoidable allocation in a Rust codebase.

2. NEVER INVENT A BYTE INDEX
   forbidden:  &s[0..4]      -- 4 may be inside a character
   correct:    chars().take(3) -> "caf"
   char_indices() gives you real boundaries when you need offsets:
   (0, 'c')   (1, 'a')   (2, 'f')   (3, 'é')
   Note the gap: the last index is 3 and the string is 5 bytes,
   because é occupies 3 and 4. There is no boundary at 4.

3. ASK FOR THE LENGTH YOU MEANT
   len()            5   bytes -- for buffers and field limits
   chars().count()  4   code points -- for most 'how long is this'
   Neither is 'characters' as a person means it; that needs a
   grapheme crate, and needing one is the honest answer.

4. LET &[u8] MEAN 'NOT CHECKED YET'
   from_utf8 err at byte 3 -- and the type says so
   "caf�"
   A function taking &[u8] is saying 'these may not be text'. A
   function taking &str is saying 'somebody already checked'. The
   boundary between them is the only place a decode belongs.

5. THE SIGNATURE THE COMPILER REFUSES
   fn first(s: &str) -> char { s[0] }   // does not compile
   String has no Index<usize>, so the O(1) indexing every other
   language offers is simply absent -- not slow, absent. That is
   the UTF-8-everywhere rules with the compiler holding them:
   the operation that would silently split a character is not
   available to be written by accident.

See also