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/4needs 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")
endschema_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.