Skip to content

_deepcopy_with_exceptions turns dict subclasses (defaultdict, OrderedDict, Counter) into plain dict, so pipeline inputs lose their type #13092

Description

@Harsh23Kashyap

Describe the bug

_deepcopy_with_exceptions in haystack/core/pipeline/utils.py rebuilds every dict as a plain dict:

if isinstance(obj, dict):
    return {k: _deepcopy_with_exceptions(v) for k, v in obj.items()}

For list, tuple and set the function keeps the original type (type(obj)(...)), and #11981 added the same care for namedtuples. Dict subclasses do not get that treatment. A defaultdict, OrderedDict or Counter comes back as a plain dict, and a defaultdict loses its default_factory.

Pipeline.run copies its inputs with this function (pipeline.py and base.py call it on the input data), so a component receives a different type than the caller passed in.

Error message and reproduction

Direct call:

from collections import OrderedDict, defaultdict
from haystack.core.pipeline.utils import _deepcopy_with_exceptions as d

print(type(d(OrderedDict(a=1))).__name__)           # dict
print(type(d(defaultdict(list, a=[1]))).__name__)   # dict
d(defaultdict(list))["missing"]                     # KeyError: 'missing'

Through a pipeline:

from collections import defaultdict
from haystack import Pipeline, component

@component
class Lookup:
    @component.output_types(v=int)
    def run(self, d: dict):
        return {"v": len(d["absent"])}

p = Pipeline()
p.add_component("d", Lookup())
p.run({"d": {"d": defaultdict(list)}})
# PipelineRuntimeError ... Error: 'absent'

Calling Lookup().run(d=defaultdict(list)) directly returns {"v": 0}. Only the pipeline run fails, because the input was converted to a plain dict on the way in.

I ran both snippets twice and got identical output each time.

Expected behavior

The copy keeps the type of a dict subclass, the same way it already does for lists, tuples, sets and namedtuples.

Possible fix

For dict subclasses, shallow copy the container so its type and state are kept, then fill it with copied values:

if isinstance(obj, dict):
    if type(obj) is dict:
        return {k: _deepcopy_with_exceptions(v) for k, v in obj.items()}
    try:
        new = copy.copy(obj)
        new.clear()
        for k, v in obj.items():
            new[k] = _deepcopy_with_exceptions(v)
        return new
    except Exception:
        return {k: _deepcopy_with_exceptions(v) for k, v in obj.items()}

I tried this on a local copy. OrderedDict, defaultdict and Counter all keep their type, compare equal to the original, and the defaultdict still creates missing keys. I did not run the full test suite. Given the current Haystack contribution state I am leaving this as an issue and am happy to send a PR if a maintainer wants one.

Additional context

Checked against main at 87dba65; haystack/core/pipeline/utils.py there is identical to the file I tested. Python 3.14 locally, Haystack from main. I searched open and closed issues and PRs for this function and for dict subclass copying and found only #11981 (namedtuples), which this is the sibling of.

Disclosure: I used an AI coding assistant to help find and write this up. I ran the reproductions myself and checked the output.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions