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 6 – Errors, files, time and text

Regex, sigils and Unicode identifiers

Match and extract text with Elixir's Regex and named captures, keep patterns safe on Unicode and long input, use the built-in sigils and write your own.

  • Intermediate
  • 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

  • Match and extract text with Regex.run/2, Regex.scan/2, named captures and Regex.replace/3
  • Predict what the u modifier changes, and choose ASCII digit classes and whole-text anchors for exact checks
  • Recognise a pattern with nested repeats, and bound the work a regex may do on untrusted input
  • Use the built-in sigils and write a custom sigil as a sigil_x/2 function
  • Explain how Elixir refuses mixed-script identifiers and warns about confusable ones

Before you start

What you’ll learn · Before you start

What you will learn

  • Match and extract text with Regex.run/2, Regex.scan/2, named captures and Regex.replace/3
  • Predict what the u modifier changes, and choose ASCII digit classes and whole-text anchors for exact checks
  • Recognise a pattern with nested repeats, and bound the work a regex may do on untrusted input
  • Use the built-in sigils and write a custom sigil as a sigil_x/2 function
  • Explain how Elixir refuses mixed-script identifiers and warns about confusable ones

Before you start

On this page

A support tool must pull TEA-1 and 2 out of "order TEA-1 qty 2", accept the code "110001" and refuse "११०००१", six digits in Devanagari script that \d matches once Unicode is switched on. And a short pattern, ^(a+)+$, gave up on a text of 23 characters without saying so.

Below, you will match and extract text with Regex, see what the u modifier changes, keep untrusted input from making a pattern work without end, use the built-in sigil: A tilde, a letter or an uppercase name, text between delimiters and optional modifiers, such as ~w(tea mug)a or ~r/\d+/. It is a call of a function named sigil_ and the letter, which receives the text and the modifiers. and write your own, and see how the compiler guards names against letters that only look alike. Erlang/OTP 29.1.1, the release these runs used with Elixir 1.20.4, bundles PCRE2 10.47, as :re.version/0 prints at the end of the first session.

A regex finds text by its shape, and captures the parts you name

An order number is letters, a hyphen and digits; a quantity is digits after the word qty. A regular expression: A pattern that describes the shape of text, such as ~r/order (\w+-\d+)/ for an order number. Elixir's Regex module compiles it with PCRE2, through Erlang's :re, and finds, captures, replaces or splits on its matches. says that shape once, and Regex finds every place in a text that has it. The Regex documentation says the module “is based on PCRE (Perl Compatible Regular Expressions)” and “built on top of Erlang’s :re module”. regex.exs reads one line of an order log in six ways:

regex.exs Elixir · regex.exs
# One line of an order log, read six ways with regular expressions.
line = "order TEA-1 qty 2 at 09:30; order MUG-12 qty 1 at 10:05"

IO.inspect(Regex.match?(~r/qty \d+/, line), label: "match?")
IO.inspect(Regex.run(~r/order (\w+-\d+)/, line), label: "run")
IO.inspect(Regex.scan(~r/order (\w+-\d+) qty (\d+)/, line), label: "scan")
IO.inspect(Regex.named_captures(~r/order (?<sku>\w+-\d+) qty (?<qty>\d+)/, line), label: "named_captures")
IO.inspect(Regex.replace(~r/(\d\d):(\d\d)/, line, "\\1h\\2"), label: "replace")
IO.inspect(Regex.replace(~r/qty (\d+)/, line, fn _whole, n -> "qty #{String.to_integer(n) * 10}" end), label: "replace with a function")
IO.inspect(Regex.split(~r/;\s*/, line), label: "split")
IO.inspect(Regex.run(~r/\d+/, "price 450", return: :index), label: "run with return: :index")
IO.inspect(:re.version(), label: "the PCRE2 of Erlang/OTP")
One order line, six functions of Regex Python · regex_session.py
from session import run

run("""
elixir regex.exs
""")

Output

$ elixir regex.exs
match?: true
run: ["order TEA-1", "TEA-1"]
scan: [["order TEA-1 qty 2", "TEA-1", "2"], ["order MUG-12 qty 1", "MUG-12", "1"]]
named_captures: %{"qty" => "2", "sku" => "TEA-1"}
replace: "order TEA-1 qty 2 at 09h30; order MUG-12 qty 1 at 10h05"
replace with a function: "order TEA-1 qty 20 at 09:30; order MUG-12 qty 10 at 10:05"
split: ["order TEA-1 qty 2 at 09:30", "order MUG-12 qty 1 at 10:05"]
run with return: :index: [{6, 3}]
the PCRE2 of Erlang/OTP: "10.47 2025-10-21"

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

Function What it returned here
Regex.match?/2 true: the shape occurs somewhere
Regex.run/2 the first match and its groups: ["order TEA-1", "TEA-1"]
Regex.scan/2 every match, each a list of the match and its groups
Regex.named_captures/2 the first match’s groups by name, as a map
Regex.replace/3 a new string, with \\1 and \\2 naming the groups, or a function computing each replacement
Regex.split/2 the pieces between the matches

Step through the scan and watch where each piece of its result comes from:

Interactive · stage by stage Regex.scan/2 on one order line: every match, with its groups Step through the scan. Each match gives the whole matched text first, then what each group captured.
Regex.scan/2 on one order line: every match, with its groups, step 6 of 6. Showing The line, The first match, Its two groups, The second match, What scan/2 returns. In focus: Its two groups.The line"order TEA-1 qty 2 at …"The first matchorder TEA-1 qty 2Its two groups"TEA-1" and "2"The second matchorder MUG-12 qty 1What scan/2 returnsone list for each match

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

Every step as text
  1. Where to look. Regex.scan/2 starts at the beginning of the line and looks for the first place where order (\w+-\d+) qty (\d+) matches.
  2. The first match. It matches "order TEA-1 qty 2". The whole match goes first in the result for this match.
  3. The groups. Then each group, in the order its parenthesis opens: \w+-\d+ captured "TEA-1" and \d+ captured "2". run/2 would stop here and return this one list.
  4. The next match. scan/2 goes on from the end of the first match, passes " at 09:30; " and finds "order MUG-12 qty 1", with "MUG-12" and "1".
  5. Both lists. Nothing else matches, so scan/2 returns two lists, each the whole match and its two groups, the scan line of the session.
  6. Names instead of numbers. named_captures/2 gives the groups names, (?<sku>…) and (?<qty>…), and returns the first match as a map: %{"qty" => "2", "sku" => "TEA-1"}.

replace/3 with "\\1h\\2" turned 09:30 into 09h30: in an Elixir string the backslash is written twice. With a function, each match was turned into a new quantity, ten times the old one. return: :index gave [{6, 3}], the byte position and length of 450 in "price 450", which binary_part/3 can cut out without copying the match.

A regex is not a parser

For JSON, CSV or a binary protocol, use the decoder or binary pattern matching: they know nesting, quoting and escapes. Keep regexes for shapes inside one piece of text, such as an order number in a line of a log.

Checkpoint

Check: Matching and capturing with Regex

  1. Question 1 of 2 A log line names several orders. Which function returns every order number in it, with its groups?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Regex.scan/2

    run/2 for the first match, scan/2 for all of them; both put the whole match first and the groups after it.

  2. Question 2 of 2 What does Regex.named_captures(~r/(?<sku>\w+-\d+) qty (?<qty>\d+)/, "order TEA-1 qty 2") return?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: %{"qty" => "2", "sku" => "TEA-1"}

    Named groups give a map, which reads better than positions when a pattern has several groups. Convert the values yourself, after checking them.

The u modifier makes classes Unicode-aware, digits included

Without modifiers, a regex works on bytes and knows ASCII. The u modifier, in the documentation’s words, “causes character classes like \w, \W, \s, and the like to also match on Unicode”, and it makes \p{…} classes, such as \p{Lu} for capital letters, available. The session tries both on names and on digits:

unicode.exs Elixir · unicode.exs
# What the u modifier changes: letters, capitals, scripts and digits; then i and x.
IO.inspect(Regex.match?(~r/^\w+$/, "José"), label: "\\w+ without u")
IO.inspect(Regex.match?(~r/^\w+$/u, "José"), label: "\\w+ with u")
IO.inspect(Regex.scan(~r/\p{Lu}/u, "Asha, Élodie and Ömer"), label: "\\p{Lu}")
IO.inspect(Regex.match?(~r/^\p{Devanagari}+$/u, "नमस्ते"), label: "\\p{Devanagari}")
IO.inspect(Regex.match?(~r/^\d{6}$/, "११०००१"), label: "\\d{6} without u, Devanagari digits")
IO.inspect(Regex.match?(~r/^\d{6}$/u, "११०००१"), label: "\\d{6} with u, Devanagari digits")
IO.inspect(Regex.match?(~r/^[0-9]{6}$/u, "११०००१"), label: "[0-9]{6} with u, Devanagari digits")
IO.inspect(Regex.match?(~r/^[0-9]{6}$/, "110001\n"), label: "^…$ and a newline at the end")
IO.inspect(Regex.match?(~r/\A[0-9]{6}\z/, "110001\n"), label: "\\A…\\z and a newline at the end")
IO.inspect(Regex.match?(~r/^tea$/i, "TEA"), label: "i, caseless")

spaced = ~r/
  (?<hour>\d\d) : (?<minute>\d\d)   # x ignores these spaces and this comment
/x
IO.inspect(Regex.named_captures(spaced, "at 09:30"), label: "x, extended")
Patterns with and without u, on names and digits Python · unicode_session.py
from session import run

run("""
elixir unicode.exs
""")

Output

$ elixir unicode.exs
\w+ without u: false
\w+ with u: true
\p{Lu}: [["A"], ["É"], ["Ö"]]
\p{Devanagari}: true
\d{6} without u, Devanagari digits: false
\d{6} with u, Devanagari digits: true
[0-9]{6} with u, Devanagari digits: false
^…$ and a newline at the end: true
\A…\z and a newline at the end: false
i, caseless: true
x, extended: %{"hour" => "09", "minute" => "30"}

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

\w+ refused "José" without u and accepted it with u; \p{Lu} found the capitals A, É and Ö, and \p{Devanagari} matched नमस्ते. The digits are the trap. With u, \d{6} accepted "११०००१", six digits of the Devanagari script, because Unicode counts every script’s decimal digits as digits. A check that means “six digits from 0 to 9” must say [0-9]{6}, which refused them.

The next two lines show the second trap: ^[0-9]{6}$ accepted "110001\n", because $ also matches just before a newline at the end of the text. \A and \z mean the very start and the very end, so \A[0-9]{6}\z refused it. The last two lines show two other modifiers: i ignores case, and x ignores spaces and allows comments, which makes a long pattern readable.

\d with u, and ^…$ for a whole field

~r/^\d{6}$/u accepts digits of every script and a trailing newline. For a field that must be exactly six ASCII digits, write ~r/\A[0-9]{6}\z/.

Checkpoint

Check: The u modifier and anchors

  1. Question 1 of 2 ~r/^\d{6}$/u is matched against "११०००१", six Devanagari digits. What is the result?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It matches

    Unicode-aware classes are right for names and wrong for fixed ASCII formats. Write [0-9] when you mean the ten ASCII digits.

  2. Question 2 of 2 Which pattern accepts "110001" and refuses "110001\n", with a newline at the end?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ~r/\A[0-9]{6}\z/

    For a whole field, anchor with \A and \z: the start and the end of the text, nothing before and nothing after.

Nested repeats can make a pattern give up without a word

^(a+)+$ looks like a longer way of writing ^a+$, and on a text of a’s alone the two agree. On a text of a’s that ends in a b, they do not: the nested repeat makes the engine try every way to divide the a’s into runs before it can say “no match”. The Erlang re documentation describes the case: “A common example is a pattern with nested unlimited repeats applied to a long string that does not match.” limits.exs runs both patterns on growing texts:

limits.exs Elixir · limits.exs
# A nested repeat, (a+)+, on text that almost matches, and a flat one, a+, on the same text.
{:ok, nested} = :re.compile("^(a+)+$")
{:ok, flat} = :re.compile("^a+$")
run = fn pattern, text, extra -> :re.run(text, pattern, [:report_errors, {:capture, :none} | extra]) end

for n <- [16, 18, 20, 21, 22, 24] do
  text = String.duplicate("a", n) <> "b"
  IO.puts("#{n} a's and a b: #{inspect(run.(nested, text, []))}; Regex.run/2: #{inspect(Regex.run(~r/^(a+)+$/, text))}")
end

IO.inspect(run.(flat, String.duplicate("a", 22) <> "b", []), label: "the flat pattern, 22 a's and a b")

# The nested pattern under smaller limits: the longest input each limit still decides.
for limit <- [1_000, 10_000, 100_000, 1_000_000] do
  decided =
    Enum.filter(4..30, fn n -> run.(nested, String.duplicate("a", n) <> "b", [{:match_limit, limit}]) == :nomatch end)

  IO.puts("match_limit #{limit}: up to #{Enum.max(decided)} a's")
end
A nested repeat against the match limit Python · limits_session.py
from session import run

# How far the match limit of Erlang/OTP lets a nested repeat go, and the limits that decide less.
run("""
elixir limits.exs
""")

Output

$ elixir limits.exs
16 a's and a b: :nomatch; Regex.run/2: nil
18 a's and a b: :nomatch; Regex.run/2: nil
20 a's and a b: :nomatch; Regex.run/2: nil
21 a's and a b: :nomatch; Regex.run/2: nil
22 a's and a b: {:error, :match_limit}; Regex.run/2: nil
24 a's and a b: {:error, :match_limit}; Regex.run/2: nil
the flat pattern, 22 a's and a b: :nomatch
match_limit 1000: up to 8 a's
match_limit 10000: up to 11 a's
match_limit 100000: up to 15 a's
match_limit 1000000: up to 18 a's

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

Up to 21 a’s, :re.run/3 answered :nomatch. From 22 a’s it answered {:error, :match_limit}: the engine had used up its budget, which the documentation puts at “10,000,000 in the Erlang VM”, before it could decide. Regex.run/2 said nil in both cases, so a caller cannot tell “no match” from “gave up”. The flat pattern decided 22 a’s at once. Step through the tries on a text of three a’s:

Interactive · stage by stage ^(a+)+$ against "aaab": every way to divide the a's Step through the tries. Each divides the three a's into runs in a new way, and each fails at the b, so the engine tries the next.
^(a+)+$ against "aaab": every way to divide the a's, step 6 of 6. Showing aaa, one run, aa, then a, a, then aa, a, a and a, No match (gave up: the match limit). In focus: No match.aaa, one runthen $ meets the baa, then athen $ meets the ba, then aathen $ meets the ba, a and athen $ meets the bNo matchgave up: the match limit

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

Every step as text
  1. The longest run first. ^ fixes the start. The inner a+ takes all three a's as one run, and the outer + is done. $ needs the end of the text but finds the b, so the try fails and the engine backtracks.
  2. Two runs. The inner a+ gives one a back. The outer + repeats the group, and the second run takes the last a. $ meets the b again.
  3. Two runs, the other way. The first run shrinks to one a and the second takes two. The text is the same, the division is new, and the result is the same failure.
  4. Three runs. One a in each of three runs. That is the 4th way to divide three a's into runs, and the last; then the engine tries the same with only the first two a's, and with one.
  5. Doubling. n a's can be divided into runs 2^(n − 1) ways, so each extra a doubles the tries. The flat pattern ^a+$ has one way to take the a's and fails at once.
  6. 22 a's. 22 a's can be divided 2,097,152 ways. The limit of 10,000,000 steps ran out before the engine was done: :re.run/3 reported {:error, :match_limit}, and Regex.run/2 just said nil.

The smaller limits show the growth from the other side. A limit of 1,000 decided texts of up to 8 a’s, 10,000 up to 11, 100,000 up to 15 and 1,000,000 up to 18, so each tenfold budget buys only three or four more characters: this catastrophic backtracking: A regex trying exponentially many ways to match before it can fail, as ^(a+)+$ does on a row of a's that ends in another letter. Bound the input and avoid a repeat inside a repeat. doubles its work with each extra a. For patterns or inputs that come from outside, three habits keep it bounded: refuse text longer than the field can be before matching, write patterns without a repeat inside a repeat, and when a pattern itself is untrusted, call :re.run/3 with a smaller {:match_limit, n} and :report_errors, so a pattern that runs away fails as an error you can see.

Key idea

A repeat inside a repeat can make a regex try exponentially many ways before it says no; bound the input, flatten the pattern, and let :re.run/3 report a limit instead of reading nil as “no match”.

Checkpoint

Check: Nested repeats and the match limit

  1. Question 1 of 2 Regex.run(~r/^(a+)+$/, text) returned nil for 22 a's and a b. What does that nil tell you?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Only that no match came back; the engine may have given up at its match limit, as :re.run/3 reported

    Regex.run/2 returns nil for no match and for a run that hit the limit. Ask :re.run/3 with :report_errors when you need to know which.

  2. Question 2 of 2 A form field holds at most 64 characters and is checked with a pattern that has a repeat inside a repeat. What bounds the work best?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Refuse text longer than 64 characters before matching, and rewrite the pattern without the nested repeat

    Bound the input, flatten the pattern, and for patterns that come from outside, set a smaller match limit with :report_errors.

Sigils write words, text, regexes and dates, and you can add your own

~r/…/ is one of several sigils: a tilde, a letter, text between delimiters and optional modifiers after them. The sigils guide notes that “sigils support 8 different delimiters”, so a regex full of slashes can use braces, and that “uppercase-letter textual sigils do not perform interpolation nor escaping”. A custom sigil is a function, and the guide gives the rule for its name: “Custom sigils may be either a single lowercase character, or an uppercase character followed by more uppercase characters and digits.” sigils.exs uses the built-in ones and two of its own:

sigils.exs Elixir · sigils.exs
defmodule Sigils do
  # ~i(13) is 13 and ~i(42)n is -42; ~PCT(18) is 0.18.
  def sigil_i(text, []), do: String.to_integer(text)
  def sigil_i(text, [?n]), do: -String.to_integer(text)
  def sigil_PCT(text, []), do: String.to_integer(text) / 100
end

defmodule Pin do
  # A regex kept in a module attribute, compiled with the module.
  @pin ~r/\A[1-9][0-9]{5}\z/
  def valid?(text), do: Regex.match?(@pin, text)
end

defmodule Main do
  import Sigils

  def run do
    IO.inspect(~w(tea mug cup), label: "~w")
    IO.inspect(~w(tea mug cup)a, label: "~w with a")
    IO.inspect(~s(she said "hi" #{1 + 1}), label: "~s")
    IO.inspect(~S(she said "hi" #{1 + 1}\n), label: "~S")
    IO.inspect(~c(abc), label: "~c")
    IO.inspect(~r{/api/v\d+}.source, label: "~r with braces")
    IO.inspect(~i(13), label: "~i")
    IO.inspect(~i(42)n, label: "~i with n")
    IO.inspect(~PCT(18), label: "~PCT")
    IO.inspect({Pin.valid?("110001"), Pin.valid?("011001")}, label: "a regex in an attribute")
    exported = ~r/a+/E
    IO.inspect({exported, Regex.import(exported)}, label: "E, then Regex.import/1")
  end
end

Main.run()
Built-in sigils, two custom ones and a regex in an attribute Python · sigils_session.py
from session import run

run("""
elixir sigils.exs
""")

Output

$ elixir sigils.exs
~w: ["tea", "mug", "cup"]
~w with a: [:tea, :mug, :cup]
~s: "she said \"hi\" 2"
~S: "she said \"hi\" \#{1 + 1}\\n"
~c: ~c"abc"
~r with braces: "/api/v\\d+"
~i: 13
~i with n: -42
~PCT: 0.18
a regex in an attribute: {true, false}
E, then Regex.import/1: {~r/a+/E, ~r/a+/}

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

Sigil Gives From the session
~w(…) a list of words; a, c or s after it choose atoms, charlists or strings [:tea, :mug, :cup] with a
~s(…) and ~S(…) a string, with and without interpolation and escapes ~S kept #{1 + 1} and \n as written
~c(…) a charlist ~c"abc"
~r{…} a regex, here between braces the source /api/v\d+
~D, ~T, ~N, ~U the calendar types of the previous lesson
~i(…), ~PCT(…) whatever sigil_i/2 and sigil_PCT/2 return 13, -42, 0.18

~i(42)n called sigil_i("42", [?n]): the modifiers arrive as a charlist, so one function can have a clause for each. A custom sigil must be imported to be used, as Main imported Sigils. The Pin module kept a regex in a module attribute, which compiled and matched on Elixir 1.20.4 with Erlang/OTP 29. The E modifier makes an exported regex that, as the documentation puts it, “can be shared across nodes or passed through config”, and Regex.import/1, new in Elixir 1.20, turns it back into an ordinary one, so it “won’t need to be imported on the fly every time it is executed.”

Checkpoint

Check: Built-in and custom sigils

  1. Question 1 of 2 What does ~i(42)n call?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: sigil_i("42", [?n])

    A sigil is a call: ~x(text)mods becomes sigil_x("text", ~c"mods"), so one function can have a clause for each modifier, as sigil_i/2 had for n.

  2. Question 2 of 2 Which of these is a valid name for a custom sigil?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ~PCT

    The sigils guide: a single lowercase character, or an uppercase character followed by more uppercase characters and digits.

Identifiers may be Unicode, but never mixed scripts

Elixir allows Unicode in names: the Unicode syntax reference says it “supports Unicode in variables, atoms, and calls since Elixir v1.5”. It also stops one trick that letters from two scripts make possible, a name that looks like another but is a different name. The session compiles four small files:

names/unicode_names.exs Elixir · names/unicode_names.exs
# Names in several scripts, each name in one script, or scripts joined by _.
naïve = true
ação = :ação
दाम = 450
prix_аренды = 12
IO.inspect({naïve, ação, दाम, prix_аренды})
Unicode names: accepted, refused, warned about and normalised Python · names_session.py
from session import run

# Names in Unicode: accepted, refused, warned about, and made equal by normalisation.
run("""
cd names
elixir unicode_names.exs
!elixir mixed.exs
elixir confusable.exs
elixir normalised.exs
""")

Output

$ cd names
$ elixir unicode_names.exs
{true, :ação, 450, 12}
$ elixir mixed.exs
** (SyntaxError) invalid syntax found on mixed.exs:2:5:
    error: invalid mixed-script identifier found: аdmin

    Mixed-script identifiers are not supported for security reasons. 'аdmin' is made of the following scripts:

      \u0430 а {Cyrillic}
      \u0064 d {Latin}
      \u006D m {Latin}
      \u0069 i {Latin}
      \u006E n {Latin}

    Characters in identifiers from different scripts must be separated by underscore (_).
    Codepoint failed identifier tokenization, but a simpler form was found.

    Got:

        "аdmin" (code points 0x00430 0x00064 0x0006D 0x00069 0x0006E)

    Hint: You could write the above in a similar way that is accepted by Elixir:

        "admin" (code points 0x00061 0x00064 0x0006D 0x00069 0x0006E)

    See https://hexdocs.pm/elixir/unicode-syntax.html for more information.
    │
  2 │ аdmin = true
    │     ^
    │
    └─ mixed.exs:2:5
    (elixir 1.20.4) lib/code.ex:1639: Code.require_file/2
[exit status 1]
$ elixir confusable.exs
    warning: confusable identifier: 'сор' looks like 'cop' on line 2, but they are written using different characters


    │
  3 │ сор = 2
    │ ~
    │
    └─ confusable.exs:3:1

    warning: confusable identifier: 'сор' looks like 'cop' on line 2, but they are written using different characters


    │
  4 │ IO.inspect({cop, сор})
    │                  ~
    │
    └─ confusable.exs:4:18

{1, 2}
$ elixir normalised.exs
    warning: variable "café" is unused (if the variable is not meant to be used, prefix it with an underscore)
    │
  2 │ café = 1
    │ ~~~~
    │
    └─ normalised.exs:2:1

2
a quoted atom, not checked: :"аdmin"

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

naïve, ação and दाम were fine, each a name in one script, and so was prix_аренды, whose Latin and Cyrillic halves are separated by an underscore. mixed.exs was refused before it ran: its name begins with a Cyrillic а and continues in Latin, and the compiler said “Mixed-script identifiers are not supported for security reasons”, listed the script of every letter and suggested the all-Latin spelling. The reference states the rule: Elixir “disallows mixed-scripts which are not explicitly separated by _”, the check of Unicode’s security standard, UTS #39.

confusable.exs compiled, with a warning: its Cyrillic сор “looks like ‘cop’”, the Latin name on the line before, because Elixir “will also warn of confusable identifiers in the same file.” normalised.exs wrote café twice, once with é as one code point and once as e and a combining accent; Elixir “normalizes all atoms and variables to NFC form”, so both were the same variable, which the warning about the first one being unused shows. The last line shows the limit of all this: a quoted atom, :"аdmin", is not checked at all.

The checks cover source code, not data

The compiler guards the names in your code. Text from users, such as account names, gets no such check: normalise it with String.normalize/2 and compare it against an allowlist of characters before treating two names as different.

Checkpoint

Check: Unicode names in code

  1. Question 1 of 2 Why did mixed.exs not compile?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Its variable joins a Cyrillic letter and Latin letters without an underscore between them

    Elixir disallows mixed scripts in an identifier unless they are separated by _, the mixed-script check of Unicode's UTS #39.

  2. Question 2 of 2 confusable.exs used the Latin name cop and a Cyrillic name that looks the same. What happened?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It compiled with a warning that the two look alike, and they stayed two variables

    Confusable names are warned about within one file; treat the warning as an error, because a reader cannot see the difference either.

Regex Tester Try a pattern on your own text before you put it in code; remember the u and x modifiers of this lesson.

Practise: three checks for a form

A form sends a postal code, a phone number and a date as text. The rules are the exercise’s own: a PIN code is six ASCII digits whose first digit is 1 to 9, a phone number is +91 followed by ten ASCII digits and nothing else, and a ~d sigil turns ISO 8601 text into a Date.

Formats from India

The two formats follow the six-digit PIN codes of India’s postal service and mobile numbers written with the country code +91. The rules here are this exercise’s; a service that relies on real codes checks them against the official directory.

Copy the two files into an empty folder:

practice/validate.ex: the starter Elixir · practice/validate.ex
# Checks for text a form sends, by rules this exercise defines:
#   a PIN code is six ASCII digits whose first digit is 1 to 9;
#   a phone number is +91 followed by ten ASCII digits, and nothing else.
defmodule Validate do
  # {:ok, text} or {:error, :bad_pin}.
  def pin_code(text) do
    if Regex.match?(~r/^\d{6}$/u, text), do: {:ok, text}, else: {:error, :bad_pin}
  end

  # {:ok, text} or {:error, :bad_phone}.
  def phone(text) do
    if Regex.match?(~r/\+91\d{10}/, text), do: {:ok, text}, else: {:error, :bad_phone}
  end

  # ~d[2026-10-04] is the Date ~D[2026-10-04]; text that is not an ISO 8601
  # date raises ArgumentError. Import it with: import Validate, only: [sigil_d: 2]
  def sigil_d(text, []), do: text
end
practice/validate_test.exs: the tests Elixir · practice/validate_test.exs
Code.require_file("validate.ex", __DIR__)
ExUnit.start(seed: 0)

defmodule ValidateTest do
  use ExUnit.Case, async: true
  import Validate, only: [sigil_d: 2]

  test "a PIN code: six ASCII digits, the first from 1 to 9" do
    assert Validate.pin_code("110001") == {:ok, "110001"}

    for bad <- ["011001", "11000", "1100011", "11 0001", "११०००१", "110001\n"] do
      assert Validate.pin_code(bad) == {:error, :bad_pin}, "accepted #{inspect(bad)}"
    end
  end

  test "a phone number: +91 and ten ASCII digits, nothing more" do
    assert Validate.phone("+919876543210") == {:ok, "+919876543210"}

    for bad <- ["9876543210", "+91 98765 43210", "+9198765432109", "call +919876543210", "+91९८७६५४३२१०"] do
      assert Validate.phone(bad) == {:error, :bad_phone}, "accepted #{inspect(bad)}"
    end
  end

  test "a long string of digits is refused" do
    phone = "+91" <> String.duplicate("9", 100_000)
    pin = String.duplicate("1", 100_000)
    assert Validate.phone(phone) == {:error, :bad_phone}, "accepted a phone number of #{byte_size(phone)} characters"
    assert Validate.pin_code(pin) == {:error, :bad_pin}, "accepted a PIN code of #{byte_size(pin)} characters"
  end

  test "~d turns ISO 8601 text into a Date, and refuses a day that does not exist" do
    assert ~d[2026-10-04] == ~D[2026-10-04]
    assert_raise ArgumentError, fn -> ~d[2026-02-30] end
  end
end

Run elixir validate_test.exs. The starter’s patterns are too loose and its sigil returns the text, so all four tests fail, each on the first input it should have refused:

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

# The tests against the starter, whose patterns are too loose.
run("""
cd practice
!elixir validate_test.exs
""")

Output

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



  1) test a PIN code: six ASCII digits, the first from 1 to 9 (ValidateTest)
     validate_test.exs:8
     accepted "011001"
     code: for bad <- ["011001", "11000", "1100011", "11 0001", "११०००१", "110001\n"] do
     stacktrace:
       (elixir 1.20.4) lib/enum.ex:1725: Enum."-map/2-lists^map/1-1-"/2
       validate_test.exs:11: (test)



  2) test a phone number: +91 and ten ASCII digits, nothing more (ValidateTest)
     validate_test.exs:16
     accepted "+9198765432109"
     code: for bad <- ["9876543210", "+91 98765 43210", "+9198765432109", "call +919876543210", "+91९८७६५४३२१०"] do
     stacktrace:
       (elixir 1.20.4) lib/enum.ex:1725: Enum."-map/2-lists^map/1-1-"/2
       (elixir 1.20.4) lib/enum.ex:1725: Enum."-map/2-lists^map/1-1-"/2
       validate_test.exs:19: (test)



  3) test a long string of digits is refused (ValidateTest)
     validate_test.exs:24
     accepted a phone number of 100003 characters
     code: assert Validate.phone(phone) == {:error, :bad_phone}, "accepted a phone number of #{byte_size(phone)} characters"
     stacktrace:
       validate_test.exs:27: (test)



  4) test ~d turns ISO 8601 text into a Date, and refuses a day that does not exist (ValidateTest)
     validate_test.exs:31
     Assertion with == failed
     code:  assert ~d[2026-10-04] == ~D[2026-10-04]
     left:  "2026-10-04"
     right: ~D[2026-10-04]
     stacktrace:
       validate_test.exs:32: (test)


Finished in 0.03 seconds (0.03s 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

Each fix is a lesson of this page: [0-9] instead of \d with u, \A and \z instead of ^ and $, an anchor at both ends so that no extra text slips through, and Date.from_iso8601!/1 behind the sigil. The long input needs no special code once the pattern is anchored and flat. A finished version passes all four:

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

# The same tests against a finished Validate.
run("""
cd finished
cp ../practice/validate_test.exs .
elixir validate_test.exs
""")

Output

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

....
Finished in 0.03 seconds (0.03s 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

Then check what you can do now. The self-check asks one question at a time, from the idea you know least, and every choice explains itself when you pick it:

Practice that adapts to you

Practise this lesson

11 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 11 What does Regex.run/2 return when the pattern does not match the text?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: nil

    nil from run/2 means no match came back, which, as the match limit showed, is not always the same as no match.

  2. Question 2 of 11 What does Regex.replace(~r/qty (\d+)/, "qty 3", fn _whole, n -> "qty #{String.to_integer(n) + 1}" end) return?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: "qty 4"

    With a function, replace/3 passes the whole match first, then each group, and uses the result in place of the match.

  3. Question 3 of 11 Should a program pull fields out of a JSON request with a regex?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: No: decode it, because quoting, escapes and nesting defeat a pattern

    A regex is for a shape within one piece of text. For structured data, use its decoder, as the JSON lesson of this module did.

  4. Question 4 of 11 What does the u modifier do to \w?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: It makes \w match letters of every script, so "José" matches \w+

    u makes classes such as \w, \s and \d Unicode-aware and enables \p{…} classes; the input must then be valid UTF-8.

  5. Question 5 of 11 A name field must accept Élodie and Ömer and refuse digits. Which pattern fits?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: ~r/\A\p{L}+\z/u

    Choose the class by meaning: \p{L} for letters of any script, [0-9] for ASCII digits, and anchors for the whole text.

  6. Question 6 of 11 Why does ^(a+)+$ take so long on a row of a's that ends in a b?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: The nested repeat lets the engine divide the a's into runs in exponentially many ways, and it tries them before saying no

    A repeat inside a repeat multiplies the ways to match; on a near-match every way is tried. Flatten such patterns.

  7. Question 7 of 11 In how many ways can a run of 5 a's be divided into runs of one or more a's?

    Type a number.

    A hint

    Between each two neighbouring a's you either cut or not.

    Show the answer: Question 7 of 11

    Answer: 16 ways

    5 a's have 4 gaps, and each gap is cut or not: 2^4 = 16 ways, the 2^(n − 1) of the figure. With 22 a's it is 2,097,152.

  8. Question 8 of 11 What is the difference between ~s(…) and ~S(…)?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: ~S does no interpolation and no escapes; ~s does both

    Most lowercase sigils have an uppercase twin that takes the text literally, which suits regexes and Windows paths.

  9. Question 9 of 11 What does ~w(tea mug)a return?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: [:tea, :mug]

    ~w splits on whitespace, and a, c or s choose atoms, charlists or strings.

  10. Question 10 of 11 Which of these lines compiles?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: naïve = 1

    Variables and atoms may use Unicode in one script; aliases are ASCII only; scripts in one name are separated by _.

  11. Question 11 of 11 Account names are stored as people type them. What protection do the compiler's mixed-script and confusable checks give them?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: None: they check names in source code; account names need String.normalize/2 and an allowlist

    Look-alike account names are a real risk; protect them with normalisation and a rule about which characters are allowed.

Interview questions

Warm-up: what does the u modifier change in an Elixir regex?

It makes the pattern work on Unicode text instead of bytes. Character classes such as \w, \s and \d then match letters, spaces and digits of every script, \p{…} classes such as \p{Lu} and \p{Devanagari} become available, and the input must be valid UTF-8. That is what you want for names: \w+ accepts José only with u. It is not what you want for formats with fixed ASCII digits, because \d{6} with u also accepts six Devanagari digits; write [0-9]{6} there. Whichever you choose, anchor whole-field checks with \A and \z, since $ also matches before a newline at the end.

Two questions about patterns that meet real input:

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

  1. Fresher to mid

    A signup form validates PIN codes with ~r/^\d{6}$/u. A look at the stored data finds codes written in Devanagari digits, codes that start with 0 and codes with a newline at the end, all accepted. Explain each, fix the pattern, and say what else you would change.

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

  2. Mid to senior

    An admin tool you run lets staff filter 50,000 log lines with a regex they type. Some filters make a request run for many seconds and return nothing, while the node gets busy. Explain what is happening, and make the feature safe without taking regexes away.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Regex.run/2 returns the first match and its groups, Regex.scan/2 every match, named_captures/2 a map of named groups, and replace/3 a new string, with \\1 for a group or a function for each match.
  • With the u modifier, classes are Unicode-aware, so \d matches digits of every script; exact checks use [0-9] and the whole-text anchors \A and \z.
  • A repeat inside a repeat, such as ^(a+)+$, doubles its work with each extra character on near-matches; Erlang/OTP stops it at its match limit, and Regex.run/2 then says only nil.
  • Sigils are a tilde, a letter and delimiters; a custom sigil is a sigil_x/2 function, imported, with a lowercase letter or an uppercase name.
  • Elixir accepts Unicode names in one script, refuses mixed scripts not separated by _, warns about confusable names in one file and normalises names to NFC; data from users needs its own checks.

Terms in this lesson

catastrophic backtracking
A regex trying exponentially many ways to match before it can fail, as ^(a+)+$ does on a row of a's that ends in another letter. Bound the input and avoid a repeat inside a repeat.
regular expression
A pattern that describes the shape of text, such as ~r/order (\w+-\d+)/ for an order number. Elixir's Regex module compiles it with PCRE2, through Erlang's :re, and finds, captures, replaces or splits on its matches.
sigil
A tilde, a letter or an uppercase name, text between delimiters and optional modifiers, such as ~w(tea mug)a or ~r/\d+/. It is a call of a function named sigil_ and the letter, which receives the text and the modifiers.

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.