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.
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 value | Intended | Old code sent |
|---|---|---|---|
| nothing (None) | reasoning=True | True | True |
| reasoning=False | reasoning=True | False | True |
| 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 passNoneexplicitly to mean "clear this". That changes the public signature and every docstring, which is more than a bug fix should do. - Fixing only
reasoningand 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== Falseor a truthiness check, so it cannot pass by accident onNoneor0. - 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 contextis 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
Nonestill means "use the default", so a caller cannot clear an init value by passingNone. That is the existing contract across Haystack components, and changing it would need the sentinel approach above.
8. Process, briefly
- Checked the issue tracker and open PRs for duplicates.
- Opened a bug report with a minimal reproduction and the proposed fix (#4168), so the maintainers could object before I wrote code.
- Forked, used a sparse checkout of just
integrations/lara, made the change, ranhatch run test:unitand Ruff. - 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 defaultonly when no falsy value could be a meaningful choice. For booleans, numbers, strings and collections, checkis 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.