Elixir JSON: Jason vs Poison, Encoding, Decoding & Phoenix
Last updated:
Elixir has two dominant JSON libraries: Jason (recommended, ~2× faster) and Poison (older, widely used) — Jason decodes to maps with string keys by default and is the Phoenix Framework default since Phoenix 1.7. Jason.decode!("{}") raises Jason.DecodeError on invalid JSON; Jason.decode("{}") returns { :ok, map } | { :error, %Jason.DecodeError{} } for safe pattern matching. Jason benchmarks at ~150 MB/s decode throughput on a modern CPU, compared to Poison's ~80 MB/s. This guide covers Jason.encode/decode with option flags, atom vs string keys, implementing the Jason.Encoder protocol for custom structs, Phoenix controller JSON responses, streaming JSON with Jason.encode_to_iodata!, and Ecto changeset error serialization.
Jason vs Poison: Choosing a JSON Library
Jason and Poison are the two most common JSON libraries in the Elixir ecosystem. Jason is the community standard for new projects — it is faster, uses less memory, and is the default in Phoenix 1.7+. Poison remains present in legacy codebases and older tutorials. Choose Jason unless you are maintaining an existing Poison-based project with protocol implementations that would require migration effort.
# mix.exs — add Jason to your project
defp deps do
[
{:jason, "~> 1.4"},
# Do NOT add both Jason and Poison; pick one
# {:poison, "~> 5.0"}, # legacy alternative
]
end
# Basic API comparison — Jason and Poison share the same function names
# Jason
{:ok, map} = Jason.decode(~s({"name": "Alice", "age": 30}))
json_string = Jason.encode!(%{name: "Alice", age: 30})
# Poison (same API surface)
{:ok, map} = Poison.decode(~s({"name": "Alice", "age": 30}))
json_string = Poison.encode!(%{name: "Alice", age: 30})
# Key differences:
# 1. Protocol name: Jason.Encoder vs Poison.Encoder
# 2. Atom key option: Jason uses keys: :atoms / keys: :atoms!
# Poison uses keys: :atoms
# 3. Jason.encode_to_iodata!/1 — iodata output for efficient socket writes
# Poison has no iodata equivalent
# 4. Performance: Jason ~2x faster on encode and decode benchmarks
# Phoenix 1.7+ configuration — Jason is pre-configured
# config/config.exs (generated automatically, no action needed)
config :phoenix, :json_library, Jason
# If migrating from Poison to Jason in an existing Phoenix project:
# 1. Replace {:poison, ...} with {:jason, "~> 1.4"} in mix.exs
# 2. Run: mix deps.get
# 3. Rename all Poison.Encoder implementations to Jason.Encoder
# 4. Update encode/2 to return Jason.Encode.map(%{...}, opts)
# instead of Poison.Encoder.BitString.encode(value, options)Jason's performance advantage comes from its iodata-based encoding pipeline — instead of building one large string, it produces a nested list of binaries that the BEAM VM writes directly to network sockets without concatenation. Poison builds intermediate strings, requiring more allocations and garbage collection pressure under high throughput. For most applications the difference is not the deciding factor; the stronger reason to choose Jason is that the Phoenix ecosystem, Ecto, and most hex.pm packages now assume Jason as the JSON library. See JSON performance for benchmark methodology.
Jason.decode/encode: Basic Usage and Error Handling
Jason provides two forms of each operation: the bang form (decode! / encode!) raises on error and returns the value directly; the non-bang form (decode / encode) returns an { :ok, value } or { :error, reason } tuple for pattern matching. Use the non-bang form for external or user-supplied JSON where errors are expected; use the bang form only when you control the input and a crash is acceptable.
# ── Jason.decode/1 — safe, returns {:ok, value} | {:error, error} ──
json = ~s({"user": {"id": 42, "name": "Alice", "roles": ["admin", "editor"]}})
case Jason.decode(json) do
{:ok, data} ->
IO.inspect(data)
# %{"user" => %{"id" => 42, "name" => "Alice", "roles" => ["admin", "editor"]}}
{:error, %Jason.DecodeError{position: pos, token: tok}} ->
IO.puts("JSON parse error at position #{pos}: unexpected #{tok}")
end
# ── Jason.decode!/1 — raises Jason.DecodeError on invalid JSON ────
data = Jason.decode!(json)
# Returns the map directly — use only when input is trusted
# ── Jason.encode/1 — safe, returns {:ok, string} | {:error, error} ─
case Jason.encode(%{name: "Alice", score: 42}) do
{:ok, json_string} -> json_string # '{"name":"Alice","score":42}'
{:error, %Jason.EncodeError{message: msg}} -> IO.puts("Encode error: #{msg}")
end
# ── Jason.encode!/1 — raises Jason.EncodeError on failure ────────
json_string = Jason.encode!(%{name: "Alice", active: true})
# '{"active":true,"name":"Alice"}'
# Note: map keys are sorted alphabetically in Jason output
# ── Encoding options ───────────────────────────────────────────────
# pretty: true — formatted output with indentation
pretty_json = Jason.encode!(%{name: "Alice", age: 30}, pretty: true)
# {
# "age": 30,
# "name": "Alice"
# }
# ── What Jason encodes natively ────────────────────────────────────
Jason.encode!(nil) # "null"
Jason.encode!(true) # "true"
Jason.encode!(42) # "42"
Jason.encode!(3.14) # "3.14"
Jason.encode!("hello") # "\"hello\""
Jason.encode!([1, 2, 3]) # "[1,2,3]"
Jason.encode!(%{a: 1}) # "{\"a\":1}" — atom keys become strings
Jason.encode!(%{"a" => 1}) # "{\"a\":1}" — string keys work too
# ── Structs require Jason.Encoder protocol ─────────────────────────
# Without the protocol, Jason raises Protocol.UndefinedError
# Jason.encode!(%MyStruct{}) # raises if no Jason.Encoder implementedJason sorts map keys alphabetically when encoding — %{ b: 2, a: 1 } encodes as {"a":1,"b":2}. This is deterministic but may differ from insertion order if your downstream consumer is order-sensitive (most JSON parsers should not care, but some legacy systems do). Atom keys in Elixir maps are automatically converted to strings in JSON output — :name becomes "name". Integer keys in maps are not valid JSON object keys and will raise Jason.EncodeError; convert them to strings before encoding. For JSON best practices around error propagation, always use the tuple-returning form in boundary code (controllers, LiveView callbacks, GenServer handlers).
Key Options: String Keys vs Atom Keys
By default, Jason.decode returns maps with string keys: %{"name" => "Alice"}. The keys option changes this behaviour. keys: :atoms converts all string keys to atoms; keys: :atoms! converts only to existing atoms and raises if a key does not exist as an atom in the BEAM. For production systems receiving external JSON, keep the default string keys.
# ── Default: string keys ───────────────────────────────────────────
{:ok, data} = Jason.decode(~s({"name": "Alice", "age": 30}))
data["name"] # "Alice"
data["age"] # 30
# ── keys: :atoms — convert all keys to atoms ──────────────────────
# WARNING: do not use on untrusted external JSON
{:ok, data} = Jason.decode(~s({"name": "Alice", "age": 30}), keys: :atoms)
data.name # "Alice"
data.age # 30
# Internally creates atoms :name and :age — fine for known schemas
# ── keys: :atoms! — only convert to already-existing atoms ────────
# Safer: raises ArgumentError if a key has no existing atom
# Ensures unknown keys from external APIs cannot exhaust atom table
{:ok, data} = Jason.decode(~s({"name": "Alice"}), keys: :atoms!)
# :name atom must already exist (it does — it is used in your code)
# Unknown key "x_custom_123abc" would raise ArgumentError
# ── Atom exhaustion risk explained ────────────────────────────────
# Atoms in Elixir/Erlang are never garbage collected.
# Default atom table limit: 1,048,576 atoms.
# An attacker sending JSON with unique random keys:
# {"aaaaaa": 1} {"aaaaab": 1} {"aaaaac": 1} ...
# Each new key creates a permanent atom.
# At ~1M unique keys, the VM crashes with :system_limit.
# Always use string keys (default) for external input.
# ── Practical pattern: decode with strings, convert selectively ────
{:ok, %{"user" => user}} = Jason.decode(json)
# Convert only specific known keys to atoms using Map.new/2
typed_user = Map.new(user, fn
{"id", v} -> {:id, v}
{"name", v} -> {:name, v}
{"email", v} -> {:email, v}
{k, v} -> {k, v} # keep unknown keys as strings
end)
# ── String key access helpers ──────────────────────────────────────
# Use Map.get/3 with a default for safe access
name = Map.get(data, "name", "Unknown")
# Use the Access behaviour with string keys
name = data["name"]
score = get_in(data, ["user", "score"])
# ── Structs from decoded JSON ─────────────────────────────────────
defmodule User do
defstruct [:id, :name, :email]
def from_json(%{"id" => id, "name" => name, "email" => email}) do
%__MODULE__{id: id, name: name, email: email}
end
end
{:ok, raw} = Jason.decode(json)
user = User.from_json(raw)The safest pattern for production Elixir services is to decode all external JSON with default string keys and convert to typed structs using explicit pattern matching in from_json/1 functions. This avoids atom exhaustion, provides compile-time struct field validation, and makes the mapping explicit — an incoming JSON field "user_id" can be mapped to :id with no ambiguity. Reserve keys: :atoms for internal tooling, configuration files, and fixed-schema messages between trusted services where the key set is known and finite.
Implementing Jason.Encoder for Custom Structs
Jason does not know how to encode custom Elixir structs by default — attempting to encode a struct without a protocol implementation raises Protocol.UndefinedError. There are two approaches: the @derive macro for simple field inclusion, and a manual defimpl Jason.Encoder for full control over the output shape.
# ── @derive [Jason.Encoder] — automatic, all fields ─────────────
defmodule User do
@derive [Jason.Encoder]
defstruct [:id, :name, :email, :inserted_at]
end
Jason.encode!(%User{id: 1, name: "Alice", email: "alice@example.com"})
# {"email":"alice@example.com","id":1,"inserted_at":null,"name":"Alice"}
# ── @derive with only: — whitelist specific fields ────────────────
defmodule PublicUser do
@derive {Jason.Encoder, only: [:id, :name]}
defstruct [:id, :name, :password_hash, :email]
end
Jason.encode!(%PublicUser{id: 1, name: "Alice", password_hash: "secret"})
# {"id":1,"name":"Alice"} — password_hash excluded
# ── @derive with except: — exclude specific fields ────────────────
defmodule SafeUser do
@derive {Jason.Encoder, except: [:password_hash, :__meta__]}
defstruct [:id, :name, :email, :password_hash]
end
# ── Manual defimpl — full control ─────────────────────────────────
defmodule Product do
defstruct [:id, :name, :price_cents, :inserted_at, :active]
end
defimpl Jason.Encoder, for: Product do
def encode(product, opts) do
Jason.Encode.map(
%{
id: product.id,
name: product.name,
# Transform: cents -> dollars with 2 decimal places
price: product.price_cents / 100,
price_display: "$#{:erlang.float_to_binary(product.price_cents / 100, decimals: 2)}",
# Format DateTime as ISO 8601
created_at: DateTime.to_iso8601(product.inserted_at),
active: product.active
},
opts
)
end
end
Jason.encode!(%Product{id: 42, name: "Widget", price_cents: 1999,
inserted_at: ~U[2026-01-15 10:00:00Z], active: true})
# {"active":true,"created_at":"2026-01-15T10:00:00Z","id":42,
# "name":"Widget","price":19.99,"price_display":"$19.99"}
# ── Encoding Ecto schemas ─────────────────────────────────────────
# Ecto schemas include __meta__ and associations — use except:
defmodule MyApp.Accounts.User do
use Ecto.Schema
@derive {Jason.Encoder, except: [:__meta__, :password_hash]}
schema "users" do
field :name, :string
field :email, :string
field :password_hash, :string
timestamps()
end
end
# ── Encoding a list of structs ─────────────────────────────────────
users = [%User{id: 1, name: "Alice"}, %User{id: 2, name: "Bob"}]
Jason.encode!(users)
# [{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]When using @derive { Jason.Encoder, only: [...] } with Ecto schemas, always exclude :__meta__ (the Ecto metadata struct) and any association fields that may not be loaded — encoding an unloaded association (%Ecto.Association.NotLoaded{}) raises a protocol error at runtime. The manual defimpl approach is preferable for Ecto schemas in production because it makes the JSON contract explicit and decoupled from the database schema. For JSON API design patterns including versioning and envelope structures, see the dedicated guide.
Phoenix JSON Responses: json/2 and render/3
Phoenix provides two primary mechanisms for JSON responses: the json/2 function for direct map encoding and render/3 with JSON view modules for structured, reusable responses. Phoenix 1.7+ generates JSON views as plain Elixir modules (not EEx templates), which return maps that Phoenix encodes with Jason.
# ── json/2 — simplest JSON response ──────────────────────────────
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
def show(conn, %{"id" => id}) do
user = Accounts.get_user!(id)
json(conn, %{data: %{id: user.id, name: user.name, email: user.email}})
# Sets Content-Type: application/json, encodes with Jason, status 200
end
def create(conn, %{"user" => user_params}) do
case Accounts.create_user(user_params) do
{:ok, user} ->
conn
|> put_status(:created) # HTTP 201
|> json(%{data: %{id: user.id, name: user.name}})
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity) # HTTP 422
|> json(%{errors: format_errors(changeset)})
end
end
end
# ── Phoenix 1.7+ JSON views ───────────────────────────────────────
# lib/my_app_web/controllers/user_json.ex (generated by mix phx.gen.json)
defmodule MyAppWeb.UserJSON do
# index action: render list
def index(%{users: users}) do
%{data: Enum.map(users, &data/1)}
end
# show action: render single user
def show(%{user: user}) do
%{data: data(user)}
end
# Shared helper — single source of truth for user JSON shape
defp data(user) do
%{
id: user.id,
name: user.name,
email: user.email,
inserted_at: user.inserted_at
}
end
end
# In controller — render/3 calls UserJSON.show/1 automatically
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
def show(conn, %{"id" => id}) do
user = Accounts.get_user!(id)
render(conn, :show, user: user)
# Phoenix calls UserJSON.show(%{user: user}) and encodes the result
end
end
# ── Fallback controller for API error handling ─────────────────────
defmodule MyAppWeb.FallbackController do
use MyAppWeb, :controller
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> json(%{error: "Resource not found"})
end
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_changeset_errors(changeset)})
end
end
# ── Custom HTTP status with json/2 ─────────────────────────────────
conn
|> put_status(202) # Accepted
|> put_resp_header("x-request-id", conn.assigns.request_id)
|> json(%{status: "queued", job_id: job.id})Phoenix 1.7's JSON view pattern (plain modules, not templates) is more testable than the older view system — you can unit test UserJSON.show(%{ user: user }) directly without an HTTP request. The fallback controller pattern (action_fallback MyAppWeb.FallbackController) centralizes error handling: controller actions return { :ok, resource } or { :error, reason } tuples, and Phoenix calls the fallback controller for errors, keeping controller action bodies clean. See the JSON WebSockets guide for encoding JSON in Phoenix Channels and LiveView events.
Streaming JSON with encode_to_iodata! and Plug
Jason.encode_to_iodata!/1returns an iodata list (nested list of binaries) instead of a single concatenated string. This is more efficient for socket writes because the BEAM's networking layer accepts iodata directly without allocating a new binary. Combined with Plug's chunked transfer encoding, this pattern streams large JSON datasets from the database with constant memory usage.
# ── Jason.encode_to_iodata!/1 vs Jason.encode!/1 ─────────────────
# encode!/1 — returns a binary string (one allocation)
json_string = Jason.encode!(%{name: "Alice"})
# '{"name":"Alice"}' (type: binary)
# encode_to_iodata!/1 — returns iodata (no allocation for concatenation)
iodata = Jason.encode_to_iodata!(%{name: "Alice"})
# ["{", "\"name\"", ":", "\"Alice\"", "}"] (type: iolist)
# Equivalent content, but written to socket without merging the list
# Convert iodata to binary when you need a string
IO.iodata_to_binary(iodata) # '{"name":"Alice"}'
# ── Streaming with Plug chunked transfer ─────────────────────────
defmodule MyAppWeb.ExportController do
use MyAppWeb, :controller
# Stream all users as NDJSON (newline-delimited JSON)
# Memory usage: constant — one record in memory at a time
def export(conn, _params) do
conn =
conn
|> put_resp_content_type("application/x-ndjson")
|> put_resp_header("x-content-type-options", "nosniff")
|> send_chunked(200)
MyApp.Repo.transaction(fn ->
MyApp.Repo.stream(MyApp.Accounts.User, max_rows: 500)
|> Stream.map(fn user ->
[Jason.encode_to_iodata!(%{id: user.id, name: user.name, email: user.email}), "
"]
end)
|> Enum.each(fn chunk ->
case chunk(conn, chunk) do
{:ok, conn} -> conn
{:error, :closed} -> MyApp.Repo.rollback(:client_disconnected)
end
end)
end)
conn
end
# Stream a JSON array — bracket the output manually
def export_array(conn, _params) do
conn =
conn
|> put_resp_content_type("application/json")
|> send_chunked(200)
{:ok, conn} = chunk(conn, "[")
{conn, first} =
MyApp.Repo.transaction(fn ->
MyApp.Repo.stream(MyApp.Accounts.User)
|> Enum.reduce({conn, true}, fn user, {conn, first} ->
separator = if first, do: "", else: ","
json_chunk = [separator | Jason.encode_to_iodata!(%{id: user.id, name: user.name})]
case chunk(conn, json_chunk) do
{:ok, conn} -> {conn, false}
{:error, :closed} -> throw(:client_disconnected)
end
end)
end)
chunk(conn, "]")
conn
end
end
# ── Ecto.Repo.stream requires a transaction ────────────────────────
# Always wrap Repo.stream in a transaction; Ecto requires it
# Use max_rows to control how many rows are fetched per DB round-trip
MyApp.Repo.transaction(fn ->
MyApp.Repo.stream(MyApp.Accounts.User, max_rows: 100)
|> Enum.each(fn user -> process(user) end)
end)
# ── encode_to_iodata!/2 with options ──────────────────────────────
iodata = Jason.encode_to_iodata!(%{name: "Alice"}, pretty: true)NDJSON (newline-delimited JSON, also called JSON Lines) is the preferred format for large export streams — each line is a complete JSON object, clients can parse records as they arrive, and there is no need to buffer the entire response. The chunked transfer approach with Ecto.Repo.stream keeps memory usage at roughly max_rows * avg_record_size regardless of total dataset size — a table with 10 million rows streams with the same memory footprint as one with 10 thousand rows. Always handle the { :error, :closed } return from chunk/2 to detect client disconnections and abort the database query early.
Ecto Changeset Error Serialization
Ecto changeset errors are stored as a keyword list of { field, [{message, opts}] } pairs and cannot be JSON-encoded directly. The standard approach is to traverse the changeset with Ecto.Changeset.traverse_errors/2 and format each error into a string or structured map before encoding with Jason.
# ── Ecto changeset error structure ───────────────────────────────
# After a failed Accounts.create_user(%{"name" => "", "email" => "bad"})
# changeset.errors looks like:
# [
# name: [{"can't be blank", [validation: :required]}],
# email: [{"has invalid format", [validation: :format]}]
# ]
# ── traverse_errors/2 — standard approach ─────────────────────────
defmodule MyAppWeb.ChangesetHelpers do
def format_errors(%Ecto.Changeset{} = changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
# Interpolate constraint values: "should be at least %{count} character(s)"
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
end
end
# Result of format_errors/1:
# %{name: ["can't be blank"], email: ["has invalid format"]}
# This is a map with atom keys — Jason.encode!/1 encodes it correctly
# ── In a Phoenix controller ───────────────────────────────────────
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
import MyAppWeb.ChangesetHelpers
def create(conn, %{"user" => user_params}) do
case Accounts.create_user(user_params) do
{:ok, user} ->
conn |> put_status(:created) |> json(%{data: %{id: user.id}})
{:error, %Ecto.Changeset{} = changeset} ->
errors = format_errors(changeset)
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: errors})
# Response: {"errors":{"name":["can't be blank"],"email":["has invalid format"]}}
end
end
end
# ── Nested association errors ─────────────────────────────────────
# traverse_errors handles nested changesets (has_many, embeds_many)
# %{
# name: ["can't be blank"],
# items: [
# %{quantity: ["must be greater than 0"]}, # item 1
# %{} # item 2 (no errors)
# ]
# }
# ── Phoenix-generated error helper (lib/my_app_web.ex) ────────────
# Phoenix generators create this function automatically:
def translate_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
end
# translate_error/1 uses Gettext for i18n error messages
defp translate_error({msg, opts}) do
if count = opts[:count] do
Gettext.dngettext(MyAppWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(MyAppWeb.Gettext, "errors", msg, opts)
end
end
# ── Direct Jason encoding of formatted errors ─────────────────────
changeset
|> format_errors()
|> Jason.encode!()
# '{"email":["has invalid format"],"name":["can\'t be blank"]}'The traverse_errors/2 approach handles deeply nested changeset errors correctly — embedded schemas and has_many associations produce nested maps in the output that match the structure of the input JSON, making it straightforward for clients to map errors back to form fields. Phoenix generators (from mix phx.gen.json) create a translate_error/1 helper that integrates with Gettext for internationalized error messages — use it when your API serves multiple locales. For JSON API design error format conventions (JSON:API spec, RFC 7807 Problem Details), always wrap the errors map in a top-level errors key as shown above.
Key Terms
- Jason library
- A pure-Elixir JSON library (
hex.pm/packages/jason) and the Phoenix Framework default since version 1.7. Jason uses an iodata-based encoding pipeline that avoids intermediate string concatenation, achieving approximately 200 MB/s encode and 150 MB/s decode throughput on modern hardware. It decodes JSON objects to maps with string keys by default and encodes Elixir terms through theJason.Encoderprotocol. Add it with{ :jason, "~> 1.4" }inmix.exsdependencies. Phoenix configures Jason automatically; no additional configuration is required for controllerjson/2calls orrender/3with JSON views. - Poison library
- An older Elixir JSON library (
hex.pm/packages/poison) that was the de-facto standard before Jason became the Phoenix default. Poison provides the samedecode/1,decode!/1,encode/1,encode!/1API surface as Jason but benchmarks at roughly half the throughput (~100 MB/s encode, ~80 MB/s decode). Poison uses thePoison.Encoderprotocol for custom types. New projects should use Jason; existing Poison-based projects can migrate by replacing the dependency, renaming protocol implementations, and updating the Phoenix:json_libraryconfig if overridden. Poison is still maintained but no longer the community recommendation. - Jason.Encoder protocol
- An Elixir protocol that Jason uses to encode custom types and structs. Implement it with
defimpl Jason.Encoder, for: MyStruct do def encode(value, opts) do ... end end. Theencode/2callback receives the value and encoder options; it must call aJason.Encode.*function (map/2,list/2,string/2, etc.) and return the result. The simpler alternative is the@derivemacro:@derive [Jason.Encoder]or@derive { Jason.Encoder, only: [:id, :name] }abovedefstruct. Without an implementation, encoding a struct raisesProtocol.UndefinedError. Ecto schemas require explicit@deriveordefimplbecause they include__meta__and unloaded association fields that cannot be automatically encoded. - decode!/2 vs decode/2
- Two variants of the Jason JSON parsing function.
Jason.decode!/2returns the decoded value directly on success and raisesJason.DecodeErroron invalid JSON — use it when the input is trusted and a crash is acceptable.Jason.decode/2returns{ :ok, value }on success or{ :error, %Jason.DecodeError{ position: pos, token: tok } }on failure — use it with pattern matching in boundary code (controllers, GenServers, LiveView handlers) where errors must be handled gracefully. The same bang vs non-bang convention applies toJason.encode!/2vsJason.encode/2. The second argument to both decode variants is an options keyword list; the most common option iskeys: :atomsto convert string keys to atoms. - atom key risk
- The danger of converting untrusted JSON string keys to Elixir atoms using
Jason.decode!(json, keys: :atoms). Atoms in the BEAM VM are never garbage collected and are stored in a global table with a default limit of 1,048,576 entries. An attacker who can send JSON with arbitrary unique keys can exhaust this table, causing the VM to crash with:system_limit. Usekeys: :atoms!to restrict conversion to atoms that already exist in the codebase — this raisesArgumentErrorfor unknown keys instead of creating new atoms. For external APIs, webhook payloads, and user-supplied JSON, always use the default string keys and access values withmap["key"]orMap.get(map, "key", default). - encode_to_iodata!
- A Jason function (
Jason.encode_to_iodata!/2) that encodes a value to iodata — a nested list of binaries — instead of a single concatenated binary string. Iodata is more efficient for network I/O because the BEAM's socket layer accepts iodata directly, writing each binary segment without allocating a new merged binary. This makes it ideal for streaming JSON responses via Plug chunked transfer:chunk(conn, Jason.encode_to_iodata!(record)). Convert iodata to a binary string when needed withIO.iodata_to_binary(iodata). Also useful for writing JSON to files efficiently:File.write!(path, Jason.encode_to_iodata!(data)). The function raisesJason.EncodeErroron failure — useJason.encode_to_iodata/2for the tuple-returning safe variant.
FAQ
What is the best JSON library for Elixir?
Jason is the best JSON library for new Elixir projects in 2026. It is approximately 2× faster than Poison (~200 MB/s encode vs ~100 MB/s), uses less memory due to iodata-based encoding, and is the Phoenix Framework default since version 1.7. Add it with { :jason, "~> 1.4" } in mix.exs. Poison remains maintained and widely used in existing codebases, but new projects should start with Jason. The two libraries share the same basic API (decode/1, encode!/1) but differ in protocol names and some option flags. Most Hex packages that accept a JSON library accept either, but increasingly target Jason as the primary implementation.
How do I parse JSON in Elixir with Jason?
Use Jason.decode/1 for safe parsing with pattern matching, or Jason.decode!/1 for a direct return that raises on invalid input. Safe form: case Jason.decode(json_string) do {:ok, data} -> data; {:error, error} -> handle_error(error) end. Jason returns maps with string keys by default: %{"name" => "Alice"}. Pass keys: :atoms to get atom keys (%{name: "Alice"}), but only for trusted, fixed-schema input. The second argument accepts options as a keyword list: Jason.decode(json, keys: :atoms). JSON arrays decode to Elixir lists; JSON null decodes to nil; JSON booleans decode to true/false.
How do I handle JSON parse errors in Elixir?
Use Jason.decode/1 (without the bang) and pattern match the result tuple. On failure it returns { :error, %Jason.DecodeError{ position: pos, token: tok, data: raw } } — the position field is the byte offset of the error in the input string, useful for debugging. In Phoenix controllers, match the error and return a 422 response: case Jason.decode(body) do { :ok, params } -> process(params); { :error, _error } -> conn |> put_status(422) |> json(%{ error: "Invalid JSON" }) end. Avoid Jason.decode!/1 on external input — it raises an exception that must be caught with try/rescue, which is less idiomatic in Elixir than tuple pattern matching.
How do I convert JSON string keys to atoms in Elixir?
Pass keys: :atoms to Jason.decode: Jason.decode!(json, keys: :atoms). This converts all string keys to atoms so %{"name" => "Alice"} becomes %{name: "Alice"}. Use keys: :atoms! to only convert keys that already exist as atoms in the BEAM — this raises ArgumentError for unknown keys and prevents atom table exhaustion from attacker-controlled input. Critical warning: atoms in Elixir are never garbage collected. Converting untrusted JSON with unique random keys to atoms will eventually exhaust the atom table (default 1,048,576 limit) and crash the VM. For external APIs and user input, keep the default string keys and use map["key"] or get_in/2 for access.
How do I serialize a custom Elixir struct to JSON?
Either use the @derive macro or implement Jason.Encoder manually. Quick approach: add @derive [Jason.Encoder] above defstruct to include all fields, or @derive {Jason.Encoder, only: [:id, :name]} to whitelist fields. Manual approach: defimpl Jason.Encoder, for: MyStruct do def encode(value, opts) do Jason.Encode.map(%{id: value.id, name: value.name}, opts) end end. Use the manual approach when you need to transform values (convert cents to dollars, format DateTime as ISO 8601), rename fields (camelCase in JSON vs snake_case in Elixir), or exclude sensitive fields. For Ecto schemas, always use except: [:__meta__] to exclude the Ecto metadata struct.
How do I return JSON from a Phoenix controller?
Use json(conn, map) for direct encoding: def show(conn, %{"id" => id}) do json(conn, %{data: %{id: user.id, name: user.name}}) end. Phoenix sets Content-Type: application/json and encodes with Jason automatically. For structured responses, use Phoenix 1.7 JSON views: render(conn, :show, user: user) calls the corresponding UserJSON.show/1 function that returns a plain map. Set custom status codes with conn |> put_status(:created) |> json(%{...}). For error responses, use the fallback controller pattern: action_fallback MyAppWeb.FallbackController centralizes error JSON formatting.
What is the difference between Jason and Poison?
Jason and Poison are both Elixir JSON libraries with similar APIs but different implementations and performance. Jason: ~200 MB/s encode, ~150 MB/s decode, Phoenix default since 1.7, uses Jason.Encoder protocol, iodata-based encoding, active community standard. Poison: ~100 MB/s encode, ~80 MB/s decode, older library, uses Poison.Encoder protocol, string-based encoding, still maintained but no longer the default. API compatibility is high — both use decode/1, decode!/1, encode/1, encode!/1. The main migration changes are: rename the dependency in mix.exs, rename Poison.Encoder protocol implementations to Jason.Encoder, and update the encode/2 callback to use Jason.Encode.map/2. Choose Jason for all new projects.
How do I stream JSON responses in Phoenix?
Use send_chunked(conn, 200) with chunk(conn, iodata) and Jason.encode_to_iodata!/1 for efficient streaming. For NDJSON (newline-delimited JSON): Enum.each(records, fn r -> chunk(conn, [Jason.encode_to_iodata!(r), "\n"]) end). Pair with Ecto.Repo.stream inside a transaction for database streaming with constant memory: Repo.transaction(fn -> Repo.stream(User) |> Stream.each(fn u -> chunk(conn, ...) end) |> Stream.run() end). Use max_rows on Repo.stream to control fetch batch size. Always handle { :error, :closed } from chunk/2 to abort streaming when the client disconnects. NDJSON is preferred over a streaming JSON array because each line is independently parseable.
Further reading and primary sources
- Jason on Hex.pm — Jason package page with version history, download stats, and links to source and documentation
- Jason Documentation — Official HexDocs for Jason: decode/2, encode/2, encode_to_iodata!/2, and the Jason.Encoder protocol
- Poison on Hex.pm — Poison package page and documentation for teams maintaining legacy Elixir JSON code
- Phoenix: JSON and APIs — Official Phoenix guide covering JSON views, json/2, and API controller patterns
- Ecto.Changeset.traverse_errors/2 — Ecto documentation for traversing and formatting changeset validation errors for JSON responses