← Writing Note · October 2026 · Python · code review

The x or default bug in Haystack's Lara translator

A detailed review of a one-line Python idiom that silently ignored reasoning=False: how I found it, why it happens, the fix, the test that proves it, the edge cases it leaves open, and what I'd check in any codebase. Merged into deepset's haystack-core-integrations as PR #4169.

Component
LaraDocumentTranslator (integration for Translated's Lara API)
Bug
Falsy run-time overrides (False, [], "") were replaced by the values set at init.
Fix
x or self.x → x if x is not None else self.x, for all eight parameters.
Timeline
Issue #4168 and PR opened Oct 28, 2026; merged the same day, about five hours later.
8parameters fixed
1regression test, fails before the fix
100%of changed lines covered in CI

1. Context: two places to configure a component

Haystack components take settings when you create them and, for many components, again on each call. LaraDocumentTranslator follows that pattern: you set defaults in __init__ and can override any of them per run().

translator = LaraDocumentTranslator(target_lang="fr-FR", reasoning=True, glossaries=["g1"])  # defaults
translator.run(documents=docs, reasoning=False)                                              # override for this call

The contract a user expects is simple: if I pass a value to run(), use it; if I pass nothing, use the default. The eight overridable settings are source_lang, target_lang, context, instructions, style, adapt_to, glossaries and reasoning.

2. How I found it

I was looking for a small, real bug in a project that merges outside contributions quickly. Two recent merges in the same repo fixed a pattern where a falsy override was ignored (#4084 for the Jina ranker, #4159 for the Transformers reader). If a bug class appears twice, it usually appears more often, so I searched the integrations for the same shape:

grep -rnE "[a-z_]+ = [a-z_]+ or self\._?[a-z_]+$" integrations --include=*.py | grep -v /tests/

Most hits were top_k, where 0 is not a meaningful value, or tool lists, where empty and missing mean the same thing. The Lara translator stood out: one of its overrides is a boolean, and two are lists where "empty" is a deliberate choice.

3. Root cause: or tests truthiness, not presence

The code resolved each setting like this:

source_lang = source_lang or self.source_lang
...
glossaries  = glossaries  or self.glossaries
reasoning   = reasoning   or self.reasoning

a or b returns a if it is truthy, otherwise b. In Python None, False, 0, "", [] and {} are all falsy, so or cannot tell "the caller passed nothing" apart from "the caller deliberately passed an empty or false value".

Passed to run()Init valueIntendedOld code sent
nothing (None)reasoning=TrueTrueTrue
reasoning=Falsereasoning=TrueFalseTrue
glossaries=[]["g1"]no glossaries["g1"]
adapt_to=[]["memory-1"]no adaptation["memory-1"]
context="""Legal contract"no context"Legal contract"

4. Why it matters

  • Cost and latency. Lara's reasoning mode is slower and more expensive. A pipeline that turns it off for short, simple documents kept paying for it.
  • Wrong output. A glossary or adaptation memory meant for one domain could not be switched off for a single batch, so terminology from one client could leak into another's translation.
  • Silent failure. Nothing raised or warned. The only symptom was output quality or a bill, which is the hardest kind of bug to trace back to a single line.

5. The fix

Fall back only when the caller passed nothing:

reasoning = reasoning if reasoning is not None else self.reasoning

I applied it to all eight parameters rather than just the two visibly broken ones, so every override follows one rule and a future reader does not have to wonder why some lines differ. The diff is eight lines changed and nothing else, which keeps review fast.

Other ways to fix it

  • A sentinel object (_UNSET = object() as the default) would also let a caller pass None explicitly to mean "clear this". That changes the public signature and every docstring, which is more than a bug fix should do.
  • Fixing only reasoning and the two lists. Smaller, but it leaves the same trap in the string parameters and makes the code inconsistent.
  • Merging init and run settings into a dict with explicit precedence. Cleaner long term, but a refactor, not a fix.

6. Proving it: the regression test

The test builds a translator with non-default values, overrides them with falsy ones, and checks what actually reaches the Lara client. The client is replaced with a mock, so the test needs no API key and no network.

def test_run_falsy_overrides_from_init(self, mock_translation_response):
    translator = LaraDocumentTranslator(
        access_key_id=Secret.from_token("key-id"),
        access_key_secret=Secret.from_token("key-secret"),
        target_lang="fr-FR",
        adapt_to=["memory-1"],
        glossaries=["glossary-1"],
        reasoning=True,
    )
    with patch("...document_translator.Translator") as mock_translator_cls:
        mock_translator = MagicMock()
        mock_translator.translate.return_value = mock_translation_response
        mock_translator_cls.return_value = mock_translator
        translator.run(documents=[Document(content="Hi")],
                       adapt_to=[], glossaries=[], reasoning=False)

    call_kwargs = mock_translator.translate.call_args.kwargs
    assert call_kwargs["reasoning"] is False
    assert call_kwargs["adapt_to"] == []
    assert call_kwargs["glossaries"] == []
  • It asserts on call_args.kwargs, the arguments the component actually sent, rather than on internal state.
  • It uses is False, not == False or a truthiness check, so it cannot pass by accident on None or 0.
  • I ran it against the old code first and watched it fail, then against the fix and watched it pass. A regression test that has never failed proves nothing.

All 23 unit tests passed locally, Ruff was clean, and CI reported 100% coverage of the changed lines. The integration tests that call the real Lara API need secret keys, so CI skips them on fork PRs by design; the change only affects which values are passed, which the mocked test covers.

7. Edge cases the fix leaves open

A good fix also states what it does not settle. Two behaviours changed in ways a reviewer might want to discuss:

  • context="" is now respected. Downstream, the component appends a non-translatable context block whenever context is not None, so an empty string now produces an empty context block instead of falling back. Treating "" as "no context" would be a sensible follow-up.
  • Passing None still means "use the default", so a caller cannot clear an init value by passing None. That is the existing contract across Haystack components, and changing it would need the sentinel approach above.

8. Process, briefly

  1. Checked the issue tracker and open PRs for duplicates.
  2. Opened a bug report with a minimal reproduction and the proposed fix (#4168), so the maintainers could object before I wrote code.
  3. Forked, used a sparse checkout of just integrations/lara, made the change, ran hatch run test:unit and Ruff.
  4. Opened the PR with a conventional-commit title, linked the issue, explained the cause and the test, and signed the CLA.

9. Checklist for any codebase

  • Use x or default only when no falsy value could be a meaningful choice. For booleans, numbers, strings and collections, check is None.
  • Search for the pattern: grep -rnE "= ([a-z_]+) or self\.\1\b" --include=*.py .
  • When you find one, check its siblings. Bugs like this are usually copied, not invented.
  • Test overrides with the falsy value, not just a different truthy one. Most existing tests here overrode "faithful" with "creative", which can never catch this.

The same pattern still exists elsewhere in the integrations, for example a stride=0 override in the Transformers reader. That's my next candidate once this one has settled.