This document is meant to provide a guide for migrating from Haystack v2.X to v3.0.
When you merge a breaking change into the v3 branch, add an entry to this file under the appropriate section below. Follow this structure:
### <Short title describing what changed>
**What changed:** One or two sentences describing the change — what was removed, renamed, or altered.
**Why:** Brief motivation (e.g. simplification, API consistency, dependency reduction).
**How to migrate:**
Before (v2.x):
\`\`\`python
# example using the old API
from haystack.components.foo import OldComponent
component = OldComponent(old_param="value")
\`\`\`
After (v3.0):
\`\`\`python
# example using the new API
from haystack.components.foo import NewComponent
component = NewComponent(new_param="value")
\`\`\`- One entry per breaking change. Don't bundle unrelated changes into a single entry.
- Include a working code example for every rename, removal, or signature change.
- Link to the PR when extra context would help (e.g.
See [#1234](https://github.com/deepset-ai/haystack/pull/1234)). - Components moved to external packages don't need a full entry: add a row to the table in Components Moved to External Packages instead.
What changed: The dataframe field on Document and the ExtractedTableAnswer dataclass have been removed. pandas is no longer a required dependency.
Why: Reduces the default installation footprint. Components that need pandas will raise an informative error prompting the user to install it explicitly.
How to migrate:
Before (v2.x):
from haystack.dataclasses import Document
import pandas as pd
doc = Document(content=pd.DataFrame({"col": [1, 2, 3]}))After (v3.0):
# Store tabular data as plain content or create a custom component that returns pandas DataFrames as needed.
from haystack.dataclasses import Document
doc = Document(content="col\n1\n2\n3")What changed: GeneratedAnswer.to_dict() and ExtractedAnswer.to_dict() now return a flat dictionary of the object's fields instead of wrapping them in a {"type": ..., "init_parameters": {...}} envelope. from_dict() still accepts the old wrapped format, so existing serialized artifacts keep loading.
Why: Aligns these dataclasses with how every other Haystack dataclass (Document, ChatMessage, etc.) serializes, and removes redundant type metadata from pipeline snapshots and State objects.
How to migrate: Update any code that reads the serialized output to access fields at the top level instead of under init_parameters. See #11805.
Before (v2.x):
serialized = generated_answer.to_dict()
data = serialized["init_parameters"]["data"]After (v3.0):
serialized = generated_answer.to_dict()
data = serialized["data"]What changed: Some components have been moved out of Haystack into dedicated integration packages, hosted in the haystack-core-integrations repository.
Why: Moving these components to separate packages allows testing more thoroughly in isolation and releasing fixes independently of the Haystack release cycle. This also makes Haystack development and CI leaner.
How to migrate: Install the new package and update your imports as shown in the table below.
pip install <new-package>Old import (haystack-ai<3.0.0) |
New package | New import |
|---|---|---|
from haystack.components.generators.chat import HuggingFaceAPIChatGenerator |
huggingface-api-haystack |
from haystack_integrations.components.generators.huggingface_api import HuggingFaceAPIChatGenerator |
from haystack.components.embedders import HuggingFaceAPITextEmbedder |
huggingface-api-haystack |
from haystack_integrations.components.embedders.huggingface_api import HuggingFaceAPITextEmbedder |
from haystack.components.embedders import HuggingFaceAPIDocumentEmbedder |
huggingface-api-haystack |
from haystack_integrations.components.embedders.huggingface_api import HuggingFaceAPIDocumentEmbedder |
from haystack.components.rankers import HuggingFaceTEIRanker |
huggingface-api-haystack |
from haystack_integrations.components.rankers.huggingface_api import HuggingFaceTEIRanker |
from haystack.components.classifiers import TransformersZeroShotDocumentClassifier |
transformers-haystack |
from haystack_integrations.components.classifiers.transformers import TransformersZeroShotDocumentClassifier |
from haystack.components.generators.chat import HuggingFaceLocalChatGenerator |
transformers-haystack |
from haystack_integrations.components.generators.transformers import TransformersChatGenerator |
from haystack.components.readers import ExtractiveReader |
transformers-haystack |
from haystack_integrations.components.readers.transformers import TransformersExtractiveReader |
from haystack.components.routers import TransformersTextRouter |
transformers-haystack |
from haystack_integrations.components.routers.transformers import TransformersTextRouter |
from haystack.components.routers import TransformersZeroShotTextRouter |
transformers-haystack |
from haystack_integrations.components.routers.transformers import TransformersZeroShotTextRouter |
from haystack.components.websearch import SerperDevWebSearch |
serperdev-haystack |
from haystack_integrations.components.websearch.serperdev import SerperDevWebSearch |
from haystack.components.websearch import SearchApiWebSearch |
searchapi-haystack |
from haystack_integrations.components.websearch.searchapi import SearchApiWebSearch |
from haystack.components.classifiers import DocumentLanguageClassifier |
langdetect-haystack |
from haystack_integrations.components.classifiers.langdetect import DocumentLanguageClassifier |
from haystack.components.routers import TextLanguageRouter |
langdetect-haystack |
from haystack_integrations.components.routers.langdetect import TextLanguageRouter |
from haystack.components.audio import LocalWhisperTranscriber |
whisper-haystack |
from haystack_integrations.components.audio.whisper import LocalWhisperTranscriber |
from haystack.components.audio import RemoteWhisperTranscriber |
whisper-haystack |
from haystack_integrations.components.audio.whisper import RemoteWhisperTranscriber |
from haystack.components.extractors import NamedEntityExtractor (Hugging Face backend) |
transformers-haystack |
from haystack_integrations.components.extractors.transformers import TransformersNamedEntityExtractor |
from haystack.components.extractors import NamedEntityExtractor (spaCy backend) |
spacy-haystack |
from haystack_integrations.components.extractors.spacy import SpacyNamedEntityExtractor |
from haystack.components.embedders import SentenceTransformersTextEmbedder |
sentence-transformers-haystack |
from haystack_integrations.components.embedders.sentence_transformers import SentenceTransformersTextEmbedder |
from haystack.components.embedders import SentenceTransformersDocumentEmbedder |
sentence-transformers-haystack |
from haystack_integrations.components.embedders.sentence_transformers import SentenceTransformersDocumentEmbedder |
from haystack.components.embedders import SentenceTransformersSparseTextEmbedder |
sentence-transformers-haystack |
from haystack_integrations.components.embedders.sentence_transformers import SentenceTransformersSparseTextEmbedder |
from haystack.components.embedders import SentenceTransformersSparseDocumentEmbedder |
sentence-transformers-haystack |
from haystack_integrations.components.embedders.sentence_transformers import SentenceTransformersSparseDocumentEmbedder |
from haystack.components.embedders.image import SentenceTransformersDocumentImageEmbedder |
sentence-transformers-haystack |
from haystack_integrations.components.embedders.sentence_transformers import SentenceTransformersDocumentImageEmbedder |
from haystack.components.rankers import SentenceTransformersSimilarityRanker |
sentence-transformers-haystack |
from haystack_integrations.components.rankers.sentence_transformers import SentenceTransformersSimilarityRanker |
from haystack.components.rankers import SentenceTransformersDiversityRanker |
sentence-transformers-haystack |
from haystack_integrations.components.rankers.sentence_transformers import SentenceTransformersDiversityRanker |
from haystack.tracing.datadog import DatadogTracer |
datadog-haystack |
from haystack_integrations.tracing.datadog import DatadogTracer |
from haystack.tracing.opentelemetry import OpenTelemetryTracer |
opentelemetry-haystack |
from haystack_integrations.tracing.opentelemetry import OpenTelemetryTracer |
from haystack.tracing import OpenTelemetryTracer |
opentelemetry-haystack |
from haystack_integrations.tracing.opentelemetry import OpenTelemetryTracer |
from haystack.components.converters import TikaDocumentConverter |
tika-haystack |
from haystack_integrations.components.converters.tika import TikaDocumentConverter |
from haystack.components.converters import AzureOCRDocumentConverter |
azure-form-recognizer-haystack |
from haystack_integrations.components.converters.azure_form_recognizer import AzureOCRDocumentConverter |
from haystack.components.connectors import OpenAPIConnector |
openapi-haystack |
from haystack_integrations.components.connectors.openapi import OpenAPIConnector |
from haystack.components.connectors import OpenAPIServiceConnector |
openapi-haystack |
from haystack_integrations.components.connectors.openapi import OpenAPIServiceConnector |
from haystack.components.converters import OpenAPIServiceToFunctions |
openapi-haystack |
from haystack_integrations.components.converters.openapi import OpenAPIServiceToFunctions |
What changed: The DatadogTracer has been moved out of Haystack into the datadog-haystack integration package.
In addition, Haystack no longer automatically enables Datadog tracing when ddtrace is installed. You now enable it
explicitly by adding the new DatadogConnector component to your pipeline.
Why: Moving the tracer to a dedicated package keeps Haystack's dependencies leaner and lets the integration be released independently. Removing the implicit auto-enable makes tracing setup explicit and predictable.
How to migrate:
Install the integration:
pip install datadog-haystackBefore (v2.x), Datadog tracing was auto-enabled when ddtrace was installed, or set up manually:
import ddtrace
from haystack import tracing
from haystack.tracing.datadog import DatadogTracer
tracing.enable_tracing(DatadogTracer(ddtrace.tracer))After (v3.0), add the DatadogConnector to your pipeline to enable tracing:
from haystack import Pipeline
from haystack_integrations.components.connectors.datadog import DatadogConnector
pipe = Pipeline()
pipe.add_component("tracer", DatadogConnector())Alternatively, you can still enable the tracer manually using the new import path:
import ddtrace
from haystack import tracing
from haystack_integrations.tracing.datadog import DatadogTracer
tracing.enable_tracing(DatadogTracer(ddtrace.tracer))What changed: The OpenTelemetryTracer has been moved out of Haystack into the opentelemetry-haystack
integration package, and the opentelemetry-sdk dependency is no longer installed with Haystack. In addition,
Haystack no longer automatically enables OpenTelemetry tracing when opentelemetry-sdk is installed and configured.
You now enable it explicitly by adding the new OpenTelemetryConnector component to your pipeline.
Why: Moving the tracer to a dedicated package keeps Haystack's dependencies leaner and lets the integration be released independently. Removing the implicit auto-enable makes tracing setup explicit and predictable.
How to migrate:
Install the integration:
pip install opentelemetry-haystackBefore (v2.x), OpenTelemetry tracing was auto-enabled when opentelemetry-sdk was installed and configured, or set
up manually:
from opentelemetry import trace
from haystack import tracing
from haystack.tracing import OpenTelemetryTracer
tracing.enable_tracing(OpenTelemetryTracer(trace.get_tracer("my_application")))After (v3.0), enable the tracer manually using the new import path:
from opentelemetry import trace
from haystack import tracing
from haystack_integrations.tracing.opentelemetry import OpenTelemetryTracer
tracing.enable_tracing(OpenTelemetryTracer(trace.get_tracer("my_application")))Alternatively, add the OpenTelemetryConnector to your pipeline to enable tracing:
from haystack import Pipeline
from haystack_integrations.components.connectors.opentelemetry import OpenTelemetryConnector
pipe = Pipeline()
pipe.add_component("tracer", OpenTelemetryConnector())Also removed: haystack.tracing.auto_enable_tracing (it is no longer called on import haystack). Because
Haystack no longer ships a built-in tracing backend, there is nothing to auto-enable. Enable tracing explicitly via
a connector (such as OpenTelemetryConnector) or with haystack.tracing.enable_tracing(...). The
HAYSTACK_AUTO_TRACE_ENABLED environment variable no longer has any effect.
What changed: The TransformersSimilarityRanker component has been removed. It was not moved to an
integration package.
Why: The component was in legacy state and no longer received updates. SentenceTransformersSimilarityRanker
provides the same functionality plus async support and the more capable sentence-transformers backend.
How to migrate: Use SentenceTransformersSimilarityRanker, which accepts the same parameters.
Before (v2.x):
from haystack.components.rankers import TransformersSimilarityRanker
ranker = TransformersSimilarityRanker(model="cross-encoder/ms-marco-MiniLM-L-6-v2")After (v3.0):
from haystack.components.rankers import SentenceTransformersSimilarityRanker
ranker = SentenceTransformersSimilarityRanker(model="cross-encoder/ms-marco-MiniLM-L-6-v2")What changed: The ToolInvoker component has been removed. Imports from haystack.components.tools
and pipelines that use ToolInvoker as a standalone component are no longer supported.
Why: Tool execution is now owned by Agent, so the tool-calling loop, state handling, streaming callback
passthrough, warm-up, and sync/async execution live in one place.
How to migrate: Pass tools directly to Agent instead of wiring a chat generator to ToolInvoker.
The Agent will pass tool definitions to the chat generator, execute requested tool calls, append tool
results to the conversation, and continue the loop until an exit condition is reached.
Before (v2.x):
from typing import Annotated
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.tools import ToolInvoker
from haystack.dataclasses import ChatMessage
from haystack.tools import tool
@tool
def weather(city: Annotated[str, "The name of the city"]) -> str:
"""Get the weather for a city."""
return f"The weather in {city} is sunny."
chat_generator = OpenAIChatGenerator(model="gpt-4o-mini", tools=[weather])
tool_invoker = ToolInvoker(tools=[weather])
llm_result = chat_generator.run(messages=[ChatMessage.from_user("What is the weather in Berlin?")])
tool_result = tool_invoker.run(messages=llm_result["replies"])After (v3.0):
from typing import Annotated
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack.tools import tool
@tool
def weather(city: Annotated[str, "The name of the city"]) -> str:
"""Get the weather for a city."""
return f"The weather in {city} is sunny."
agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"), tools=[weather])
result = agent.run(messages=[ChatMessage.from_user("What is the weather in Berlin?")])The tool_invoker_kwargs parameter has been removed from Agent. Previously, ToolInvoker options were
forwarded through this dictionary; the relevant options are now top-level Agent constructor parameters:
max_workersis now the top-leveltool_concurrency_limitparameter.enable_streaming_callback_passthroughis now the top-leveltool_streaming_callback_passthroughparameter.
Before (v2.x):
agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"),
tools=[weather],
tool_invoker_kwargs={"max_workers": 4, "enable_streaming_callback_passthrough": True},
)After (v3.0):
agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"),
tools=[weather],
tool_concurrency_limit=4,
tool_streaming_callback_passthrough=True,
)The convert_result_to_json_string option (also previously set through tool_invoker_kwargs) has been removed.
Non-string tool results are now always serialized with json.dumps rather than str, which changes their string form.
What changed: haystack-experimental has been removed from Haystack's core dependencies. Installing haystack-ai no longer pulls in haystack-experimental automatically.
Why: Reduces the default installation footprint. Experimental features are opt-in and should not be installed for users who do not need them.
How to migrate: If your code imports from haystack_experimental (directly or through an integration that depends on it), install the package explicitly:
pip install haystack-experimentalInstallations that do not use haystack_experimental require no changes.
What changed: The agent-specific breakpoint API has been removed. The AgentBreakpoint, ToolBreakpoint, and AgentSnapshot dataclasses are no longer exported from haystack.dataclasses, and the break_point, snapshot, and snapshot_callback parameters have been removed from Agent.run and Agent.run_async. Pipeline.run no longer accepts an AgentBreakpoint for its break_point argument, and the agent_snapshot field has been removed from PipelineSnapshot. Pausing and resuming execution inside an Agent (at the chat generator or tool invoker) is no longer supported.
Why: Simplifies the breakpoint and snapshot machinery and removes the special-cased agent-internal control flow. Pipeline-level breakpoints still cover the common debugging use cases.
How to migrate:
Before (v2.x):
from haystack.components.agents import Agent
from haystack.dataclasses import AgentBreakpoint, Breakpoint, ToolBreakpoint
agent = Agent(chat_generator=..., tools=[...])
# Pause before the chat generator runs
chat_break_point = AgentBreakpoint(
agent_name="agent",
break_point=Breakpoint(component_name="chat_generator", visit_count=0),
)
# Or pause before a specific tool is invoked
tool_break_point = AgentBreakpoint(
agent_name="agent",
break_point=ToolBreakpoint(component_name="tool_invoker", tool_name="my_tool"),
)
agent.run(messages=[...], break_point=chat_break_point)After (v3.0):
# Pausing inside an Agent is no longer supported. To inspect an Agent's behavior,
# use tracing instead (https://docs.haystack.deepset.ai/docs/tracing). For example,
# you can wire up a Langfuse tracer for a standalone Agent by instantiating a
# LangfuseConnector — its constructor registers the tracer globally, so any
# subsequent Agent.run call will be traced.
# NOTE: install the langfuse integration first with `pip install langfuse-haystack` to run this example.
import os
os.environ["HAYSTACK_CONTENT_TRACING_ENABLED"] = "true"
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
from haystack_integrations.components.connectors.langfuse import LangfuseConnector
# Instantiating the connector enables tracing globally — no need to add it to a pipeline.
LangfuseConnector("Standalone Agent example")
agent = Agent(chat_generator=OpenAIChatGenerator(model="gpt-4o-mini"), tools=[...])
agent.run(messages=[ChatMessage.from_user("What's the weather in Berlin?")])What changed: Each iteration of the Agent loop now emits a single haystack.agent.step Haystack span with nested children: haystack.agent.step.llm for the chat generator call, and one haystack.agent.step.tool span per tool call (only when tool calls happen). Previously each iteration produced two child spans through Pipeline._run_component (one for the chat generator, one for the tool invoker) tagged with haystack.component.name / haystack.component.type, and a single tool span grouped all of a step's tool calls together. The new spans do NOT carry haystack.component.* tags; the LLM span exposes content tags haystack.agent.step.llm.input/.output, and each tool span carries haystack.tool.name / haystack.tool.description tags plus content tags haystack.agent.step.tool.input (the call arguments) / .output (the tool result).
Why: Removes the dependency on Pipeline._run_component inside Agent.run and produces a clearer per-iteration trace structure that maps directly onto common agent-tracing conventions — one span per tool call, as codified by the OpenTelemetry GenAI "execute tool" span and followed by backends like Langfuse (chain → {generation, tool, tool, …}).
How to migrate: Custom tracer backends or SpanHandler implementations that dispatched on component_type == "ToolInvoker" or component_type.endswith("ChatGenerator") for Agent-internal spans must now dispatch on the new operation names instead. For example, in the Langfuse integration this means subclassing or updating the DefaultSpanHandler and overriding create_span to recognize the three new operations:
from typing import cast
from haystack_integrations.tracing.langfuse import DefaultSpanHandler, LangfuseSpan
from haystack_integrations.tracing.langfuse.tracer import ObservationSpanType, SpanContext
class AgentStepSpanHandler(DefaultSpanHandler):
def create_span(self, context: SpanContext) -> LangfuseSpan:
if context.operation_name == "haystack.agent.step":
return LangfuseSpan(
self.tracer.start_as_current_observation(
name=f"agent step {context.tags.get('haystack.agent.step', 0)}",
as_type=cast(ObservationSpanType, "chain"),
)
)
if context.operation_name == "haystack.agent.step.llm":
return LangfuseSpan(
self.tracer.start_as_current_observation(name="llm", as_type=cast(ObservationSpanType, "generation"))
)
if context.operation_name == "haystack.agent.step.tool":
# One span per tool call; the tool name rides along as a tag so the observation can be named upfront.
tool_name = context.tags.get("haystack.tool.name")
return LangfuseSpan(
self.tracer.start_as_current_observation(
name=f"tool - {tool_name}" if tool_name else "tool",
as_type=cast(ObservationSpanType, "tool"),
)
)
return super().create_span(context)Pass an instance to the LangfuseConnector:
LangfuseConnector("My Agent", span_handler=AgentStepSpanHandler())What changed: The user_prompt and system_prompt parameters have been removed from Agent.run and Agent.run_async. Both prompts must now be set at initialization time on the Agent; they can no longer be passed per-run, including via Pipeline.run(data={"agent": {...}}).
Why: A single source of truth for the Agent's prompt templates simplifies the API and makes prompts a stable part of the Agent configuration. It also removes the old requirement that variables used by a runtime user_prompt override had to already exist in the initialization-time user_prompt for pipeline input sockets to be created.
How to migrate:
Before (v2.x), overriding prompts through Pipeline.run:
from haystack import Pipeline
from haystack.components.agents import Agent
init_user_prompt = (
"{% message role='user' %}"
"Answer {{query}} using these documents:"
"{% for doc in documents %}{{doc.content}}{% endfor %}"
"{% endmessage %}"
)
agent = Agent(
chat_generator=...,
tools=[...],
system_prompt="Default system prompt.",
user_prompt=init_user_prompt,
)
pipeline = Pipeline()
pipeline.add_component("retriever", ...)
pipeline.add_component("agent", agent)
pipeline.connect("retriever.documents", "agent.documents")
pipeline.run(
data={
"retriever": {"query": query},
"agent": {
"messages": [],
"query": query,
"system_prompt": "You are a knowledgeable assistant.",
"user_prompt": (
"{% message role='user' %}"
"Use these documents to answer {{query}}:"
"{% for doc in documents %}{{doc.content}}{% endfor %}"
"{% endmessage %}"
),
},
}
)After (v3.0), configure both prompts on the Agent:
from haystack import Pipeline
from haystack.components.agents import Agent
agent = Agent(
chat_generator=...,
tools=[...],
system_prompt="You are a knowledgeable assistant.",
# `user_prompt` can be a plain string template or an explicit Jinja2 message template.
# Explicit message templates must contain a single message block that renders as a user message.
user_prompt="Use these documents to answer {{query}}: {% for doc in documents %}{{doc.content}}{% endfor %}",
)
pipeline = Pipeline()
pipeline.add_component("retriever", ...)
pipeline.add_component("agent", agent)
pipeline.connect("retriever.documents", "agent.documents")
pipeline.run(data={"retriever": {"query": query}, "agent": {"messages": [], "query": query}})If the prompt itself must still be assembled per run, build ChatMessage objects before the Agent (e.g. with a ChatPromptBuilder) and pass them through the messages input.
For a runtime system prompt, construct an Agent without system_prompt or user_prompt and include a system message at the start of messages.
What changed: A tool now reads a value from the Agent's State by name only when it declares an explicit inputs_from_state mapping. Previously, a tool without inputs_from_state had every parameter implicitly treated as a potential State key: any parameter whose name matched a State key (and that the LLM did not supply) was silently filled from State. This implicit name-matching has been removed. It applies to every tool type, since ComponentTool, PipelineTool, MCPTool, and others all derive from the base Tool class.
Why: The implicit matching was hidden — nothing in a tool's definition signaled that it read from State, so a tool could start consuming State purely because of an incidental parameter-name collision. With many tools and a large state schema this made accidental overlaps likely, and renaming a parameter or adding a State key could silently change what a tool received. Requiring an explicit mapping makes a tool's State dependencies visible and intentional. Auto-injection of the full State object into a parameter annotated as State is unaffected — that is explicit at the signature level.
How to migrate: Add an explicit inputs_from_state mapping ({state_key: parameter_name}) to any tool that should read from State by name.
Before (v2.x), whenever the model called weather_tool without supplying a location argument, the parameter was filled from the location state key automatically, because the parameter name matched a key in the Agent's state_schema. (If the model did supply location, the LLM-provided value always took precedence and State was not consulted.)
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.tools import Tool
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters={"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]},
function=weather_function,
)
agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"),
tools=[weather_tool],
# `location` lives in State, so it was silently injected into the tool's same-named parameter
# on any call where the model didn't already provide a `location` argument.
state_schema={"location": {"type": str}},
)After (v3.0), declare the mapping explicitly so the read from State is visible in the tool definition:
from haystack.components.agents import Agent
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.tools import Tool
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters={"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]},
function=weather_function,
inputs_from_state={"location": "location"},
)
agent = Agent(
chat_generator=OpenAIChatGenerator(model="gpt-5.4-nano"),
tools=[weather_tool],
state_schema={"location": {"type": str}},
)Tools that take the full State object via a State-annotated parameter need no change:
from typing import Annotated
from haystack.components.agents import State
from haystack.tools import Tool
def weather_function(location: Annotated[str, "The name of the city"], state: State) -> str:
...
weather_tool = Tool(
name="weather_tool",
description="Provides weather information for a given location.",
parameters={"type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"]},
function=weather_function, # `state` is still injected automatically
)What changed: Agent now reserves several names in its state_schema: the auto-populated run-metadata outputs step_count, token_usage, and tool_call_counts, and the hook-facing keys continue_run (set by an on_exit hook to keep the Agent running), tools (the tools available in the current step, for hooks to inspect), and hook_context (the per-run resources passed to Agent.run). Passing any of them as a state_schema key now raises ValueError.
Why: These keys are managed by Agent itself; allowing users to redefine them would let a user-defined entry shadow the value the Agent reads or writes.
How to migrate: Rename any clashing state_schema entries.
Before (v2.x):
agent = Agent(
chat_generator=...,
tools=[...],
state_schema={"token_usage": {"type": dict}},
)After (v3.0):
agent = Agent(
chat_generator=...,
tools=[...],
state_schema={"my_token_usage": {"type": dict}},
)What changed: The confirmation_strategies and confirmation_strategy_context parameters have been removed from Agent.__init__, Agent.run, and Agent.run_async. Human-in-the-Loop tool confirmation is now expressed through the general Agent hooks mechanism: wrap your confirmation strategies in a ConfirmationHook and register it under the before_tool hook point. Request-scoped resources that used to be passed via confirmation_strategy_context are now passed via the generic hook_context run argument (read by hooks with state.get("hook_context")).
Why: Confirmation was a one-off, before-tool interception bolted onto the Agent. Hooks generalize that seam, so HITL becomes one application of a single, uniform extension point instead of a parallel concept with its own serialization and run plumbing.
How to migrate:
Before (v2.x):
from haystack.components.agents import Agent
from haystack.human_in_the_loop import BlockingConfirmationStrategy, AlwaysAskPolicy, RichConsoleUI
agent = Agent(
chat_generator=...,
tools=[...],
confirmation_strategies={
"my_tool": BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(), confirmation_ui=RichConsoleUI()
)
},
)
agent.run(messages=[...], confirmation_strategy_context={"websocket": ws})After (v3.0):
from haystack.components.agents import Agent
from haystack.human_in_the_loop import (
BlockingConfirmationStrategy,
AlwaysAskPolicy,
ConfirmationHook,
RichConsoleUI,
)
confirmation_hook = ConfirmationHook(
confirmation_strategies={
"my_tool": BlockingConfirmationStrategy(
confirmation_policy=AlwaysAskPolicy(), confirmation_ui=RichConsoleUI()
)
}
)
agent = Agent(chat_generator=..., tools=[...], hooks={"before_tool": [confirmation_hook]})
agent.run(messages=[...], hook_context={"websocket": ws})What changed: Confirmation strategies now receive the arguments the model produced for a tool call in tool_params, rather than the fully-prepared arguments. Values injected from State via a tool's inputs_from_state mapping (and the State object passed to State-typed parameters) are no longer included in what is presented for confirmation — that injection now happens only at tool execution time.
Why: Preparing and baking each tool's arguments up front defeated the per-batch argument preparation in tool execution, so a tool that read a state key written by another tool in the same step could run with stale values. Confirmation now operates on the model-requested arguments and leaves state injection to execution. See the release note for the failure mode details.
How to migrate: If your ConfirmationUI or ConfirmationPolicy displayed or inspected state-injected argument values, update it to expect only the arguments the model provided. No change is needed if you only relied on the model-requested arguments.
What changed: The user_prompt and system_prompt parameters have been removed from LLM.run and LLM.run_async. Both prompts must now be set at initialization time on the LLM; they can no longer be passed per-run, including via Pipeline.run(data={"llm": {...}}).
Why: LLM prompt templates define the component's dynamic input sockets. The old runtime override path was misleading because any variables used by the override still had to be present in the initialization-time user_prompt for pipeline connections such as llm.documents to exist.
How to migrate:
Before (v2.x), overriding prompts through Pipeline.run:
from haystack import Pipeline
from haystack.components.generators.chat import LLM
init_user_prompt = "Answer {{query}} using these documents: {% for doc in documents %}{{doc.content}}{% endfor %}"
llm = LLM(
chat_generator=...,
system_prompt="Default system prompt.",
user_prompt=init_user_prompt,
)
pipeline = Pipeline()
pipeline.add_component("retriever", ...)
pipeline.add_component("llm", llm)
# This connection only worked because `documents` was already present in
# `init_user_prompt`, so the LLM had a `documents` input socket.
pipeline.connect("retriever.documents", "llm.documents")
pipeline.run(
data={
"retriever": {"query": query},
"llm": {
"query": query,
"system_prompt": "You are a knowledgeable assistant.",
"user_prompt": "Use these documents to answer {{query}}: {% for doc in documents %}{{doc.content}}{% endfor %}",
},
}
)After (v3.0), configure both prompts on the LLM:
from haystack import Pipeline
from haystack.components.generators.chat import LLM
llm = LLM(
chat_generator=...,
system_prompt="You are a knowledgeable assistant.",
user_prompt="Use these documents to answer {{query}}: {% for doc in documents %}{{doc.content}}{% endfor %}",
)
pipeline = Pipeline()
pipeline.add_component("retriever", ...)
pipeline.add_component("llm", llm)
pipeline.connect("retriever.documents", "llm.documents")
pipeline.run(data={"retriever": {"query": query}, "llm": {"query": query}})If the prompt itself must still be assembled per run, build ChatMessage objects before the LLM and pass them through the messages input. For a runtime system prompt, construct an LLM without system_prompt or user_prompt and include a system message at the start of messages.
What changed: PromptBuilder and ChatPromptBuilder now treat every Jinja2 template variable as required by default. Previously, variables were optional by default and missing values were silently rendered as empty strings. The required_variables parameter's default has been changed from None (all optional) to "*" (all required). Passing required_variables=None explicitly still opts into the old "all optional" behavior.
Why: Avoids silent rendering bugs where a missing variable produces an unexpectedly empty section of the prompt — especially in multi-branch pipelines where the issue often surfaces far from its root cause. Aligns the default with ConditionalRouter's convention that inputs are required unless declared otherwise.
How to migrate:
Before (v2.x):
from haystack.components.builders import PromptBuilder
# All variables were optional by default; missing values rendered as "".
builder = PromptBuilder(template="Hello, {{ name }}! {{ greeting }}")
builder.run(name="John") # greeting silently becomes "" → "Hello, John! "After (v3.0):
from haystack.components.builders import PromptBuilder
# Option 1: provide every variable (matches the new safe default).
builder = PromptBuilder(template="Hello, {{ name }}! {{ greeting }}")
builder.run(name="John", greeting="Welcome")
# Option 2: declare which variables are required; everything else stays optional.
builder = PromptBuilder(
template="Hello, {{ name }}! {{ greeting }}",
required_variables=["name"],
)
builder.run(name="John") # greeting renders as ""
# Option 3: restore the old "all optional" behavior.
builder = PromptBuilder(
template="Hello, {{ name }}! {{ greeting }}",
required_variables=None,
)
builder.run(name="John") # greeting renders as ""What changed: Pipeline.load, Pipeline.loads, and Pipeline.from_dict now refuse to import classes from modules outside a trusted-module allowlist and raise a DeserializationError instead. The default allowlist contains haystack, haystack_integrations, haystack_experimental, builtins, typing, and collections. Pipelines that reference custom components, callables, or types in other packages will fail to load until those modules are explicitly allowed.
In addition, default_from_dict now rejects nested {"type": "..."} dictionaries whose key is not an __init__ parameter of the parent class — this can surface pre-existing YAML bugs (typos, leftovers from removed parameters, stale snapshots).
Why: Loading a pipeline from YAML used to dynamically import any class referenced in the file, which made a crafted YAML capable of causing arbitrary classes to be imported and instantiated. Gating imports through an allowlist closes that gap while leaving Haystack's own packages working out of the box.
How to migrate:
If your pipeline only references components from haystack, haystack_integrations, or haystack_experimental, no action is needed.
Otherwise, extend the allowlist via one of the four mechanisms below.
Before (v2.x), all modules implicitly trusted:
from haystack import Pipeline
# Worked for any class on the import path, including third-party packages.
with open("pipeline.yaml") as fp:
pipeline = Pipeline.load(fp)After (v3.0), pick one of the following options. The first two scope the trust to a single call; the others extend it process-wide.
# 1. Per-call kwarg — recommended for application code that knows exactly which extra
# packages a given YAML needs.
from haystack import Pipeline
with open("pipeline.yaml") as fp:
pipeline_a = Pipeline.load(fp, allowed_modules=["mypkg.*", "anotherpkg.components.*"])
# 2. Per-call bypass — equivalent to "I fully trust this YAML; skip the allowlist".
# Mirrors the `yaml.safe_load` / `yaml.unsafe_load` convention.
with open("pipeline.yaml") as fp:
pipeline_b = Pipeline.load(fp, unsafe=True)
# 3. Process-wide programmatic — call once at startup, e.g. in your application's
# entry point or a custom integration package's __init__.
from haystack.core.serialization import allow_deserialization_module
allow_deserialization_module("mypkg.*")
with open("pipeline.yaml") as fp:
pipeline_c = Pipeline.load(fp) # `mypkg.*` is now trusted for every load in this process.# 4. Environment variable — useful for ops/deployments where code shouldn't change.
# Comma-separated patterns; read at runtime on every deserialization call.
export HAYSTACK_DESERIALIZATION_ALLOWLIST="mypkg.*,otherpkg.*"Patterns are matched as prefixes by default ("mypkg" matches mypkg and any submodule), or as fnmatch globs if they contain *, ?, or [ somewhere other than a trailing .*.
What changed: OpenAIGenerator, AzureOpenAIGenerator, HuggingFaceAPIGenerator, and HuggingFaceLocalGenerator have been removed.
Generators living in Haystack Core Integrations will also be removed soon.
Their chat counterparts (OpenAIChatGenerator, AzureOpenAIChatGenerator, HuggingFaceAPIChatGenerator, HuggingFaceLocalChatGenerator) are the replacement. As of Haystack 3.0, all ChatGenerators also accept a plain str as input, so the migration rarely requires structural changes.
Why: Over time, Generators became shallow wrappers over the ChatGenerators, converting str → ChatMessage → str around the exact same model calls. All new features (tool calling, structured outputs, etc.) were introduced only in ChatGenerators, leaving the legacy classes behind. They were also a source of confusion for newcomers and an unnecessary duplication of code and tests.
How to migrate:
Before (v2.x):
from haystack.components.generators import OpenAIGenerator
gen = OpenAIGenerator()
result = gen.run("What is NLP?")
text = result["replies"][0] # str
meta = result["meta"][0] # dict with model metadataAfter (v3.0):
from haystack.components.generators.chat import OpenAIChatGenerator
gen = OpenAIChatGenerator()
result = gen.run("What is NLP?") # str input accepted directly
reply = result["replies"][0] # ChatMessage
text = reply.text # str
meta = reply.meta # dict with model metadata (now on the message)Before (v2.x):
from haystack.components.generators import OpenAIGenerator
gen = OpenAIGenerator(system_prompt="You are concise.")
result = gen.run("What is NLP?")After (v3.0):
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.dataclasses import ChatMessage
gen = OpenAIChatGenerator()
result = gen.run([
ChatMessage.from_system("You are concise."),
ChatMessage.from_user("What is NLP?"),
])Pipelines that connected PromptBuilder (output: str) to a legacy Generator continue to work unchanged when you swap in a ChatGenerator. The Haystack pipeline type system automatically converts str to list[ChatMessage] at the connection edge.
Before (v2.x):
from haystack.components.generators import OpenAIGenerator
from haystack.components.builders import PromptBuilder
pipeline.add_component("prompt_builder", PromptBuilder(template=prompt_template))
pipeline.add_component("llm", OpenAIGenerator())
pipeline.connect("prompt_builder", "llm") # str → strAfter (v3.0), minimal change (smart connection still works):
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.builders import PromptBuilder
pipeline.add_component("prompt_builder", PromptBuilder(template=prompt_template))
pipeline.add_component("llm", OpenAIChatGenerator())
pipeline.connect("prompt_builder", "llm") # str → list[ChatMessage], auto-convertedAlternatively, for an idiomatic v3 pipeline use ChatPromptBuilder:
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.builders import ChatPromptBuilder
from haystack.dataclasses import ChatMessage
template = [ChatMessage.from_user(prompt_template)]
pipeline.add_component("prompt_builder", ChatPromptBuilder(template=template))
pipeline.add_component("llm", OpenAIChatGenerator())
pipeline.connect("prompt_builder.prompt", "llm.messages")Legacy Generators exposed a second output socket meta: list[dict]. ChatGenerators do not; per-reply metadata is embedded in each ChatMessage.meta. If you had a pipeline connection to llm.meta, remove it. AnswerBuilder already handles this automatically (it reads metadata from ChatMessage.meta when the replies are list[ChatMessage]).
Before (v2.x):
pipeline.connect("llm.replies", "answer_builder.replies")
pipeline.connect("llm.meta", "answer_builder.meta") # separate meta socketAfter (v3.0):
pipeline.connect("llm.replies", "answer_builder.replies")
# no meta connection needed; AnswerBuilder reads it from ChatMessage.metaWhat changed: DALLEImageGenerator has been renamed to OpenAIImageGenerator and moved to haystack.components.generators.openai_image_generator. The API and parameters are otherwise unchanged.
Why: OpenAI retired the DALL-E model family. The new name reflects that the component works with the full OpenAI image generation API and is no longer tied to a specific model family.
How to migrate:
Before (v2.x):
from haystack.components.generators import DALLEImageGenerator
generator = DALLEImageGenerator(model="dall-e-3")
result = generator.run("A photo of a red apple")After (v3.0):
from haystack.components.generators import OpenAIImageGenerator
generator = OpenAIImageGenerator(model="gpt-image-2")
result = generator.run("A photo of a red apple")What changed: The AsyncPipeline class has been removed. Its asynchronous methods (run_async, run_async_generator, stream) are now part of the single Pipeline class, alongside the synchronous run.
Why: Two classes caused friction where sync and async met: AsyncPipeline.run() wrapped asyncio.run() and raised inside an already-running event loop (e.g. Jupyter, FastAPI), and a SuperComponent exposed run_async even for sync pipelines, where it always failed. A single Pipeline with native run and run_async fixes both.
How to migrate:
Replace AsyncPipeline with Pipeline; the async methods are unchanged.
Before (v2.x):
from haystack import AsyncPipeline
pipeline = AsyncPipeline()
result = await pipeline.run_async(data)After (v3.0):
from haystack import Pipeline
pipeline = Pipeline()
result = await pipeline.run_async(data)If you used the synchronous AsyncPipeline.run(), note it was a wrapper around the concurrent async engine, so Pipeline.run() is not a drop-in replacement. Choose by intent:
# Keep concurrent execution from sync code:
result = asyncio.run(pipeline.run_async(data, concurrency_limit=4))
# Sequential execution is fine:
result = pipeline.run(data) # components run one at a time; no concurrency_limitUnlike AsyncPipeline.run(), Pipeline.run() does not raise when called inside a running event loop: it runs and blocks the loop. In an async context, use await pipeline.run_async(...).
Behavior to be aware of:
Pipeline.runruns components sequentially and does not acceptconcurrency_limit; onlyrun_async/run_async_generatorrun components concurrently.- Only
runsupports breakpoints (break_point/pipeline_snapshot). - Both run paths are traced under a single
haystack.pipeline.runoperation name, distinguished by ahaystack.pipeline.execution_modetag (syncorasync); previously asynchronous runs usedhaystack.async_pipeline.run.
What changed: The hash used to auto-generate Document.id is now computed from a canonical (key-sorted) JSON serialization of meta instead of the dict's repr. Documents with empty meta keep the same IDs as before, but documents with non-empty meta get different IDs in v3.0. Non-JSON-serializable meta values (e.g. datetime or custom classes) are now serialized via str(...) rather than repr(...), which also changes their IDs. See #11446.
Why: Previously the hash reflected the insertion order of keys in meta, so two documents with the same content and the same metadata could end up with different IDs depending on how the meta dict was constructed. This silently broke DuplicatePolicy.SKIP / FAIL and any cache or dedup table keyed on the document ID. Sorting the keys before hashing makes the ID order-independent.
How to migrate:
If you rely on auto-generated IDs to match documents already persisted in a DocumentStore written by Haystack v2.x, re-ingest the affected documents so the new IDs are used consistently, or pass the previous id explicitly when constructing the Document.
Before (v2.x):
from haystack.dataclasses import Document
# ID was derived from meta's dict repr, so it depended on key insertion order:
# these two documents could end up with different IDs.
doc1 = Document(content="Berlin is the capital of Germany.", meta={"source": "wiki", "lang": "en"})
doc2 = Document(content="Berlin is the capital of Germany.", meta={"lang": "en", "source": "wiki"})
After (v3.0):
```python
from haystack.dataclasses import Document
# Same content + meta now always yields the same ID, regardless of key order,
# but that ID differs from the one v2.x produced for documents with non-empty meta.
doc1 = Document(content="Berlin is the capital of Germany.", meta={"source": "wiki", "lang": "en"})
doc2 = Document(content="Berlin is the capital of Germany.", meta={"lang": "en", "source": "wiki"})
assert doc1.id == doc2.idIt is possible to migrate an existing index without rerunning your indexing pipeline, for example to avoid recalculating embeddings. To do that, read stored documents, regenerate their IDs using Haystack 3.0, write the updated documents, and delete the documents stored under their old IDs.
from dataclasses import replace
from haystack import Document
from haystack.document_stores.in_memory import InMemoryDocumentStore
from haystack.document_stores.types import DuplicatePolicy
# Example DocumentStore with IDs generated with Haystack 2.x
store = InMemoryDocumentStore()
store.write_documents(
[
Document(
id="b51c3ee6b892f52bf28af01f5d823a254e438356ec335a20133ad940ef7b8cd7",
content="Berlin is the capital of Germany.",
meta={"source": "wiki", "lang": "en"},
),
Document(
id="f022d8d89a99f89547215f8adcfed92f41518f2bb3e11d14e27987bd9d265ead",
content="Paris is the capital of France.",
meta={"source": "wiki", "lang": "en"},
),
],
policy=DuplicatePolicy.OVERWRITE,
)
# Exemplary steps to re-calculate IDs. Note that all documents are retrieved at once in this example but larger indices require pagination.
old_documents = store.filter_documents()
new_documents = [replace(doc, id="") for doc in old_documents]
store.write_documents(new_documents, policy=DuplicatePolicy.OVERWRITE)
new_ids = {doc.id for doc in new_documents}
store.delete_documents([doc.id for doc in old_documents if doc.id not in new_ids])What changed: Importing Haystack no longer attaches its formatting handler to the root logger, and no longer
configures structlog process-wide. The handler is now scoped to Haystack's own logger namespaces (haystack,
haystack_integrations, haystack_experimental), and the global structlog configuration is set only when you call
configure_logging() explicitly. As a result, importing Haystack no longer reformats the logs of the host application
or other libraries running in the same process.
Why: Haystack should behave as a well-mannered library when it runs alongside other services in the same process, rather than taking over logging for the whole process.
How to migrate: If you relied on Haystack formatting every log record in the process, opt back in explicitly.
Before (v2.x):
import haystack # formatted every log record in the process and configured structlog globallyAfter (v3.0):
from haystack import logging
# Restore the old behavior: format every log record in the process (also configures structlog globally).
logging.configure_logging(logger_name="")Note on duplicate log lines: Haystack's handler now sits on the haystack.* loggers, which still propagate to the
root logger. If the host application also configures a handler on the root logger, Haystack's own logs can appear
twice. To make Haystack fully own its output and stop the duplication, disable propagation:
from haystack import logging
logging.configure_logging(propagate=False)What changed: Components that use external services now create their resources (such as API clients) during warm_up() instead of in __init__. As a consequence, a missing API key (for example, an unset environment variable behind a Secret.from_env_var default) is now reported at warm-up or first run rather than at construction. This affects OpenAI and Azure OpenAI components.
Why: Creating resources in warm_up() / warm_up_async() and releasing them in close() / close_async() gives components and pipelines a single, predictable resource lifecycle.
How to migrate: If you relied on construction failing for a missing API key, expect the same error at warm_up() (or the first run) instead.
Before (v2.x), with OPENAI_API_KEY unset:
from haystack.components.embedders import OpenAITextEmbedder
embedder = OpenAITextEmbedder() # raised hereAfter (v3.0), with OPENAI_API_KEY unset:
from haystack.components.embedders import OpenAITextEmbedder
embedder = OpenAITextEmbedder() # no error at construction
embedder.warm_up() # raised here