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:
elixirJason.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:
elixiriex> 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:
elixiriex> Jason.decode!(~s({"whatever_the_client_sent":true}))%{"whatever_the_client_sent" => true}
With keys: :atoms, those keys become atoms:
elixiriex> 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.
elixirJason.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:
elixirJason.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:
elixiriex> :name:nameiex> Jason.decode!(~s({"name":"Robin"}), keys: :atoms!)%{name: "Robin"}
But this fails if the atom is unknown:
elixiriex> 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:
elixirdefmodule SignupParams dodef from_json(%{"name" => name, "email" => email}) do{:ok, %{name: name, email: email}}enddef from_json(_payload) do{:error, :invalid_signup_payload}endend
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:
elixirdef load_fixture!(path) dopath|> 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:
elixirJason.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.