We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
`:persistent_term` outperforms ETS for static data
almirsarajcic
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.
copied to clipboard