Skip to content
ETS — In-Memory Tables
step 1/7

Reading — step 1 of 7

Learn

~3 min readETS, Applications, with

ETS (Erlang Term Storage) is BEAM's built-in concurrent in-memory key-value store. Used by libraries like Cachex, Phoenix.PubSub, and rate limiters. Programming Elixir treats it as essential for serious BEAM development.

Creating an ETS table

:ets.new(:my_table, [:set, :public, :named_table])

Options:

  • :set — keys are unique (most common); other options: :bag, :duplicate_bag, :ordered_set
  • :public — any process can read/write
  • :protected — owner can write, others read (default)
  • :private — only the owner can access
  • :named_table — refer to it by atom (otherwise need the returned reference)

The owning process is whoever called :ets.new/2. If that process dies, the table is destroyed.

Basic operations

# Insert (overwrites for :set):
:ets.insert(:my_table, {"alice", 30})
:ets.insert(:my_table, {"bob", 25})

# Lookup — returns a list of matches:
:ets.lookup(:my_table, "alice")
# [{"alice", 30}]

# Multiple records:
:ets.lookup(:my_table, "missing")
# []

# Delete:
:ets.delete(:my_table, "alice")

# Match — pattern-based query:
:ets.match(:my_table, {:"$1", :"$2"})
# [["bob", 25]]

# Match with conditions:
:ets.match_object(:my_table, {:"_", :_})
# [{"bob", 25}]

# Counter:
:ets.update_counter(:my_table, "counter", 1, {"counter", 0})

:ets.update_counter/4 is atomic — perfect for high-throughput counters without locks.

Use cases

Application caches:

defmodule MyCache do
    use GenServer
    
    def start_link(_) do
        GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
    end
    
    def init(:ok) do
        :ets.new(:my_cache, [:set, :public, :named_table])
        {:ok, nil}
    end
    
    def get(key) do
        case :ets.lookup(:my_cache, key) do
            [{^key, value}] -> {:ok, value}
            [] -> :error
        end
    end
    
    def put(key, value) do
        :ets.insert(:my_cache, {key, value})
    end
end

Much faster than going through a GenServer for every operation — ETS reads/writes are concurrent.

Rate limiting:

def allow?(user_id) do
    count = :ets.update_counter(:rate_limits, user_id, 1, {user_id, 0})
    count <= 100
end

Phoenix.PubSub uses ETS to track subscribers. Cachex builds on ETS.

Performance

  • Reads: nanoseconds, completely concurrent
  • Writes: also fast; can configure for high concurrency with :write_concurrency
  • No serialization overhead — terms are stored directly
  • Memory-only; cleared on VM restart (no persistence)

Caveats

  • ETS data is COPIED in/out by default — terms are duplicated when read
  • Owner process death destroys the table (unless transferred via :ets.give_away)
  • No built-in TTL — use a separate expiry mechanism or a library like Cachex
  • Limited query language — use Match Specifications or :ets.fun2ms for complex queries

When to use ETS vs alternatives

  • ETS — concurrent in-process key-value store; fast, but data lost on VM restart
  • GenServer state — when state is small and access is naturally serial
  • Mnesia — distributed/persistent ETS-like store (heavyweight)
  • External (Redis, Postgres) — when persistence + cross-node distribution matters

Common mistakes

  • Using public ETS without coordination — race conditions for non-atomic ops. Use update_counter, update_element, or wrap in a GenServer.
  • Putting huge values into ETS — copies on every read; expensive. Reference larger data via IDs.
  • Forgetting owner process death cleanup — table dies with its owner. Make a long-lived process the owner.
  • Pattern matching ETS contents naively:ets.match is a separate query API; learn match specifications for production code.
  • Treating ETS as durable — it isn't. For persistence, write to disk or use a database.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…