2025, Oct 19 22:18
Почему format_html_join в Django падает с KeyError и как это исправить
Почему в Django возникает KeyError в format_html_join со словарями: причина в версиях до 5.2 и три решения — обновление, обёртка или позиционные аргументы.
Сборка таблицы из списка словарей с помощью Django format_html_join() кажется простой задачей, но на практике многие натыкаются на KeyError при попытке передать отображения (dict). Причина не в генераторе и не в структуре данных — дело в версии Django и в том, как format_html_join() раньше обрабатывал входные данные.
Постановка задачи
У вас есть метод модели, который собирает историю версий в виде списка словарей, и вы хотите превратить его в аккуратную HTML‑таблицу через format_html_join(). Структура данных проста: каждая строка — это mapping с ключами question, answer, user и timestamp. Однако вызов format_html_join() падает с KeyError: 'question'.
Воспроизводимый пример кода
Ниже — демонстрация окружения: сборщик истории и генератор HTML. Логика минимальна, схема данных соответствует заполнителям в HTML‑фрагменте.
from django.utils.html import format_html_join
class SomeModel:
    def collect_history(self):
        records = Version.objects.get_for_object(self)
        rows = []
        for rec in records:
            snapshot = rec.field_dict
            item = {
                "question": snapshot["question"],
                "answer": snapshot["answer"],
                "user": rec.revision.user.username,
                "timestamp": rec.revision.date_created.strftime("%Y-%m-%d %H:%M"),
            }
            rows.append(item)
        return rows
    def render_history_html(self):
        table_html = format_html_join(
            "\n",
            """<tr>
                <td>{question}</td>
                <td>{answer}</td>
                <td>{user}</td>
                <td>{timestamp}</td>
            </tr>""",
            self.collect_history()
        )
        return table_html
В такой конфигурации возникает KeyError с именем первого заполнителя, например KeyError: 'question'.
Почему так происходит
Проблема не в том, что список словарей «неитерируемый». Поведение зависит от версии. До Django 5.2 format_html_join() внутри передавал в format_html() только позиционные аргументы, из‑за чего именованные плейсхолдеры в вашей строке форматирования не могли быть сопоставлены при передаче словаря. Иными словами, отображения не распаковывались как именованные аргументы. Фрагмент исходников выглядел так:
return mark_safe( conditional_escape(sep).join( format_html(format_string, *args) for args in args_generator ) )
Поддержка отображений добавлена сравнительно недавно и вошла в релиз Django 5.2. В примечаниях к выпуску сказано:
format_html_join()теперь поддерживает итерируемые коллекции отображений, передавая их содержимое как именованные аргументы вformat_html().
Практические решения
Есть три простых способа заставить это работать — выбор зависит от ваших ограничений.
Вариант 1: используйте Django 5.2 или новее
Если у вас Django 5.2+, всё работает «из коробки»: передавайте итерируемую коллекцию словарей и используйте именованные плейсхолдеры. Тот же метод render из примера выше выдаст нужный HTML. Дополнительных изменений не требуется.
Вариант 2: добавьте небольшую совместимую обёртку
Если обновление пока невозможно, сделайте небольшую вспомогательную функцию, которая поддерживает и последовательности, и отображения. Она повторяет поведение, появившееся в 5.2, и при этом не требует менять структуру данных.
from collections.abc import Mapping
from django.utils.html import conditional_escape, format_html, mark_safe
def format_html_join_compat(sep, fmt, arg_iter):
    return mark_safe(
        conditional_escape(sep).join(
            (
                format_html(fmt, **args)
                if isinstance(args, Mapping)
                else format_html(fmt, *args)
            )
            for args in arg_iter
        )
    )
С такой обёрткой метод рендеринга остаётся наглядным и корректно работает со словарями:
def render_history_html(self):
    return format_html_join_compat(
        "\n",
        """<tr>
            <td>{question}</td>
            <td>{answer}</td>
            <td>{user}</td>
            <td>{timestamp}</td>
        </tr>""",
        self.collect_history()
    )
Вариант 3: перейдите на позиционные аргументы
Ещё один путь — преобразовать данные к позиционным кортежам и использовать позиционные плейсхолдеры в HTML‑фрагменте. Это полностью обходится без отображений и работает в старых версиях format_html_join().
from operator import itemgetter
from django.utils.html import format_html_join
def render_history_html(self):
    return format_html_join(
        "\n",
        '<tr>' + '<tr>{}</td>'*4 + '</tr>',
        map(
            itemgetter('question', 'answer', 'user', 'timestamp'),
            self.collect_history()
        ),
    )
Почему это важно
Формирование HTML на стороне сервера в Django часто опирается на format_html() и format_html_join() — они обеспечивают безопасное экранирование и удобное «мини‑шаблонизирование» прямо в Python. Небольшие различия в обработке аргументов легко приводят к запутанным ошибкам, которые выглядят как проблемы с данными, но на деле связаны с ограничениями API. Понимание, что именованные плейсхолдеры требуют именованных аргументов, и знание, поддерживает ли ваша версия фреймворка отображения, экономит время и избавляет от желания переписывать корректные структуры данных.
Выводы
Если вы передаёте словари в format_html_join() и получаете KeyError по имени плейсхолдера, сперва проверьте версию Django. Начиная с 5.2 поддерживается итерируемая коллекция отображений, и список словарей будет работать как задумано. В более старых версиях либо добавьте небольшую обёртку, которая распаковывает словари в именованные аргументы, либо преобразуйте словари в позиционные кортежи и используйте позиционные плейсхолдеры. В обоих случаях логика остаётся прозрачной, а вывод — безопасным.
Статья основана на вопросе на StackOverflow от robline и ответе willeM_ Van Onsem.