Skip to main content

Elixir engineer coding interview questions

11 questions on coding for elixir engineer candidates. Expect prompts such as “Fetch multiple URLs in parallel using Task.async_stream” and “Implement a per-key rate limiter as a GenServer”, each with a worked answer outline and the follow-ups interviewers push on.

Questions

Showing 1 to 11 of 11 coding questions.

As asked

Given a list of URLs, fetch them all in parallel with a concurrency limit of 10, collect results (including errors per URL), and return a map of url to {:ok, body} or {:error, reason}. Use Task.async_stream.

Sample answer outline

Task.async_stream/3 with max_concurrency: 10 and on_timeout: :kill_task handles parallelism and timeout. The result stream emits {:ok, result} or {:exit, reason} per task. A strong answer wraps the fetch in a try/rescue or relies on on_timeout: :kill_task and collects results with Enum.reduce or Enum.into, handling task failures gracefully rather than letting one error crash the caller. Note: on_exit is not a valid option; the correct option is on_timeout.

Reference implementation (elixir)

elixir
defmodule Fetcher do
  def fetch_all(urls) do
    urls
    |> Task.async_stream(
         fn url -> {url, HTTPoison.get(url)} end,
         max_concurrency: 10,
         timeout: 5_000,
         on_timeout: :kill_task
       )
    |> Enum.reduce(%{}, fn
         {:ok, {url, {:ok, %{body: body}}}}, acc -> Map.put(acc, url, {:ok, body})
         {:ok, {url, {:error, reason}}}, acc -> Map.put(acc, url, {:error, reason})
         {:exit, _reason}, acc -> acc  # TODO: track which URL timed out
       end)
  end
end

Expect these follow-ups

  • What is the difference between Task.async_stream and Task.async + Task.await in a loop?
  • How do you set a per-task timeout and a global deadline for the whole batch?
taskconcurrencyhttpasyncelixir

As asked

Write a GenServer that limits how many times a given key (e.g., user ID) can call an action within a sliding window. The API should be: RateLimiter.check(key, limit, window_ms) returning :ok or {:error, :rate_limited}.

Sample answer outline

A strong implementation keeps a map of key to list of timestamps in state, prunes entries older than window_ms on each check, and returns :ok if the count is below the limit or {:error, :rate_limited} otherwise. Bonus points for using :erlang.monotonic_time for accuracy, and for discussing how to shard across multiple GenServers or use ETS for higher throughput.

Reference implementation (elixir)

elixir
defmodule RateLimiter do
  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)

  def check(key, limit, window_ms) do
    GenServer.call(__MODULE__, {:check, key, limit, window_ms})
  end

  def init(state), do: {:ok, state}

  def handle_call({:check, key, limit, window_ms}, _from, state) do
    now = :erlang.monotonic_time(:millisecond)
    timestamps = Map.get(state, key, [])
    # TODO: prune old timestamps and enforce limit
    {:reply, :ok, state}
  end
end

Expect these follow-ups

  • How would you make this rate limiter work across multiple nodes in a cluster?
  • What happens to in-memory rate limit state if the node crashes?
genserverstateconcurrencyrate-limitingotp

As asked

Implement a module that starts a DynamicSupervisor and provides start_worker(id) and stop_worker(id) functions. Workers should be registered by their id so they can be looked up and stopped by id.

Sample answer outline

Use a DynamicSupervisor for spawning and a Registry for name-based lookup. start_worker/1 calls DynamicSupervisor.start_child/2 with via-tuple name {Registry, id}. stop_worker/1 looks up the pid from the Registry and calls DynamicSupervisor.terminate_child/2. A strong answer also handles the case where a worker with that id already exists (returning {:error, :already_started}).

Reference implementation (elixir)

elixir
defmodule WorkerPool do
  def start_link(_), do: DynamicSupervisor.start_link(name: __MODULE__, strategy: :one_for_one)

  def start_worker(id) do
    spec = {MyWorker, id: id, name: via(id)}
    DynamicSupervisor.start_child(__MODULE__, spec)
  end

  def stop_worker(id) do
    case Registry.lookup(MyRegistry, id) do
      [{pid, _}] -> DynamicSupervisor.terminate_child(__MODULE__, pid)
      [] -> {:error, :not_found}
    end
  end

  defp via(id), do: {:via, Registry, {MyRegistry, id}}
end

Expect these follow-ups

  • What happens to the Registry entry when a worker crashes and is restarted?
  • How would you add a limit to the maximum number of concurrent workers?
supervisorregistryotpdynamic-workersprocesses

As asked

You receive a 5 GB CSV file. Write a function that streams it line by line, parses each row into a struct, filters invalid rows, and inserts valid rows in batches of 500 into PostgreSQL via Ecto without loading the file into memory.

Sample answer outline

Use File.stream!/1 for lazy line-by-line reading, pipe through Stream.map for parsing, Stream.filter for validation, Stream.chunk_every(500) for batching, and Repo.insert_all/3 per batch inside each chunk. A strong answer also handles partial batch failures (wrapping each batch in a transaction or using Ecto.Multi), discusses encoding issues with CSV files, and mentions NimbleCSV for robust parsing.

Reference implementation (elixir)

elixir
defmodule CSVImporter do
  alias NimbleCSV.RFC4180, as: CSV

  def import(path) do
    path
    |> File.stream!()
    |> CSV.parse_stream()
    |> Stream.map(&parse_row/1)
    |> Stream.filter(&valid?/1)
    |> Stream.chunk_every(500)
    |> Stream.each(fn batch ->
         Repo.insert_all(MySchema, batch)
       end)
    |> Stream.run()
  end

  defp parse_row([name, email | _]), do: %{name: name, email: email}
  defp valid?(%{email: e}), do: String.contains?(e, "@")
end

Expect these follow-ups

  • How would you parallelize the CSV processing across multiple cores while preserving insert order?
  • How do you track progress and resume from a failed batch without reprocessing already-inserted rows?
streamscsvectopostgresqlmemory-efficiency

As asked

Implement a memoized Fibonacci function in Elixir. First show a version using the process dictionary, then refactor it to use an Agent so the cache persists across calls from different processes. What are the BEAM-specific trade-offs of each approach compared to using ETS for a high-concurrency memoization table?

Sample answer outline

Process dictionary version uses Process.put/get but only persists within a single process. Agent version starts an Agent holding a map, and fib/1 looks up the Agent cache first, computing and storing if absent. A strong answer distinguishes the lifetime and concurrency characteristics of each approach and notes that the Agent introduces a serialization bottleneck for high-concurrency scenarios (suggesting ETS instead).

Reference implementation (elixir)

elixir
# Process dictionary version
defmodule Fib.PD do
  def fib(0), do: 0
  def fib(1), do: 1
  def fib(n) do
    case Process.get({:fib, n}) do
      nil ->
        result = fib(n - 1) + fib(n - 2)
        Process.put({:fib, n}, result)
        result
      cached -> cached
    end
  end
end

# TODO: refactor to use an Agent for cross-process sharing

Expect these follow-ups

  • How does tail recursion in Elixir differ from body recursion in terms of stack usage?
  • Could you implement this without any mutable state using pure recursion with accumulator arguments?
algorithmsmemoizationagentrecursionelixir

As asked

Build a LiveView page where multiple users can see and increment a shared counter in real time. Each click should update all connected clients simultaneously.

Sample answer outline

A strong implementation uses a GenServer to hold the counter state, with Phoenix.PubSub to broadcast updates to all LiveView processes. mount/3 subscribes to a topic and renders the initial count. handle_event/3 for 'increment' calls the GenServer, which increments and broadcasts the new count. handle_info/2 receives the broadcast and updates the assign. A simpler approach with PubSub directly from the LiveView is also valid.

Reference implementation (elixir)

elixir
defmodule CounterLive do
  use Phoenix.LiveView

  def mount(_params, _session, socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "counter")
    {:ok, assign(socket, count: Counter.get())}
  end

  def handle_event("increment", _, socket) do
    Counter.increment()
    {:noreply, socket}
  end

  def handle_info({:counter_updated, count}, socket) do
    {:noreply, assign(socket, count: count)}
  end

  def render(assigns) do
    ~H"""
    <div><%= @count %></div>
    <button phx-click="increment">+1</button>
    """
  end
end

Expect these follow-ups

  • How would you ensure the counter increment is atomic across 1000 simultaneous clicks?
  • What happens to a client's counter display if it disconnects and reconnects while increments occur?
liveviewpubsubreal-timephoenixgenserver

As asked

You are syncing external API data into your database. The same external ID may arrive multiple times and should update existing records rather than error. How do you implement an upsert in Ecto using Repo.insert_all or Ecto.Changeset?

Sample answer outline

Repo.insert_all/3 with on_conflict: {:replace, [:field1, :field2]} and conflict_target: [:external_id] maps directly to PostgreSQL's ON CONFLICT DO UPDATE. For single-record upserts, Repo.insert/2 with the same options works. A strong answer also covers using timestamps correctly (setting updated_at manually in the list for insert_all) and knowing when to use on_conflict: :replace_all_except_primary_key.

Reference implementation (elixir)

elixir
Repo.insert_all(
  Product,
  rows,
  on_conflict: {:replace, [:name, :price, :updated_at]},
  conflict_target: [:external_id],
  returning: [:id]
)

# Single-record version:
%Product{external_id: "abc"}
|> Product.changeset(attrs)
|> Repo.insert(
     on_conflict: {:replace, [:name, :price]},
     conflict_target: :external_id
   )

Expect these follow-ups

  • How do you avoid updating the inserted_at timestamp when a conflict is resolved with an update?
  • What is the behavior when conflict_target is omitted and there are multiple unique constraints?
ectopostgresqlupsertdatabasessync

As asked

Write an Elixir function that handles chat commands represented as tuples, validates their shape, and returns tagged results without raising for normal bad input.

Sample answer outline

The answer should use multiple function heads and guards rather than a large nested case. Return tagged tuples such as {:ok, value} and {:error, reason} so callers can compose the parser without exceptions. Pattern matching should make the supported command shapes obvious and should reject malformed input at the boundary. Guards are useful for simple checks such as non-empty binary room names or positive integer limits. Candidates often overuse atom conversion on untrusted strings, which can exhaust the atom table.

Reference implementation (elixir)

elixir
defmodule Chat.CommandParser do
  def parse({:join, room}) when is_binary(room) and byte_size(room) > 0 do
    {:ok, {:join, room}}
  end

  def parse({:history, room, limit})
      when is_binary(room) and is_integer(limit) and limit in 1..100 do
    {:ok, {:history, room, limit}}
  end

  def parse({:send, room, body})
      when is_binary(room) and is_binary(body) and byte_size(body) > 0 do
    {:ok, {:send, room, String.trim(body)}}
  end

  def parse(_), do: {:error, :invalid_command}
end

Expect these follow-ups

  • Why avoid String.to_atom on user input?
  • How would you add a default limit while keeping the function heads readable?
  • Where would you put validation that requires a database lookup?
pattern-matchingvalidationelixir

As asked

Each node in a BST is its own GenServer process. Implement insert/2 and search/2 such that each node recursively delegates to its left or right child process. What are the trade-offs of this design?

Sample answer outline

Each node GenServer stores {value, left_pid, right_pid}. Insert sends a message to the root, which compares and delegates to the appropriate child, spawning a new GenServer for a missing child. Search works the same way recursively. The cost: process overhead per node, message passing latency per level, no batching. A strong answer critically evaluates this pattern, noting it is mainly a BEAM learning exercise rather than a practical data structure.

Reference implementation (elixir)

elixir
defmodule BST.Node do
  use GenServer

  def start_link(value), do: GenServer.start_link(__MODULE__, {value, nil, nil})

  def insert(pid, value), do: GenServer.call(pid, {:insert, value})
  def search(pid, value), do: GenServer.call(pid, {:search, value})

  def init(state), do: {:ok, state}

  def handle_call({:insert, new_val}, _from, {val, left, right}) do
    # TODO: compare and delegate or spawn
    {:reply, :ok, {val, left, right}}
  end
end

Expect these follow-ups

  • What happens if a node process crashes while a search traversal is in progress?
  • How would you add a supervisor tree for the node processes?
data-structuresgenserverprocessestreesotp

As asked

Write an Ecto query that returns each user's most recent order (by inserted_at) using a ROW_NUMBER window function, without doing a subquery in Elixir code.

Sample answer outline

Ecto supports window functions via Ecto.Query.windows/2 and the over/2 macro. A strong answer uses from(o in Order, windows: [ranked: [partition_by: o.user_id, order_by: [desc: o.inserted_at]]], select: %{..., rank: row_number() |> over(:ranked)}) and wraps it in a subquery to filter rank == 1. Mentions that this is more efficient than N+1 and explains the PARTITION BY semantics.

Reference implementation (elixir)

elixir
import Ecto.Query

query =
  from o in Order,
    windows: [
      ranked: [
        partition_by: o.user_id,
        order_by: [desc: o.inserted_at]
      ]
    ],
    select: %{
      id: o.id,
      user_id: o.user_id,
      rank: row_number() |> over(:ranked)
    }

from(r in subquery(query), where: r.rank == 1)

Expect these follow-ups

  • How do you use Ecto.Query.subquery/1 to filter on computed window function results?
  • Can you express DENSE_RANK in Ecto, and why might you need it over ROW_NUMBER here?
ectopostgresqlquerieswindow-functionsdatabases

As asked

Implement a fixed-capacity LRU cache module using ETS. Expose get(key) and put(key, value). When capacity is exceeded, evict the least recently used entry.

Sample answer outline

Use two ETS tables: one for key-value storage with a last-accessed timestamp, and one ordered_set keyed by timestamp for eviction order. On put, if at capacity, delete the entry with the lowest timestamp. On get, update the timestamp. A strong answer discusses the atomicity problem (two tables are not updated atomically) and suggests wrapping in a GenServer or using a single table with a counter-based ordering scheme.

Reference implementation (elixir)

elixir
defmodule LRUCache do
  @cache :lru_cache
  @order :lru_order

  def new(capacity) do
    :ets.new(@cache, [:named_table, :public, :set])
    :ets.new(@order, [:named_table, :public, :ordered_set])
    :ets.insert(@cache, {:capacity, capacity})
  end

  def get(key) do
    case :ets.lookup(@cache, key) do
      [{^key, value, _ts}] ->
        ts = System.monotonic_time()
        :ets.insert(@cache, {key, value, ts})
        {:ok, value}
      [] -> :miss
    end
  end

  # TODO: implement put with eviction
end

Expect these follow-ups

  • How would you make this cache thread-safe without a GenServer bottleneck?
  • What are the trade-offs of using :persistent_term for a read-heavy cache where eviction is rare?
etscachingdata-structuresperformancealgorithms