Elixir Interview Questions and Answers

Last updated:

Check out 40 of the most common Elixir interview questions, then take an AI-powered practice interview

PhoenixErlangOTPFunctional ProgrammingLiveView
40+
Questions
14
Basic
18
Intermediate
8
Advanced
Q1

What is Elixir and what makes it different from other functional languages?

BasicFundamentals

Answer

Elixir is a dynamic, functional, compiled language created by Jose Valim in 2012 that runs on the Erlang Virtual Machine (BEAM). What separates Elixir from other functional languages like Haskell or F# is the runtime: BEAM was designed by Ericsson in the 1980s for telecom switches that needed nine 9s of uptime (~31ms downtime per year). You get lightweight processes (millions per node), preemptive scheduling, message-passing concurrency, hot code reload, and built-in distribution.

Elixir layers a modern syntax (inspired by Ruby), powerful macros, a great tooling story (Mix, Hex, ExUnit), and the Phoenix web framework on top of that battle-tested runtime. The pitch: pick Elixir when you need fault-tolerant, concurrent, soft real-time systems, chat, payments, IoT, telemetry pipelines, and you'd rather scale by adding cheap nodes than tuning JVM garbage collectors. Interviewers push on what BEAM gives you that a library cannot bolt on: per-process garbage collection (each process collects its own heap, so there is no stop-the-world pause), reduction-based preemption (a process is descheduled after roughly 2000 reductions, so one tight loop cannot starve everything else), and supervision as a runtime feature rather than a framework.

Unlike Haskell, Elixir is dynamically typed and impure, though 1.17 and 1.18 added set-theoretic type inference that flags impossible matches at compile time. The usual follow-up is what Elixir adds over plain Erlang: macros, protocols, the Enum and Stream APIs, Mix and Hex, doctests, and zero-cost interop (`:crypto.hash(:sha256, data)` is callable directly because both languages compile to the same BEAM bytecode).

Key Points

  • Runs on BEAM (Erlang VM), built for nine 9s of uptime
  • Lightweight processes, message passing, preemptive scheduling
  • Functional + immutable + pattern matching
  • Hot code reload and distributed-by-default
Q2

What is the pipe operator (|>) and why do Elixir developers love it?

BasicSyntax

Answer

The pipe operator `|>` passes the result of the left expression as the FIRST argument to the right function. It turns nested function calls into a top-to-bottom data pipeline, which mirrors how you naturally think about transformations. Without pipes, code reads inside-out: `String.upcase(String.trim(input))`.

With pipes, it reads in execution order: `input |> String.trim() |> String.upcase()`. This is so foundational to Elixir style that the standard library is designed around it, most functions take the data as their first argument specifically so they pipe well. The pipe operator is the reason Elixir code often resembles a Unix shell pipeline of small, composable transforms.

Mechanically `|>` is only a macro: at compile time `a |> f(b)` is rewritten to `f(a, b)`, so there is no runtime cost and no intermediate closure. Two gotchas interviewers like: the right side must be a function call, so `1 |> 2` fails to compile with 'cannot pipe 1 into 2', and to pipe into an anonymous function or a `case` you need `then/2` (added in Elixir 1.12), as in `data |> then(&handle/1)`. `tap/2` is its sibling: it runs a side effect and passes the original value through, and `|> IO.inspect(label: "after filter")` is the standard way to inspect one stage without breaking the chain. Style tooling will also flag a single-step pipe such as `x |> foo()`, which Credo's `Credo.Check.Readability.SinglePipe` asks you to write as plain `foo(x)`.

# Without pipes, reads inside-out
result = Enum.sum(Enum.map(Enum.filter(1..100, &(rem(&1, 2) == 0)), &(&1 * &1)))

# With pipes, reads top-to-bottom
result =
  1..100
  |> Enum.filter(&(rem(&1, 2) == 0))
  |> Enum.map(&(&1 * &1))
  |> Enum.sum()
# => 171700
💡 Pro Tip: Pipes only work when the data flows as the first argument. Design your own functions that way.
Q3

What is pattern matching in Elixir and how is it different from assignment?

BasicPattern Matching

Answer

In Elixir the `=` operator is the match operator, not assignment. It tries to make the left side equal the right side, binding variables on the left to the corresponding values on the right. If the structure doesn't match, you get a `MatchError`.

This single operator is used everywhere: function heads, case clauses, with statements, anonymous functions. Pattern matching destructures tuples, lists, maps, and structs in one line, and it also acts as a runtime assertion that the data shape is what you expected. The pin operator `^` lets you match against an existing variable's value instead of rebinding.

Beyond destructuring, matching drives function dispatch: multiple clauses compile into a decision tree, so the runtime selects a clause without running any of your code. Details a senior interviewer probes: map patterns are partial (`%{name: n}` matches any map containing that key) while tuple and list patterns must match arity and length exactly; you cannot call functions on the left side, so `length(list) = 3` is a compile error and that check belongs in a guard; repeating a variable inside one pattern asserts equality, so `{x, x} = {1, 2}` raises MatchError; and `_` discards a value while `_total` still binds it but silences the unused-variable warning. Binary patterns are the underrated part: `<<version::8, len::16, body::binary-size(len)>> = packet` parses a wire protocol in one line, which is why Elixir shows up so often in protocol, telecom and IoT work.

{:ok, value} = {:ok, 42}        # value = 42
[head | tail] = [1, 2, 3]        # head = 1, tail = [2, 3]
%{name: name} = %{name: "Asha"}  # name = "Asha"

# Match error, different shapes
{:ok, _} = {:error, :timeout}    # ** (MatchError)

# Pin to match an existing value, not rebind
expected = 10
^expected = some_fn()             # asserts result equals 10

Key Points

  • `=` matches, doesn't assign
  • Use `^` to pin existing values
  • Destructures and validates in one step
Q4

What does immutability mean in Elixir and what are the implications?

BasicFundamentals

Answer

In Elixir, data is immutable, once a value is created, it can never be changed in place. When you 'update' a map or list, you're producing a NEW value; the original is untouched. This is the foundation of BEAM's concurrency model: if no two processes can mutate shared memory, you eliminate entire classes of race conditions.

Practical implications: (1) you write expressions, not statements, every transform returns a new value, (2) you never worry about a function modifying its argument behind your back, (3) the runtime can share structure between old and new versions (persistent data structures) so it's cheaper than naive copying, (4) garbage collection is per-process, when a short-lived process dies, all its data goes with it without affecting other processes. Structural sharing is what makes this affordable: `Map.put/3` copies only the path to the changed key, and `[0 | list]` allocates a single cons cell, so copy-on-write is far cheaper than it sounds. Tuples are the exception, since `put_elem/3` copies the whole tuple, which is why you never use a 1000-element tuple as an accumulator.

The bug this causes in interviews is always the same one: `Enum.map(list, &f/1)` with the result discarded does nothing, and `Map.put(map, :k, 1)` without rebinding leaves `map` untouched. Binaries larger than 64 bytes are the one thing not copied per process: they live on a shared reference-counted heap, so passing a 5MB payload between processes moves a pointer, but a long-lived process holding one small sub-binary keeps the entire original alive. That is the classic binary leak, and the fix is `:binary.copy/1` on the slice you keep, or hibernating the process.

list = [1, 2, 3]
new_list = [0 | list]   # new_list = [0, 1, 2, 3], list still [1, 2, 3]

map = %{a: 1, b: 2}
map2 = Map.put(map, :c, 3)  # map still %{a: 1, b: 2}
Q5

What are atoms in Elixir and what's the gotcha with creating them dynamically?

BasicData Types

Answer

Atoms are named constants whose value is their own name, written like `:ok`, `:error`, `:timeout`. They're like Ruby symbols or Java enums but cheaper to compare (just an integer comparison under the hood). Common uses: tagged tuples (`{:ok, value}` vs `{:error, reason}`), map keys, config flags, module names.

The critical gotcha: atoms are never garbage collected, the BEAM keeps a global atom table with a hard limit (default ~1,048,576). If you create atoms dynamically from user input (`String.to_atom(user_input)`), an attacker can fill the table and crash the node. ALWAYS use `String.to_existing_atom/1` for untrusted input, it raises if the atom doesn't already exist.

When the table does fill, you don't get a catchable exception: the VM aborts with `no more index entries in atom_tab (max=1048576)` and every process on the node dies with it, which is what makes this a real denial-of-service vector rather than a style nit. The usual entry points are JSON decoding with `keys: :atoms` (use `keys: :atoms!`, which routes through to_existing_atom), `List.to_atom/1` on parsed headers, and module names assembled from request parameters. Raising the ceiling with the `+t` flag in `vm.args` buys headroom but is not a fix. Two extras that come up: booleans and nil are literally the atoms `:true`, `:false` and `:nil`, so `is_atom(true)` returns true, and atoms sort before numbers in Erlang term ordering, which surprises people writing custom `Enum.sort/2` comparators over mixed data.

# Safe: only resolves atoms the VM already knows
String.to_existing_atom("admin")   # => :admin
String.to_existing_atom("h4x0r")   # ** (ArgumentError) errors were found

# Check headroom on a live node
:erlang.system_info(:atom_count)   # => 15012
:erlang.system_info(:atom_limit)   # => 1048576

# JSON: :atoms! validates, plain :atoms is the DoS vector
Jason.decode!(body, keys: :atoms!)

Key Points

  • Atoms live in a global table, never GC'd
  • Default limit ~1M, `:erlang.system_info(:atom_count)` to check
  • Use `String.to_existing_atom/1` for user input
  • Module names are atoms (`:lists`, `Enum`)
Q6

What's the difference between a list and a tuple in Elixir?

BasicData Types

Answer

Lists are linked lists, O(1) prepend, O(n) length and random access, ideal for sequential processing and recursion. Tuples are contiguous arrays, O(1) random access by index, O(n) to modify (because immutable means copy-on-write). Rule of thumb: use lists for variable-length collections you'll iterate, use tuples for small fixed-size records where you return multiple values from a function.

The classic `{:ok, value}` and `{:error, reason}` patterns are tuples because they're always 2 elements. Anything you'd write as `Enum.map`/`filter` should be a list. The costs interviewers expect you to name out loud: `length/1` walks the whole list, so `length(list) == 0` is an anti-pattern (match on `[]` or call `Enum.empty?/1`), and `list ++ [item]` copies the entire left operand, which makes `acc = acc ++ [x]` inside a loop O(n^2).

Build by prepending and finish with `Enum.reverse/1`, a single linear pass. On the tuple side `tuple_size/1` and `elem/2` are O(1) because the elements are contiguous, but `put_elem/3` copies the tuple, so tuples stay small and fixed. Keyword lists like `[timeout: 5_000, retries: 3]` are just lists of two-element tuples, so lookup is O(n) and duplicate keys are legal, which is precisely why they model function options rather than data. For building output, prefer iolists (nested lists of binaries) over repeated string concatenation: `IO.iodata_to_binary/1` and the IO layer flatten them once at write time with no intermediate copies.

# List, prepend is O(1), append is O(n)
list = [1, 2, 3]
[0 | list]      # O(1), good
list ++ [4]     # O(n), avoid in hot loops

# Tuple, fixed-size record
{:ok, response} = HTTPoison.get("https://example.com")
elem({1, 2, 3}, 1)  # => 2, O(1) access
Q7

How do you define a module and a function in Elixir?

BasicSyntax

Answer

Modules group related functions and live in a file named after the module (snake_case file, PascalCase module). Use `def` for public functions and `defp` for private ones. Function clauses support pattern matching and guards, Elixir picks the first clause whose head matches.

This is how you write polymorphic code without classes or inheritance. Functions are identified by name AND arity, so `divide/2` and `divide/3` are genuinely different functions, and that arity is what you see in stack traces and in captures like `&Math.add/2`. Default arguments use `\\`, and when a function with defaults has more than one clause you must declare a bodiless head first, or the compiler stops you with 'def greet/2 defines defaults multiple times'.

Clause order matters: specific clauses go above general ones, because a catch-all placed first makes the rest unreachable and you get the 'this clause cannot match because a previous clause always matches' warning. Reviewers also look for `@moduledoc` and `@doc` (which power `h Math.add` in IEx and can be executed as doctests), `@spec` annotations for Dialyzer, and module attributes as compile-time constants: `@timeout 5_000` is inlined during compilation, so it can never read runtime config, which is a common production bug. Namespacing is by dots, and `MyApp.Accounts.User` conventionally lives in `lib/my_app/accounts/user.ex`.

defmodule Math do
  def add(a, b), do: a + b

  def divide(_a, 0), do: {:error, :division_by_zero}
  def divide(a, b) when is_number(a) and is_number(b), do: {:ok, a / b}

  defp helper(x), do: x * 2
end

Math.add(2, 3)         # => 5
Math.divide(10, 0)     # => {:error, :division_by_zero}
Math.divide(10, 2)     # => {:ok, 5.0}
Q8

What is Mix and what does `mix new` create?

BasicTooling

Answer

Mix is Elixir's built-in build tool, equivalent to Maven, Cargo, or npm. It handles project scaffolding, dependency management (via Hex.pm), compilation, running tests, and custom tasks. `mix new my_app` creates a new project with: `lib/` for source, `test/` for tests, `mix.exs` for project config and dependencies, `.formatter.exs` for `mix format` rules, and a `README.md`. Add `--sup` to scaffold a supervision tree, `--umbrella` to create a multi-app project.

Day-to-day commands: `mix deps.get`, `mix compile`, `mix test`, `mix format`, `mix phx.server` (in Phoenix apps). Under the hood `mix.exs` defines a `Mix.Project` module: `project/0` returns the app name, version, the `elixir:` requirement and `deps`, while `application/0` declares `extra_applications` and the `mod:` entry point that boots your supervision tree. Resolved versions are pinned in `mix.lock`, which you commit; `mix deps.get` honours it and `mix deps.update --all` rewrites it.

Flags that show up in real projects: `MIX_ENV=prod mix compile`, `mix compile --warnings-as-errors` as a CI gate, `mix test --failed` to rerun only what broke, `mix test --seed 0` for deterministic ordering, `mix format --check-formatted`, `mix deps.tree` to find which package pulled in a transitive dependency, and `mix hex.outdated` or `mix hex.audit` for stale and retired packages. You add your own task by defining `Mix.Tasks.MyApp.Backfill` with a `run/1` callback, after which it appears in `mix help`. One catch worth naming: Mix is build tooling and is not shipped inside a `mix release` artifact, so production one-offs run through `bin/my_app eval` or a dedicated release module.

$ mix new payment_service --sup
$ cd payment_service
$ mix deps.get
$ mix test
$ iex -S mix    # interactive shell with project loaded
Q9

What is IEx and why is it central to Elixir development?

BasicTooling

Answer

IEx (Interactive Elixir) is the REPL, but more powerful than what that word usually implies. Beyond evaluating expressions, IEx lets you connect to a running production node, inspect live processes, call any function in the loaded app, debug with `IEx.pry/0`, recompile a module on the fly, and tab-complete module/function names. `iex -S mix` starts IEx with your whole project loaded. In production, you can remote-shell into a running Phoenix node and inspect or fix things without a redeploy, a workflow Java/Python developers usually don't have access to.

Be precise about how that works in a release: `bin/my_app remote` starts a hidden node and connects to the running one over Erlang distribution, so quitting your shell leaves production untouched, whereas `bin/my_app attach` takes over the node's own console and a stray Ctrl-C can stop the system. Once inside, `:sys.get_state(pid)` dumps a GenServer's state without adding a debug callback, `Process.info(pid, [:message_queue_len, :memory])` tells you if a process is drowning, and `Application.get_all_env(:my_app)` shows the config that actually loaded rather than what you think is in `runtime.exs`. Day-to-day helpers worth naming: `h Enum.reduce/3` for docs, `i term` for a type report, `r MyModule` to recompile one module, `recompile` for the project, `open MyModule.fun` to jump to the source line, `v(3)` to reuse an earlier result, and `break!

MyMod.fun/1` to set a breakpoint without editing code. The caution: the shell is itself a process, so pasting an infinite loop blocks that shell, and a `GenServer.call` typed at the prompt is subject to the same 5 second default timeout as application code.

$ iex -S mix                    # project compiled and loaded
iex> h Enum.reduce/3            # docs inline
iex> :sys.get_state(MyApp.Counter)
iex> Process.info(pid, [:message_queue_len, :memory])
iex> r MyApp.Worker             # recompile a single module
iex> v(2)                       # reuse the result from line 2

# Attach to a running release without restarting it
$ bin/my_app remote
Q10

What is the difference between `==` and `===` in Elixir?

BasicOperators

Answer

`==` is value equality with type coercion between numbers: `1 == 1.0` is `true`. `===` is strict equality, type AND value must match: `1 === 1.0` is `false`. For everything except number-vs-number comparisons, the two behave identically. Use `===` when you specifically care that an integer is not a float (or vice versa); use `==` otherwise.

Pattern matching, which is what most Elixir code uses anyway, is always strict. The deeper point interviewers are usually driving at is that comparison in Elixir never fails: every pair of terms is comparable under one total ordering, `number < atom < reference < function < port < pid < tuple < map < list < bitstring`, so `1 < :atom` returns true rather than raising the way Python 3 does. That means `Enum.max/1` over mixed data is silently wrong instead of loud.

For structs, `==` compares the underlying maps field by field, so two `%Decimal{}` values that are numerically equal but stored with different exponents compare as false, which is exactly why Decimal ships `Decimal.equal?/2` and the calendar types ship `Date.compare/2` and `DateTime.compare/2` returning `:lt`, `:eq` or `:gt`. Since Elixir 1.10 you can pass a module as the sorter, `Enum.sort(dates, Date)` or `Enum.sort(dates, {:desc, DateTime})`, so sorting uses semantic comparison instead of raw term order. `!=` and `!==` are the negations, and when you want a boolean out of a pattern rather than a MatchError, reach for `Kernel.match?/2`.

1 == 1.0    # true
1 === 1.0   # false

# Pattern match is strict like ===
1 = 1.0     # ** (MatchError)
Q11

What are guards in Elixir and where can you use them?

BasicPattern Matching

Answer

Guards are expressions that further constrain a pattern match, they appear after `when` in function heads, case clauses, and anonymous functions. Because guards are checked by the BEAM in a very restricted context, they support only a fixed set of operations: type checks (`is_integer/1`, `is_binary/1`), comparisons, basic arithmetic, and a handful of approved functions. You cannot call your own functions in guards (with one exception: `defguard` lets you define reusable guard macros).

The benefit is that guards stay fast and side-effect-free, which is why the BEAM can dispatch on them efficiently. Guards are available in more places than most candidates list: function heads, `case` and `receive` clauses, `for` comprehension filters, `with` clauses, `try/rescue`, and anonymous functions. The rule that catches people out is that an exception raised inside a guard never propagates, it only makes that guard fail, so `def f(x) when hd(x) == 1` quietly skips the clause when `x` is `[]` instead of raising ArgumentError.

Composition has two flavours with different semantics: `and`/`or` require real booleans and raise on anything else, while the comma and semicolon separators behave like and-else/or-else and swallow failures. `in` is guard-safe only when the right side is a literal list or a range, because `x in [1, 2, 3]` expands into a comparison chain at compile time; a runtime list will not compile there. For reuse, `defguard is_even(n) when is_integer(n) and rem(n, 2) == 0` defines a guard macro (with `defguardp` for the private version). If you truly need a function call, move the check into the body and return a tagged tuple instead.

defmodule Account do
  def withdraw(balance, amount) when is_number(amount) and amount > 0 and amount <= balance do
    {:ok, balance - amount}
  end
  def withdraw(_, _), do: {:error, :invalid_amount}
end
Q12

What are anonymous functions and the capture operator?

BasicSyntax

Answer

Anonymous functions are defined with `fn args -> body end` and called with a dot: `add.(1, 2)`. The dot is mandatory and distinguishes calling an anonymous function from calling a named one. The capture operator `&` is shorthand: `&(&1 + 1)` is equivalent to `fn x -> x + 1 end`, and `&Module.fun/2` captures a named function into a value you can pass around.

Captures are the idiomatic way to pass small functions to `Enum.map/2` and friends. What gets probed beyond the syntax: anonymous functions are closures over the variables in scope when they were defined, and because data is immutable, rebinding that variable afterwards is invisible inside the closure. They can carry multiple clauses with patterns and guards, but every clause must have the same arity or compilation fails with 'cannot mix clauses with different arities in anonymous functions'.

The two capture forms are not equivalent: `&Mod.fun/1` is a remote capture resolved at call time, so a hot-reloaded module is picked up, while `&(&1 + 1)` compiles a fresh closure, and building those inside a tight loop allocates a term per iteration. Captures cannot nest, so `&Enum.map(&1, &(&1 * 2))` fails with 'nested captures are not allowed'; write the inner one as a full `fn`. `&{&1, &2}` builds tuples, `Function.capture(Mod, :fun, 1)` does it dynamically, and `apply(Mod, :fun, args)` is the escape hatch when the function name is only known at runtime, at the cost of losing compile-time undefined-function warnings.

add = fn a, b -> a + b end
add.(1, 2)               # => 3

Enum.map([1, 2, 3], &(&1 * 2))           # => [2, 4, 6]
Enum.map([1, 2, 3], &String.duplicate("x", &1))   # => ["x", "xx", "xxx"]

upcase = &String.upcase/1
upcase.("hello")         # => "HELLO"
Q13

What is a keyword list and when should you use one instead of a map?

BasicData Types

Answer

A keyword list is a list of two-element tuples whose first element is an atom, so `[timeout: 5_000, retries: 3]` is literally `[{:timeout, 5000}, {:retries, 3}]`. Elixir adds one more piece of sugar: when a keyword list is the last argument of a function call you may drop the brackets, which is why `Repo.all(query, timeout: 15_000)` reads the way it does. `do`, `else` and `after` blocks are keyword lists too, which is why `if` is an ordinary macro rather than a language keyword. Three properties fall out of it being a list: keys must be atoms, keys may repeat, and order is preserved.

That combination is exactly what function options and Ecto query fragments need, since `order_by: [asc: :name, desc: :id]` depends on both order and duplicates, while maps are the right choice for data. The cost is lookup: `Keyword.get/3` walks the list, so keyword lists are wrong for hundreds of entries and wrong for user-supplied keys, because those keys are atoms and atoms are never garbage collected. The API worth knowing: `Keyword.get/3`, `Keyword.fetch!/2`, `Keyword.put_new/3`, `Keyword.merge/2`, and `Keyword.validate!/2` (Elixir 1.13 and later), which raises on unknown options instead of silently ignoring a typo'd key. Pattern matching a keyword list is order-sensitive, so read options with the Keyword functions rather than matching them.

opts = [timeout: 5_000, retries: 3]
opts == [{:timeout, 5_000}, {:retries, 3}]   # => true

# Brackets are optional in the last argument position
Repo.all(query, timeout: 15_000, log: false)

def fetch(url, opts \\ []) do
  opts = Keyword.validate!(opts, timeout: 5_000, retries: 3)
  {url, Keyword.fetch!(opts, :timeout)}
end

fetch("https://example.dev", timeuot: 1)
# ** (ArgumentError) unknown keys [:timeuot], the allowed keys are: [:timeout, :retries]

Key Points

  • Sugar over a list of {atom, value} tuples
  • Ordered and duplicate-friendly, so ideal for options
  • O(n) lookup, never use it as a large dictionary
  • `Keyword.validate!/2` rejects misspelled option keys
Q14

What's the difference between a String, a binary, and a charlist in Elixir?

BasicData Types

Answer

A String in Elixir is a UTF-8 encoded binary written in double quotes, and a binary is just a sequence of bytes, so `is_binary("hi")` returns true and `"hi" == <<104, 105>>` is also true. There is no separate string type. A charlist is a list of Unicode code points, printed with the `~c` sigil since Elixir 1.15 (older material shows `'hi'`, which is why tutorials and your shell disagree), and it exists mainly because Erlang APIs demand it: `:file.open(~c"log.txt", [:read])` will not take a binary path, so you convert with `String.to_charlist/1` and `List.to_string/1`.

The distinction that actually bites is bytes versus graphemes: `byte_size("héllo")` is 6 while `String.length("héllo")` is 5, because the accented character occupies two bytes, and `String.length/1` walks the whole string, so it is O(n). `String.at/2` is O(n) for the same reason, which is why idiomatic text processing uses binary pattern matching such as `<<first::utf8, rest::binary>>`. Two production details worth naming: `<>` concatenation copies both operands, so build output as an iolist and flatten once with `IO.iodata_to_binary/1`, and binaries over 64 bytes live on a shared reference-counted heap, so holding a small slice of a large HTTP response keeps the entire response in memory until you `:binary.copy/1` the part you need.

is_binary("hi")                 # => true
"hi" == <<104, 105>>            # => true
?a                              # => 97 (code point literal)

~c"hi" == [104, 105]            # => true (charlist)
:file.open(~c"log.txt", [:read])  # Erlang APIs want charlists

byte_size("héllo")              # => 6 bytes
String.length("héllo")          # => 5 graphemes

<<first::utf8, rest::binary>> = "héllo"
first                           # => 104
rest                            # => "éllo"
💡 Pro Tip: If a library function returns something that inspects as a list of small integers, you are looking at a charlist from Erlang, not a broken string.
Q15

What is a GenServer and when should you reach for one?

IntermediateOTP

Answer

GenServer (Generic Server) is the most common OTP behaviour, a process that holds state, responds to messages, and gets supervised. You implement callbacks (`init/1`, `handle_call/3` for synchronous requests, `handle_cast/2` for fire-and-forget, `handle_info/2` for arbitrary messages) and OTP handles the message loop, registration, and error reporting. Use a GenServer when you need: (1) state that lives between requests, (2) serialised access to a resource, (3) periodic tasks, (4) a queue.

DON'T reach for one just to hold config (use ETS or Application env) or to parallelise work (use `Task` or `Task.Supervisor`). Beware: every `handle_call` is synchronous, so a slow handler becomes a bottleneck for the entire process, the typical mailbox-overflow disaster starts here. Be precise about the mechanics: `GenServer.call/3` is a `send` plus a monitored selective `receive`, so on timeout the CALLER exits with `{:timeout, {GenServer, :call, [...]}}` while the server keeps grinding on a request nobody is waiting for.

The callback return tuples drive the lifecycle: `{:reply, value, state}`, `{:noreply, state}`, `{:stop, reason, state}`, and the `{:continue, term}` third element (OTP 21 and later) that triggers `handle_continue/2` immediately after `init/1`. That last one matters because `init/1` runs inside `start_link`, so any slow work there blocks the entire supervision tree from booting; returning `{:ok, state, {:continue, :warm_cache}}` registers the process first and loads afterwards. `terminate/2` is not a guaranteed hook: it runs only when the process traps exits or stops normally, so never rely on it to flush state to disk. Finally, `name: __MODULE__` allows exactly one instance per node, and the moment you need many you switch to `{:via, Registry, {MyApp.Registry, id}}`.

defmodule Counter do
  use GenServer

  def start_link(initial \\ 0), do: GenServer.start_link(__MODULE__, initial, name: __MODULE__)
  def increment, do: GenServer.cast(__MODULE__, :inc)
  def value, do: GenServer.call(__MODULE__, :get)

  @impl true
  def init(initial), do: {:ok, initial}

  @impl true
  def handle_cast(:inc, count), do: {:noreply, count + 1}

  @impl true
  def handle_call(:get, _from, count), do: {:reply, count, count}
end

Key Points

  • handle_call = sync, handle_cast = async, handle_info = arbitrary
  • Single mailbox = serial processing, don't put slow I/O inside
  • Always start under a supervisor in production
Q16

What is a Supervisor and what are the restart strategies?

IntermediateOTP

Answer

A Supervisor is a process whose only job is to start, monitor, and restart child processes when they crash. This is the foundation of Elixir's 'let it crash' philosophy: child processes don't need defensive error handling because their supervisor will restart them in a known-good state. Four restart strategies: `:one_for_one` (restart only the crashed child), `:one_for_all` (restart all children if any crashes, for tightly-coupled siblings), `:rest_for_one` (restart the crashed child and any started AFTER it, for ordered dependencies), and `:simple_one_for_one` (deprecated; replaced by `DynamicSupervisor` for runtime-spawned children).

Each child also has a `:restart` setting: `:permanent` (always restart), `:temporary` (never restart), `:transient` (restart only on abnormal exit). The child spec is the other half of the answer. A `{Counter, 0}` tuple expands to `Counter.child_spec(0)`, which `use GenServer` generates, and you override it with `use GenServer, restart: :transient, shutdown: 10_000`.

That `shutdown` value bites in production: the default 5000ms means the supervisor sends an exit signal, waits five seconds, then brute-kills, so a worker that must drain a queue has to trap exits and finish inside that window (`:infinity` is legal for supervisor children, and `:brutal_kill` skips the grace period entirely). Order matters too: children start in list order and terminate in reverse, which is why your Repo sits above anything that queries it and the Phoenix Endpoint sits last. Restart intensity defaults to `max_restarts: 3` within `max_seconds: 5`, tunable through `Supervisor.init/2`, and when it trips the supervisor exits with `:shutdown` and hands the problem upward. For children created at runtime you use `DynamicSupervisor.start_child/2` instead, and OTP 24 with Elixir 1.14 added `:significant` children plus `:auto_shutdown` for supervisors that should stop once their meaningful child is gone.

defmodule MyApp.Supervisor do
  use Supervisor

  def start_link(opts), do: Supervisor.start_link(__MODULE__, :ok, opts)

  @impl true
  def init(:ok) do
    children = [
      {Counter, 0},
      {Cache, name: MyApp.Cache},
      {Phoenix.PubSub, name: MyApp.PubSub}
    ]
    Supervisor.init(children, strategy: :one_for_one)
  end
end
💡 Pro Tip: Supervisors have a max_restarts limit (default 3 in 5 seconds). If a child keeps crashing, the supervisor itself will crash, escalating to its supervisor. That's the design.
Q17

Explain the actor model and how Elixir processes implement it.

IntermediateConcurrency

Answer

The actor model treats every concurrent unit as an isolated 'actor' that owns its state and communicates only by sending asynchronous messages. Actors can create other actors, change their behaviour, and respond to received messages. Elixir's processes are the canonical modern implementation: each one has its own heap and mailbox, is preemptively scheduled by the BEAM, and can only affect another process by sending it a message via `send/2` or `GenServer.call/3`.

Because nothing is shared, you don't need locks, and a crash in one process can't corrupt another, the supervisor sees the exit signal and restarts. Processes are cheap: spawning takes microseconds, memory footprint starts at ~2KB. A typical BEAM node runs 100,000+ processes; WhatsApp famously had 2 million concurrent connections per node.

Where Elixir departs from the textbook actor model is worth saying out loud: processes can also be linked and monitored so failures propagate, and ETS gives you shared tables that sit deliberately outside the model when message passing would be too slow. The scheduling detail that impresses interviewers: the BEAM runs one scheduler thread per core, each process starts with a heap of a few hundred words (roughly 2KB), and a process is preempted after about 2000 reductions, where a reduction is approximately one function call. That is why a CPU-bound loop cannot starve the node the way a blocking goroutine or a synchronous handler on a Node.js event loop can.

Messages are copied into the receiver's own heap, which is what makes isolation real rather than conventional. The natural follow-up is how you address a process: pids print as `#PID<0.123.0>` where the first field is the node id, `self()` returns your own, `Process.register/2` gives a global-per-node atom name, and a `Registry` handles the dynamic case. `Process.alive?/1` and `Process.info/1` are how you inspect one at runtime.

pid = spawn(fn ->
  receive do
    {:ping, from} -> send(from, :pong)
  end
end)

send(pid, {:ping, self()})
receive do
  :pong -> IO.puts("got pong")
end
Q18

What's the difference between `spawn`, `spawn_link`, and `spawn_monitor`?

IntermediateConcurrency

Answer

All three create a new process; the difference is the failure relationship. `spawn/1` is fire-and-forget, if the spawned process crashes, the parent isn't notified. `spawn_link/1` creates a bidirectional link: if either process crashes, the other receives an exit signal and (by default) crashes too. This is how OTP propagates failures up a supervision tree. `spawn_monitor/1` is a one-way relationship: the parent receives a `:DOWN` message when the child exits, but the parent doesn't crash. Use `spawn_link` inside supervised code where failure should propagate; use `spawn_monitor` when you want to handle the failure yourself.

Calling raw `spawn` is rare in production, most code uses `Task.start_link/1` or a `Task.Supervisor`. The mechanism underneath links is exit signals: a crash sends a non-normal exit signal along every link, and the default behaviour of a receiving process is to die with the same reason, which is exactly how a failure climbs a supervision tree. `Process.flag(:trap_exit, true)` converts those signals into ordinary `{:EXIT, pid, reason}` messages in the mailbox, which is what Supervisor does internally and what you do when a process must clean up after its children. Two traps: `Process.exit(pid, :kill)` is untrappable and skips `terminate/2` completely, while `Process.exit(pid, :normal)` sent to another process does nothing at all.

Monitors differ from links in three ways that get asked about: they are unidirectional, they stack (each `Process.monitor/1` returns a fresh reference), and they are one-shot, since delivering the `:DOWN` message removes the monitor. Always pair a cancellation with `Process.demonitor(ref, [:flush])`, otherwise a stale `:DOWN` sits in your mailbox and confuses the next `receive`, a genuine bug in hand-rolled request/response code. `spawn_link` also returns a pid before `init` finishes, which is why OTP start functions block until the child is ready.

# Linked, if child crashes, parent crashes too
spawn_link(fn -> raise "boom" end)

# Monitored, parent gets a :DOWN message but survives
{pid, ref} = spawn_monitor(fn -> raise "boom" end)
receive do
  {:DOWN, ^ref, :process, ^pid, reason} -> IO.inspect(reason)
end
Q19

What are Tasks and when do you use Task.async vs Task.Supervisor?

IntermediateConcurrency

Answer

`Task` is OTP's high-level abstraction for one-shot concurrent work. `Task.async/1` spawns a linked task and gives you a struct you can `Task.await/2` on later, perfect for fan-out/fan-in patterns where you want N parallel HTTP calls. The crucial gotcha: `Task.async` LINKS the task to the calling process, so if the task crashes, the caller crashes too (and vice versa). For background work that should survive caller crashes, use `Task.Supervisor` with `Task.Supervisor.async_nolink/3` or `Task.Supervisor.start_child/2`.

Phoenix endpoints almost always want the supervised variant, a buggy task shouldn't kill the request process. Also remember `await` defaults to a 5-second timeout: long-running tasks need `Task.await(task, :infinity)` or a longer timeout. Be specific about what a timeout does: `Task.await/2` exits the caller with `{:timeout, {Task, :await, [...]}}` and kills the task, so it is not a soft failure.

When you want to survive it, use `Task.yield(task, 2_000)` followed by `Task.shutdown(task, :brutal_kill)` and treat `nil` as 'did not finish'. For a collection, `Task.async_stream/3` is the tool that actually belongs in production: `max_concurrency` defaults to `System.schedulers_online()`, `ordered: false` improves throughput when order does not matter, and `on_timeout: :kill_task` stops one slow element from taking down the batch. It returns a lazy stream, so nothing runs until you call `Enum.to_list/1` or `Stream.run/1`, which is a classic 'why is my code doing nothing' moment. `Task.Supervisor.async_stream_nolink/4` is the supervised sibling. Two more details: an awaited task replies with a message tagged by its ref, so a GenServer using tasks must not swallow unmatched messages in `handle_info/2`, and `Task.start/1` work is unsupervised, so it is cut off abruptly during application shutdown.

# Fan-out, fan-in with timeouts
tasks =
  Enum.map(urls, fn url ->
    Task.async(fn -> HTTPoison.get(url) end)
  end)

results = Task.await_many(tasks, 10_000)

# Supervised, survives caller crash
Task.Supervisor.async_nolink(MyApp.TaskSup, fn -> charge_card(order) end)
Q20

What is the Phoenix framework and how does it relate to Elixir?

IntermediatePhoenix

Answer

Phoenix is the dominant web framework in the Elixir ecosystem, think Django or Rails, but built around the actor model and shipped with native real-time tooling. The big wins versus traditional frameworks: (1) each request runs in its own BEAM process, so one slow request can't block others, (2) Channels and PubSub are built in, no need for Socket.io or Pusher, (3) LiveView lets you build SPA-like interactivity in Elixir with no JavaScript, (4) Ecto provides a composable query DSL and changesets for validation. As of 2026 Phoenix 1.7+ uses Tailwind + esbuild by default and ships with verified routes (`~p"/users/#{id}"`) for compile-time route checking.

Companies like Discord, Lemonade, and PepperContent run Phoenix at massive scale. Architecturally Phoenix is a stack of libraries over Plug, not a monolith: `Plug.Conn` is the struct threaded through every layer, the endpoint is a plug pipeline (`Plug.Static`, `Plug.Parsers`, session, then the router), and the router composes named pipelines with `pipe_through [:browser]` or `[:api]`. Controllers are plain modules that transform a conn, which is why controller tests are function calls rather than a booted server.

Generators come up constantly in interviews: `mix phx.new my_app --no-ecto`, `mix phx.gen.context Accounts User users`, `mix phx.gen.live`, and `mix phx.gen.auth`, which writes a complete session and token flow into your codebase instead of adding a dependency. Phoenix 1.7 was the big break: verified routes (`~p`) that fail compilation on a wrong path, views collapsed into function components under a single `MyAppWeb` module, and LiveView as the generator default. One concurrency detail to state: the acceptor pool (Bandit by default in recent versions, Cowboy before that) spawns one BEAM process per connection, so a 500ms request holds a 2KB process rather than a pooled OS thread.

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :api do
    plug :accepts, ["json"]
    plug MyAppWeb.Auth.BearerToken
  end

  scope "/api", MyAppWeb do
    pipe_through :api
    resources "/orders", OrderController, only: [:index, :show, :create]
  end
end

# Verified routes are checked at compile time
redirect(conn, to: ~p"/api/orders/#{order.id}")
Q21

What is Ecto and what is a changeset?

IntermediatePhoenix

Answer

Ecto is the database wrapper and query toolkit used in nearly every Elixir app. It has four parts: `Ecto.Repo` (the database adapter, Postgres, MySQL, SQLite, MSSQL), `Ecto.Schema` (your data model), `Ecto.Query` (composable SQL DSL), and `Ecto.Changeset` (validation + transformation pipeline). A changeset is Ecto's answer to 'how do I validate user input and turn it into a safe DB update?'

You cast the params into typed fields, run validations (`validate_required`, `validate_format`, `unique_constraint`), and end up with either a valid changeset (insert/update it) or one full of errors you can show in a form. Changesets are also where you defend against mass-assignment, `cast/3` is an allowlist. What separates a Phoenix candidate from a beginner here is knowing that `cast/3` silently drops params outside the allowlist, while `change/2` and `put_change/3` skip casting entirely for values your own code computed.

Validations run in Elixir before the database is touched, but constraints do not: `unique_constraint/3`, `foreign_key_constraint/3` and `check_constraint/3` only translate a Postgres violation raised during `Repo.insert/2` into a changeset error, so the matching index has to exist in a migration or you get a raw Postgrex error instead of a form message. On the query side, `from(o in Order, where: o.status == ^status)` uses `^` to interpolate as a bound parameter, which is what makes Ecto injection-safe, and queries are composable because each one returns a struct you can pipe into the next. `Ecto.Multi` groups operations into a single transaction with named steps, so a failure tells you exactly which step broke, and `Repo.transaction/2` rolls back when any step returns an error tuple. N+1 queries are the most common performance bug, fixed with `Repo.preload/2` or a `join` plus `preload` in one query.

defmodule MyApp.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :name, :string
    field :age, :integer
    timestamps()
  end

  def changeset(user, params) do
    user
    |> cast(params, [:email, :name, :age])
    |> validate_required([:email])
    |> validate_format(:email, ~r/@/)
    |> validate_number(:age, greater_than_or_equal_to: 18)
    |> unique_constraint(:email)
  end
end
Q22

What is Phoenix LiveView and what problem does it solve?

IntermediatePhoenix

Answer

LiveView is Phoenix's answer to 'do I really need a SPA?' It renders HTML on the server, opens a WebSocket, and ships only the DIFF when state changes, so the user sees interactive UI without you writing JavaScript or maintaining a separate API. State lives in a long-running BEAM process per browser tab, which is cheap because BEAM processes are cheap.

Use cases where LiveView shines: dashboards, admin panels, real-time feeds, multi-step forms, and most internal tools. Where it struggles: offline-first apps, heavy client-side animations, and apps where a 200ms WebSocket round trip is too slow for UI feedback (use JS hooks for those bits). PepperContent's editor and many India-based fintech dashboards are built on LiveView in 2026.

The mechanics interviewers dig into: `mount/3` runs TWICE for a live route, once over plain HTTP for the static first paint (which is what search engines see) and again after the WebSocket connects, so expensive loading goes behind `if connected?(socket)`. State lives in `socket.assigns` and the diff engine only ships the parts of the HEEx template whose assigns actually changed, so passing an entire struct where the template needs one field makes every diff bigger than it should be. Long lists use `stream/4` (LiveView 0.18 and later) rather than an assign, because streams keep rows in the DOM and out of server memory, which is the direct fix for a LiveView process holding tens of megabytes of query results. `handle_info/2` receives Phoenix.PubSub broadcasts so external events can push updates into a live page, `push_event/3` with a JS hook covers genuinely client-side behaviour like chart rendering, and `phx-change` with `to_form/2` gives live validation straight off an Ecto changeset. Remember that a reconnect re-runs mount, so anything that must survive a network blip belongs in the database or a separate process.

defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket), do: {:ok, assign(socket, count: 0)}

  def handle_event("inc", _params, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def render(assigns) do
    ~H"""
    <button phx-click="inc">+</button>
    <span><%= @count %></span>
    """
  end
end
Q23

How does message passing work between Elixir processes?

IntermediateConcurrency

Answer

Every process has a mailbox, an unbounded FIFO queue. `send(pid, msg)` is non-blocking; it appends a copy of `msg` to the destination's mailbox. The receiver pulls messages with `receive do ... end`, which pattern-matches against the mailbox in order: the first matching message is removed and bound, others stay. If nothing matches, `receive` blocks until something does (or hits its optional `after` timeout).

Critical gotchas: (1) messages are COPIED between processes (except for large binaries >64 bytes, which are reference-counted), so passing megabytes around is expensive, (2) mailboxes are unbounded, a slow consumer with a fast producer is a classic memory blow-up. Always add backpressure (GenStage / Broadway / Flow) when a producer can outpace its consumer. The cost detail a senior interviewer chases is selective receive: `receive` scans the mailbox from the front looking for the first clause that matches, so with 50,000 unmatched messages queued ahead, every receive walks all of them and behaviour turns quadratic.

The BEAM has a receive-mark optimisation for the case where you match on a reference created after the messages were queued, which is precisely why `GenServer.call/3` tags each request with `make_ref()`, and it is the strongest argument against hand-rolling your own send/receive protocol. Two further mechanics: `send/2` never blocks and never errors, not even when the pid is already dead, so 'the message went nowhere' is a real failure mode you defend against with a monitor or a call; and ordering is guaranteed only pairwise, so messages from A to B keep their order but messages from A and C can interleave any way. Diagnose with `Process.info(pid, :message_queue_len)` or `:recon.proc_count(:message_queue_len, 5)`; a number that climbs steadily means the consumer is slower than the producer, and no extra hardware fixes that without backpressure.

parent = self()

spawn(fn ->
  Process.sleep(100)
  send(parent, {:result, 42})
end)

receive do
  {:result, n} -> IO.puts("got #{n}")
after
  1_000 -> IO.puts("timeout")
end
Q24

What is the `with` special form and when is it useful?

IntermediateSyntax

Answer

`with` is Elixir's solution to the 'pyramid of doom' you get from chaining operations that each return `{:ok, value}` or `{:error, reason}`. You write a sequence of pattern matches separated by commas, then a `do` block that runs only if EVERY match succeeds. If any match fails, the un-matched value is returned as-is (or you can handle specific failures in an `else` block).

This is the canonical Phoenix controller pattern, you do auth, lookup, authorise, and update in one readable block, and the first failure short-circuits cleanly. Details that come up in code review: the `else` block sees only values produced by `<-` clauses, and once you write an `else` it must cover every failure shape or you get a `WithClauseError` at runtime, which is why teams normalise everything to `{:error, reason}` before the `with`. Leaving `else` off entirely is often the better design, since the unmatched value simply becomes the return value.

You can interleave plain expressions between clauses (`total = price + tax`), but a bare `=` match that fails raises MatchError rather than falling through to `else`, so use `<-` for anything that can legitimately fail. Guards are allowed on clauses, as in `{:ok, n} when n > 0 <- fetch_count()`. The subtle design flaw to watch for is losing the origin of an error: if two clauses can both return `{:error, :not_found}`, the caller cannot tell which lookup failed, so tag them distinctly. In Phoenix controllers the `else` block is often removed altogether in favour of `action_fallback MyAppWeb.FallbackController`, which maps each error tuple to a status code in exactly one place.

with {:ok, user} <- Accounts.find(user_id),
     {:ok, _} <- Accounts.authorize(user, :update),
     {:ok, updated} <- Accounts.update(user, params) do
  {:ok, updated}
else
  {:error, :not_found} -> {:error, :user_missing}
  {:error, reason} -> {:error, reason}
end

Key Points

  • Linear flow instead of nested case
  • First non-matching value returns unless caught in `else`
  • Standard Phoenix controller idiom
Q25

What is ETS and when would you use it over a GenServer?

IntermediateStorage

Answer

ETS (Erlang Term Storage) is a built-in, in-memory key-value store. Unlike data inside a GenServer, which is serialised through one mailbox, ETS tables can be read and written by many processes concurrently with no message-passing overhead. Lookups are O(1) for the default `:set` and `:hash` table types.

Use ETS for: caches, session stores, registries with very high read concurrency, and anything that would bottleneck on a single GenServer's mailbox. Caveats: (1) data is lost when the owning process dies (use `:named_table` + `heir` if you need persistence across crashes), (2) it does NOT survive node restarts (use `dets`, Mnesia, or an external store for that), (3) atomic updates require `update_counter` or `:ets.select_replace`, naive read-modify-write is racy. Fill in the specifics and you sound like someone who has used it: table types are `:set` (unique keys), `:ordered_set` (sorted, O(log n), supports key-range traversal), `:bag` and `:duplicate_bag`.

Access is `:public`, `:protected` (the default, owner writes and everyone reads) or `:private`. `read_concurrency: true` and `write_concurrency: true` switch the locking strategy and should match your real access pattern, because turning both on costs memory. Each insert is atomic per object, and `:ets.update_counter/3` is the atomic increment behind most rate limiters. `:ets.select/2` and `:ets.match_object/2` take match specs, and `:ets.fun2ms/1` compiles an Elixir fun into one, while `:ets.tab2list/1` on a large table copies every row into the caller's heap and is a reliable way to spike memory in production. Introspect with `:ets.info(:cache, :size)` and `:ets.info(:cache, :memory)`, remembering the memory figure is in words, so multiply by `:erlang.system_info(:wordsize)`. For TTLs, size limits and eviction, teams use Cachex or Nebulex instead of hand-rolling expiry.

:ets.new(:cache, [:set, :named_table, :public, read_concurrency: true])
:ets.insert(:cache, {:user_42, %{name: "Asha"}})
:ets.lookup(:cache, :user_42)   # => [{:user_42, %{name: "Asha"}}]
Q26

How do Elixir umbrella projects work and when are they worth it?

IntermediateTooling

Answer

An umbrella project is a Mix project that contains multiple inner apps under `apps/`, each with its own `mix.exs`. They share dependencies and tooling but compile and version independently. Worth it when: (1) you want clear module boundaries (e.g. `payments`, `notifications`, `web`) inside a single repo, (2) you might extract one app into a separate service later, (3) you want one app to depend on another without publishing to Hex.

NOT worth it when: the project is small (< 5k LOC), or when the boundaries are still in flux, umbrellas make refactoring slower. In 2026 the community trend is back toward single-app projects with internal module boundaries (`Boundary` library), because umbrellas added more friction than value for most teams. The mechanics are worth knowing precisely: the root `mix.exs` sets `apps_path: "apps"`, there is one shared `config/`, one `deps/` and one `_build/` for the whole tree, and inner apps depend on each other with `{:payments, in_umbrella: true}`. `mix test` at the root runs every suite and `mix cmd --app payments mix test` narrows it to one.

The trap is that an umbrella enforces nothing at runtime: every app is loaded into the same VM, so any module can call any sibling module and the boundary is a convention the compiler never checks, which is exactly why `mix xref graph --format stats` and the Boundary library exist. Releases get fiddlier too, since `mix release` needs each application listed and a decision about which ones start in which release. If you actually want independent deployability, separate repositories with versioned dependencies through a private Hex organisation give you the isolation an umbrella only implies. The pragmatic middle ground most 2026 codebases pick is one app with clear context modules plus an `mix xref` check in CI that fails the build on a forbidden cross-context call.

$ mix new my_platform --umbrella
$ cd my_platform/apps
$ mix new payments --sup
$ mix phx.new web --no-ecto

# apps/web/mix.exs
defp deps do
  [{:payments, in_umbrella: true}]
end

# Run one app's tests, then check the dependency graph
$ mix cmd --app payments mix test
$ mix xref graph --format stats
Q27

What is `telemetry` and how do you instrument an Elixir app?

IntermediateObservability

Answer

`telemetry` is the standard library for emitting metrics and events from Elixir code, adopted by Phoenix, Ecto, Broadway, Plug, and basically every major library. You attach a handler to a named event (e.g. `[:phoenix, :endpoint, :stop]`) and your function gets called every time the event fires. From there you forward to whatever backend you use: StatsD, Prometheus (`telemetry_metrics_prometheus`), OpenTelemetry, or a custom logger.

The huge win is uniformity, you don't sprinkle metric code across libraries, you subscribe in one place. Pair with `Telemetry.Metrics` to declaratively define which events become counters, summaries, or distributions. The contract is fixed: a handler is called with `(event_name, measurements, metadata, config)`, where measurements is a map of numbers and metadata carries context such as the SQL query, the route or the socket id.

Libraries model timed work as three events, `[:my_app, :thing, :start]`, `:stop` and `:exception`, and `:telemetry.span/3` emits that trio for you with `duration` in `:native` units, which is why you always convert with `System.convert_time_unit/3` before reporting. Two operational rules decide whether this works in production. First, handlers execute INSIDE the process that emitted the event, so a slow or blocking handler slows the very request it is measuring.

Second, if a handler raises, telemetry detaches it permanently and logs that the handler has failed and has been detached, which shows up as metrics that silently stop rather than an alert. Prefer `:telemetry.attach_many/4` pointing at a named module function over an anonymous function, since local captures block code upgrades. In a Phoenix app the plumbing is already generated in `MyAppWeb.Telemetry`, where you declare `summary("phoenix.endpoint.stop.duration")` or `summary("my_app.repo.query.total_time")` and add `:telemetry_poller` for VM gauges.

:telemetry.attach(
  "phoenix-request-duration",
  [:phoenix, :endpoint, :stop],
  fn _event, %{duration: d}, _meta, _config ->
    duration_ms = System.convert_time_unit(d, :native, :millisecond)
    Logger.info("request took #{duration_ms}ms")
  end,
  nil
)
Q28

How do you structure tests in ExUnit, and what does `async: true` actually require?

IntermediateTesting

Answer

A test module begins with `use ExUnit.Case, async: true` and `test/test_helper.exs` calls `ExUnit.start()`. The thing candidates get wrong is what async means: it runs that MODULE concurrently with other async modules, while tests inside one module always run serially. It is only safe when the module touches no shared global state, so a named process, a shared ETS table, `Application.put_env/3`, or a mocked global all force `async: false`.

For database tests, `Ecto.Adapters.SQL.Sandbox` is what makes concurrency possible: each test checks out a connection wrapped in a transaction that is rolled back afterwards, and `Sandbox.mode(Repo, {:shared, self()})` is the escape hatch when the code under test spawns its own processes, which also makes that test non-async. Setup comes from `setup` (per test) and `setup_all` (once per module), each returning `{:ok, context}` or a map merged into the test context, with `on_exit/1` for teardown. Assertions worth naming: `assert_raise/3`, `assert_receive/3` which waits (unlike `assert_received`, which checks the mailbox immediately and is the cause of most flaky message tests), `assert_in_delta/4` for floats, and `capture_log/1`.

Mocking is done with Mox against a behaviour injected through config, not by replacing modules at runtime. Practical flags: `@tag :integration` with `ExUnit.configure(exclude: [:integration])`, `mix test --failed`, `mix test --seed 0`, `mix test test/my_test.exs:42`, and `doctest MyApp.Math` to execute the examples in your `@doc` strings.

defmodule MyApp.AccountsTest do
  use MyApp.DataCase, async: true

  setup do
    user = insert(:user, email: "asha@example.com")
    {:ok, user: user}
  end

  test "rejects a duplicate email", %{user: user} do
    assert {:error, changeset} = Accounts.create_user(%{email: user.email})
    assert %{email: ["has already been taken"]} = errors_on(changeset)
  end

  @tag :integration
  test "notifies the caller asynchronously" do
    Accounts.notify_async(self())
    # assert_receive waits; assert_received would flake here
    assert_receive {:notified, _id}, 500
  end
end

Key Points

  • `async: true` parallelises modules, never tests within a module
  • SQL.Sandbox gives per-test transactional isolation
  • `assert_receive` waits, `assert_received` does not
  • Mox mocks a behaviour, it does not patch modules
Q29

What is the difference between a behaviour and a protocol, and when do you use each?

IntermediateLanguage Design

Answer

A behaviour is a compile-time contract on a MODULE. You declare `@callback charge(map()) :: {:ok, map()} | {:error, term()}` and an implementing module writes `@behaviour MyApp.Gateway`, after which the compiler warns about missing or misspelled callbacks and `@impl true` documents the intent. GenServer, Supervisor, Plug and Ecto.Type are all behaviours, and polymorphism happens through the module you pass around, which is why swapping an adapter through `Application.get_env(:my_app, :payment_gateway)` and mocking with Mox both work on behaviours.

A protocol dispatches on the DATA TYPE of the first argument instead: `defprotocol Summary` plus `defimpl Summary, for: MyApp.Order`. Most of the ones you already use are protocols: `Enumerable` (what makes `Enum` and `for` work on your type), `Collectable` (`Enum.into/2`), `String.Chars` (string interpolation), `Inspect` and `Jason.Encoder`. The rule of thumb: behaviour when the variation is a strategy or adapter, protocol when the variation follows the shape of the data.

Two production details get asked about. Protocol dispatch is a runtime lookup, so releases enable `consolidate_protocols: true` (the default for `mix release` and prod builds), which compiles dispatch into one fast module; the side effect is that an implementation defined after consolidation is ignored, which is the classic 'my defimpl works in dev but does nothing in production' bug. And `@derive {Jason.Encoder, only: [:id, :total]}` on a struct opts into an implementation without leaking every field, which matters when a struct holds tokens or internal notes.

# Behaviour: a contract on a module (adapter or strategy)
defmodule MyApp.Gateway do
  @callback charge(map()) :: {:ok, map()} | {:error, term()}
end

defmodule MyApp.Gateway.Razorpay do
  @behaviour MyApp.Gateway

  @impl true
  def charge(params), do: {:ok, %{id: "pay_1", amount: params.amount}}
end

# Protocol: dispatch on the type of the data
defprotocol Summary do
  def to_line(term)
end

defmodule MyApp.Order do
  @derive {Jason.Encoder, only: [:id, :total]}
  defstruct [:id, :total, :internal_notes]
end

defimpl Summary, for: MyApp.Order do
  def to_line(order), do: "Order #{order.id}: #{order.total}"
end
Q30

How do you start and address one process per entity using Registry and DynamicSupervisor?

IntermediateOTP

Answer

This is the one-process-per-entity pattern: a process per order, per game room, per connected device, started on demand and addressed by a business id rather than by pid. `Registry` handles naming. You start `{Registry, keys: :unique, name: MyApp.Registry}` in the supervision tree, then name each worker with a via tuple, `{:via, Registry, {MyApp.Registry, {:order, order_id}}}`. Anything that takes a name accepts a via tuple, including `GenServer.start_link/3` and `GenServer.call/3`, and the registry monitors every registered process so entries disappear automatically on crash, with no cleanup code of your own. `DynamicSupervisor` supervises children that did not exist at boot: `DynamicSupervisor.start_child(MyApp.OrderSup, {OrderServer, id})`, with `:one_for_one` as its only strategy and an optional `max_children` cap.

Always write a `start_or_find/1` helper that matches `{:error, {:already_started, pid}}` back to `{:ok, pid}`, because two concurrent requests for the same entity will race and one of them loses. Interviewers usually probe three things: `Registry.lookup/2` is a concurrent ETS read rather than a GenServer call, so it does not become a bottleneck; `keys: :duplicate` turns the same registry into local pub/sub; and idle processes must terminate themselves, either with a GenServer timeout or `Process.send_after/3` returning `{:stop, :normal, state}`, otherwise a per-entity model leaks a process per entity forever. Registry is node-local, so the clustered version is `Horde.Registry` or hashing the id to a node.

# application.ex
children = [
  {Registry, keys: :unique, name: MyApp.Registry},
  {DynamicSupervisor, name: MyApp.OrderSup, strategy: :one_for_one}
]

defmodule MyApp.OrderServer do
  use GenServer, restart: :transient

  def via(id), do: {:via, Registry, {MyApp.Registry, {:order, id}}}

  def start_or_find(id) do
    case DynamicSupervisor.start_child(MyApp.OrderSup, {__MODULE__, id}) do
      {:ok, pid} -> {:ok, pid}
      {:error, {:already_started, pid}} -> {:ok, pid}
      other -> other
    end
  end

  def start_link(id), do: GenServer.start_link(__MODULE__, id, name: via(id))

  @impl true
  def init(id), do: {:ok, %{id: id}, 60_000}

  @impl true
  def handle_info(:timeout, state), do: {:stop, :normal, state}
end
Q31

How do you run background jobs with retries in Elixir, and why not just use a Task?

IntermediateBackground Jobs

Answer

A `Task` lives in memory and dies with the node, so any work that must survive a deploy, a crash or an OOM needs a durable queue. Oban is the default choice in 2026 because jobs are rows in a Postgres table (`oban_jobs`) that you insert in the SAME transaction as your business data through `Ecto.Multi` and `Oban.insert/3`. That is a transactional outbox: a job can never exist for an order that rolled back, and an order can never commit without its job.

A worker is `use Oban.Worker, queue: :payments, max_attempts: 5` with a `perform/1` callback receiving an `%Oban.Job{args: args}`. Args are stored as JSON, so atom keys come back as strings and structs or pids must never go in there, which is a very common first bug. Return values drive the state machine: `:ok` completes, `{:error, reason}` retries with exponential backoff, `{:snooze, seconds}` defers without burning an attempt, and `{:cancel, reason}` stops retrying permanently for things like a declined card.

Concurrency is configured per queue (`queues: [payments: 10, mailers: 50]`) and is enforced across the whole cluster rather than per node. Details that show seniority: `unique: [period: 300, fields: [:worker, :args]]` for deduplication, the Cron plugin for scheduled work, `Oban.Testing` with `testing: :manual` so tests assert with `assert_enqueued/1` instead of executing jobs, and the reminder that delivery is at-least-once, so every worker must be idempotent.

defmodule MyApp.Workers.ChargeCard do
  use Oban.Worker,
    queue: :payments,
    max_attempts: 5,
    unique: [period: 300, fields: [:worker, :args]]

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"order_id" => id}}) do
    case Payments.charge(id) do
      {:ok, _receipt} -> :ok
      {:error, :rate_limited} -> {:snooze, 30}
      {:error, :card_declined} -> {:cancel, :card_declined}
      {:error, reason} -> {:error, reason}
    end
  end
end

# Enqueue inside the same transaction that creates the order
Ecto.Multi.new()
|> Ecto.Multi.insert(:order, Order.changeset(%Order{}, params))
|> Oban.insert(:charge, fn %{order: order} ->
  MyApp.Workers.ChargeCard.new(%{order_id: order.id})
end)
|> Repo.transaction()
💡 Pro Tip: If a job's args contain a struct, JSON encoding will either fail or silently flatten it. Pass ids and reload inside perform/1.
Q32

What changed in Elixir 1.15 through 1.18, and what does the built-in type system do today?

IntermediateVersions

Answer

1.15 was the compiler and tooling release: compilation got substantially faster because dependencies are no longer all added to the code path, `Logger.warn/1` was deprecated in favour of `Logger.warning/1`, and charlists began inspecting as `~c"abc"` instead of `'abc'`, which is why old tutorials and your shell disagree. 1.16 focused on diagnostics, printing the offending snippet alongside the error, and added an anti-patterns catalogue to the official docs. 1.17 shipped the first slice of set-theoretic types: the compiler infers types through patterns and guards and reports impossible clauses and comparisons at compile time with nothing for you to annotate. It also added `Duration`, `Date.shift/2` and `Kernel.to_timeout/1`, and supported Erlang/OTP 27. 1.18 extended inference across function definitions, so a mismatched call inside a module is flagged without a `@spec`, and shipped a built-in `JSON` module, `mix format --migrate` for automated deprecation rewrites, and the compiler-side machinery behind an official language server. Two points to make in an interview: this is gradual inference with zero runtime cost, and it complements rather than replaces Dialyzer, which still handles cross-module success typing from `@spec`. The upgrade friction teams actually hit is mechanical: `Logger.warn` call sites, tests that assert on `'charlist'` literals, `use Mix.Config` replaced by `import Config`, and dependencies that were compiled against an older Elixir and need a `mix deps.clean --all`.

# 1.15: Logger.warn/1 is deprecated
Logger.warning("cache miss for #{key}")

# 1.15: charlists inspect with the ~c sigil
iex> ~c"abc"
~c"abc"

# 1.17: calendar arithmetic without an extra dependency
Date.shift(~D[2026-01-31], month: 1)   # => ~D[2026-02-28] (clamped)
to_timeout(minute: 5)                  # => 300_000

# 1.17 and 1.18: caught at compile time, no @spec needed
def area(%{w: w, h: h}), do: w * h
area(:circle)
# warning: incompatible types given to area/1, :circle is not a map

# 1.18: JSON in core
JSON.encode!(%{status: "ok"})
Q33

How does Elixir handle hot code reloading in production and what are the limits?

AdvancedOperations

Answer

The BEAM allows two versions of a module to coexist, 'old' and 'new'. When you compile a new version, running processes continue with the old code; the next time they make a FULLY QUALIFIED call (`MyMod.fun()`), they jump to the new version. A local call (`fun()`) stays in the old.

This is how telecoms upgrade switches without dropping calls. In Elixir/OTP, you use `:code.load_file/1` for ad-hoc swaps or `:appup`/`:relup` files for release upgrades via `:systools.make_relup/4`. The realistic 2026 picture: most teams DON'T use hot upgrades.

They do blue-green or rolling deploys via Kubernetes because hot upgrades require carefully written state-migration callbacks (`code_change/3`) on every GenServer that holds state, and one mistake mid-upgrade can corrupt running processes. Hot reload is still loved in IEx during dev (`r MyMod`) and indispensable when you genuinely cannot drop connections, VoIP, payments, IoT brokers. The mechanism has a hard edge worth naming: the code server keeps at most TWO versions of a module, so loading a third purges the oldest and kills any process still executing it with reason `:killed`. `:code.soft_purge/1` refuses to purge while old code is still running and returns false, `:code.purge/1` kills those processes, and `Process.info(pid, :current_function)` tells you who is holding the old version.

For stateful upgrades you implement `code_change/3` on every GenServer so the old state term is migrated to the new shape, and the release tooling generates the `.appup` and `.relup` files that sequence the whole thing. In practice teams limit hot reload to narrow shapes: swapping a pure function, patching a node holding long-lived WebSocket or SIP sessions, or flipping a feature flag. Anything touching a database schema, a dependency tree or the ERTS version goes through a rolling deploy, which is why the skill that actually matters in 2026 is graceful node draining: stop accepting new connections, let in-flight requests finish, then terminate.

# Dev: recompile a single module inside the running IEx session
iex> r MyApp.Pricing

# Ad-hoc load on a live node, then check nobody is on old code
:code.load_file(MyApp.Pricing)
:code.soft_purge(MyApp.Pricing)   # false => processes still running old code

# State migration hook used during a release upgrade
@impl true
def code_change("1.2.0", %{rate: rate} = state, _extra) do
  {:ok, state |> Map.put(:currency, "INR") |> Map.delete(:rate)}
end

Key Points

  • BEAM keeps old + new module in memory
  • Fully qualified calls jump to new version
  • Production upgrades need code_change/3 on stateful processes
  • Most teams do rolling deploys; hot upgrades for high-uptime niches
Q34

How do you design a distributed Elixir cluster across multiple nodes?

AdvancedDistribution

Answer

BEAM nodes form a cluster by knowing each other's names, `Node.connect(:"app@host")`. Once connected, sending a message to a remote pid is identical to a local send (location transparency), and you can spawn processes on remote nodes. In production you almost never wire `Node.connect` by hand: use `libcluster` with a strategy matching your platform (`Kubernetes.DNS` for k8s, `Gossip` for VPS pools, `EC2` for AWS, `Consul` for service-mesh setups).

For state replication, three patterns: (1) **Stateless nodes + shared DB**, simplest, scales until DB is the bottleneck. (2) **Phoenix.PubSub**, each node broadcasts to others, used for chat, LiveView presence. (3) **Distributed data structures**, Mnesia (built-in), DeltaCRDT, or Horde for global registries. Watch for: net-splits, where two halves of the cluster lose connectivity and both keep working (split-brain, your CRDT must handle this), and the cluster-wide global registry which doesn't scale beyond ~50 nodes (use `Horde.Registry` instead). Operational facts that get asked next: nodes authenticate with a shared cookie (`Node.set_cookie/1`, or `RELEASE_COOKIE` for a release), which is authentication and not encryption, so distribution must run on a private network or over TLS distribution configured through the ssl_dist options.

The transport needs epmd on port 4369 plus a per-node port, which is why container deployments pin `inet_dist_listen_min` and `inet_dist_listen_max` to a known range. Names have to resolve in both directions, so `Kubernetes.DNS` requires a headless service and `RELEASE_DISTRIBUTION=name` with a fully qualified `RELEASE_NODE`. Failure detection is heartbeat-based through `net_ticktime`, 60 seconds by default, meaning a dead node can appear alive for up to a minute unless you lower it; react to changes with `:net_kernel.monitor_nodes(true)`.

Finally, the mesh is fully connected by default, so N nodes means N*(N-1)/2 TCP connections and cluster-wide chatter, which is the real reason `:global` degrades. Large clusters either run `-connect_all false` with an explicit topology or push coordination out to Postgres, Redis or Kafka.

# config/runtime.exs
config :libcluster,
  topologies: [
    k8s: [
      strategy: Elixir.Cluster.Strategy.Kubernetes.DNS,
      config: [service: "my-app-headless", application_name: "my_app"]
    ]
  ]
Q35

How would you architect a real-time chat system in Elixir for 1M concurrent users?

AdvancedArchitecture

Answer

Discord is the public reference here, they routinely handle millions of concurrent WebSockets on Elixir. The shape: (1) Phoenix Channels for the WebSocket layer, one BEAM process per connected user, distributed across N nodes via libcluster. (2) Phoenix.PubSub for fan-out across nodes, when a message lands on node A and needs to go to a user on node B, PubSub relays it. (3) A `Horde.Registry` or `Phoenix.Tracker` for distributed presence and locating which node holds a given user/room process. (4) A dedicated 'room' GenServer per chatroom, scheduled on whichever node first creates it; for VERY hot rooms (>100k subscribers), shard by message hash. (5) Cassandra or Postgres + Kafka for durable message storage, the BEAM holds live state, the DB holds history. Critical lessons from Discord's blog: avoid `:global` for registries (doesn't scale), watch out for GenServer mailbox overflows on hot rooms (apply backpressure by dropping non-critical messages), and pre-empt large fan-out broadcasts.

India context: PepperContent and Razorpay-scale messaging platforms use very similar architectures. Be ready to reason about the sizing rather than quote a benchmark: budget tens of kilobytes per connection once the transport process, the channel process and socket buffers are counted, so memory per node (not CPU) sets your connection ceiling, and you raise the `+P` process limit and the file-descriptor ulimit before touching anything else. Presence, not messaging, is usually the thing that breaks first: `Phoenix.Presence` replicates a CRDT diff to every node, so a single huge channel makes presence traffic grow faster than chat traffic, and the mitigations are sharding rooms and not tracking passive members at all.

State the delivery semantics too, because interviewers listen for it: WebSocket plus PubSub is at-most-once, so anything that must not be lost needs a monotonic per-room message id, a client ack, and a resume-from-id endpoint used on reconnect. Last, plan the reconnect storm: a rolling deploy pushes every client to reconnect within seconds, which you absorb with jittered client backoff, a slow drain per node, and rate limiting on the join path.

Q36

What are the most common production gotchas in Elixir/OTP and how do you avoid them?

AdvancedOperations

Answer

Five that bite real teams in production: (1) **Mailbox overflow**, a fast producer hammering a slow GenServer fills its mailbox, memory grows unbounded, and the BEAM OOMs. Fix: add backpressure with GenStage/Broadway/Flow, monitor `Process.info(pid, :message_queue_len)`, set a max queue and shed load. (2) **Atom exhaustion**, calling `String.to_atom` on user input creates atoms that never get GC'd. Fix: use `String.to_existing_atom/1` or a whitelist; monitor `:erlang.system_info(:atom_count)`. (3) **GenServer call timeouts**, `GenServer.call/3` defaults to 5s; if a downstream is slow, the caller crashes.

Fix: bound call timeouts explicitly and design slow operations as `cast` + reply-by-message. (4) **Ecto changeset misuse**, calling `cast/3` with a too-broad field list lets users overwrite `:admin` flags etc. Fix: keep the allowlist tight; never reuse the same changeset for user-facing and admin contexts. (5) **Supervisor restart loops**, a child that crashes faster than `max_restarts/max_seconds` crashes its supervisor, which can take down an app. Fix: investigate the root cause, don't just bump the limits; use `:transient` restart for jobs that shouldn't auto-restart. Three more that show up in real postmortems: (6) **unbounded cache growth**, an ETS table used as a cache with no TTL or size cap that slowly eats the node, fixed with Cachex limits or an explicit sweeper process. (7) **Ecto pool exhaustion**, where `pool_size` (10 by default) is far below your concurrency, so requests queue and then fail with `DBConnection.ConnectionError: connection not available and request was dropped from queue after 2000ms`; size the pool against the database's own max_connections and get long transactions out of the request path. (8) **logging entire structs**, since `Logger.info(inspect(conn))` copies a large term per request and leaks secrets into logs; pass `limit:` and `printable_limit:` to `inspect/2` and use Logger metadata instead. The connective tissue interviewers are listening for: every item on this list is an unbounded queue or an unbounded table somewhere, and the engineering discipline is to give it an explicit bound plus a telemetry gauge on that bound, so the failure shows up on a dashboard before it shows up as an OOM.

Key Points

  • Mailbox overflow, backpressure with Broadway/GenStage
  • Atom exhaustion, never `String.to_atom/1` on user input
  • GenServer.call timeouts, bound explicitly, don't default to 5s
  • Ecto changeset cast/3, tight allowlist always
  • Supervisor restart loops, fix the cause, don't bump limits
Q37

How do you profile and find performance bottlenecks in a BEAM application?

AdvancedPerformance

Answer

First, decide which kind of bottleneck you have, CPU, scheduler contention, mailbox queueing, or GC pressure, because tools differ. Live inspection: `:observer.start()` gives a GUI of processes, ETS, memory, and scheduler utilisation. For headless production, `:recon` (Fred Hebert's library) is essential: `recon:proc_count(memory, 10)` shows top memory hogs, `recon:proc_count(message_queue_len, 10)` finds the GenServer that's drowning.

For CPU/function-level profiling, `:eprof` (small workloads) or `:fprof` (more detail, more overhead); for production sampling, `recon_trace` lets you trace specific function calls live. For end-to-end latency, instrument via `telemetry` and ship to Prometheus + Grafana or OpenTelemetry. Common findings: a single GenServer serialising too much work (split into a pool or DynamicSupervisor), an N+1 query in Ecto (add `Repo.preload/2`), a hot loop allocating large binaries (reuse via iolists), or one scheduler pinned at 100% because of NIFs (move to dirty schedulers).

In India fintech production: Razorpay-style payment systems primarily hit DB / external HTTP bottlenecks, rarely BEAM itself. Order of operations matters as much as tool names. Start with `:erlang.statistics(:run_queue_lengths)` and `:scheduler.utilization/1` to establish whether the node is genuinely busy or just blocked on IO, because those two lead to completely different fixes.

Then go after memory with `:recon.proc_count(:memory, 10)` and `:recon.bin_leak(10)`, since refc binary leaks present as a memory problem but are really a reference-holding problem. `:recon_trace.calls({Mod, :fun, :return_trace}, 50)` is safe to run against production because it rate-limits itself, whereas raw `:dbg` tracing on a hot function can flood the tracer process and take the node down, which is a genuinely career-limiting mistake. For garbage collection, `Process.info(pid, [:garbage_collection, :total_heap_size])` shows whether a process needs `fullsweep_after` tuning or a `hibernate` on idle. Benchmark any candidate fix with Benchee under a load generator rather than a stopwatch, because BEAM behaviour under concurrency rarely matches single-shot timings.

# Is the node busy or blocked?
:erlang.statistics(:run_queue_lengths)
:scheduler.utilization(1)

# Top memory hogs and drowning mailboxes
:recon.proc_count(:memory, 5)
:recon.proc_count(:message_queue_len, 5)
:recon.bin_leak(5)          # refc binary holders

# Rate-limited production tracing: stops after 50 calls
:recon_trace.calls({MyApp.Pricing, :quote, :return_trace}, 50)

# Local function-level profile
:eprof.profile(fn -> MyApp.Report.build(args) end)
:eprof.analyze()
Q38

How does BEAM scheduling actually work, and what do NIFs and dirty schedulers change?

AdvancedRuntime

Answer

The BEAM starts one scheduler thread per logical core, each with its own run queue, and preempts processes by counting reductions: roughly one reduction per function call, with a process yielding after about 2000 of them. That accounting is the whole reason the BEAM gives soft real-time behaviour, because no process can hold a core, and it is also why raw single-threaded throughput trails languages that never check. Load balancing migrates processes between run queues, and file IO and timers run on a separate async thread pool (`+A`) so a slow disk does not block a scheduler.

Native code is what breaks the model. A NIF is a C function called directly on the scheduler thread with no reduction accounting, so a NIF that runs longer than about a millisecond stalls that scheduler, delays every process queued behind it, and shows up as one core pinned while the others idle. It can also crash the entire VM, since there is no isolation.

The fixes in order of preference: chunk the work and report progress with `enif_consume_timeslice`, move it to a dirty scheduler (`ERL_NIF_DIRTY_JOB_CPU_BOUND` or `IO_BOUND`, pool sizes set with `+SDcpu` and `+SDio`), or run it out of process behind a port or `System.cmd/3` so a segfault cannot take the node with it. Rustler exposes this directly as `schedule = "DirtyCpu"`. Diagnose with `:msacc`, `:scheduler.utilization/1`, and `:erlang.system_monitor/2` with `long_schedule` and `long_gc` thresholds.

# Is one scheduler pinned while the rest idle? Suspect native code.
:scheduler.utilization(1)
:msacc.start(1_000)
:msacc.print()

# Get told when something hogs a scheduler or a GC runs long
:erlang.system_monitor(self(), [{:long_schedule, 500}, {:long_gc, 200}])

receive do
  {:monitor, pid, :long_schedule, info} -> IO.inspect({pid, info})
after
  0 -> :ok
end

# Rustler: heavy native work belongs on a dirty CPU scheduler
# #[rustler::nif(schedule = "DirtyCpu")]
# fn resize(image: Binary) -> Binary { ... }

Key Points

  • One scheduler per core, preemption every ~2000 reductions
  • NIFs run unaccounted on the scheduler thread and can crash the VM
  • Long native work belongs on dirty schedulers or in a port
  • `:msacc` and `long_schedule` monitors are how you catch it
Q39

How do you build and deploy a `mix release`, and how do migrations run in production?

AdvancedDeployment

Answer

`mix release` packages your compiled application, its dependencies and the Erlang runtime itself into a self-contained directory under `_build/prod/rel/my_app`, so the target machine needs neither Elixir nor Erlang installed, only a matching architecture and libc, which is exactly why you build inside a Docker image based on the same distro you deploy to. The release gives you `bin/my_app start`, `daemon`, `remote`, `rpc` and `eval`. The config split is where interviews concentrate: `config/config.exs` and `config/prod.exs` are evaluated at COMPILE time and frozen into the artifact, while `config/runtime.exs` is evaluated on every boot inside the release, so every secret and every environment-specific value belongs there behind `System.fetch_env!("DATABASE_URL")`.

Reading config at module level with `Application.compile_env/3` bakes the build-time value in, though it does at least make the release refuse to start when the runtime value has drifted. Migrations are the classic gotcha: `mix ecto.migrate` does not exist in a release because Mix is not shipped, so you write a `MyApp.Release` module that loads the app, starts the repo and calls `Ecto.Migrator.with_repo/2`, invoked as `bin/my_app eval "MyApp.Release.migrate()"` from an init container or a deploy step before the new version starts serving. Round it out with `RELEASE_COOKIE`, `RELEASE_NODE` and `RELEASE_DISTRIBUTION` for clustering, `:runtime_tools` in `extra_applications` so remote observation works, and a `Plug`-level drain so in-flight requests finish before the old container exits.

# lib/my_app/release.ex
defmodule MyApp.Release do
  @app :my_app

  def migrate do
    Application.load(@app)

    for repo <- Application.fetch_env!(@app, :ecto_repos) do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end

  def rollback(repo, version) do
    Application.load(@app)
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
  end
end

# config/runtime.exs, read on every boot instead of at build time
if config_env() == :prod do
  config :my_app, MyApp.Repo,
    url: System.fetch_env!("DATABASE_URL"),
    pool_size: String.to_integer(System.get_env("POOL_SIZE", "10"))
end

$ MIX_ENV=prod mix release
$ bin/my_app eval "MyApp.Release.migrate()"
$ bin/my_app start
Q40

How do you apply backpressure to a data pipeline with GenStage and Broadway?

AdvancedData Pipelines

Answer

GenStage inverts the usual push model: consumers ask for a bounded number of events and a producer may never send more than was asked for, so a slow consumer simply stops asking instead of filling somebody's mailbox. A stage declares itself a `:producer`, `:consumer` or `:producer_consumer`, the consumer sets `min_demand` and `max_demand` when it subscribes, and the producer's `handle_demand/2` returns at most the outstanding demand. Getting those two numbers wrong is the classic mistake: `max_demand: 1000, min_demand: 500` means the consumer works in chunks of 500 and one slow item stalls the whole chunk, so slow per-item work wants small values like `max_demand: 10, min_demand: 5`. `ConsumerSupervisor` starts one supervised process per event when each event needs isolation.

Broadway sits on top and is what you should actually reach for in production: it wires producers for SQS, RabbitMQ, Kafka or Pub/Sub, splits work into `processors` and `batchers` with independent concurrency, acknowledges a message only after successful processing, gives you `handle_failed/2` for dead-lettering, and drains in-flight messages during a deploy instead of dropping them. The judgement interviewers listen for is when NOT to use it: `Task.async_stream/3` or Flow is enough for a bounded in-memory collection, GenStage is for a custom stage topology you genuinely need, and Broadway is for anything with an external broker, because hand-rolling acknowledgement, rate limiting and graceful shutdown is how messages quietly get lost.

defmodule MyApp.Pipeline do
  use Broadway
  alias Broadway.Message

  def start_link(_opts) do
    Broadway.start_link(__MODULE__,
      name: __MODULE__,
      producer: [
        module: {BroadwaySQS.Producer, queue_url: System.fetch_env!("SQS_URL")},
        concurrency: 2,
        rate_limiting: [allowed_messages: 100, interval: 1_000]
      ],
      processors: [default: [concurrency: 20, max_demand: 5]],
      batchers: [db: [concurrency: 2, batch_size: 100, batch_timeout: 2_000]]
    )
  end

  @impl true
  def handle_message(_processor, message, _ctx) do
    message
    |> Message.update_data(&Jason.decode!/1)
    |> Message.put_batcher(:db)
  end

  @impl true
  def handle_batch(:db, messages, _info, _ctx) do
    Repo.insert_all(Event, Enum.map(messages, & &1.data))
    messages
  end

  @impl true
  def handle_failed(messages, _ctx), do: messages
end

Companies Hiring Elixir

Discord
Pinterest
Spotify
WhatsApp
PepperContent
Lemonade
Brex

Salary Insights

Average in India
₹8-25 LPA

Frequently Asked Questions

Is Elixir worth learning in 2026?

Yes, if you're targeting roles in real-time systems, fintech, payments, chat platforms, or anywhere BEAM's concurrency and uptime story matters. The Elixir job market in India is smaller than Java/Python but pays well (₹8-25 LPA) and is dominated by senior, interesting work, companies hiring include PepperContent, Lemonade, and remote teams at Discord/Brex/Pinterest. The Phoenix + LiveView story is also attractive for full-stack developers who don't want to maintain a separate frontend.

How much do Elixir developers earn in India?

₹8-25 LPA in 2026 for mid-to-senior Elixir engineers. Remote roles at US companies (Brex, Discord, Lemonade, Pinterest) can push the upper end to ₹40+ LPA. Local Bengaluru/Pune meetups (Elixir Bangalore, ElixirConf India) are a good source of leads, the community is small and tight. The fresher versus experienced split matters more here than in Java or Python, because Elixir roles exist precisely where concurrency and uptime are hard, so very few teams hire a fresher straight onto an Elixir codebase. Freshers typically enter through a general backend opening (₹4-8 LPA) and pick up Elixir on the job. With two to four years of Phoenix and OTP, ₹12-18 LPA is the common band, and the ₹20-25 LPA end generally expects production experience with supervision trees, releases and on-call. Since supply is thin, a merged Hex package or a visible open-source contribution moves you through shortlisting far more than it would in a crowded ecosystem.

Should I learn Erlang before Elixir?

No. Elixir is a much friendlier surface and shares the same runtime, libraries, and concurrency primitives. You'll absorb the Erlang concepts you need (atoms, OTP, BEAM) through Elixir. Reach for raw Erlang docs only when you're using an Erlang-only library or debugging at the BEAM level, both worth doing eventually, but not on day one.

How does Elixir compare to Go for backend services?

Go wins on raw single-process throughput, static typing, and smaller binaries. Elixir wins on fault-tolerance (supervisors, let-it-crash), soft real-time guarantees (preemptive scheduling vs Go's cooperative goroutines), distributed-by-default, and developer ergonomics for stateful workloads. For 'stateless HTTP CRUD service', the two are comparable. For chat, IoT, telemetry pipelines, or anything needing millions of long-lived connections, Elixir is the natural fit.

What is the most common reason Elixir interviews are failed?

Treating GenServer as a magic state container and reaching for it before understanding when NOT to use one, every interviewer's red flag. The other big one: not understanding immutability deeply enough to debug a question like 'why didn't this list change after I called Enum.map on it?' Spend time on pattern matching, process semantics, and supervision trees, those three topics dominate Indian Elixir interview loops. On preparation time: if you already ship Ruby, Python or Node backends, budget three to four weeks of evenings. Week one on pattern matching, immutability and the standard library, week two on OTP by actually building something with a supervision tree (GenServer, Supervisor, Task, Registry, DynamicSupervisor), week three on Phoenix, Ecto changesets and LiveView, and week four on the production layer: telemetry, releases, ETS, and debugging with recon and observer. Double the OTP week if you have never worked with concurrency before. Candidates who only read tend to fail the round where they are asked to draw a supervision tree for a real system, so build one small stateful app and deliberately crash processes in it until the restart behaviour stops surprising you.

Is Phoenix LiveView mature enough for production in 2026?

Yes. As of Phoenix 1.7+ (with LiveView 1.0 released in late 2024), it's the default choice for new internal tools, admin panels, and dashboards in the Elixir ecosystem. Production users include PepperContent's editor, several Indian fintech dashboards, and many Lemonade internal apps. The remaining caveats are mobile-network latency (200ms+ round trips hurt UX) and developer ramp-up for teams used to React/Vue patterns.

Introduction

Elixir is a dynamic, functional language that runs on the Erlang VM (BEAM), giving you the same fault-tolerance and massive concurrency that powers WhatsApp, Discord, and most of the world's telecom infrastructure. Released by Jose Valim in 2012 and now at version 1.18 on Erlang/OTP 27, Elixir has carved out a reputation as the language of choice for soft real-time systems, scalable web backends, and anything where uptime matters more than peak single-threaded speed.

If you're interviewing for an Elixir role in India in 2026, expect deep questions on the actor model, OTP (Open Telecom Platform) behaviours like GenServer and Supervisor, pattern matching, immutability, and the Phoenix framework, including LiveView, which has redefined how Elixir teams build interactive UIs without writing JavaScript. Companies like PepperContent, Lemonade, and Discord's distributed teams routinely probe BEAM internals, distributed Elixir, and telemetry.

This guide covers 40 of the most-asked Elixir interview questions in 2026, grouped by difficulty: 14 basic, 18 intermediate and 8 advanced. Alongside the language fundamentals it works through the areas that decide senior loops, ExUnit and the SQL sandbox, behaviours versus protocols, Registry with DynamicSupervisor, Oban, `mix release` and migrations without Mix, BEAM scheduling and dirty schedulers, GenStage and Broadway backpressure, and what actually changed across Elixir 1.15 to 1.18. Each answer explains the concept, surfaces the gotchas you only learn in production, and includes a code example where it adds clarity. Whether you're targeting a Bengaluru fintech, a Razorpay-style payments system, or a remote chat platform, these are the questions hiring teams ask.

Ready to practice Elixir interviews?

Don't just read, practice these Elixir questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview