10 min read
On this page

Persistent Storage Options

ETS gives you a shared in-memory table that disappears when the BEAM stops. That is fine for caches and ephemeral state, but eventually every system needs something that survives a restart. The Erlang/OTP world has three built-in answers — DETS, persistent_term, and Mnesia — and one practical answer that ate everything else: just use PostgreSQL. Knowing when each makes sense, and when it does not, saves you from a lot of bad architectural turns.

The honest opening: in 2026, most production Elixir apps store their durable data in PostgreSQL through Ecto, full stop. The built-in storage primitives still matter, but their roles have narrowed. DETS is a niche tool for small disk-backed local state. persistent_term is a specialist for read-mostly globals. Mnesia is occasionally the right answer for distributed in-cluster state but is operationally heavy enough that most teams reach for Postgres or a dedicated key-value store instead.

DETS: Disk-Backed ETS

DETS is the disk version of ETS. The API is almost identical — you open_file, insert, lookup, close. Behind the scenes, your data lives in a file on the local filesystem, with an in-memory cache for hot reads.

{:ok, _} = :dets.open_file(:my_table, type: :set, file: ~c"./my_table.dets")

:dets.insert(:my_table, {"user:42", %{name: "Alice"}})

[{"user:42", user}] = :dets.lookup(:my_table, "user:42")

:dets.close(:my_table)

The shape is so close to ETS that you can write code that treats them interchangeably — and indeed Mnesia internally builds on top of both. DETS supports :set, :bag, and :duplicate_bag types. It does not support :ordered_set (which is the most common reason people get bitten when they expect a drop-in upgrade from ETS).

The hard limit that defines DETS is its file size cap: 2 GB per table. This is not a soft suggestion. Once you hit it, writes start failing. There is no native way to shard a logical DETS table across multiple files — you have to do it yourself with consistent hashing, which at that point means you have rebuilt half of a real database.

When DETS is the right tool:

  • You need to persist a small amount of local-to-this-node state across BEAM restarts. A few hundred MB at most.
  • The data is not shared across nodes. DETS is single-node only.
  • You do not need transactions, secondary indexes, or query flexibility.

Real uses in the wild: storing local rate limit windows that should survive a node restart, caching expensive-to-recompute configuration, holding offline message queues for a desktop client. Anything bigger or more demanding wants a real database.

When DETS is not the right tool:

  • Anything approaching the 2 GB limit. Migrate to Postgres or RocksDB before you get there.
  • Anything where multiple nodes need access. DETS files are local, period.
  • Anything write-heavy. DETS sync semantics are not exciting — auto_save defaults to three minutes, meaning a crash can lose recent writes unless you call :dets.sync/1 explicitly.

A pattern that works: open DETS at startup, copy everything into an ETS table for fast operation, write through to DETS on every mutation, sync periodically. This gives you ETS performance on reads with DETS durability on writes.

defmodule LocalStore do
  use GenServer

  @table :store_cache
  @file ~c"./store.dets"

  def start_link(_), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__)

  def get(key) do
    case :ets.lookup(@table, key) do
      [{^key, val}] -> {:ok, val}
      [] -> :error
    end
  end

  def put(key, val), do: GenServer.call(__MODULE__, {:put, key, val})

  @impl true
  def init(_) do
    :ets.new(@table, [:set, :public, :named_table, read_concurrency: true])
    {:ok, _} = :dets.open_file(:store_dets, type: :set, file: @file)
    :dets.to_ets(:store_dets, @table)
    {:ok, nil}
  end

  @impl true
  def handle_call({:put, key, val}, _from, state) do
    :ets.insert(@table, {key, val})
    :dets.insert(:store_dets, {key, val})
    {:reply, :ok, state}
  end

  @impl true
  def terminate(_reason, _state) do
    :dets.close(:store_dets)
  end
end

This is roughly the shape Cachex uses for its optional persistence and is a reasonable starting point if you genuinely need durable local state without a database.

persistent_term: Read-Mostly Globals

persistent_term is the newest of the bunch (OTP 21.2, late 2018) and the most specialized. It is a global key/value store with one defining property: reads are free. Not "fast" — literally free, in the sense that the term is compiled into the lookup site and accessed without copying or locking.

:persistent_term.put({:config, :database_url}, "postgres://localhost/myapp")

:persistent_term.get({:config, :database_url})
# "postgres://localhost/myapp"

The trick is in how it is implemented. Every put triggers a full scan of the BEAM's processes to find references to the previous value and migrate them. Writes are O(n) in the total number of processes on the node. On a system with 100,000 active processes, a single put can stall for a measurable amount of time.

This makes persistent_term a hard "no" for anything that changes during normal operation. But it makes it a perfect fit for data that is set once at startup (or rarely at all) and read on every hot path.

When persistent_term is the right tool:

  • Configuration that is loaded at boot and never changes again. Database URLs, feature-flag definitions, lookup tables for parsing, schema metadata.
  • Compiled state that ETS would handle but with measurable copy cost per read. The classic example is a regex compiled at startup and matched against millions of inputs.
  • Large terms (lists, maps) that you want to read without copying. ETS copies the term on lookup; persistent_term does not.

Phoenix uses persistent_term internally for router metadata. Erlang's own modules use it for things like the JIT's call cache. The pattern is always the same: write at boot, read forever.

defmodule MyApp.Config do
  def load do
    config = Application.fetch_env!(:my_app, :runtime_config)
    :persistent_term.put(__MODULE__, config)
  end

  def get(key), do: Map.fetch!(:persistent_term.get(__MODULE__), key)
end

When persistent_term is the wrong tool:

  • Anything that updates more than a few times per minute. The whole-VM scan on every write is genuinely expensive, and people have brought down production nodes by treating it as a fast write store.
  • Per-user or per-request data. Wrong granularity entirely.
  • Data that needs to be shared with other nodes. persistent_term is local.

The rule of thumb worth remembering: if you would not put it in a module attribute, do not put it in persistent_term either — except persistent_term can hold data computed at runtime, where module attributes cannot.

Mnesia: Distributed and Transactional

Mnesia is the OTP-bundled distributed database. It supports tables that are RAM-only, disk-backed, or both. It does multi-node replication, transactions across tables, secondary indexes, and even hot table redefinitions. On paper, it is everything ETS is not.

:mnesia.create_schema([node()])
:mnesia.start()

:mnesia.create_table(Account,
  attributes: [:id, :balance],
  disc_copies: [node()]
)

:mnesia.transaction(fn ->
  :mnesia.write({Account, "alice", 100})
  :mnesia.write({Account, "bob", 50})
end)

{:atomic, [account]} = :mnesia.transaction(fn ->
  :mnesia.read(Account, "alice")
end)

The API is more involved than ETS or DETS, but the capabilities are real. Mnesia genuinely does support ACID transactions over multiple tables on multiple nodes.

So why don't modern Elixir apps use it? Three reasons:

Operational weight. Mnesia has its own concepts for schema management, table types, replication topology, and failure recovery. Network partitions in a Mnesia cluster require manual intervention to resolve (set_master_nodes, choosing which node's data wins). For a small team, this is a meaningful new operational surface.

Limited query expressiveness. No SQL, no joins beyond what you write yourself, no query planner. You write match specifications or fold over tables. Adequate for simple lookups, painful for ad-hoc analysis.

The whole industry uses Postgres. Tooling, monitoring, backups, ORMs, hosted offerings — all of it has settled on the SQL world. Picking Mnesia means giving up the entire ecosystem of Postgres tools (Datadog dashboards, pgBouncer, Supabase, RDS, every BI tool ever) in exchange for tighter integration with the BEAM. For most teams the trade is not worth it.

Mnesia still has a real niche: data that needs to live alongside the BEAM nodes for latency reasons, that benefits from BEAM-level transactions, and that does not need to be queried by anything outside the cluster. RabbitMQ uses Mnesia internally for cluster metadata. Several gaming and trading systems use it for in-cluster session and order state. If you have a hard reason — sub-millisecond cross-node consistency, embedded deployment, no external database — Mnesia can be the right answer. If you do not, Postgres is the right answer.

What Modern Apps Actually Pick

In real production Elixir codebases written today, the storage stack typically looks like:

  • Source of truth: PostgreSQL through Ecto. User accounts, orders, content, anything durable that you need to query.
  • Cache: ETS owned by a GenServer, or a library like Cachex on top of it. For multi-node caches, Redis or Nebulex with a distributed adapter.
  • Boot-time config: persistent_term for anything read on every request that does not change at runtime.
  • Local ephemeral state: GenServer state.
  • Local durable state across restarts: DETS, sparingly, when the data really cannot be in Postgres.
  • Distributed state: usually punted to Postgres, Redis, or a real distributed KV store like FoundationDB. Mnesia only when there is a specific reason.

This is not the OTP textbook answer — the textbook answer is "use Mnesia." But the textbooks were written when Postgres was less universal and operating a Postgres cluster was more painful than it is today. The practical answer has shifted.

Choosing Between Them

A quick decision flow:

  • Does it survive a restart? No → ETS or GenServer state. Yes → keep going.
  • Is it set once at boot and read forever? Yes → persistent_term. No → keep going.
  • Does it need to be queried by other systems, joined with other data, or have rich querying? Yes → Postgres. No → keep going.
  • Does it need to be shared across nodes? Yes → Postgres, Redis, or in rare cases Mnesia. No → keep going.
  • Is it under 2 GB and local to this node? Yes → DETS is viable. No → Postgres.

In practice, most "do I need persistence?" questions end at Postgres. That is not a failure of the BEAM's storage primitives — it is a recognition that durable storage is a separate concern from runtime state, and most teams are better served by separating them than by trying to do everything inside the VM.

Common Pitfalls

Treating DETS like a real database. It is a key/value file with a 2 GB cap. No indexes, no transactions across keys, no concurrent writers from outside the BEAM. The moment your requirements grow beyond "small local KV store," you have outgrown DETS.

Updating persistent_term frequently. Every put scans every process on the node. Doing it once per request can collapse the system. The "once at boot, never again" mental model is not a suggestion — it is how the data structure is designed to be used.

Reaching for Mnesia because it is bundled. It is in OTP, so people assume it is the obvious choice. It is the obvious choice for a small set of problems and overkill for most. If you are building a normal web app, use Postgres and revisit Mnesia only if you hit a specific limitation.

Forgetting that DETS files are not crash-safe by default. auto_save is three minutes. A crash in between loses everything since the last sync. If you care about every write surviving, call :dets.sync/1 after important writes or set auto_save lower — but if you care that much, you probably want Postgres anyway.

Mixing storage layers without a clear ownership model. Caching Postgres data into ETS into DETS into persistent_term is a recipe for stale reads at every level. Each layer should have one writer, a clear invalidation path, and a documented consistency contract. Most apps need at most two layers.

Key Takeaways

  • DETS is disk-backed ETS with a 2 GB per-table cap. Useful for small local durable state, useless beyond that. The ETS-mirror-with-write-through pattern gives you fast reads on top of DETS durability.
  • persistent_term makes reads free at the cost of expensive writes. Use it for config and lookup tables that are set at boot and never updated. Phoenix and the BEAM itself rely on it for router and JIT metadata.
  • Mnesia is distributed and transactional but operationally heavy. Modern Elixir apps usually pick PostgreSQL over Mnesia because the broader Postgres ecosystem (tooling, hosting, backups, observability) wins on every axis except BEAM integration.
  • The typical 2026 production stack: Postgres for durable data, ETS for in-process cache, persistent_term for boot-time globals, GenServer state for ephemeral per-process state. DETS and Mnesia fill narrow gaps.
  • Pick storage by working through: durable? cross-node? read-mostly? small and local? The answers usually point at exactly one option.