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
end

Phoenix 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
end

A 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
end

Ecto 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
end

Phoenix 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}
end

The 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!/1 converts Elixir maps, lists, strings, numbers, booleans, and nil to JSON strings. Jason.decode!/1 converts JSON strings to Elixir maps with string keys. The Jason.Encoder protocol allows custom structs to define their own JSON serialization. Jason.encode_to_iodata!/1 returns IO data for efficient HTTP responses without binary concatenation. Add Jason to an Elixir project with {:jason, "~> 1.4"} in mix.exs.
json/2 (Phoenix.Controller)
json/2 is a helper function from Phoenix.Controller that serializes an Elixir map or list to JSON, sets the Content-Type: application/json; charset=utf-8 response header, and sends the response. It accepts a conn struct and any Jason-encodable value. json/2 internally calls Jason.encode_to_iodata!/1 for efficiency. Status codes default to 200; use put_status/2 before json/2 to set a different code. json/2 is automatically imported in Phoenix controllers via use MyAppWeb, :controller.
Plug.Parsers
Plug.Parsers is a Plug middleware that reads and parses HTTP request bodies. Configured in Phoenix's endpoint.ex, it supports URL-encoded forms (:urlencoded), multipart (:multipart), and JSON (:json) parsers. For JSON requests with Content-Type: application/json, it uses the configured json_decoder (Jason by default) to parse the body and makes the result available as conn.body_params. The default body size limit is 8 MB; adjust with the length option. Plug.Parsers consumes the request body on first read; use cache_body_reader to preserve the raw body for HMAC webhook verification.
Ecto embeds
Ecto embeds (embeds_one/3 and embeds_many/3) define nested data structures stored inside a parent schema's JSONB column rather than in a separate database table. An embeds_one field stores a single nested object; embeds_many stores a JSON array of objects. Embedded schemas use Ecto.Changeset.cast_embed/3 for recursive casting and validation. When loaded from the database, embedded data is automatically deserialized to typed Elixir structs. Migrations use :map for single embeds and {:array, :map} for multiple embeds. The on_replace: option controls behavior when embedded data is updated — :delete for lists, :update for single embeds.
FallbackController
FallbackController is a Phoenix design pattern for centralizing error handling across multiple controllers. A controller registers it with action_fallback MyAppWeb.FallbackController; when an action returns an {:error, reason} tuple (from a failed with expression), Phoenix passes it to the FallbackController's call/2 function. The FallbackController implements call/2 clauses 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-html that omits HTML templates, the asset pipeline, LiveView, and browser-specific Plugs. The router uses the :api pipeline with plug :accepts, ["json"] instead of the :browser pipeline 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 with mix 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 Formatter

Further reading and primary sources