# `:persistent_term` outperforms ETS for static data

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.

```elixir
# ✅ 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:

```elixir
# ❌ 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:

```elixir
: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](https://www.erlang.org/doc/man/persistent_term.html)


---

Created by: almirsarajcic
Date: June 08, 2026
URL: https://elixirdrops.net/d/pyxqzbGX
