fix: Rewrite translated field names in all annotate() expressions - #825
Merged
last-partizan merged 1 commit intoSep 17, 2026
Merged
Conversation
MultilingualQuerySet.annotate only rewrote a keyword argument when it
was a bare F() or a Concat() of bare F() leaves, so under an active
language other than the original every other expression wrapping a
translated field name read the untranslated column.
annotate(x=F("title")) returned title_de while
annotate(x=Lower("title")) returned title_en. The same gap reached
QuerySet.datetimes, which Django implements as
annotate(datetimefield=Trunc(field_name, kind, ...),
plain_field=F(field_name)): the F() half was rewritten and the Trunc()
half was not, so dates was language-aware while datetimes returned the
years of the untranslated column.
The cause is the pair of isinstance checks at
modeltranslation/manager.py:531 and 533. _rewrite_f at
modeltranslation/manager.py:306 already walks any expression
generically through get_source_expressions and set_source_expressions,
and returns anything it does not recognise unchanged, which is why the
keyword argument paths of _rewrite_filter_or_exclude and update can
already hand it arbitrary values. The hand-rolled _rewrite_concat
helper at modeltranslation/manager.py:516 was a special case of that
same walk.
annotate now passes every keyword argument value through _rewrite_f
and _rewrite_concat is removed. Positional annotate arguments are left
alone, because rewriting Count("title") would change its generated
alias from title__count to title_de__count. A keyword aggregate is
rewritten along with everything else, so annotate(n=Count("title"))
counts the current language's column and a row whose translation is
NULL now counts 0 where it counted 1 before. The usage docs gain
datetimes() and annotate() in their list of methods that perform
rewriting.
Rewriting the field name also swaps the resolved output field from
Django's CharField to modeltranslation's TranslationCharField, so an
expression that puts a plain literal before a translated field, such
as Concat(Value("prefix: "), Lower("title")), now raises FieldError
about mixed types instead of quietly returning the untranslated value.
The same error already happens today for Concat(Value("x"),
F("title")) because F() was already rewritten, and setting
output_field=CharField() explicitly, which Django's own Concat
documentation already requires for mixed types, makes these
expressions work and return the translated value.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
annotate(x=F("title"))returns the German value under an activede, butannotate(x=Lower("title"))returns the English one. Every annotation expression that wraps a translated field name, except a bareF()or aConcat()of bareF()s, silently reads the untranslated column: on a row created underenastitle_en="title_en",title_de="title_de",Upper(F("title")),Coalesce("title", Value("fallback"))andCase(When(pk__gt=0, then=F("title")))all return the English value underde. The same gap reachesQuerySet.datetimes(), which Django implements asannotate(datetimefield=Trunc(field_name, kind, output_field=DateTimeField(), tzinfo=tzinfo), plain_field=F(field_name))and then filters onplain_field__isnull=False: theF()half was rewritten and theTrunc()half was not, so the filter readdatetime_dewhile the returned values readdatetime.dates()escapes this only becauseMultilingualQuerySetoverrides it.MultilingualQuerySet.annotaterewrites a keyword argument only when it passes one of twoisinstancechecks (modeltranslation/manager.py:531 and modeltranslation/manager.py:533); anything else goes tosuper().annotate()untouched._rewrite_f(modeltranslation/manager.py:306) already walks any expression generically throughget_source_expressions()/set_source_expressions()and returns what it does not recognise unchanged, which is why_rewrite_filter_or_excludeandupdate()can already hand it arbitrary values._rewrite_concat(modeltranslation/manager.py:516) is a hand-rolled special case of that same walk.The fix replaces the two branches with the call the other rewriting paths already make,
kwargs[key] = self._rewrite_f(val), and deletes_rewrite_concat. The generic walk handlesConcaton its own, and the existingConcatassertions intest_annotatestill pass. One existing shape changes its results: a keyword aggregate is rewritten along with everything else, soannotate(n=Count("title"))now compiles toCOUNT("title_de")instead ofCOUNT("title"). With two rows, one translated and one withtitle_de=None,deused to give[1, 1]and now gives[1, 0]. That matchesfilter(title__isnull=False), and there is a test for it.The widened rewrite has a cost. Rewriting
titletotitle_deswaps the resolved output field from Django'sCharFieldto modeltranslation'sTranslationCharField(modeltranslation/fields.py:88 buildsTranslationFieldSpecific(TranslationField, baseclass), withCharFieldas the base class here), andBaseExpression._resolve_output_fieldtakes the first non-None source as the output field and checks that it is an instance of each remaining source's class. A translated field first is fine; a literal first with a translated field after it now raisesFieldError: Expression contains mixed types: CharField, TranslationCharField. You must set output_field.Three ordinary shapes go from returning a value to raising:Concat(Value("x"), Lower("title")),Case(When(..., then=Value("a")), When(..., then=Lower("title")), default=Value("d"))andConcat(Upper("title"), Value("-"), Lower("title")). That is the same errorConcat(Value("x"), F("title"))already raises today, sinceF()was already rewritten, and what these three returned before was the untranslated value, so a silently wrong result becomes a loud one. Addingoutput_field=CharField(), which Django's ownConcatdocumentation already requires for mixed types, makes all three work and return the translated value;test_annotate_literal_before_translated_fieldpins both halves.Coalesce(Value("x"), F("title"))raises too; it returned the literal before and still does onceoutput_fieldis set.Four things this leaves alone. Positional
annotate(*args)is untouched, because rewritingCount("title")would change its generated alias fromtitle__counttotitle_de__count. AQpassed as an aggregate'sfilter=, and the lookups inside aWhen()condition, are still not rewritten, since_rewrite_fleavesQ's tuple children alone.QuerySet.alias()is not overridden at all, soalias(x=Lower("title"))still reads the untranslated column, before and after this change. Andaggregate()is not overridden either, soaggregate(n=Count("title"))still counts the untranslated column whileannotate(n=Count("title"))now counts the current language's; closing that gap wants its own PR and test.This addresses the other-expressions half of #728, "Support for Subquery and other expressions in annotate()", where last-partizan wrote "you can look at the source code and try to add this feature. I'll gladly review and merge it". The
Subqueryside of #728 and #702 is a different root cause I have not touched: a plainSubquery(qs.values("title")[:1])already returnstitle_detoday, because the innerMultilingualQuerySet.values()rewrote the name when the subquery was built, and this change does nothing to it either way, sinceQuery.get_source_expressions()returns[]and_rewrite_ffinds nothing to rewrite inside.One caveat:
_rewrite_fmutates expressions in place, so an expression stored at module level and reused across languages keeps the language it was first rewritten for.SHARED = Lower("title")annotated underdeand then underencompiles toLOWER("title_de")both times. This is pre-existing forF()andConcat(), but the reach grows to every expression type, and a proper fix wants its own PR and test.There are four regression tests, one for
datetimes()and three for the annotation shapes, each next to the closest existing test. Thedatetimes()test creates its rows underenon purpose, sincesetUpactivatesdeand creating under the language you later query would hide the bug. The rewriting-methods list indocs/modeltranslation/usage.rstgainsdatetimes()andannotate(), with theoutput_fieldcaveat.Verified on sqlite only, with Python 3.14.3 and Django 6.0.2 (
USE_TZisFalsein the test settings, sodatetimes()passestzinfo=Noneand does no timezone conversion):