Robin van der Vleuten

Never turn JSON keys into atoms in Elixir

You're building a small JSON API client in Elixir. The endpoint returns a tidy response, Jason decodes it without complaint, and then you add the option that makes the rest of the code feel nicer:

elixir
Jason.decode!(body, keys: :atoms)

Now you can write user.name instead of user["name"]. Small upgrade.

A remote response can now decide which atoms exist in your VM.

Slow down there.

Atoms in Elixir are not normal strings with a colon in front. They live in the Erlang VM's atom table, shared by the whole running system. Once an atom exists, you should treat it as something the VM keeps around.

When you created the atom yourself, fine. When the key came from the internet, different deal.

The convenience trap

The default behaviour in Jason is exactly what you want for arbitrary JSON:

elixir
iex> Jason.decode!(~s({"name":"Robin","role":"developer"}))
%{"name" => "Robin", "role" => "developer"}

String keys are a little noisier to access, but they stay ordinary data. Send a key the application has never heard of and nothing special happens:

elixir
iex> Jason.decode!(~s({"whatever_the_client_sent":true}))
%{"whatever_the_client_sent" => true}

With keys: :atoms, those keys become atoms:

elixir
iex> Jason.decode!(~s({"name":"Robin","role":"developer"}), keys: :atoms)
%{name: "Robin", role: "developer"}

This is the tempting version. The map looks like the maps you write by hand, especially once it moves deeper into application code.

The example above is harmless. The problem is the stream of payloads after it, especially the ones sent by somebody who does not care about your atom table.

elixir
Jason.decode!(~s({"field_1":true}), keys: :atoms)
Jason.decode!(~s({"field_2":true}), keys: :atoms)
Jason.decode!(~s({"field_3":true}), keys: :atoms)

Each new key asks the VM to create a new atom.

On your laptop, nothing explodes. In production, the problem can stay quiet for a long time too.

That is what makes the option dangerous. It sits at the edge of the system and turns untrusted input into something global.

Existing atoms are different

Jason gives you a stricter option:

elixir
Jason.decode!(body, keys: :atoms!)

The exclamation mark is not decoration. Instead of creating atoms for every key it sees, Jason only accepts atoms that already exist.

So this works:

elixir
iex> :name
:name
iex> Jason.decode!(~s({"name":"Robin"}), keys: :atoms!)
%{name: "Robin"}

But this fails if the atom is unknown:

elixir
iex> Jason.decode!(~s({"made_up_key":true}), keys: :atoms!)
** (ArgumentError) errors were found at the given arguments:
* 1st argument: not an already existing atom

That failure mode is useful. It is loud, early, and easier to understand than "the VM fell over because every request invented a few more atoms."

Still, I don't reach for it as the default.

keys: :atoms! is useful when the payload is internal, versioned, and genuinely has a fixed shape. For external API responses, webhooks, request bodies, imported files, or anything users can influence, string keys are the safer tradeoff.

Pattern match at the boundary

The annoying part is usually not decoding. It is the code after decoding.

String keys feel clumsy:

elixir
%{"name" => name, "email" => email} = payload

I don't think that is a bad thing.

The ugliness is a signal. You're still at the boundary, dealing with external data as external data.

Convert it deliberately when it enters your system:

elixir
defmodule SignupParams do
def from_json(%{"name" => name, "email" => email}) do
{:ok, %{name: name, email: email}}
end
def from_json(_payload) do
{:error, :invalid_signup_payload}
end
end

Now the conversion is explicit: you choose the atoms, and you decide which keys matter. Unknown data can be ignored, logged, validated, or rejected without being promoted into the VM's global vocabulary.

The boundary I try to keep is boring:

  • outside the application, JSON keys are strings
  • inside the application, atom keys are fine
  • at the boundary, convert only the fields you understand

A little pattern matching at the edge beats a convenient decoder option that changes the cost of every unknown key.

When atom keys are okay

I still use atom keys. They are fine when the input is not really input anymore:

  • You decode a static fixture committed to the repository.
  • You parse configuration that only your application authors can edit.
  • You receive internal messages with a versioned schema.
  • You use keys: :atoms! and you want unknown keys to crash immediately.

Even then, I like making the choice visible near the code that knows why it is safe:

elixir
def load_fixture!(path) do
path
|> File.read!()
|> Jason.decode!(keys: :atoms!)
end

The option tells the next person something important: this is not arbitrary input.

If that stops being true later, the decoder option should change too.

The boring rule

JSON keys should stay strings until you have a specific reason to turn them into something else.

Atom keys are not evil. They are one of the nicest parts of writing Elixir data structures.

But JSON is not an Elixir data structure yet, and pretending it is can make the edge of your system too soft.

So my default is simple:

elixir
Jason.decode!(body)

Then pattern match, validate, and convert the fields I actually understand.

It costs a few extra characters, and it keeps untrusted data from teaching the VM new words.