2025, Oct 19 22:00

Django format_html_join KeyError with list of dicts: why it happens and how to fix it (pre-5.2 vs 5.2+)

Learn why format_html_join raises KeyError with dicts in older Django and how to fix it: upgrade to 5.2, add a small shim, or switch to positional arguments.

Rendering a table from a list of dicts with Django's format_html_join() looks straightforward, yet many run into a KeyError when trying to pass mappings. The root cause isn't the generator, nor the data shape — it's the version of Django and how format_html_join() used to process its input.

Problem statement

You have a model method that assembles a version history as a list of dictionaries and want to turn it into a neatly formatted HTML table using format_html_join(). The data looks like this: each row is a mapping with keys question, answer, user, and timestamp. The call to format_html_join(), however, throws KeyError: 'question'.

Reproducible code example

The following illustrates the setup: a history collector and an HTML generator. The logic is minimal and the data schema matches the intended HTML placeholders.

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

This setup raises a KeyError with the name of the first placeholder, for instance KeyError: 'question'.

Why this happens

The issue isn’t that a list of dictionaries is not iterable. The underlying behavior is version-dependent. Prior to Django 5.2, format_html_join() internally passed only positional arguments to format_html(), which makes named placeholders in your format string unresolvable when you supply a dict. In other words, mappings were not unpacked as keyword arguments. The source looked like this:

return mark_safe(
    conditional_escape(sep).join(
        format_html(format_string, *args) for args in args_generator
    )
)

Support for mappings was added recently and shipped in Django 5.2. As noted in the release notes:

format_html_join() now supports taking an iterable of mappings, passing their contents as keyword arguments to format_html().

Practical fixes

There are three straightforward ways to make this work, depending on your constraints.

Option 1: Use Django 5.2 or newer

If you're on Django 5.2+, your original intention works out of the box: pass an iterable of dicts, and use named placeholders. The same render method from the example above will produce the desired HTML. No further changes required.

Option 2: Provide a small compatibility shim

If upgrading isn’t feasible yet, you can create a tiny helper that supports both sequences and mappings. It mirrors the behavior introduced in 5.2 while keeping your data structure intact.

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
        )
    )

With this helper, the rendering method stays expressive and safe for mappings:

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()
    )

Option 3: Switch to positional arguments

Another way is to adapt the data you pass into positional tuples and use positional placeholders in the HTML fragment. This avoids mappings entirely and works with older 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()
        ),
    )

Why this is worth knowing

Server-side HTML construction in Django often leans on format_html() and format_html_join() for safe escaping and clean templating inside Python. Small differences in argument handling can create confusing errors that look like data problems but are really API capability mismatches. Recognizing when named placeholders require keyword arguments — and whether your framework version supports mappings — saves time and reduces the urge to rewrite perfectly good data structures.

Takeaways

If you’re passing dicts to format_html_join() and see KeyError on a placeholder name, check your Django version first. On 5.2 or newer, iterable of mappings is supported and your list of dicts will work as intended. If you’re on an older release, either introduce a tiny compatibility wrapper that unpacks mappings as keyword args, or map your dicts to positional tuples and switch the template to positional placeholders. Each approach keeps the logic clear and the output safe.

The article is based on a question from StackOverflow by robline and an answer by willeM_ Van Onsem.