Protocols in Elixir
A protocol is Elixir's mechanism for polymorphism: it declares a set of functions whose actual implementation is chosen at runtime based on the type of the first argument, without any inheritance hierarchy. You define a protocol with defprotocol, listing function signatures but no bodies, and then supply per-type behavior with defimpl NameOfProtocol, for: SomeType do ... end. The standard library relies on protocols constantly — String.Chars powers to_string/1 and string interpolation, Enumerable powers every Enum function, Inspect powers IO.inspect/1 — which is why the same to_string(x) call works correctly whether x is an integer, an atom, or a custom struct you defined yourself.
Cricket analogy: A protocol is like the LBW rule applied identically across formats — Test, ODI, T20 — but each format's DRS technology (Hawk-Eye review count) implements the review process slightly differently, just as different types implement a protocol's functions differently.
Defining a Protocol and Implementations
Defining a protocol looks like defprotocol Describable do def describe(value) end, after which you write one defimpl Describable, for: Integer do def describe(n), do: "integer: #{n}" end block per type you want to support — Integer, BitString, Map, or any struct module like MyApp.User. Dispatch is based purely on the runtime type of the first argument: calling Describable.describe(42) looks up the Integer implementation, and Describable.describe(%MyApp.User{}) looks up the implementation registered for that specific struct, with no need to modify the protocol definition itself when you add a new type later — you just add another defimpl block, even from a separate library.
Cricket analogy: Defining separate defimpl blocks per type mirrors the ICC writing separate playing conditions documents for Test, ODI, and T20 formats under one overarching Laws of Cricket, each format 'implementing' the laws its own way.
Protocols vs. Behaviours
It's easy to confuse protocols with behaviours because both describe a contract of functions, but they solve different problems: a behaviour (defined with @callback inside a module using Module.behaviour or @behaviour) is implemented by a single module that adopts it wholesale, like a GenServer callback module, whereas a protocol dispatches per data type and lets many independent implementations exist side by side, discovered at runtime by the value's type. In practice you reach for a behaviour when you're defining 'a module that plays this role' (a plugin, a GenServer, a Plug), and a protocol when you're defining 'an operation that works differently depending on the shape of the data passed in.'
Cricket analogy: A behaviour is like a single bowling coach's certified curriculum that one academy fully adopts, while a protocol is like the LBW law that every format independently applies its own way depending on the match situation.
Deriving and Fallback Implementations
For structs specifically, you rarely write a full defimpl by hand for every protocol: @derive [Protocol1, Protocol2] before defstruct asks Elixir to auto-generate a sensible default implementation (commonly used with Enumerable or Inspect), and defprotocol ... do ... end can declare @fallback_to_any true so that any type without an explicit implementation falls back to the Any implementation instead of raising a Protocol.UndefinedError. This matters because, by default, calling a protocol function on a struct with no matching defimpl raises immediately — a deliberate design choice so that missing polymorphic behavior fails loudly at the call site rather than silently doing the wrong thing.
Cricket analogy: @derive is like a franchise team auto-adopting a standard training protocol from the parent board instead of writing bespoke drills, while @fallback_to_any is like a reserve umpire stepping in with generic rules when a niche format's specific rule isn't written yet.
defprotocol Describable do
@doc "Returns a human-readable description of the value"
def describe(value)
end
defimpl Describable, for: Integer do
def describe(n), do: "integer: #{n}"
end
defimpl Describable, for: BitString do
def describe(s), do: "string of length #{String.length(s)}"
end
defmodule Order do
defstruct [:id, :total]
end
defimpl Describable, for: Order do
def describe(%Order{id: id, total: total}) do
"order ##{id} totalling $#{total}"
end
end
Describable.describe(42) # "integer: 42"
Describable.describe("hello") # "string of length 5"
Describable.describe(%Order{id: 7, total: 199}) # "order #7 totalling $199"Calling a protocol function on a value whose type has no defimpl raises Protocol.UndefinedError at runtime, not compile time, since dispatch happens based on the argument's type when the code runs. Add @fallback_to_any true and a defimpl ..., for: Any block only when a sensible generic default truly makes sense — otherwise let it fail loudly so missing implementations surface immediately.
Protocols are open to extension without modifying existing code: a third-party library can ship its own defimpl SomeProtocol, for: TheirStruct without ever touching the original protocol definition or your code, which is the polymorphism escape hatch that Elixir's otherwise closed pattern-matching style doesn't give you elsewhere.
- defprotocol declares function signatures; defimpl supplies a per-type implementation.
- Dispatch is based on the runtime type of the first argument, with no inheritance hierarchy.
- String.Chars, Enumerable, and Inspect are core protocols used throughout the standard library.
- Behaviours define a contract one module adopts wholesale; protocols dispatch per data type.
- @derive auto-generates a default protocol implementation for a struct, commonly for Enumerable.
- @fallback_to_any true plus a for: Any implementation avoids Protocol.UndefinedError for unhandled types.
- Third-party code can add new defimpl blocks for existing protocols without modifying original source.
Practice what you learned
1. How does Elixir decide which defimpl block to run for a protocol function call?
2. What is the key difference between a protocol and a behaviour?
3. What happens when you call a protocol function on a type with no matching defimpl and no @fallback_to_any?
4. What does @derive [Enumerable] do when placed before defstruct?
5. Which standard library protocol powers string interpolation like "#{value}"?
Was this page helpful?
You May Also Like
Maps in Elixir
Understand Elixir's key-value data structure, how to build, update, and pattern-match against maps, and when to reach for structs instead.
Comprehensions in Elixir
Learn Elixir's for special form — a concise syntax for looping over enumerables and generating new collections with filters, multiple generators, and pattern matching.
Keyword Lists
Learn Elixir's keyword list — an ordered list of atom-keyed tuples used heavily for options, configuration, and DSL-style function calls.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics