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.

Elixir & Erlang/OTP  Module 2 – Values and types

Strings, binaries and charlists

Elixir strings as UTF-8 binaries: bytes, code points and graphemes, safe slicing, Unicode normalisation and casing, and the charlists that Erlang functions use.

  • Beginner
  • 15 minutes
  • Examples run with elixir 1.20.4, erlang 29.1.1 and Python 3.14.8
  • 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

  • Distinguish bytes, code points and graphemes when you measure or slice text
  • Convert between strings, binaries and charlists without corrupting text
  • Normalise text to NFC before comparing it or using it as a key
  • Change the case of text with the mode its language needs, including Greek and Turkish
  • Display a list of numbers as numbers when IEx would show it as a charlist

Before you start

What you’ll learn · Before you start

What you will learn

  • Distinguish bytes, code points and graphemes when you measure or slice text
  • Convert between strings, binaries and charlists without corrupting text
  • Normalise text to NFC before comparing it or using it as a key
  • Change the case of text with the mode its language needs, including Greek and Turkish
  • Display a list of numbers as numbers when IEx would show it as a charlist

Before you start

On this page

byte_size("नमस्ते") is 18, the word has 6 code points, and String.length("नमस्ते") is 3, and all three answers are right: they count bytes, Unicode code points and the characters a reader sees. A program that limits a name to 20 characters, cuts a preview at 100 or compares two names must pick the right one, or it rejects Indian names, splits a letter in half or treats two identical-looking names as different people.

Below, you will measure text three ways, cut it without breaking characters, normalise it before comparing, change its case for the right language, and recognise the charlists that Erlang functions use and the lists of numbers that IEx prints as text. The outputs below are exactly what Elixir 1.20.4 printed, on Erlang/OTP 29.1.1.

A string is UTF-8 bytes, and you can count it three ways

Elixir has one string type, and its guide defines it plainly: “A string is a UTF-8 encoded binary”. A binary: A sequence of bytes, written <<104, 105>>. An Elixir string is a binary whose bytes are valid UTF-8, so "hi" and <<104, 105>> are the same value. is a sequence of bytes, and UTF-8: The encoding of Elixir strings. It stores each code point in 1 to 4 bytes: ASCII letters take 1, é takes 2, Devanagari and Tamil letters 3, and most emoji 4. stores the number of each character in one to four bytes. So every string can be counted in three units, and this script counts five strings in all three, writing each non-ASCII character as an escape such as \u0928 so the source shows what is in it:

text_units.exs: five strings, three counts Elixir · text_units.exs
# Five strings measured three ways. The \u escapes spell out each code point, so nothing is hidden in the source.
words = [
  {"cafe", "cafe"},
  {"e acute", "\u00E9"},
  {"flag of India", "\u{1F1EE}\u{1F1F3}"},
  {"namaste", "\u0928\u092E\u0938\u094D\u0924\u0947"},
  {"vanakkam", "\u0BB5\u0BA3\u0B95\u0BCD\u0B95\u0BAE\u0BCD"}
]

IO.puts("string          bytes  code points  graphemes  text")

for {name, text} <- words do
  counts = [byte_size(text), length(String.codepoints(text)), String.length(text)]
  [bytes, points, graphemes] = Enum.map(counts, &Integer.to_string/1)

  IO.puts(
    String.pad_trailing(name, 14) <>
      String.pad_leading(bytes, 7) <>
      String.pad_leading(points, 13) <>
      String.pad_leading(graphemes, 11) <> "  " <> text
  )
end

# The code points of namaste, each with the UTF-8 bytes that store it.
namaste = "\u0928\u092E\u0938\u094D\u0924\u0947"

for point <- String.codepoints(namaste) do
  [number] = String.to_charlist(point)
  bytes = for byte <- :binary.bin_to_list(point), do: Integer.to_string(byte, 16)
  IO.puts("U+" <> String.pad_leading(Integer.to_string(number, 16), 4, "0") <> "  " <> Enum.join(bytes, " "))
end

IO.inspect(String.graphemes(namaste), label: "graphemes of namaste")

# The graphemes of vanakkam: where String.length/1 found its 4.
vanakkam = "\u0BB5\u0BA3\u0B95\u0BCD\u0B95\u0BAE\u0BCD"
IO.inspect(String.graphemes(vanakkam), label: "graphemes of vanakkam")
Bytes, code points and graphemes Python · text_units_session.py
from session import run

run("""
elixir text_units.exs
""")

Output

$ elixir text_units.exs
string          bytes  code points  graphemes  text
cafe                4            4          4  cafe
e acute             2            1          1  é
flag of India       8            2          1  🇮🇳
namaste            18            6          3  नमस्ते
vanakkam           21            7          4  வணக்கம்
U+0928  E0 A4 A8
U+092E  E0 A4 AE
U+0938  E0 A4 B8
U+094D  E0 A5 8D
U+0924  E0 A4 A4
U+0947  E0 A5 87
graphemes of namaste: ["न", "म", "स्ते"]
graphemes of vanakkam: ["வ", "ண", "க்க", "ம்"]

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

  • byte_size/1 counts bytes: 4 for “cafe”, 2 for é, 18 for नमस्ते. That is what the text costs in memory, in a file or on the network, and the documentation says it “happens in constant time”.
  • String.codepoints/1 lists the code point: The number that Unicode gives to a character or a mark, written U+0928 for न. String.codepoints/1 lists the code points of a string., the numbers Unicode gives to characters and marks. The flag of India is two of them, two regional indicator letters.
  • String.length/1 counts grapheme: What a reader sees as one character, made of one or more code points: स्ते in नमस्ते is one grapheme of four code points. String.length/1 counts graphemes., what a reader sees as one character. The flag is one grapheme, and so is स्ते, though it holds four code points.

Unicode’s text segmentation annex treats grapheme clusters as the characters a user perceives, and that is why String.length/1, String.slice/3 and String.reverse/1 work in graphemes: they treat text the way a reader sees it. The String module follows the Unicode Standard 17.0.0. Step through the Devanagari word from its bytes to its graphemes:

Interactive · step by step नमस्ते: 18 bytes, 6 code points, 3 graphemes Step through the string from its bytes to what a reader sees. The left column is each code point with its bytes; the right column is the graphemes.
नमस्ते: 18 bytes, 6 code points, 3 graphemes, step 6 of 6. Showing न U+0928, म U+092E, स U+0938, U+094D virama, त U+0924, U+0947 vowel sign, न, म, स्ते (grapheme 3: 4 code points).न U+0928bytes E0 A4 A8म U+092Ebytes E0 A4 AEस U+0938bytes E0 A4 B8U+094D viramabytes E0 A5 8Dत U+0924bytes E0 A4 A4U+0947 vowel signbytes E0 A5 87नgrapheme 1 of 3मgrapheme 2 of 3स्तेgrapheme 3: 4 code points

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

Every step as text
  1. 18 bytes. In UTF-8 each of these six code points takes 3 bytes, so the string is 18 bytes long: byte_size/1 counts these. Every left-hand box is one code point with the three bytes that store it.
  2. न on its own. The first code point, न, is a letter with nothing attached to it, so it is a whole grapheme by itself: what a reader sees as one character.
  3. म on its own. म is the same: one code point, one grapheme. So far bytes, code points and graphemes have kept in step, three bytes to each.
  4. The virama joins two letters. The virama, U+094D, has no letter of its own: it joins स to the त after it into the conjunct स्त. Unicode's rules for splitting text keep a Devanagari letter, a virama and the next letter in one grapheme.
  5. The vowel sign attaches. The last code point, U+0947, is a vowel sign. It marks the e sound on the letters before it and attaches to them, so the third grapheme, स्ते, ends up with four code points and 12 bytes.
  6. Three ways to count. So byte_size/1 gives 18, String.codepoints/1 gives 6 items and String.length/1 gives 3. Slicing by graphemes keeps स्ते whole; cutting by code points would split the vowel sign from its letters.

The Tamil word வணக்கம் is 21 bytes and 7 code points, and String.length/1 gives 4. The last line of the program shows the four: Elixir keeps க, the virama ் and the next க together in one grapheme, as it kept स्त, and ம் with its virama in another. Tamil writes க் and க as two letters, though, and Unicode’s list of named sequences even names க் on its own, TAMIL CONSONANT K, so a Tamil reader counts five letters where Elixir counts four graphemes. Unicode’s segmentation annex calls graphemes a best-effort approximation of what readers see, so test a length limit with names in the scripts your users write.

Unicode Character Inspector Paste नमस्ते or வணக்கம் and see every code point with its name and bytes.

Key idea

Count bytes for storage and transport, graphemes for what people see, and code points only when you work with Unicode itself.

Cutting a string by bytes can split a character in half

Because a string is a binary, the byte functions of Kernel work on it too, and they do not know where one character ends and the next begins. Watch what happens to é in “héllo”:

Strings are binaries Python · binaries_session.py
from session import shell

# Strings are binaries: counting, cutting and joining bytes.
shell([
    'byte_size("héllo")',
    'String.length("héllo")',
    'binary_part("héllo", 0, 2)',
    'String.valid?(binary_part("héllo", 0, 2))',
    'String.slice("héllo", 0, 2)',
    "<<104, 105>>",
    'String.valid?(<<0xFF>>)',
    '"tea for " <> "two"',
    '"#{3} cups of #{:tea}"',
    "System.halt()",
])

Output

$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]

Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> byte_size("héllo")
6
iex(2)> String.length("héllo")
5
iex(3)> binary_part("héllo", 0, 2)
<<104, 195>>
iex(4)> String.valid?(binary_part("héllo", 0, 2))
false
iex(5)> String.slice("héllo", 0, 2)
"hé"
iex(6)> <<104, 105>>
"hi"
iex(7)> String.valid?(<<0xFF>>)
false
iex(8)> "tea for " <> "two"
"tea for two"
iex(9)> "#{3} cups of #{:tea}"
"3 cups of tea"
iex(10)> System.halt()

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

byte_size("héllo") is 6 while String.length("héllo") is 5, since é takes two bytes. binary_part("héllo", 0, 2) took the first two bytes and returned <<104, 195>>: the h and the first half of é. IEx printed it byte by byte, in the <<>> form, rather than as text, because it is no longer valid UTF-8, and String.valid?/1 said so. String.slice("héllo", 0, 2) counted graphemes instead and returned “hé”.

The <<>> syntax writes a binary byte by byte. <<104, 105>> is the same value as “hi”, which is why IEx printed it as a string, and <<0xFF>> is a binary but not a string, because 0xFF never appears in UTF-8. Two ways build text from parts: <> joins two binaries, and interpolation, "#{3} cups of #{:tea}", turns each value into text first.

Truncating text with binary_part/3

A preview made with binary_part(text, 0, 100) can end in the middle of a character, and the half character turns into a replacement mark or an error further on. Cut with String.slice(text, 0, 100), which counts graphemes, and check a byte limit with byte_size/1 only after cutting.

Checkpoint

Check: Bytes, code points and graphemes · Strings as binaries, and cutting them safely

  1. Question 1 of 2 How many graphemes does String.length/1 count in नमस्ते?

    Type a number.

    A hint

    The virama joins स and त, and the vowel sign attaches to them.

    Show the answer: Question 1 of 2

    Answer: 3 graphemes

    नमस्ते is 18 bytes and 6 code points, but a reader sees three characters: न, म and स्ते, in which a virama and a vowel sign join the code points of the third. String.length/1 counts those graphemes.

  2. Question 2 of 2 What does binary_part("héllo", 0, 2) return?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: <<104, 195>>, the h and the first byte of é, which is not valid UTF-8

    Byte functions do not know where characters end. binary_part("héllo", 0, 2) returned <<104, 195>>, which String.valid?/1 rejected; cut text with String.slice/3.

Text that looks the same can differ: normalise before comparing

The letter é can be written in two ways: as one code point, U+00E9, or as e followed by U+0301, a combining acute accent. A keyboard usually types the first; text copied from some file names, documents or other systems may hold the second. They look identical and compare as different:

Two spellings of é Python · normalise_session.py
from session import shell

# Two spellings of é: one code point, or e followed by a combining accent; and a look-alike that is another letter.
shell([
    'composed = "\\u00E9"',
    'decomposed = "e\\u0301"',
    "composed == decomposed",
    "{byte_size(composed), byte_size(decomposed)}",
    "{String.length(composed), String.length(decomposed)}",
    "String.normalize(decomposed, :nfc) == composed",
    "String.equivalent?(composed, decomposed)",
    'String.normalize("\\u0430", :nfc) == "a"',
    "System.halt()",
])

Output

$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]

Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> composed = "\u00E9"
"é"
iex(2)> decomposed = "e\u0301"
"é"
iex(3)> composed == decomposed
false
iex(4)> {byte_size(composed), byte_size(decomposed)}
{2, 3}
iex(5)> {String.length(composed), String.length(decomposed)}
{1, 1}
iex(6)> String.normalize(decomposed, :nfc) == composed
true
iex(7)> String.equivalent?(composed, decomposed)
true
iex(8)> String.normalize("\u0430", :nfc) == "a"
false
iex(9)> System.halt()

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

The composed é is 2 bytes and the decomposed one 3, and == compares bytes, so it returned false, though both are one grapheme. normalisation (Unicode): Rewriting text into one standard form so that text that looks the same has the same bytes. NFC composes e and a combining accent into the single code point é. rewrites text into one standard form. With :nfc, String.normalize/2 composes e and the accent into U+00E9, and the comparison came out true. String.equivalent?/2 answers the same question directly; its documentation says it normalises both strings each time it is called, so to compare many strings, normalise each one once and compare the results.

Unicode’s normalisation annex defines the forms. NFC, which composes, is the usual choice for text you store and compare; NFD decomposes. NFKC and NFKD also fold compatibility characters such as ligatures, and the String.normalize/2 documentation warns that they “should not be blindly applied to arbitrary text”. Normalising unifies only the spellings that Unicode counts as the same character: the last line compared the Cyrillic letter а, U+0430, with the Latin a, and they stayed different, however alike they look.

Normalise at the edge

Normalise text to NFC once, where it enters the system: a form, a file, a message. Everything stored and compared is then in one form, and a user name typed on one device matches the same name typed on another.

Upper and lower case depend on the language

Changing case is not one letter in, one letter out. German, Greek and Turkish each need something different, and String.upcase/2 and String.downcase/2 take a mode for that:

Casing in four scripts Python · casing_session.py
from session import shell

# Changing case follows the language: German sharp s, Greek final sigma and the Turkish dotted i.
shell([
    'String.upcase("straße")',
    'String.downcase("ΟΔΟΣ")',
    'String.downcase("ΟΔΟΣ", :greek)',
    'String.upcase("istanbul")',
    'String.upcase("istanbul", :turkic)',
    'String.upcase("नमस्ते") == "नमस्ते"',
    "System.halt()",
])

Output

$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]

Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> String.upcase("straße")
"STRASSE"
iex(2)> String.downcase("ΟΔΟΣ")
"οδοσ"
iex(3)> String.downcase("ΟΔΟΣ", :greek)
"οδος"
iex(4)> String.upcase("istanbul")
"ISTANBUL"
iex(5)> String.upcase("istanbul", :turkic)
"İSTANBUL"
iex(6)> String.upcase("नमस्ते") == "नमस्ते"
true
iex(7)> System.halt()

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

String.upcase("straße") returned “STRASSE”: Unicode’s special casing rules give the sharp s the two letters SS as its upper case, so one character became two, and the string grew. In Greek a lower-case sigma is written ς at the end of a word and σ elsewhere. The default mode wrote “οδοσ”; the :greek mode, which “includes the context sensitive mappings found in Greek” in the documentation’s words, wrote the correct “οδος”. In Turkish, i and ı are two letters, and the upper case of i is İ: the default mode gave “ISTANBUL”, the :turkic mode “İSTANBUL”. Devanagari has no case, so String.upcase/1 left नमस्ते as it was.

Two consequences follow. A length checked before changing case may not hold after it, as “straße” showed. And comparing two texts without regard to case means changing both to the same case with the same mode, after normalising them.

Fixed in Elixir 1.20.4

The 1.20.4 changelog lists a fix to “final sigma handling in Greek casing”, and a security fix to List.to_string/1 and List.to_charlist/1, covered below. Run the latest patch of your Elixir line, as this track pins 1.20.4.

Checkpoint

Check: Normalising text before comparing · Upper and lower case by language

  1. Question 1 of 2 A stored name is "José" with é as U+00E9, and a user types "José" as e followed by U+0301. Which comparison matches them?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: String.normalize(stored, :nfc) == String.normalize(typed, :nfc)

    Normalising both strings to one form makes text that looks the same compare the same. Do it once where text enters the system, so stored names are already in NFC.

  2. Question 2 of 2 straße has 6 graphemes. How many does String.upcase("straße") have?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 7 graphemes

    Unicode's special casing rules give the sharp s the two letters SS as its upper case, so String.upcase/1 writes SS for it: STRASSE has 7 graphemes. A length checked before a change of case may not hold after it.

Charlists are lists of code points, and some lists print as text

In Erlang’s own source code, a string in double quotes is not a type of its own but shorthand for a list of character codes, and many Erlang functions take or return text in that form. Elixir calls such a list a charlist: A list of code points, such as ~c"hi", which is the list [104, 105]. Many Erlang functions take and return text as charlists rather than as strings. and writes it with the ~c sigil: ~c"hi" is the list [104, 105]. Here is where charlists turn up, and the surprise they cause:

Charlists, and lists that print as text Python · charlists_session.py
from session import shell

# Charlists: lists of code points, and lists of numbers that IEx prints as text.
shell([
    'is_list(~c"hi")',
    "[104, 105]",
    "Enum.chunk_every(1..7, 3)",
    "IO.inspect([9, 36, 81], charlists: :as_lists)",
    'String.to_charlist("hé")',
    "List.to_string([104, 233])",
    ":erlang.integer_to_list(42)",
    '~c"tea" ++ ~c"pot"',
    "System.halt()",
])

Output

$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]

Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> is_list(~c"hi")
true
iex(2)> [104, 105]
~c"hi"
iex(3)> Enum.chunk_every(1..7, 3)
[[1, 2, 3], [4, 5, 6], ~c"\a"]
iex(4)> IO.inspect([9, 36, 81], charlists: :as_lists)
[9, 36, 81]
~c"\t$Q"
iex(5)> String.to_charlist("hé")
[104, 233]
iex(6)> List.to_string([104, 233])
"hé"
iex(7)> :erlang.integer_to_list(42)
~c"42"
iex(8)> ~c"tea" ++ ~c"pot"
~c"teapot"
iex(9)> System.halt()

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

~c"hi" is a list, and the list [104, 105] came back as ~c"hi". IEx prints any list whose numbers are all printable ASCII codes as a charlist, and the List documentation counts some control codes as printable too, such as \a, 7, and \t, 9. So Enum.chunk_every(1..7, 3) showed its last chunk, [7], as ~c"\a". The numbers are still there; only the display changed. To see them as numbers, pass charlists: :as_lists to IO.inspect/2 or inspect/2, as the fourth line did: it printed [9, 36, 81], and IEx then showed the same value as ~c"\t$Q".

Key idea

A charlist is a list of code points; IEx shows any list of printable ASCII codes as one, so print lists of numbers with charlists: :as_lists.

To cross between the two forms, String.to_charlist/1 turns a string into a charlist and List.to_string/1 turns a charlist back; [104, 233] holds é, which is not ASCII, so IEx printed it as numbers. Erlang functions such as :erlang.integer_to_list/1 return charlists, here ~c"42". And charlists are joined with ++, like any list, never with <>. Those two conversion functions are also where the security fix of 1.20.4 lies:

Elixir 1.20.4 fixed a hang in List.to_string/1

Advisory GHSA-jf5q-v438-665c (CVE-2026-75758): before 1.20.4, a long printable list followed by one invalid element made List.to_string/1 and List.to_charlist/1 recurse without end while reporting the error, until the process, or the whole node, ran out of memory. Versions 1.18.5, 1.19.6 and 1.20.4 fix it; code that converts lists from outside should run one of them.

Checkpoint

Check: Charlists, and lists that print as text

  1. Question 1 of 2 IEx shows Enum.chunk_every([1, 2, 7], 2) as [[1, 2], ~c"\a"]. What is the second chunk?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The list [7], shown as a charlist because 7 is the printable code \a

    Enum.chunk_every([1, 2, 7], 2) returns [[1, 2], [7]]. [1, 2] holds codes that are not printable, so IEx shows it as numbers, while [7] is the bell, \a, which counts as printable, so it is shown as ~c"\a".

  2. Question 2 of 2 Which expression joins the charlists ~c"tea" and ~c"pot" into one charlist?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ~c"tea" ++ ~c"pot"

    Strings join with <>, charlists with ++, because a charlist is a list of code points.

Practise: initials in any script

Write Initials.from/1, which returns the initials of a name: the first grapheme of each word, in upper case, after normalising the name to NFC. “asha rao” gives “AR”, and the Devanagari आशा राव gives आरा, because रा, the letter र with its vowel sign, is the first grapheme of राव. Copy the two files into an empty folder:

practice/initials.ex: the starter Elixir · practice/initials.ex
defmodule Initials do
  @moduledoc "Initials of a name in any script: the first grapheme of each word, in upper case."

  @doc """
  Returns the initials of `name`: the first grapheme of every word, upper-cased, with the text
  normalised to NFC first. Words are separated by any run of spaces.
  """
  def from(_name) do
    # Drop the underscore once you use the argument.
    ""
  end
end
practice/initials_test.exs: the tests Elixir · practice/initials_test.exs
Code.require_file("initials.ex", __DIR__)
ExUnit.start(seed: 0)

defmodule InitialsTest do
  use ExUnit.Case

  test "two Latin words" do
    assert Initials.from("asha rao") == "AR"
  end

  test "an accented first letter, typed as e and a combining accent" do
    # "e\u0301" is e followed by U+0301; the expected É is the single code point U+00C9.
    assert Initials.from("e\u0301mile zola") == "\u00C9Z"
  end

  test "Devanagari: the first grapheme of each word, which may be several code points" do
    assert Initials.from("\u0906\u0936\u093E \u0930\u093E\u0935") == "\u0906\u0930\u093E"
  end

  test "extra spaces between and around the words" do
    assert Initials.from("  kiran   desai ") == "KD"
  end
end

Run elixir initials_test.exs. The starter returns “” for every name, so all four tests fail:

The tests against the starter Python · practice_session.py
from session import run

# The tests against the starter, which returns "" for every name.
run("""
cd practice
!elixir initials_test.exs
""")

Output

$ cd practice
$ elixir initials_test.exs
Running ExUnit with seed: 0, max_cases: 36



  1) test two Latin words (InitialsTest)
     initials_test.exs:7
     Assertion with == failed
     code:  assert Initials.from("asha rao") == "AR"
     left:  ""
     right: "AR"
     stacktrace:
       initials_test.exs:8: (test)



  2) test an accented first letter, typed as e and a combining accent (InitialsTest)
     initials_test.exs:11
     Assertion with == failed
     code:  assert Initials.from("émile zola") == "ÉZ"
     left:  ""
     right: "ÉZ"
     stacktrace:
       initials_test.exs:13: (test)



  3) test Devanagari: the first grapheme of each word, which may be several code points (InitialsTest)
     initials_test.exs:16
     Assertion with == failed
     code:  assert Initials.from("आशा राव") == "आरा"
     left:  ""
     right: "आरा"
     stacktrace:
       initials_test.exs:17: (test)



  4) test extra spaces between and around the words (InitialsTest)
     initials_test.exs:20
     Assertion with == failed
     code:  assert Initials.from("  kiran   desai ") == "KD"
     left:  ""
     right: "KD"
     stacktrace:
       initials_test.exs:21: (test)


Finished in 0.02 seconds (0.02s on load, 0.00s async, 0.00s sync)

Result: 0/4 passed
Failed: 4 tests
[exit status 2]

This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors

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

String.normalize/2 comes first, String.split/1 splits at any run of spaces and drops the empty words, and String.first/1 returns the first grapheme of a word, so a vowel sign stays with its letter. Then Enum.map/2 takes the first grapheme of every word, Enum.join/1 joins them, and String.upcase/1 changes the case. Leave out the normalising, and the second test fails, with a hint from ExUnit that names the cause:

A version that does not normalise Python · no_nfc_session.py
from session import run

# The tests against a version that skips String.normalize/2.
run("""
cd no_nfc
cp ../practice/initials_test.exs .
!elixir initials_test.exs
""")

Output

$ cd no_nfc
$ cp ../practice/initials_test.exs .
$ elixir initials_test.exs
Running ExUnit with seed: 0, max_cases: 36

.

  1) test an accented first letter, typed as e and a combining accent (InitialsTest)
     initials_test.exs:11
     Assertion with == failed
     code:  assert Initials.from("émile zola") == "ÉZ"
     left:  "ÉZ"
     right: "ÉZ"
     hint:  you are comparing strings that have the same visual representation but are made of different Unicode codepoints
     stacktrace:
       initials_test.exs:13: (test)

..
Finished in 0.01 seconds (0.01s on load, 0.00s async, 0.00s sync)

Result: 3/4 passed
Failed: 1 test
[exit status 2]

This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors

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

Both sides of that failure print as “ÉZ”, and the hint says that the two strings “have the same visual representation but are made of different Unicode codepoints”. With String.normalize(name, :nfc) first, all four tests pass:

The tests against a finished from/1 Python · finished_session.py
from session import run

# The same tests against a finished from/1.
run("""
cd finished
cp ../practice/initials_test.exs .
elixir initials_test.exs
""")

Output

$ cd finished
$ cp ../practice/initials_test.exs .
$ elixir initials_test.exs
Running ExUnit with seed: 0, max_cases: 36

....
Finished in 0.01 seconds (0.01s on load, 0.00s async, 0.00s sync)

Result: 4 passed

This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors

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

Now the self-check for this lesson. Each question arrives on its own, the weakest of your five topics goes first, and the reason behind every answer appears the moment you pick one:

Practice that adapts to you

Practise this lesson

13 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 13 What does String.length/1 return for the flag of India, 🇮🇳?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: 1

    The flag is 8 bytes and 2 code points, which a reader sees as 1 character. String.length/1 counts that one grapheme.

  2. Question 2 of 13 A database column holds at most 255 bytes. Which check makes sure a name fits in it?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: byte_size(name) <= 255

    Choose the unit by the limit: storage and transport limits are bytes, so check them with byte_size/1; limits that people see are graphemes, so check those with String.length/1.

  3. Question 3 of 13 A Tamil word has 5 code points, all from the same Unicode block as those of வணக்கம். How many bytes does it take in UTF-8?

    Type a number.

    A hint

    வணக்கம் has 7 code points in 21 bytes.

    Show the answer: Question 3 of 13

    Answer: 15 bytes

    In the lesson's table, வணக்கம் took 21 bytes for 7 code points: 3 bytes each, as for Devanagari. Five code points of the same block take 5 × 3 = 15 bytes.

  4. Question 4 of 13 Which of these binaries is a valid string?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: <<104, 105>>

    A string is a binary that is valid UTF-8. <<104, 105>> is "hi"; <<0xFF>> and the half character <<104, 195>> are binaries but not strings.

  5. Question 5 of 13 A preview is cut with binary_part(text, 0, 100). For which texts can it produce invalid UTF-8?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: Texts with characters that take more than one byte, such as Hindi or accented Latin text

    binary_part/3 cuts at a byte, which for multi-byte characters may be inside one. Cut with String.slice/3, then check the byte size if a byte limit also applies.

  6. Question 6 of 13 What does this return?

    Read the code, then choose one answer.

    "#{1 + 1} is #{:two}"
    Show the answer: Question 6 of 13

    Answer: "2 is two"

    Interpolation turns each value into text with the String.Chars protocol: 2 for the sum, two for the atom, as "#{3} cups of #{:tea}" gave "3 cups of tea".

  7. Question 7 of 13 What does String.normalize(text, :nfc) do?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: Rewrites the text in composed form, so that two spellings of the same character get the same bytes

    NFC is Normalization Form Canonical Composition: text is decomposed and recomposed, so every composed character has one spelling. The two spellings of é then compare equal, while a look-alike letter from another script, such as the Cyrillic а, stays different.

  8. Question 8 of 13 A service stores user names and looks them up at login. Where should it normalise the names?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Once where names enter, at sign-up and at login, before storing or comparing them

    Normalise text to NFC at the edge, where it enters the system, so that everything stored and compared is in one form.

  9. Question 9 of 13 Which mode of String.downcase/2 writes ς, the final sigma, at the end of a Greek word?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: :greek

    String.downcase/2 takes a mode: :default, :ascii, :greek or :turkic. Greek text needs :greek for the final sigma.

  10. Question 10 of 13 Which check treats two user names as the same whatever their case?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Normalise both to NFC, change both to lower case with the same mode, then compare

    Comparing without regard to case means one form and one case on both sides: String.normalize/2 first, then the same String.downcase/2 mode for both.

  11. Question 11 of 13 IEx prints the list [104, 105] as ~c"hi". Why?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: Every number in it is a printable ASCII code, so IEx displays it as a charlist

    A charlist is just a list of code points, so IEx cannot tell [104, 105] from ~c"hi". It shows any list of printable ASCII codes as a charlist.

  12. Question 12 of 13 What does this print?

    Read the code, then choose one answer.

    IO.inspect([65, 66], charlists: :as_lists)
    Show the answer: Question 12 of 13

    Answer: [65, 66]

    With charlists: :as_lists, inspect prints lists as lists, so [65, 66] stays [65, 66] instead of appearing as ~c"AB".

  13. Question 13 of 13 An Erlang function returns ~c"42". Which call turns it into the Elixir string "42"?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: List.to_string(~c"42")

    Text from Erlang functions often arrives as a charlist. List.to_string/1, or to_string/1, turns it into a string, and String.to_charlist/1 goes the other way.

Interview questions

Warm-up: what is the difference between byte_size/1 and String.length/1 in Elixir?

byte_size/1 counts the bytes of a binary, and it runs in constant time because the VM stores the size; for a string it is the size of its UTF-8 encoding. String.length/1 counts graphemes, the characters a reader sees, and it has to read the whole string to find where each grapheme ends. They agree for ASCII text, where every character is one byte, and differ everywhere else: “héllo” is 6 bytes and 5 graphemes, and the Hindi greeting नमस्ते is 18 bytes, 6 code points and 3 graphemes. Use byte_size/1 for limits of storage and transport, such as a database column or a packet, and String.length/1 for limits people see, such as the length of a name on a form.

Two questions from forms and logins that handle names in many scripts:

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

  1. Fresher to mid

    A sign-up form limits names to 20 characters with byte_size(name) <= 20 and shortens long display names with binary_part/3. Users with Hindi and Tamil names are rejected, and some previews show broken characters. Explain both bugs, and design the check and the shortening.

    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

    Users named José or Zoë report that logging in sometimes fails on one device although they type their name correctly. The lookup compares the typed name with the stored one using ==. Explain the cause and fix it, including a login that ignores case.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A string is a UTF-8 binary: byte_size/1 counts bytes, String.codepoints/1 lists code points and String.length/1 counts graphemes, and नमस्ते is 18, 6 and 3 of them.
  • Byte functions such as binary_part/3 can split a character, as <<104, 195>> showed; cut text with String.slice/3.
  • Text that looks the same can hold different code points, so normalise to NFC with String.normalize/2 where text enters the system, before comparing it or using it as a key.
  • Casing depends on the language: “straße” upper-cases to “STRASSE”, Greek needs the :greek mode and Turkish the :turkic mode.
  • A charlist is a list of code points used by Erlang functions; IEx prints any list of printable ASCII codes as one, so use charlists: :as_lists to see numbers, and run Elixir 1.20.4 or another patched release for the List.to_string/1 fix.

Terms in this lesson

binary
A sequence of bytes, written <<104, 105>>. An Elixir string is a binary whose bytes are valid UTF-8, so "hi" and <<104, 105>> are the same value.
charlist
A list of code points, such as ~c"hi", which is the list [104, 105]. Many Erlang functions take and return text as charlists rather than as strings.
code point
The number that Unicode gives to a character or a mark, written U+0928 for न. String.codepoints/1 lists the code points of a string.
grapheme
What a reader sees as one character, made of one or more code points: स्ते in नमस्ते is one grapheme of four code points. String.length/1 counts graphemes.
normalisation (Unicode)
Rewriting text into one standard form so that text that looks the same has the same bytes. NFC composes e and a combining accent into the single code point é.
UTF-8
The encoding of Elixir strings. It stores each code point in 1 to 4 bytes: ASCII letters take 1, é takes 2, Devanagari and Tamil letters 3, and most emoji 4.

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.