`:persistent_term` outperforms ETS for static data

almirsarajcic

almirsarajcic

2 hours ago

0 comments

ETS is the default for shared lookups, but for read-only data that never changes at runtime it does work you don’t need: every read copies the term out of the table and takes a lock. :persistent_term stores the value in a single global, read-only area — reads have no copy and no lock.

# ✅ persistent_term — one global term, read with no copy and no lock
defmodule FeatureFlags do
  # Call once at startup, e.g. from Application.start/2
  def load do
    :persistent_term.put(__MODULE__, fetch_flags())
  end

  def enabled?(flag) do
    __MODULE__
    |> :persistent_term.get(%{})
    |> Map.get(flag, false)
  end

  defp fetch_flags do
    %{beta_search: false, new_dashboard: true}
  end
end

The ETS version needs a named table, and every lookup copies the row and contends on the table lock:

# ❌ ETS — table management plus copy-on-read for data that never changes
defmodule FeatureFlags do
  @table :feature_flags

  def load do
    :ets.new(@table, [:named_table, :public, :set])

    Enum.each(fetch_flags(), fn {flag, value} ->
      :ets.insert(@table, {flag, value})
    end)
  end

  def enabled?(flag) do
    case :ets.lookup(@table, flag) do
      [{^flag, value}] -> value
      [] -> false
    end
  end

  defp fetch_flags do
    %{beta_search: false, new_dashboard: true}
  end
end

Why it’s faster

:persistent_term keeps every term in one global area that all processes read directly — no message passing, no per-read copy, no lock. ETS copies the matched objects into the calling process on every lookup and coordinates concurrent access with locks. Measure it yourself:

:persistent_term.put(:demo, %{a: 1})
:ets.new(:demo, [:named_table, :public, :set])
:ets.insert(:demo, {:a, 1})

{pt, _} = :timer.tc(fn -> Enum.each(1..1_000_000, fn _ -> :persistent_term.get(:demo) end) end)
{ets, _} = :timer.tc(fn -> Enum.each(1..1_000_000, fn _ -> :ets.lookup(:demo, :a) end) end)
IO.puts("persistent_term: #{pt}µs, ets: #{ets}µs")

The catch: updates are expensive

Inserting a brand-new key is cheap. But updating or erasing an existing key triggers a global garbage collection — the VM scans every process for references to the old value before reclaiming it, which can pause the system briefly. That’s the whole tradeoff: reads are free, replacing a value is not. Use it only for data written once (or very rarely):

Good fits

  • Application config loaded at startup
  • Feature flags
  • Static lookup tables (country codes, currencies)
  • Compiled schemas or templates

Avoid for

  • Anything that changes per request (use ETS or GenServer state)
  • Data scoped to a single process

Store related values as one term and look them up in-process, as FeatureFlags does above. Reloading then replaces a single term — one global scan — instead of one scan per key.

:persistent_term docs

Comments (0)

Sign in with GitHub to join the discussion