Custom Sigils
Defining your own sigil is one of those Elixir features that sounds like a toy until you see what Phoenix does with ~H. A custom sigil is just a function with a specific name and shape, and the compiler wires it up so that ~X"content"modifiers calls into your code. That mechanism is what lets Phoenix embed a templating language inside Elixir source files with full compile-time validation, and it is what lets Ecto write SQL fragments as if they were native syntax.
The flip side: custom sigils are easy to abuse. Most of the time, a function call is clearer. This subtopic covers how the mechanism works, the real-world examples that justify it, and how to tell when you should reach for it.
The Mechanism
A custom sigil is a function named sigil_X/2, where X is the letter you want. The first argument is the content as a string (or a list of segments if there is interpolation). The second is a charlist of modifiers.
defmodule MySigils do
def sigil_p(content, []), do: String.split(content, ",")
def sigil_p(content, [?t]), do: content |> String.split(",") |> Enum.map(&String.trim/1)
end
To use it, you import the module and write the sigil:
import MySigils
~p"alpha,beta,gamma"
# ["alpha", "beta", "gamma"]
~p" alpha , beta , gamma "t
# ["alpha", "beta", "gamma"]
That is the whole API. The ~p is the call site; the function decides what to do with the string and the modifiers. The compiler does the dispatch.
There are two letter ranges available:
- Lowercase
~athrough~z— single-letter sigils. The historical convention. - Uppercase multi-letter sigils — starting in Elixir 1.15, you can define multi-letter sigil names like
~MYSIG"...". Multi-letter sigils must start with an uppercase letter and follow the module-name convention. They were added precisely so libraries could carve out namespaces without colliding on single letters.
For the lowercase form, the language reserves all 26 letters but only uses about ten of them, leaving plenty of room for custom ones. Still, picking a letter that does not collide with anything a reader might expect — ~r, ~w, ~D, etc. — is the right move.
Interpolation in Custom Sigils
If you want your sigil to support #{...} interpolation, you need a different signature. The compiler hands you the interpolated content as a list of segments rather than a single binary.
defmodule MySigils do
def sigil_p(content, modifiers) when is_binary(content) do
# No interpolation case
process(content, modifiers)
end
def sigil_p({:<<>>, _, segments}, modifiers) do
# Interpolated case — segments is a list of binaries and ASTs
# Usually you would emit a quote that handles this at runtime
end
end
The interpolated case is uncommon in everyday custom sigils. Most useful sigils — the ones you actually want to define — are about turning a literal string into a structured value at compile time. Interpolation defeats that.
The convention some libraries follow is to define only the binary case and let users know that interpolation is not supported for their sigil. That is fine and often desirable.
~H in Phoenix LiveView
The canonical real-world custom sigil is ~H from Phoenix.Component. It parses HEEx (HTML+EEx) templates at compile time, validating them, generating optimized render functions, and tracking which parts depend on which assigns so LiveView can do efficient diffs.
defmodule MyAppWeb.UserComponent do
use Phoenix.Component
def card(assigns) do
~H"""
<div class="card">
<h3><%= @user.name %></h3>
<p><%= @user.email %></p>
<ul>
<%= for badge <- @user.badges do %>
<li><%= badge %></li>
<% end %>
</ul>
</div>
"""
end
end
Without ~H, this would either be a string concatenation nightmare or a separate .html.heex file. With ~H, the template lives inline with the component function, and the compiler can do type-checking, attribute validation, and change-tracking on it. The LiveView team built a whole language inside Elixir source files using one custom sigil.
This is the kind of payoff that justifies a custom sigil: a domain-specific literal with compile-time semantics that would be expensive or impossible to recreate at runtime.
~Q and Other Library Sigils
Several libraries ship their own sigils:
~U(now built-in) — was originally proposed and prototyped as a custom sigil by the Timex maintainers before being promoted to the standard library in Elixir 1.9.~MAT— theNxlibrary, used heavily at organizations like Anthropic and Mozilla for numerical work, supports tensor literals via multi-letter sigils.~iand~o—IO.ANSI.Docsand similar libraries use custom sigils for terminal formatting and styled output.~Q— sometimes used by query DSLs to write raw SQL or Cypher fragments inline. Not part of Ecto core, but a common pattern in third-party query libraries.
The pattern across all of these: the sigil represents a domain-specific value that benefits from compile-time parsing and a clean inline syntax. The library hides the compile-time machinery, and the user sees what looks like a literal.
When a Custom Sigil Is Worth Defining
The honest test is: does compile-time validation matter, and does the resulting code read significantly better than a function call?
Worth defining:
- Domain literals with non-trivial syntax — a templating language, a query fragment, a matrix expression. The compile-time validation is the value.
- Frequently-used literals in test fixtures — if every test file has fifty
Decimal.new("12.34")calls, a~M"12.34"sigil forDecimalmight pay off. Although honestly, an import and a short function name often does the same job. - Domain-specific values that are awkward in standard literals — IP addresses, MAC addresses, URLs that need parsing.
Not worth defining:
- Saving a few characters.
~m{key: "value"}instead of%{"key" => "value"}is a wash that hurts every new reader of your code. - Wrapping a constructor. If
~F"100.50"is just shorthand forDecimal.new("100.50"), the gain is marginal and the cognitive cost is real. - Personal convenience in application code. Custom sigils belong in libraries or in shared infrastructure code. Defining one inside
MyApp.SomeContextand importing it into half your files makes the codebase harder to read for anyone who has not memorized your local conventions.
A test that has served me well: if a new contributor would have to ask "what does ~Q mean?" to understand a file, the custom sigil is not pulling its weight unless the alternative is genuinely worse.
How Phoenix Wires Up ~H
Looking at the Phoenix.Component source, sigil_H/2 is defined as a macro that takes the template string, parses it into HEEx AST at compile time, and emits Elixir code that constructs a Phoenix.LiveView.Rendered struct. The struct contains the static parts of the template and a list of dynamic parts that depend on assigns.
This is what powers LiveView's efficient diffing — the compiler can tell at compile time which parts of a template are static (and never need to be re-sent to the browser) and which are dynamic (and need change-tracking). All of that machinery hangs off a single sigil definition.
The pattern is general: parse at compile time, validate at compile time, emit optimized runtime code. The ~r sigil does a similar thing — it compiles the regex pattern once during compilation rather than every time the function runs. Ecto does this for query fragments. Nx does it for tensor expressions.
The lesson for your own custom sigils: if you are doing anything more than wrapping a constructor, push the work into a macro and let the compiler do it. Runtime parsing of a literal string every time the function is called is wasted work.
A Realistic Example: IP Address Sigil
For an application doing network-level work — say, firewall rule management or IP-based routing — a custom sigil for IP addresses might be worth it:
defmodule MyNet.Sigils do
def sigil_IP(content, []) do
case :inet.parse_address(String.to_charlist(content)) do
{:ok, addr} -> addr
{:error, _} -> raise ArgumentError, "invalid IP address: #{content}"
end
end
end
Used as:
import MyNet.Sigils
allowed_ips = [
~IP"10.0.0.1",
~IP"192.168.1.0",
~IP"::1"
]
The parsing happens at runtime here, but you could just as easily do it at compile time using quote and unquote so an invalid IP breaks the build. The function-call alternative — MyNet.IP.parse!("10.0.0.1") — is fine but reads more like a constructor call than a literal. For a config file or seed data with dozens of IPs, the sigil wins.
This is the bar to clear: the sigil makes the code look like data, and there is enough of it that the visual win compounds.
Defining at Compile Time
If you want the sigil to do work at compile time — validation, parsing, optimization — define it as a macro:
defmodule MyNet.Sigils do
defmacro sigil_IP({:<<>>, _, [content]}, []) when is_binary(content) do
case :inet.parse_address(String.to_charlist(content)) do
{:ok, addr} ->
Macro.escape(addr)
{:error, _} ->
raise CompileError, description: "invalid IP address: #{content}"
end
end
end
Now ~IP"not.an.ip.address" fails to compile rather than failing at runtime. This is exactly the technique ~H uses to do HTML validation, and ~r uses (in its newer form) to compile regex patterns at compile time.
The trade-off: macros are harder to write, harder to debug, and harder for new contributors to grok. Only reach for the compile-time form when the validation genuinely needs to happen at compile time. For application code, the runtime function form is usually fine.
Testing Custom Sigils
A common oversight: people define a custom sigil and forget to test it. Because the dispatch happens through a function name pattern, the usual import mechanics apply — your test file needs to import the sigil module just like any other.
defmodule MyNet.SigilsTest do
use ExUnit.Case
import MyNet.Sigils
test "parses IPv4" do
assert ~IP"10.0.0.1" == {10, 0, 0, 1}
end
test "parses IPv6" do
assert ~IP"::1" == {0, 0, 0, 0, 0, 0, 0, 1}
end
test "raises on invalid input" do
assert_raise ArgumentError, fn -> ~IP"not.an.ip" end
end
end
For compile-time sigils (the macro form), invalid input should fail to compile. You can test that with Code.eval_string/1 to confirm the compile error fires:
test "rejects invalid IP at compile time" do
assert_raise CompileError, fn ->
Code.eval_string(~s|import MyNet.Sigils; ~IP"not.an.ip"|)
end
end
The doctest mechanism also works fine inside @doc blocks for sigils, which is a nice way to document the input shape.
Common Pitfalls
Defining a custom sigil to save typing in your own application code. This is the most common misuse. A custom sigil is a public API to your codebase — every reader has to learn it. Save them for libraries and for domain-specific data that genuinely benefits from a literal-like syntax.
Picking a letter that conflicts with a well-known one. Defining sigil_r/2 and shadowing the built-in regex sigil is technically legal and absolutely cursed. Stick to letters that are not already taken by the standard library, and prefer multi-letter sigils for libraries to reduce collision risk.
Skipping the [] modifiers clause. If you only define def sigil_x(content, [?t]), then plain ~x"foo" without a modifier will raise. Define a base case first, then add modifier-specific clauses on top.
Doing heavy work at runtime when compile-time would catch errors earlier. If your sigil parses a structured format, push it into a macro. The whole point of compile-time validation is broken if every page load re-parses the same ~Q"SELECT ..." string.
Assuming interpolation works without handling the AST form. ~MY"hello #{name}" does not call your function with "hello Alice" — it calls it with a binary-AST tuple. If you do not handle that case, interpolation will produce weird errors.
Key Takeaways
- A custom sigil is a function
sigil_X/2(or a macro with the same name) that the compiler dispatches to when it sees~X"...". - The mechanism is what enables
~Hin Phoenix LiveView, custom DSLs in query libraries, and tensor literals in Nx. These work because compile-time parsing of a domain-specific syntax pays for itself. - For application code, custom sigils are rarely worth it — they add a vocabulary every reader has to learn, and a function call usually reads just as well.
- Multi-letter sigils (Elixir 1.15+) give libraries a way to namespace without colliding on single letters.
- When in doubt, define a plain function. Reach for a custom sigil only when the inline literal genuinely reads better than a constructor and the use is frequent enough to justify the learning cost.