JSON in Elixir/Phoenix: Jason, json/2 Plug, Ecto Embeds & API Mode
Last updated:
Elixir and Phoenix handle JSON through Jason — the fastest Elixir JSON library. Jason.encode!(map) serializes Elixir maps, lists, and structs to JSON; Jason.decode!(json_string) parses JSON to a map with string keys. In Phoenix controllers, conn |> json(%{status: "ok"}) sets Content-Type: application/json and serializes automatically with no explicit Jason call. Phoenix's endpoint.ex configures Jason as the parser for all Content-Type: application/json requests, making JSON body data available in controllers via conn.body_params. Ecto embeds_one and embeds_many map nested JSON objects to typed Elixir structs stored as JSONB in PostgreSQL, with changeset validation. Phoenix API mode (mix phx.new --no-html) generates a lean JSON-only application. This guide covers Jason encoding/decoding, Phoenix json/2, reading JSON request bodies, Ecto embeds for nested JSON, hiding sensitive fields, Phoenix API mode setup, and FallbackController for centralized error responses.
Jason: Encoding and Decoding JSON in Elixir
Jason is the standard JSON library in the Elixir ecosystem, shipping as the default in Phoenix, Ecto, and most production libraries. It encodes Elixir maps, lists, strings, numbers, booleans, and nil to JSON, and decodes JSON strings back to Elixir terms. The bang variants — Jason.encode!/1 and Jason.decode!/1 — raise Jason.EncodeError or Jason.DecodeError on failure; the non-bang variants return {:ok, result} or {:error, reason} tuples for pattern-matched error handling. Jason defaults to string keys on decode; use Jason.decode!(json, keys: :atoms) to get atom keys, though this is discouraged for untrusted input because atom creation is not garbage-collected in the BEAM.
# mix.exs — add Jason as a dependency
defp deps do
[
{:jason, "~> 1.4"},
# Jason is already included when you use Phoenix
]
end
# ── Encoding: Elixir map → JSON string ──────────────────────────
Jason.encode!(%{name: "Alice", age: 30, active: true})
# => ~s({"active":true,"age":30,"name":"Alice"})
# Note: keys are sorted alphabetically by default
Jason.encode!([1, "two", nil, false])
# => "[1,\"two\",null,false]"
# Pretty-print with indent option (2 spaces)
Jason.encode!(%{user: %{id: 1, name: "Alice"}}, pretty: true)
# => """
# {
# "user": {
# "id": 1,
# "name": "Alice"
# }
# }
# """
# ── Decoding: JSON string → Elixir map (string keys) ────────────
Jason.decode!(~s({"name":"Alice","age":30}))
# => %{"name" => "Alice", "age" => 30}
Jason.decode!(~s([1, 2, 3]))
# => [1, 2, 3]
# Atom keys — only use for trusted/internal data
Jason.decode!(~s({"status":"ok"}), keys: :atoms)
# => %{status: "ok"}
# ── Non-bang variants for explicit error handling ─────────────────
case Jason.decode(json_string) do
{:ok, data} -> process(data)
{:error, %Jason.DecodeError{} = err} ->
Logger.error("JSON parse failed: #{Exception.message(err)}")
{:error, :invalid_json}
end
# ── Structs require Jason.Encoder protocol implementation ────────
defmodule MyApp.Product do
defstruct [:id, :name, :price, :internal_cost]
# Implement Jason.Encoder to control JSON output
defimpl Jason.Encoder, for: MyApp.Product do
def encode(product, opts) do
Jason.Encode.map(%{
id: product.id,
name: product.name,
price: product.price
# internal_cost intentionally excluded
}, opts)
end
end
end
product = %MyApp.Product{id: 1, name: "Widget", price: 9.99, internal_cost: 3.50}
Jason.encode!(product)
# => ~s({"id":1,"name":"Widget","price":9.99})
# ── Streaming large JSON (encode_to_iodata!/1 for efficiency) ────
# Returns an IO data list instead of a binary string — faster for
# HTTP responses because Plug sends iodata directly without copying
Jason.encode_to_iodata!(%{large: "payload"})
# => [["{", ["\"large\"", ":", "\"payload\""], "}"]]Jason outperforms Poison because it uses binary pattern matching optimized for the BEAM VM, processing JSON in fewer reductions than character-by-character approaches. For HTTP API responses, prefer Jason.encode_to_iodata!/1 over Jason.encode!/1 — it returns IO data (nested lists of binaries) that Plug.Conn sends to the socket without an extra binary-concatenation step, reducing memory pressure for large responses. Phoenix's json/2 plug calls encode_to_iodata!/1 internally, so you get this optimization automatically when using the controller helper.
Phoenix Controllers: json/2 for JSON Responses
Phoenix controllers respond with JSON by piping conn through json/2, a function imported automatically from Phoenix.Controller. It serializes the second argument using Jason, sets the Content-Type header to application/json; charset=utf-8, and sends the response. You do not call Jason directly in controller actions — json/2 handles the full response cycle. To change the HTTP status code, call put_status/2 before json/2; the default is 200.
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
# json/2 is imported by Phoenix.Controller — no Jason import needed
# ── GET /api/users — list all users ─────────────────────────────
def index(conn, _params) do
users = MyApp.Accounts.list_users()
conn
|> json(%{
data: Enum.map(users, &user_json/1),
total: length(users)
})
end
# ── GET /api/users/:id — fetch one user ─────────────────────────
def show(conn, %{"id" => id}) do
case MyApp.Accounts.get_user(id) do
nil ->
conn
|> put_status(:not_found)
|> json(%{error: "User not found"})
user ->
conn |> json(%{data: user_json(user)})
end
end
# ── POST /api/users — create a user ─────────────────────────────
def create(conn, %{"user" => user_params}) do
case MyApp.Accounts.create_user(user_params) do
{:ok, user} ->
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/users/#{user.id}")
|> json(%{data: user_json(user)})
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: format_errors(changeset)})
end
end
# ── DELETE /api/users/:id ────────────────────────────────────────
def delete(conn, %{"id" => id}) do
with {:ok, user} <- MyApp.Accounts.get_user_or_error(id),
{:ok, _} <- MyApp.Accounts.delete_user(user) do
send_resp(conn, :no_content, "")
end
end
# ── Private helpers: build safe JSON map from struct ─────────────
defp user_json(user) do
%{
id: user.id,
name: user.name,
email: user.email,
inserted_at: user.inserted_at
# password_hash intentionally excluded
}
end
defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
end
endPhoenix 1.7 introduced JSON views — modules like MyAppWeb.UserJSON with functions index/1, show/1, and render/2 — that separate serialization logic from controller logic. The mix phx.gen.json task generates these view modules automatically. In earlier Phoenix versions the same pattern was accomplished with render/2 and separate view modules. Both approaches produce the same output; the 1.7 JSON view pattern scales better for large APIs because each context has its own named view module and the serialization rules are co-located with the schema rather than scattered across controllers.
Reading JSON Request Bodies in Phoenix
Phoenix reads JSON request bodies automatically through the Plug.Parsers configuration in endpoint.ex. When a request arrives with Content-Type: application/json, the :json parser decodes the body using Jason and makes the result available as conn.body_params, merged into conn.params. Controller actions receive these as the second argument, pattern-matchable against the expected JSON structure.
# endpoint.ex — configure the JSON parser once for the whole app
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Jason,
# Default body size limit is 8 MB; increase for large payloads:
# length: 50_000_000
# ── Controller: params automatically parsed from JSON body ────────
defmodule MyAppWeb.OrderController do
use MyAppWeb, :controller
# POST /api/orders with JSON body:
# { "order": { "product_id": 42, "quantity": 3, "notes": "urgent" } }
def create(conn, %{"order" => order_params}) do
# order_params is already an Elixir map with string keys:
# %{"product_id" => 42, "quantity" => 3, "notes" => "urgent"}
case MyApp.Orders.create_order(order_params) do
{:ok, order} -> conn |> put_status(:created) |> json(%{id: order.id})
{:error, cs} -> conn |> put_status(422) |> json(%{errors: format_errors(cs)})
end
end
# ── Reading nested JSON arrays ────────────────────────────────────
# POST /api/orders/bulk with:
# { "items": [{"product_id": 1, "qty": 2}, {"product_id": 5, "qty": 1}] }
def bulk_create(conn, %{"items" => items}) when is_list(items) do
results = Enum.map(items, &MyApp.Orders.create_order/1)
conn |> json(%{created: length(results)})
end
end
# ── Accessing raw body (e.g., for webhook HMAC verification) ──────
defmodule MyAppWeb.WebhookController do
use MyAppWeb, :controller
# IMPORTANT: read_body/1 only works if Plug.Parsers has NOT
# already consumed the body. Use a custom plug or cache_body_reader
# in endpoint.ex to make the raw body available alongside parsing.
def handle(conn, _params) do
{:ok, raw_body, conn} = read_body(conn)
signature = get_req_header(conn, "x-signature") |> List.first()
if valid_signature?(raw_body, signature) do
{:ok, event} = Jason.decode(raw_body)
process_event(event)
send_resp(conn, :ok, "")
else
send_resp(conn, :unauthorized, "Invalid signature")
end
end
defp valid_signature?(body, sig) do
expected = :crypto.mac(:hmac, :sha256, webhook_secret(), body)
|> Base.encode16(case: :lower)
Plug.Crypto.secure_compare(expected, sig || "")
end
defp webhook_secret, do: Application.fetch_env!(:my_app, :webhook_secret)
end
# ── Custom JSON parsing with Jason.decode/1 in a plain Plug ──────
defmodule MyApp.JsonBodyPlug do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
with ["application/json" <> _] <- get_req_header(conn, "content-type"),
{:ok, body, conn} <- read_body(conn),
{:ok, parsed} <- Jason.decode(body) do
%{conn | body_params: parsed, params: Map.merge(conn.params, parsed)}
else
_ -> conn
end
end
endA common mistake is calling read_body/1 in a controller action after Plug.Parsers has already consumed the body — the second read returns an empty binary. For webhook handlers that need both the raw body (for signature verification) and parsed params, configure a custom cache_body_reader in Plug.Parsers that stores the raw body on the conn's private map before passing it to Jason. This pattern is documented in the Phoenix security guides for Stripe and GitHub webhook integration.
Ecto Embeds: Mapping Nested JSON to Elixir Structs
Ecto's embeds_one/3 and embeds_many/3 macros map nested JSON objects to typed Elixir structs stored as JSONB columns in PostgreSQL. Unlike has_one and has_many associations (which use separate tables and foreign keys), embeds store the nested data inside the parent row's JSONB column. This eliminates join queries for common read patterns while preserving full Ecto changeset validation and casting for the nested structure.
# ── Embedded schema definition ────────────────────────────────────
defmodule MyApp.Address do
use Ecto.Schema
import Ecto.Changeset
@primary_key false # embeds typically don't need their own ID
embedded_schema do
field :street, :string
field :city, :string
field :state, :string
field :zip, :string
field :country, :string, default: "US"
end
def changeset(address, params) do
address
|> cast(params, [:street, :city, :state, :zip, :country])
|> validate_required([:street, :city, :state, :zip])
|> validate_length(:zip, min: 5, max: 10)
end
end
defmodule MyApp.OrderItem do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :product_id, :integer
field :name, :string
field :quantity, :integer
field :unit_price, :decimal
end
def changeset(item, params) do
item
|> cast(params, [:product_id, :name, :quantity, :unit_price])
|> validate_required([:product_id, :quantity, :unit_price])
|> validate_number(:quantity, greater_than: 0)
|> validate_number(:unit_price, greater_than_or_equal_to: 0)
end
end
defmodule MyApp.Order do
use Ecto.Schema
import Ecto.Changeset
schema "orders" do
field :status, :string, default: "pending"
field :total_cents, :integer
embeds_one :shipping_address, MyApp.Address, on_replace: :update
embeds_many :items, MyApp.OrderItem, on_replace: :delete
timestamps()
end
def changeset(order, params) do
order
|> cast(params, [:status, :total_cents])
|> cast_embed(:shipping_address, with: &MyApp.Address.changeset/2)
|> cast_embed(:items, with: &MyApp.OrderItem.changeset/2)
|> validate_required([:status])
|> validate_inclusion(:status, ~w(pending confirmed shipped delivered))
end
end
# Migration — JSONB columns for embeds
# priv/repo/migrations/20260528000001_create_orders.exs
defmodule MyApp.Repo.Migrations.CreateOrders do
use Ecto.Migration
def change do
create table(:orders) do
add :status, :string, null: false, default: "pending"
add :total_cents, :integer
add :shipping_address, :map # embeds_one → :map (JSONB)
add :items, {:array, :map} # embeds_many → array of JSONB
timestamps()
end
end
end
# ── Using embeds in a controller action ────────────────────────────
# JSON body:
# {
# "status": "pending",
# "shipping_address": { "street": "123 Main St", "city": "Portland", "state": "OR", "zip": "97201" },
# "items": [
# { "product_id": 10, "name": "Widget", "quantity": 2, "unit_price": "9.99" },
# { "product_id": 11, "name": "Gadget", "quantity": 1, "unit_price": "24.50" }
# ]
# }
def create_order(conn, %{"order" => params}) do
changeset = MyApp.Order.changeset(%MyApp.Order{}, params)
case MyApp.Repo.insert(changeset) do
{:ok, order} ->
# order.shipping_address is %MyApp.Address{} with atom keys
# order.items is [%MyApp.OrderItem{}, ...] — fully typed
conn |> put_status(:created) |> json(order_to_json(order))
{:error, changeset} ->
errors = Ecto.Changeset.traverse_errors(changeset, fn {msg, _} -> msg end)
conn |> put_status(:unprocessable_entity) |> json(%{errors: errors})
end
endEcto validates embedded schemas recursively through cast_embed/3. If any field in the embed fails validation, the parent changeset is marked invalid and errors appear nested under the embed's key in the error map. When you load an order from the database, order.shipping_address is an %Address{} struct with atom keys — no manual JSON parsing or key conversion required. The on_replace: :delete option on embeds_many means that updating the items list replaces the entire array atomically; on_replace: :update on embeds_one preserves the existing struct and merges changes.
Hiding Sensitive Fields from JSON Output
Elixir structs do not automatically exclude fields when serialized to JSON. Passing a struct to json/2 directly will serialize all fields — including password_hash, reset_token, or any other sensitive data. The correct approach is to build an explicit map with only the fields safe to expose, either through a helper function, the Jason.Encoder protocol, or Phoenix JSON views.
# ── WRONG: sends all fields including password_hash ──────────────
# json(conn, user) ← Do NOT do this
# ── Approach 1: Map.from_struct + Map.drop ────────────────────────
# Quick, works anywhere, no protocol needed
defp safe_user_map(user) do
user
|> Map.from_struct()
|> Map.drop([:password_hash, :reset_token, :__meta__])
# :__meta__ is added by Ecto.Schema — always drop it
end
# In a controller:
conn |> json(%{data: safe_user_map(user)})
# ── Approach 2: Explicit map construction (recommended) ───────────
# Explicit is better — you choose exactly which fields to expose
defp user_to_json(%MyApp.User{} = user) do
%{
id: user.id,
name: user.name,
email: user.email,
role: user.role,
inserted_at: DateTime.to_iso8601(user.inserted_at)
# password_hash never mentioned → never serialized
}
end
# ── Approach 3: Jason.Encoder protocol implementation ────────────
# Define once on the struct; works anywhere Jason.encode!/1 is called
defimpl Jason.Encoder, for: MyApp.User do
def encode(user, opts) do
Jason.Encode.map(%{
id: user.id,
name: user.name,
email: user.email,
role: user.role
}, opts)
end
end
# Now Jason.encode!(user) and conn |> json(user) both use this impl
# ── Approach 4: Phoenix 1.7 JSON views ────────────────────────────
# lib/my_app_web/controllers/user_json.ex
defmodule MyAppWeb.UserJSON do
# Called by render("user.json", %{user: user}) in controller
def show(%{user: user}), do: %{data: data(user)}
def index(%{users: us}), do: %{data: Enum.map(us, &data/1)}
defp data(user) do
%{
id: user.id,
name: user.name,
email: user.email
}
end
end
# In controller — Phoenix calls UserJSON.show/1 automatically:
def show(conn, %{"id" => id}) do
user = MyApp.Accounts.get_user!(id)
render(conn, :show, user: user) # invokes UserJSON.show/1
end
# ── Handling __meta__ (Ecto internal field) ────────────────────────
# Every Ecto schema struct has a :__meta__ field from Ecto.Schema.Metadata
# Jason does NOT know how to encode it by default and will raise.
# Always use explicit map construction or the protocol to avoid this.
# Safe pattern — never pass an Ecto struct directly to json/2:
# ❌ json(conn, user) ← raises Jason.EncodeError for __meta__
# ❌ json(conn, Map.from_struct(user)) ← __meta__ still present, raises
# ✅ json(conn, user_to_json(user)) ← explicit map, always safe
# ✅ json(conn, Map.drop(Map.from_struct(user), [:__meta__, :password_hash]))The Jason.Encoder protocol approach is most ergonomic for frequently-serialized structs because it applies globally — any call to Jason.encode!/1 with a MyApp.User struct uses the protocol implementation automatically. However, it defines a single representation per struct type. If you need different JSON shapes for different contexts (e.g., admin vs. public user data), use the explicit helper function approach and call the appropriate helper in each controller or JSON view.
Phoenix API Mode: JSON-Only Applications
Phoenix API mode creates a project configured purely for JSON API development. Generated with mix phx.new myapp --no-html, it omits HTML templates, the Tailwind CSS pipeline, LiveView, and the browser-oriented plugs. The resulting application uses the :api pipeline in the router, which accepts only JSON requests and applies no CSRF protection or session management — appropriate for stateless API backends.
# Generate a new Phoenix JSON API application
# (--no-html removes templates/assets; --no-live removes LiveView)
$ mix phx.new myapi --no-html
# Or to also exclude Ecto (e.g., for a gateway/proxy):
$ mix phx.new myapi --no-html --no-ecto
# ── Generated router.ex — API-only pipeline ────────────────────────
defmodule MyApiWeb.Router do
use MyApiWeb, :router
pipeline :api do
plug :accepts, ["json"]
# No :fetch_session, no :protect_from_forgery, no :fetch_flash
# These browser plugs don't apply to JSON APIs
end
scope "/api", MyApiWeb do
pipe_through :api
resources "/users", UserController, except: [:new, :edit]
resources "/orders", OrderController, except: [:new, :edit]
# :new and :edit render HTML forms — not used in API mode
end
end
# ── Scaffold a full JSON CRUD resource ───────────────────────────
# Generates controller, view, context functions, Ecto schema, migration
$ mix phx.gen.json Accounts User users name:string email:string role:string
# Output files:
# lib/my_api_web/controllers/user_controller.ex
# lib/my_api_web/controllers/user_json.ex ← JSON view (Phoenix 1.7+)
# lib/my_api/accounts/user.ex ← Ecto schema
# lib/my_api/accounts.ex ← context module
# priv/repo/migrations/20260528_create_users.exs
# test/my_api_web/controllers/user_controller_test.exs
# ── Generated endpoint.ex — Plug.Parsers with Jason ──────────────
# The JSON parser is configured by default in API mode:
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Jason
# ── FallbackController — centralized error JSON responses ──────────
# Generated at: lib/my_api_web/controllers/fallback_controller.ex
defmodule MyApiWeb.FallbackController do
use Phoenix.Controller
# Ecto changeset errors — HTTP 422 Unprocessable Entity
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> put_view(json: MyApiWeb.ChangesetJSON)
|> render(:error, changeset: changeset)
end
# Not found errors — HTTP 404
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> put_view(json: MyApiWeb.ErrorJSON)
|> render(:"404")
end
# Unauthorized — HTTP 401
def call(conn, {:error, :unauthorized}) do
conn
|> put_status(:unauthorized)
|> put_view(json: MyApiWeb.ErrorJSON)
|> render(:"401")
end
end
# ── Using FallbackController in a resource controller ────────────
defmodule MyApiWeb.UserController do
use MyApiWeb, :controller
# Delegate unhandled {:error, _} returns to FallbackController
action_fallback MyApiWeb.FallbackController
def show(conn, %{"id" => id}) do
# Returns {:error, :not_found} if user doesn't exist
# FallbackController catches it and renders the 404 JSON
with {:ok, user} <- MyApp.Accounts.fetch_user(id) do
render(conn, :show, user: user)
end
end
endPhoenix API mode applications still include all of Ecto, Phoenix.PubSub, and Phoenix.Channels — you can add WebSocket JSON messaging or PubSub-based real-time features without restructuring the project. The mix phx.gen.json scaffold creates 1 controller, 1 JSON view, 1 context module, 1 schema, and 1 migration in a single command, wiring them together with the FallbackController pattern. This scaffolding generates a fully functional CRUD API that handles validation errors, not-found responses, and proper HTTP status codes out of the box.
JSON Error Responses and FallbackController
Consistent JSON error responses across a Phoenix API require the FallbackController pattern. Rather than handling errors in each action with put_status/2 and json/2, controllers return tagged tuples like {:error, :not_found} or {:error, changeset} from failed with expressions. action_fallback/1 registers the FallbackController to handle these tuples, keeping action bodies focused on the happy path.
# ── Context module — returns tagged tuples ────────────────────────
defmodule MyApp.Accounts do
alias MyApp.Repo
alias MyApp.Accounts.User
def fetch_user(id) do
case Repo.get(User, id) do
nil -> {:error, :not_found}
user -> {:ok, user}
end
end
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
# Returns {:ok, user} or {:error, changeset}
end
def update_user(%User{} = user, attrs) do
user
|> User.changeset(attrs)
|> Repo.update()
# Returns {:ok, user} or {:error, changeset}
end
end
# ── FallbackController — handles all error tuples ─────────────────
defmodule MyApiWeb.FallbackController do
use Phoenix.Controller
# 422 — Ecto validation errors
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
errors =
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, val}, acc ->
String.replace(acc, "%{#{key}}", to_string(val))
end)
end)
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: errors})
end
# 404 — resource not found
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> json(%{error: "Not found"})
end
# 401 — unauthenticated
def call(conn, {:error, :unauthorized}) do
conn
|> put_status(:unauthorized)
|> json(%{error: "Unauthorized"})
end
# 403 — forbidden
def call(conn, {:error, :forbidden}) do
conn
|> put_status(:forbidden)
|> json(%{error: "Forbidden"})
end
# 409 — conflict (e.g., duplicate unique field)
def call(conn, {:error, :conflict}) do
conn
|> put_status(:conflict)
|> json(%{error: "Resource conflict"})
end
end
# ── Controller — clean happy-path-focused actions ─────────────────
defmodule MyApiWeb.UserController do
use MyApiWeb, :controller
action_fallback MyApiWeb.FallbackController
# GET /api/users/:id
def show(conn, %{"id" => id}) do
with {:ok, user} <- MyApp.Accounts.fetch_user(id) do
json(conn, %{data: user_json(user)})
end
# If fetch_user returns {:error, :not_found}, FallbackController
# handles it — no error code in this action needed
end
# POST /api/users
def create(conn, %{"user" => params}) do
with {:ok, user} <- MyApp.Accounts.create_user(params) do
conn
|> put_status(:created)
|> json(%{data: user_json(user)})
end
# If create_user returns {:error, changeset}, FallbackController
# sends a 422 with the validation errors JSON
end
# PATCH /api/users/:id
def update(conn, %{"id" => id, "user" => params}) do
with {:ok, user} <- MyApp.Accounts.fetch_user(id),
{:ok, updated_user} <- MyApp.Accounts.update_user(user, params) do
json(conn, %{data: user_json(updated_user)})
end
end
defp user_json(u), do: %{id: u.id, name: u.name, email: u.email}
endThe FallbackController pattern ensures that every error type produces a consistent JSON structure throughout the API. Adding a new error type (e.g., {:error, :rate_limited}) requires a single new call/2 clause in the FallbackController, rather than editing every controller that might encounter the error. For APIs following RFC 9457 (Problem Details for HTTP APIs), replace the simple json(%{error: ...}) calls in the FallbackController with the RFC 9457 format: json(%{type: "...", title: "...", status: 422, detail: "..."}).
Key Terms
- Jason
- Jason is a pure-Elixir JSON library that encodes and decodes JSON using binary pattern matching on the BEAM VM. It is 2–3× faster than Poison for most workloads and ships as the default JSON library in Phoenix.
Jason.encode!/1converts Elixir maps, lists, strings, numbers, booleans, and nil to JSON strings.Jason.decode!/1converts JSON strings to Elixir maps with string keys. TheJason.Encoderprotocol allows custom structs to define their own JSON serialization.Jason.encode_to_iodata!/1returns IO data for efficient HTTP responses without binary concatenation. Add Jason to an Elixir project with{:jason, "~> 1.4"}inmix.exs. - json/2 (Phoenix.Controller)
json/2is a helper function fromPhoenix.Controllerthat serializes an Elixir map or list to JSON, sets theContent-Type: application/json; charset=utf-8response header, and sends the response. It accepts aconnstruct and any Jason-encodable value.json/2internally callsJason.encode_to_iodata!/1for efficiency. Status codes default to 200; useput_status/2beforejson/2to set a different code.json/2is automatically imported in Phoenix controllers viause MyAppWeb, :controller.- Plug.Parsers
Plug.Parsersis a Plug middleware that reads and parses HTTP request bodies. Configured in Phoenix'sendpoint.ex, it supports URL-encoded forms (:urlencoded), multipart (:multipart), and JSON (:json) parsers. For JSON requests withContent-Type: application/json, it uses the configuredjson_decoder(Jason by default) to parse the body and makes the result available asconn.body_params. The default body size limit is 8 MB; adjust with thelengthoption.Plug.Parsersconsumes the request body on first read; usecache_body_readerto preserve the raw body for HMAC webhook verification.- Ecto embeds
- Ecto embeds (
embeds_one/3andembeds_many/3) define nested data structures stored inside a parent schema's JSONB column rather than in a separate database table. Anembeds_onefield stores a single nested object;embeds_manystores a JSON array of objects. Embedded schemas useEcto.Changeset.cast_embed/3for recursive casting and validation. When loaded from the database, embedded data is automatically deserialized to typed Elixir structs. Migrations use:mapfor single embeds and{:array, :map}for multiple embeds. Theon_replace:option controls behavior when embedded data is updated —:deletefor lists,:updatefor single embeds. - FallbackController
FallbackControlleris a Phoenix design pattern for centralizing error handling across multiple controllers. A controller registers it withaction_fallback MyAppWeb.FallbackController; when an action returns an{:error, reason}tuple (from a failedwithexpression), Phoenix passes it to the FallbackController'scall/2function. The FallbackController implementscall/2clauses for each error type — Ecto changeset,:not_found,:unauthorized, etc. — and returns the appropriate JSON error response and HTTP status code. This pattern eliminates repetitive error-handling code in individual controller actions.- Phoenix API mode
- Phoenix API mode is a project configuration generated by
mix phx.new appname --no-htmlthat omits HTML templates, the asset pipeline, LiveView, and browser-specific Plugs. The router uses the:apipipeline withplug :accepts, ["json"]instead of the:browserpipeline with sessions and CSRF protection. API mode is appropriate for pure JSON API backends — mobile app APIs, microservices, or headless backends. The application still includes Ecto, Phoenix.PubSub, and Phoenix.Channels. JSON CRUD resources are scaffolded withmix phx.gen.json, which generates the controller, JSON view, context, schema, and migration automatically.
FAQ
How do I return a JSON response in Phoenix?
Use conn |> json(%{key: "value"}) in any Phoenix controller action. The json/2 function — imported automatically from Phoenix.Controller — sets the Content-Type header to application/json, serializes the map using Jason, and sends the response. You do not need to call Jason manually. Status codes default to 200; set a different status with put_status/2 before calling json/2: conn |> put_status(:created) |> json(%{id: user.id}). For error responses, conn |> put_status(:unprocessable_entity) |> json(%{errors: errors}) returns HTTP 422. The recommended pattern for APIs is using action_fallback/1 with a FallbackController so that individual controller actions only handle the happy path and return tagged tuples for errors. Phoenix 1.7 projects use JSON views — modules like UserJSON with show/1 and index/1 functions — that render/3 calls automatically.
How do I parse a JSON request body in Phoenix?
Phoenix parses JSON request bodies automatically when the request includes a Content-Type: application/json header. The Plug.Parsers middleware in endpoint.ex handles decoding with Jason before the request reaches your controller. In controller actions, parsed parameters are available as the second argument — pattern-match directly against the expected structure: def create(conn, %{"user" => user_params}) do ... end. For raw body access — for example, to verify an HMAC webhook signature — use read_body(conn) to get the binary before Plug.Parsers consumes it, then call Jason.decode!/1 manually. Because Plug.Parsers consumes the body on first read, webhook handlers typically configure a cache_body_reader in the Plug.Parsers options to store the raw body on the conn for later access. The default body size limit is 8 MB; increase it with the length: option.
What is Jason and why does Phoenix use it?
Jason is the fastest pure-Elixir JSON library, 2–3× faster than Poison for encoding and decoding in benchmarks. Phoenix adopted Jason as its default starting with Phoenix 1.4. Jason.encode!/1 converts Elixir maps, lists, strings, numbers, booleans, and nil to JSON strings; Jason.decode!/1 parses JSON strings to Elixir maps with string keys. Jason achieves its speed by using binary pattern matching optimized for the BEAM VM rather than character-by-character processing. The Jason.Encoder protocol lets any Elixir struct define custom JSON serialization by implementing an encode/2 callback. Jason.encode_to_iodata!/1 returns IO data — a nested list of binaries — that Plug sends directly to the socket without an extra copy step, reducing memory usage for large responses. Install Jason with {:jason, "~> 1.4"} in mix.exs; Phoenix projects include it by default.
How do I validate JSON input with Ecto changesets?
Ecto changesets validate JSON input by casting a string-keyed map (from Phoenix's JSON body parser) into typed schema fields, then running validation rules. In a controller, pass the params to a changeset function: changeset = User.changeset(%User{}, params). Inside the changeset, Ecto.Changeset.cast/4 converts string keys to atom keys and coerces values to declared types. Validation functions like validate_required/2, validate_length/3, validate_format/3, and validate_number/3 add errors to the changeset. Check changeset.valid? before calling Repo.insert/2. To return validation errors as JSON, use Ecto.Changeset.traverse_errors/2 on the invalid changeset and serialize the result with a 422 status. The FallbackController pattern handles this automatically — any controller using action_fallback/1 sends changeset errors to a centralized handler that formats and returns the 422 JSON response.
How do Ecto embeds map to JSON structure?
Ecto embeds_one/3 maps a nested JSON object to an Elixir struct stored in a JSONB column; embeds_many/3 maps a JSON array of objects to a list of structs in a JSONB array column. For example, a User schema with embeds_one :address, Address stores the address as a single JSONB object in the address column. When Phoenix parses a JSON request body containing {"address": {"street": "123 Main St", "city": "Portland"}}, casting with cast_embed(:address, with: &Address.changeset/2) validates and converts the nested map recursively. Loading the user from the database returns user.address as an %Address{} struct with atom keys — no manual JSON parsing needed. Migrations use add :address, :map for embeds_one and add :items, {:array, :map} for embeds_many. 1 embedded schema replaces 1 join table for data that is always read with its parent.
How do I exclude sensitive fields from Phoenix JSON responses?
Phoenix does not automatically exclude struct fields from JSON serialization. Passing an Ecto struct directly to json/2 will include all fields — including password_hash and reset_token — and will also raise a Jason.EncodeError for the :__meta__ field that Ecto adds to every schema struct. The correct approach is to build an explicit map with only safe fields before serializing. Three patterns work well. First, explicit map construction: %{id: user.id, name: user.name, email: user.email} — declare exactly what to expose. Second, Map.drop(Map.from_struct(user), [:password_hash, :reset_token, :__meta__]) — useful for dropping a small number of fields from a large struct. Third, implement Jason.Encoder for the struct — defines the serialization globally so any call to Jason.encode!/1 uses the safe representation. Phoenix 1.7 JSON views (UserJSON modules) formalize the explicit map approach with named functions per action context.
What is Phoenix API mode and when should I use it?
Phoenix API mode — generated with mix phx.new myapp --no-html — creates a Phoenix application without HTML templates, the Tailwind/esbuild asset pipeline, LiveView, or browser-specific Plugs. The resulting project is roughly 30% smaller and compiles faster than a full Phoenix project. API mode is the right choice when building a pure JSON backend: a mobile app API, a microservice, a headless CMS API, or a backend-for-frontend (BFF) service. The router's :api pipeline applies only :accepts, ["json"] — no CSRF protection, no session management, no cookie handling — which is correct for stateless JSON APIs that authenticate via tokens. The project still includes Ecto (for database access), Phoenix.PubSub, and Phoenix.Channels (for optional WebSocket JSON). Scaffold JSON resources with mix phx.gen.json, which generates 5 files (controller, JSON view, context, schema, migration) wired together in 1 command.
How do I return a JSON error response in Phoenix?
Phoenix offers three levels of error response patterns. For simple inline errors in a controller action, combine put_status/2 and json/2: conn |> put_status(:not_found) |> json(%{error: "User not found"}). For validation errors from Ecto changesets, use put_status(:unprocessable_entity) (HTTP 422) and serialize Ecto.Changeset.traverse_errors/2. For centralized error handling across all controllers, use the FallbackController pattern — add action_fallback MyAppWeb.FallbackController to each controller and return {:error, :not_found} or {:error, changeset} from with expression failures. Common HTTP status atoms in Phoenix: :ok (200), :created (201), :no_content (204), :bad_request (400), :unauthorized (401), :forbidden (403), :not_found (404), :unprocessable_entity (422), :too_many_requests (429). For RFC 9457 Problem Details compliance, structure the error body as %{type: "...", title: "...", status: 422, detail: "..."}.
Validate and format JSON instantly
Use Jsonic's free online tools to format, validate, and transform your Elixir/Phoenix API JSON payloads before writing your changeset code.
Open JSON FormatterFurther reading and primary sources
- Jason Documentation — Official Jason docs: encoding, decoding, the Encoder protocol, and performance notes
- Phoenix.Controller — json/2 — Phoenix controller docs for the json/2 response helper
- Ecto.Changeset — cast_embed/3 — Ecto documentation for casting and validating embedded schemas
- Phoenix Guides: JSON API — Official Phoenix guide for building JSON APIs with mix phx.gen.json
- Plug.Parsers documentation — Plug.Parsers options including json_decoder, length limits, and cache_body_reader