Elixir by Example

The Process Dictionary

Edit any cell and run it in your browser Download source

Every process has a small key-value store of its own, the process dictionary. Only that process can write to it, it's gone when the process exits, and it's mutable: Process.put/2 changes it in place, with no new value returned. That last part is why Elixir code rarely uses it directly. But OTP and Elixir use it under the hood, and knowing it's there explains some surprises.

The demo runs in a fresh process so the dictionary starts empty. run waits for it to finish.

run = fn fun ->
  {pid, ref} = spawn_monitor(fun)
  receive do: ({:DOWN, ^ref, :process, ^pid, _} -> :ok)
end

run.(fn ->
  IO.inspect(Process.get_keys(), label: "fresh process")

  # `put` returns the *previous* value: there's no new
  # dictionary to return, the old one was changed.
  IO.inspect(Process.put(:visits, 1), label: "first put")
  IO.inspect(Process.put(:visits, 2), label: "second put")
  IO.inspect(Process.get(:visits), label: "get")
end)
fresh process: []
first put: nil
second put: 1
get: 2

Who uses it? Logger.metadata/1 stores its keyword list there, which is why metadata set in one process shows up in every log line that process writes.

require Logger

run.(fn ->
  Logger.metadata(request_id: "req-42")
  IO.inspect(Process.get_keys(), label: "after Logger.metadata")

  # A `Task` is a new process with its own dictionary,
  # so the metadata doesn't follow. Elixir does copy one
  # thing over: `:"$callers"`, the chain of processes
  # that started the task (tools like Ecto's sandbox use
  # it to find the test that owns a connection).
  me = self()

  Task.async(fn ->
    IO.inspect(Logger.metadata(), label: "metadata in the task")
    IO.inspect(Process.get(:"$callers") == [me], label: "$callers is the parent")
  end)
  |> Task.await()
end)
after Logger.metadata: [:"$logger_metadata$"]
metadata in the task: []
$callers is the parent: true

OTP keeps its own bookkeeping there too: every GenServer and Supervisor stores :"$ancestors" and :"$initial_call". Anyone can read another process's dictionary with Process.info/2, which is how :observer shows them.

{:ok, agent} = Agent.start_link(fn -> 0 end)
{:dictionary, dict} = Process.info(agent, :dictionary)
IO.inspect(dict |> Keyword.keys() |> Enum.sort(), label: "an Agent's keys")
an Agent's keys: [:"$ancestors", :"$initial_call"]

Pitfall: the process dictionary is hidden state. A function that reads it gives different answers depending on which process calls it and what ran there before, so it's hard to test and easy to break by moving code into a Task. Pass values as arguments or keep them in GenServer state instead.

Pitfall: to keep request metadata in logs written from a Task, copy it yourself: metadata = Logger.metadata() before starting the task, then Logger.metadata(metadata) inside it.

Pitfall: Process.info(pid, :dictionary) shows the whole dictionary, including whatever a library put there. Don't keep secrets in it.

Go deeper: The BEAM Book by Erik Stenman, section 3.7, explains how the dictionary is stored and what it costs.