Structured LLM Output in Elixir: Which Library, When

Four Elixir options for schema-validated LLM output, scored on maintenance, provider coverage, retry behavior, and escape hatches.

TL;DR: You have four real options for getting an LLM to return a validated Elixir struct instead of a string you regex apart, and the SERP for this query is HexDocs and GitHub — nobody’s scored them against each other. InstructorEx is the well-known name (781 stars) but its last commit was over a year ago and its latest Hex release predates that. InstructorLite is the fork that’s actually shipping — its repo was last pushed July 9, 2026. ReqLLM’s generate_object/4 needs no extra dependency if you’re already using it as your LLM client, but I read the source and it does not retry the model on a validation failure the way both Instructor libraries do — it locally coerces types and gives up, which most people evaluating it are not expecting. And the fourth option is the one I already pointed at in 15 Elixir Libraries I Reach For in 2026: Req plus an Ecto changeset plus a hand-rolled retry loop, in about 50 lines, with zero new dependencies. Here’s the scoring, with the receipts.

This isn’t a tutorial on using Instructor

If you searched “elixir structured llm output” to find out how to call Instructor.chat_completion/1, HexDocs already owns that — the quickstart walks the basic Ecto-schema-plus-completion-call pattern better than I’m going to re-explain it here. This post answers the question one layer up, the one nobody’s written because it requires actually opening four repos and comparing them instead of writing up whichever one you already knew about: which of these should a new Elixir project actually depend on, and when should it depend on none of them?

That question matters more than the tutorial does, because the wrong answer here isn’t “slightly slower code” — it’s a dependency that stops getting patched while your product’s core reliability mechanism (does the model’s output match the shape my database expects) sits on top of it.

What I actually checked before scoring anything

Every claim below is pulled from the GitHub API (pushed_at, star count, open issue count), Hex.pm’s package pages, the libraries’ own READMEs, and — for the one claim that surprised me — the actual generate_object source in ReqLLM’s lib/. Nothing here is recalled from a training cutoff; library maintenance state moves fast enough in this ecosystem that a six-month-old memory is actively misleading. Every date and count below was checked on July 13, 2026 — re-verify before you commit to a dependency; that’s the whole point.

Option 1: InstructorEx — the name everyone knows, the one that’s stalled

thmsmlr/instructor_ex, published to Hex as instructor, is the library my libraries roundup called “early-stage” a few weeks ago. Rechecking it now: 781 stars, but the GitHub API’s pushed_at reads 2025-06-07 — over a year of no commits — and the latest tagged Hex release is 0.1.0, shipped 2025-02-09. GitHub’s own issue count (open issues plus PRs, its convention) sits at 40. That’s not “quiet because it’s done” the way Mox is quiet; it’s a maintainer who’s moved on, with a real backlog nobody’s triaging.

The odd part: it still pulls 214,720 total downloads and 7,160 in the last 7 days on Hex. That’s not new adoption — it’s every existing project’s mix.lock still resolving to it on mix deps.get. Download count is a lagging indicator here, not a health signal.

Providers, per the README: OpenAI, Anthropic, Groq, Ollama, Gemini, vLLM, llama.cpp. The API is the one you’ve probably seen — an Ecto.Schema with a use Instructor.Validator, a validate_changeset/1 callback for cross-field checks, and a max_retries option that re-prompts the model with the validation errors on failure:

defmodule SpamPrediction do
  use Ecto.Schema
  use Instructor.Validator

  @primary_key false
  embedded_schema do
    field(:class, Ecto.Enum,
      values: [:spam, :not_spam])
    field(:reason, :string)
    field(:score, :float)
  end

  @impl true
  def validate_changeset(changeset) do
    Ecto.Changeset.validate_number(
      changeset, :score,
      greater_than_or_equal_to: 0.0,
      less_than_or_equal_to: 1.0
    )
  end
end

Instructor.chat_completion(
  model: "gpt-4o-mini",
  response_model: SpamPrediction,
  max_retries: 3,
  messages: [
    %{role: "user", content: email_text}
  ]
)

That retry behavior is genuinely good design — verified, working, feeding the changeset’s own errors back into the next prompt. The problem isn’t the API. It’s that you’d be starting a new project in August 2026 on a dependency whose maintainer hasn’t touched it since before this post’s own site existed.

Option 2: InstructorLite — the fork that’s actually shipping

martosaur/instructor_lite describes itself, in its own README, as “a fork, spiritual successor, and almost an entire rewrite” of InstructorEx — and the maintenance numbers back that framing up. GitHub’s pushed_at: 2026-07-09, four days before I started researching this post. The Hex page shows the latest release, v1.2.0, landed February 1, 2026, on a cadence of roughly every one to three months back to its v0.1.0 in September 2024. 140 stars, zero open issues. Total downloads are smaller — 62,935 — but that’s a younger, narrower rewrite against an incumbent with a two-year head start, not a signal of thin adoption.

Provider coverage is narrower and explicit rather than aspirational: OpenAI, Anthropic, Gemini, and “any Chat Completions-compatible API” (which covers Grok and most self-hosted OpenAI-shaped endpoints). The README states plainly that it “facilitates generating prompts, calling LLMs, casting and validating responses, including retrying prompts when validation fails” — so the retry-with-feedback behavior InstructorEx has, InstructorLite keeps.

defmodule UserInfo do
  use Ecto.Schema
  use InstructorLite.Instruction

  @primary_key false
  embedded_schema do
    field(:name, :string)
    field(:age, :integer)
  end
end

InstructorLite.instruct(
  %{input: [
      %{role: "user", content: text}
    ]},
  response_model: UserInfo,
  adapter_context: [
    api_key: System.fetch_env!("OPENAI_KEY")
  ]
)

What I’d actually flag for anyone evaluating it: the project’s own positioning is “lean,” “composable,” “magic-free” — its docs explicitly say it does little enough that “it makes you question if you should just write your own version.” That’s an unusually honest thing for a library to say about itself, and it’s the right instinct for exactly the reason the libraries roundup argues for wrapping any Instructor variant behind your own boundary module in the first place — a thin, composable library is cheaper to swap out from behind that boundary than a thick, opinionated one.

Option 3: ReqLLM’s generate_object — free if you’re already on it, with a catch

agentjido/req_llm is the library behind the telemetry events I wrote up separately — if you’re already sending LLM calls through it for provider abstraction or streaming, ReqLLM already gives you telemetry too, and structured output is a function call away with no new dependency. Provider abstraction is the real purchase here: absorbing protocol churn on your behalf is the recurring tax on Elixir tooling that talks to model APIs directly. Its numbers are the strongest of the four: 1.17.1 released July 6, 2026, pushed_at on the repo is today, 544 stars, and per its own docs it covers 21 providers exposing 1,205 models — more raw surface area than either Instructor variant.

generate_object/4 takes a model spec, a prompt (or messages), and a schema — but the schema is a NimbleOptions keyword list or a raw JSON Schema map, not an Ecto.Schema. That’s a real design difference, not a detail: if your codebase already validates everything at the boundary with changesets (the pattern the libraries roundup argues for), ReqLLM’s structured output speaks a different validation dialect than the rest of your app.

schema = [
  name: [type: :string, required: true],
  age: [type: :pos_integer, required: true]
]

ReqLLM.generate_object!(
  "anthropic:claude-3-sonnet",
  "Extract: John Doe is 42",
  schema
)
#=> %{name: "John Doe", age: 42}

Here’s the part worth slowing down for. Both Instructor libraries re-prompt the model on a validation failure — that’s the headline feature of the whole category. I went and read ReqLLM’s generate_object implementation directly rather than trust a README summary, because getting this wrong in either direction (assuming retry that isn’t there, or missing retry that is) is exactly the kind of thing that ships a bug six weeks later. What it actually does on a type mismatch is attempt local coercion — and if coercion fails, the code path returns {:ok, coerced_data} anyway rather than looping back to the model with the error.

ReqLLM’s generate_object doesn’t re-prompt the model on a validation failure — it coerces locally and, if that fails too, hands you back the malformed data as a success.

That’s not a bug, it’s a different design goal — ReqLLM is a general-purpose provider client with structured output as one feature among many, not a library purpose-built around the retry-on-invalid workflow. But it means if you pick ReqLLM for structured output specifically because it’s the library you’re already using, you need to add your own outer retry loop for the validation-failure case, the same one you’d write for option four below. Skipping that step is the single most likely way this option burns someone.

Option 4: raw JSON mode + Ecto changeset + a retry loop — the ~50-line escape hatch

This is the paragraph from the libraries roundup grown up: “If you’d rather not take the dependency, the pattern is reproducible in ~50 lines with Req + Ecto changesets + a retry loop.” Here’s that pattern, in full, using nothing that isn’t already a default dependency on this site’s own stack — Req and Ecto. The shape is close to the 50-line agent loop’s own guardrail pattern: a recursive function, an explicit step counter, and a hard stop.

defmodule LLM.Structured do
  @max_retries 3

  def extract(schema_mod, prompt) do
    loop(schema_mod, prompt, nil, 0)
  end

  defp loop(schema_mod, prompt, err, n)
       when n < @max_retries do
    text = call(schema_mod, prompt, err)

    case Jason.decode(text) do
      {:ok, data} ->
        cs = schema_mod.changeset(
               struct(schema_mod), data
             )

        if cs.valid? do
          {:ok, Ecto.Changeset.apply_changes(cs)}
        else
          loop(schema_mod, prompt,
            format_errors(cs), n + 1)
        end

      {:error, reason} ->
        loop(schema_mod, prompt,
          inspect(reason), n + 1)
    end
  end

  defp loop(_schema_mod, _prompt, err, _n),
    do: {:error, {:max_retries, err}}

  defp call(schema_mod, prompt, nil) do
    request(schema_mod, prompt)
  end

  defp call(schema_mod, prompt, err) do
    request(
      schema_mod,
      prompt <> "\nFix: " <> err
    )
  end

  defp request(schema_mod, prompt) do
    {:ok, %{body: body}} =
      Req.post(
        "https://api.anthropic.com/v1/messages",
        json: %{
          model: "claude-sonnet-4-5",
          max_tokens: 1024,
          system: "Return only JSON matching "
                  <> schema_mod.json_hint(),
          messages: [
            %{role: "user", content: prompt}
          ]
        },
        headers: [
          {"x-api-key", api_key()},
          {"anthropic-version", "2023-06-01"}
        ]
      )

    body["content"]
    |> Enum.find(&(&1["type"] == "text"))
    |> Map.get("text")
  end

  defp format_errors(cs) do
    Ecto.Changeset.traverse_errors(
      cs,
      fn {msg, _} -> msg end
    )
    |> inspect()
  end

  defp api_key,
    do: System.fetch_env!("ANTHROPIC_API_KEY")
end

schema_mod is any Ecto schema module exposing changeset/2 and a json_hint/0 you write once (a plain string describing the expected JSON shape — no macro magic, no library-specific DSL). Everything here is Elixir you already know how to debug at 2am: Req.post, a changeset, a recursive function with a counter. No community release cadence to track, no provider-coverage gap to hit, no dependency to audit in the next security review. The cost is the one you’d expect — you own the retry-prompt wording, the JSON-mode reliability quirks per provider, and every edge case the two Instructor libraries have already had community bug reports fix for them. Testing this boundary works exactly like testing the agent loop’s LLM call: stub Req.Test in place of the real endpoint and script the validation-failure-then-success transcript directly.

The decision table

Option Last shipped Providers Retries on invalid output New deps Escape hatch
InstructorEx 2025-02-09 (Hex); repo quiet since 2025-06-07 OpenAI, Anthropic, Groq, Ollama, Gemini, vLLM, llama.cpp Yes, built-in (max_retries) 1 (Ecto already assumed) Fork or vendor it — no active upstream to lean on
InstructorLite 2026-02-01, active cadence OpenAI, Anthropic, Gemini, Chat Completions-compatible Yes, built-in 1 (Ecto already assumed) Explicitly designed to be read and overridden
ReqLLM generate_object 2026-07-06 (repo pushed today) 21 providers / 1,205 models No — coerces, doesn’t re-prompt 0 if ReqLLM’s already your client You write the outer retry loop yourself
Raw JSON + Ecto + retry You control it Whatever you wire up Yes — you write the loop 0 It is the escape hatch

When to skip all four

Skip the libraries, write the loop, when: you have one provider, one or two schemas, and a team that already lives in Ecto changesets daily. Fifty lines you own outright beats a dependency for a problem this small, and it’s the choice the libraries roundup’s own rubric points to — a dependency earns its place by doing one thing you can’t trivially replicate; this is one you can.

Reach for InstructorLite when: you want the re-prompt-on-failure behavior without writing it yourself, you’re starting fresh, and you’re comfortable being an early adopter of a library that’s a few months into its second life as a rewrite. Wrap it behind a boundary module regardless — not because it’s shaky, but because that’s the discipline that makes swapping it out later free.

Reach for ReqLLM’s generate_object when: you’re already using ReqLLM as your provider client for its actual selling point — one interface across 21 providers, streaming, and the telemetry events I’ve already wired into LiveDashboard and PromEx — and you’d rather not add a second HTTP-and-schema library just for the structured-output slice. Budget the outer retry loop yourself; don’t assume it’s there.

Don’t reach for InstructorEx on a new project. Its API is genuinely good and its README is still the best-written quickstart of the four, but a 14-month-old commit and a 40-item unattended backlog are exactly the maintenance-status red flags the libraries roundup’s rubric tells you to screen for before you mix deps.get anything. If you’ve already got it in production, that’s a different, lower-urgency conversation — wrap it behind a boundary module now, and let the boundary decide whether InstructorLite or the raw loop replaces it later, on your schedule instead of an incident’s.

None of this changes the one thing that was already true before any of these four options existed: the payoff of the BEAM for AI workloads was never any single library, it’s the runtime underneath all of them — supervision, cheap concurrency, :telemetry as a first-class primitive instead of a bolted-on SDK. I’ve made the fuller case for that elsewhere; this post is just the one narrow decision inside it.

Where to go from here

Whichever option you pick, the failure mode that actually costs you in production isn’t “the library is missing a feature” — it’s shipping structured output with no test coverage on what happens when the model returns something your schema rejects. The ExUnit patterns for agent output apply directly here: stub the HTTP boundary with Req.Test, script a validation-failure-then-retry transcript, and assert on the retry actually happening — not just on the final struct looking right.

If you’re mid-decision on this for a real product and want a second set of eyes on which of these fits your actual constraints — team size, provider lock-in tolerance, how much of your validation logic already lives in Ecto — that’s a conversation worth having.