Instructor, structured outputs from language models
Instructor wraps a language model client and adds one parameter to the call, response_model. Instead of a string you get a validated Pydantic object, and when validation fails the library re-sends the request together with the error text. The Python version is 1.15.4, released on 28 June 2026, under the MIT licence.
What Instructor actually does
The mechanism is simple and it is good that it stays that way, because the library's predictability depends on it. You pass a class deriving from BaseModel. Instructor turns it into a JSON Schema, drops that schema into a tool definition or into the response_format field, sends the request, receives the tool call arguments and runs them through model_validate_json. If Pydantic raises ValidationError, the library appends the model's reply plus a new user message reading Validation Error found: with the exception text and the sentence Recall the function correctly, fix the errors, then repeats the call. Once the attempts run out you get InstructorRetryException.
That is all of it. Instructor is not an agent framework, it keeps no tool registry, it does not manage conversation memory and it has nothing to do with semantic search. If you are looking for an orchestration layer with an agent loop and dependency injection, Pydantic AI is closer to what you want. If prompt optimisation is your concern, look at DSPy. Instructor deliberately sits lower and does one thing.
The layer is thin, the dependency list is not. Package instructor 1.15.4 pulls in ten libraries: openai in the range from 2.0.0 to 3.0.0, pydantic from 2.8.0, pydantic-core, tenacity from 8.2.3, jiter, jinja2, docstring-parser, requests, rich and typer, plus aiohttp from 3.9.1. That means even when you work exclusively with Anthropic, the OpenAI package still lands in your environment. Support for the remaining providers comes from extras, for instance instructor[anthropic] or instructor[google-genai]. The required Python is at least 3.9 and below 4.0.
Three packages sharing one name
This is the most common stumble on a first install and the reason this section sits so high. Three entities with similar names exist in the registries and only one of them is what the documentation talks about.
| Package | Registry | Version | Licence | Last change | Is it Instructor |
|---|---|---|---|---|---|
instructor | PyPI | 1.15.4 | MIT | 28 June 2026 | yes, this is the project |
instructor | npm | 1.0.0 | ISC | 19 June 2022 | no, a parked name |
@instructor-ai/instructor | npm | 1.7.0 | MIT | 27 January 2025 | yes, but the TypeScript port |
The instructor package on npm has nothing to do with this project and never did. The unpacked archive contains exactly two files: package.json at 450 bytes and README.md at 222 bytes. There is not a single line of code, there is no licence file, and the repository field points at github.com/npm/deprecate-holder. The README states plainly that the package is deprecated and npm is holding the name so nobody takes it for malicious use. The registry entry was created in February 2016, seven years before Instructor existed. So npm install instructor hands you an empty shell with no warning that you picked the wrong package.
The TypeScript version is called @instructor-ai/instructor and it is the correct counterpart. Here the problem is different and worse in its consequences: version 1.7.0 was published on 27 January 2025 and nothing has changed since. The main branch of the 567-labs/instructor-js repository still carries 1.7.0 in package.json, the last release tag is v1.7.0, and the repository has roughly 802 stars against 13,761 for the Python version. This is not an actively maintained project, it is one that stopped more than a year and a half ago. If you write TypeScript and need something that keeps pace with provider changes, treat this package as a risk rather than an equal option.
Licence checked in three places
A registry declaration is sometimes a different thing from the content of the published archive, so I checked both packages at three independent points.
| Source | Python version | TypeScript version |
|---|---|---|
| LICENSE file in the repository | MIT, Copyright (c) 2023 Jason Liu | MIT, Copyright (c) 2024 Jason Liu |
| license field in the registry | MIT in the PyPI metadata | MIT in the npm metadata |
| Content of the published archive | LICENSE in the dist-info directory, 195 .py files | LICENSE in the root directory, 9 files |
The result agrees in both cases and that is a rare pleasure. The wheel instructor-1.15.4-py3-none-any.whl weighs 252,522 bytes, holds 195 .py files and a licence file at instructor-1.15.4.dist-info/licenses/LICENSE, with the License-File metadata header pointing at it. The @instructor-ai/instructor 1.7.0 archive has 9 files totalling 110,938 unpacked bytes, including a LICENSE with MIT text and a compiled dist directory. Nowhere is there a mismatch between the declaration and the content, no extra file under a different licence, no empty package posing as working code.
One thing surprises you when fetching the sources. The instructor-1.15.4.tar.gz archive on PyPI weighs 70,049,678 bytes, roughly 278 times more than the wheel. The archive carries the repository along with documentation and images. Installing through pip fetches the wheel and you will not notice the difference, but a tool building from source or a registry mirror certainly will.
There is no paid variant. Instructor is a library with no service behind it, so there is no price list, no free plan and no limits. Costs come solely from the model provider, and Instructor adds the overhead the retry section describes. In practice a narrow group around the author maintains the project, which across 96 PyPI releases gives a fast pace but also the usual open source risk of having no support contract.
Providers and operating modes
You pick a provider either through a dedicated function or through a single string. The from_provider function takes a value in the provider/model format and recognises 23 aliases: openai, anthropic, google, generative-ai, gemini, vertexai, azure_openai, bedrock, mistral, cohere, groq, cerebras, fireworks, together, anyscale, databricks, deepseek, perplexity, openrouter, litellm, ollama, writer and xai.
import instructor
from pydantic import BaseModel, Field
class Invoice(BaseModel):
number: str = Field(description="Invoice number from the header")
net_total: float
currency: str
client = instructor.from_provider("openai/gpt-4o-mini")
invoice = client.chat.completions.create(
response_model=Invoice,
messages=[{"role": "user", "content": raw_text}],
max_retries=2,
)
print(invoice.net_total, invoice.currency)The from_provider signature is model, async_client=False, cache=None and mode=None, with the remaining arguments travelling to the provider client constructor. Setting async_client=True returns AsyncInstructor instead of Instructor. The cache parameter takes an adapter, for example AutoCache(maxsize=1000) or RedisCache, and flows onward through kwargs into the provider implementation.
The mode decides how the schema reaches the request. The Mode enum has values such as Mode.TOOLS with the value tool_call, Mode.JSON with the value json_mode, Mode.JSON_SCHEMA, Mode.MD_JSON with the value markdown_json_mode, Mode.PARALLEL_TOOLS and Mode.RESPONSES_TOOLS for the newer OpenAI interface. On top of that come provider specific variants, among them Mode.ANTHROPIC_TOOLS, Mode.ANTHROPIC_JSON, Mode.GEMINI_TOOLS, Mode.GENAI_JSON, Mode.COHERE_TOOLS and Mode.BEDROCK_TOOLS.
from openai import OpenAI
import instructor
# tool calling, the default and usually the most reliable
tools = instructor.from_openai(OpenAI(), mode=instructor.Mode.TOOLS)
# JSON mode, when the model has no tool calls
plain_json = instructor.from_openai(OpenAI(), mode=instructor.Mode.JSON)
# schema enforced on the provider side
strict = instructor.from_openai(OpenAI(), mode=instructor.Mode.JSON_SCHEMA)
# last resort: JSON inside a markdown block
markdown = instructor.from_openai(OpenAI(), mode=instructor.Mode.MD_JSON)For the openai provider the library treats six modes as supported: TOOLS, JSON, JSON_SCHEMA, MD_JSON, PARALLEL_TOOLS and RESPONSES_TOOLS. Three modes are silently mapped onto others because they were retired: Mode.FUNCTIONS moves to TOOLS with a deprecation warning, Mode.TOOLS_STRICT also to TOOLS, and Mode.JSON_O1 to JSON_SCHEMA. If you set one of the old modes and wonder why the behaviour does not match the name, that is exactly why. The practical difference between modes comes down to this: TOOLS and JSON_SCHEMA give you a schema enforced by the provider, while JSON and MD_JSON rest on the model following instructions, so their share of failed validations is markedly higher.
Retries, validation and hooks
The max_retries parameter takes an integer or a Retrying instance from the tenacity library. Given an integer, Instructor builds the stop condition as stop_after_attempt(max(max_retries, 0) + 1), so max_retries counts retries after the first attempt. Setting max_retries=3 means at worst four calls to a paid interface, not three.
Watch out for two different defaults. The Instructor.create method and client.chat.completions.create carry max_retries=3 in their signature. The function wrapped directly by instructor.patch has max_retries: int | Retrying = 1 in the code. Same parameter name, different call count on the bill, depending on which entry point you came through.
from pydantic import BaseModel, field_validator
from instructor.core.exceptions import InstructorRetryException
class Ticket(BaseModel):
title: str
priority: str
@field_validator("priority")
@classmethod
def known_priority(cls, value: str) -> str:
allowed = {"low", "medium", "high"}
if value not in allowed:
raise ValueError(f"priority must be one of {sorted(allowed)}")
return value
try:
ticket = client.chat.completions.create(
response_model=Ticket,
messages=[{"role": "user", "content": report}],
max_retries=2,
context={"tenant": "acme"},
strict=True,
)
except InstructorRetryException as err:
print(err.n_attempts, err.total_usage)
print(err.last_completion)
print(err.create_kwargs)The InstructorRetryException carries a full set of diagnostic information: last_completion with the final failed response, n_attempts with the number of attempts, total_usage with cumulative token usage, create_kwargs with the call parameters and failed_attempts with details of every failed try. The messages field still exists but the code marks it deprecated in favour of create_kwargs.
Hooks are there for watching the run. The client.on, client.off and client.clear methods take one of five event names: completion:kwargs, completion:response, completion:error, completion:last_attempt and parse:error. Attaching a counter to parse:error is the simplest way to measure what retries actually cost you before the provider's bill does it for you. If you need a full dashboard with a call log, that event stream is easy to route into an observability tool or into a gateway such as LiteLLM or OpenRouter.
The library also ships a validator backed by a model. The llm_validator function takes statement, client, allow_override=False, model="gpt-3.5-turbo" and temperature=0. The default for the model field is clearly outdated, so pass it explicitly rather than relying on the default.
Streaming and helper models
The client exposes four ways of producing a response. Plain create returns a finished object. create_with_completion returns a tuple with the object and the raw provider response, which helps when reading token usage. create_iterable returns a generator of objects when you ask for a list and want to process elements as they arrive. create_partial returns a generator of successive, increasingly complete versions of the same object.
from instructor import Partial
# successive approximations of one object
for draft in client.chat.completions.create_partial(
response_model=Invoice,
messages=[{"role": "user", "content": raw_text}],
max_retries=1,
):
render(draft)
# list elements as they are produced
for row in client.chat.completions.create_iterable(
response_model=Invoice,
messages=[{"role": "user", "content": batch_text}],
):
save(row)
# object together with the raw provider response
invoice, completion = client.chat.completions.create_with_completion(
response_model=Invoice,
messages=[{"role": "user", "content": raw_text}],
)
print(completion.usage.total_tokens)Partial objects have one limitation that is easy to forget: until the stream ends, validation is incomplete, because the fields simply do not exist yet. Streaming suits showing progress in an interface, not making decisions on unfinished data.
Beyond that, the instructor.dsl module holds a few ready patterns. Partial[Model] describes the partial type. Maybe(Model) creates a model with result, error and message fields, where error defaults to False and __bool__ checks whether result is not None. That is how you get a model able to say honestly that the text had nothing to extract, instead of inventing something. IterableModel, CitationMixin, ListResponse and ResponseList round out the set.
Instructor against the alternatives
| Feature | Instructor | Pydantic AI | DSPy | Guardrails AI | Native structured outputs |
|---|---|---|---|---|---|
| Scope | response schema only | full agent framework | prompt optimisation | validation and repair | provider feature |
| Schema | Pydantic | Pydantic | signatures | custom validators | JSON Schema |
| Retry on failure | built in | built in | depends on the module | built in | none |
| Switching provider | 23 aliases | many providers | many providers | many providers | none, ties you to one |
| Version | 1.15.4 for Python, 1.7.0 for TypeScript | see the separate article | see the separate article | see the separate article | not applicable |
| Licence | MIT | MIT | MIT | Apache 2.0 | not applicable |
The choice comes down to one question: do you need anything beyond a validated object. If you do not, Instructor is the smallest thing you can use for the job, and its code reads in a single evening. If you are building an agent with tools, history and checkpoints, the thin layer will stop being enough quickly. If all you want is a shape enforced and you work with a single provider, that provider's native structured outputs settle the matter without an extra dependency, at the cost of being tied to one interface. For validating content rather than shape, reach for Guardrails AI.
Outlines stands apart, because it solves the same problem from the other end. Instructor asks the model for a result and retries when validation fails, so it works with every vendor, but you pay for each failed attempt. Outlines masks tokens during generation, so the model cannot produce anything that violates the schema and the retries disappear. The price is hard: enforcement works only for the three engines holding weights inside your process, and with OpenAI or Anthropic the library hands the schema to the vendor and offers no guarantee beyond the one the vendor provides on its own.
Common mistakes
The first is npm install instructor instead of npm install @instructor-ai/instructor. The install succeeds without an error and you get npm's deprecated shell with no code in it. Check the name in package.json before you start hunting for the cause in your own code.
The second is treating the TypeScript port as an equal. Package @instructor-ai/instructor 1.7.0 pins zod-stream to the exact version 3.0.0, and that version declares peer dependencies of zod in the ^3.23.3 range and openai at exactly 4.47.1. Meanwhile npm carries zod at 4.4.3 today and openai at 7.5.0. Instructor itself declares openai at >=4.58.0, which contradicts its own pinned internal dependency. Practically this means the TypeScript port freezes you on Zod version 3 and on an OpenAI client generation two years old.
The third is misreading max_retries. A value of 3 means three retries after the first call, so four requests to the provider. With a reasoning model and a large schema that can triple the bill for a single user request, especially in MD_JSON mode where the share of failed validations is highest.
The fourth is validators that tell the model nothing useful. The Pydantic exception text goes verbatim into the next request, so a message reading invalid value wastes a retry. Write in the exception what value is expected and the second attempt usually suffices.
The fifth is forgetting that openai is a hard dependency. Even installing instructor[anthropic] drags in the OpenAI client, because the base package requires it. In a dependency audit or a container image kept on a diet, that is a visible entry. The Anthropic extra pins anthropic to the exact version 0.93.0 on top of that, rather than a range, which can collide with another library in the same environment.
The sixth is strict=True in a mode that knows nothing about strict behaviour. The parameter exists in the create signature regardless of the chosen mode, but its meaning depends on the provider, and with MD_JSON there is nothing on the other side to enforce it.
The seventh is relying on the default model value in llm_validator. The gpt-3.5-turbo written there is a relic and will either return a weak result or stop being available at the provider altogether.
FAQ
Which package should I install in a Python project?
pip install instructor from PyPI, version 1.15.4. That is the project, under the MIT licence, with the 567-labs/instructor repository. The instructor-ai/instructor address redirects to the same place, so both links in the documentation are correct.
Is the TypeScript version maintained?
In practice no. The last publication of @instructor-ai/instructor was 1.7.0 on 27 January 2025, the main branch of the repository carries the same version number and there is no newer release tag. The code works, but it does not keep pace with changes in zod and in the OpenAI client.
How many extra requests do retries cost?
As many as you set in max_retries, counted after the first attempt. The default 3 in client.chat.completions.create means at most four calls. You can measure the real cost by attaching a counter to the parse:error event.
When should I pick JSON mode over tool calling?
When the model or provider has no tool calls, or when you need Mode.JSON_SCHEMA with enforcement on the provider side. In every other case Mode.TOOLS produces fewer failed validations, because the schema is enforced rather than suggested.
Does Instructor replace an agent framework?
No. There is no agent loop, no tool registry and no conversation state management. It returns a validated object and its role ends there, with the rest assembled by you or taken ready made from another layer.
Can I use Instructor with a locally hosted model?
Yes, through the ollama alias in from_provider or through an OpenAI compatible client with a changed base_url. The mode is the limitation then: smaller local models often struggle with tool calls, leaving Mode.JSON or Mode.MD_JSON and a higher share of retries.
Documentation for the Python version lives at python.useinstructor.com, the source code in the 567-labs/instructor repository, and the package in the PyPI registry.