Jinja2 unpacks multiple variables in a loop the same way Python does: put a comma separated list of names in the for target and iterate something that yields tuples. {% for key, value in mapping.items() %} is the form you will write most often. What surprises people coming from Python is that zip and enumerate do not exist in a Jinja template by default, and that a variable you assign inside a loop is gone by the time the loop ends.
{% for a, b in pairs %} for any iterable of tuples, and {% for key, value in mydict.items() %} or {% for key, value in mydict|items %} for a mapping. For an index use loop.index rather than enumerate. To pair two lists, either zip them in Python before rendering, or register the builtin with env.globals["zip"] = zip.The examples below were run against Jinja 3.1, including the error messages, so the failure text you see in your traceback should match. Everything here works in the standalone library and in Flask, since Flask just configures a Jinja environment for you.
Tuple unpacking in a for loop
Any iterable whose items are sequences of the right length can be unpacked. The arity has to match exactly.
{# rows = [("Alice", 31, "NY"), ("Bo", 24, "LA")] #}
{% for name, age, city in rows %}
<tr><td>{{ name }}</td><td>{{ age }}</td><td>{{ city }}</td></tr>
{% endfor %}Nested unpacking works too, with parentheses exactly as in Python.
{# nested = [(1, (2, 3))] #}
{% for a, (b, c) in nested %}{{ a }}{{ b }}{{ c }}{% endfor %}
{# renders: 123 #}You can filter in the same statement, which saves an inner if block and keeps loop.first and loop.last meaningful for the filtered set rather than the raw one.
{% for key, value in counts.items() if value > 1 %}{{ key }} {% endfor %}Iterating dictionaries
A bare {% for k, v in mydict %} does not work, because iterating a mapping yields keys, and a key is a single value. Jinja raises the Python error directly.
{% for k, v in mydict %}...{% endfor %}
ValueError: not enough values to unpack (expected 2, got 1)There are three correct forms, and they differ in ordering and in portability.
| Form | Order | Notes |
|---|---|---|
mydict.items() | Insertion order | Calls the Python method. Fails on objects without .items(). |
mydict|items | Insertion order | Jinja filter. Portable to Jinja ports in other languages. Returns an empty iterator if the value is undefined. |
mydict|dictsort | Sorted by key | Takes reverse, case_sensitive and by arguments to sort by value instead. |
{# mydict = {"b": 2, "a": 1} #}
{% for k, v in mydict.items() %}{{ k }}={{ v }};{% endfor %} {# b=2;a=1; #}
{% for k, v in mydict|items %}{{ k }}={{ v }};{% endfor %} {# b=2;a=1; #}
{% for k, v in mydict|dictsort %}{{ k }}={{ v }};{% endfor %} {# a=1;b=2; #}
{% for k, v in mydict|dictsort(reverse=true) %}{{ k }}{% endfor %}zip and enumerate are not there
This is the single most common stumble. Jinja’s default global namespace does not include zip or enumerate, so calling either raises an undefined error.
{% for i, x in enumerate(items) %}...{% endfor %}
jinja2.exceptions.UndefinedError: 'enumerate' is undefinedYou have three ways out, in rough order of preference.
Use loop.index instead of enumerate. It is one indexed, and loop.index0 is zero indexed. There is no reason to reach for enumerate at all.
{% for x in items %}{{ loop.index0 }}: {{ x }}{% endfor %}Zip in Python and pass a list of tuples. This keeps the template dumb, which is the point of a template. Pass list(zip(names, scores)) into the context and unpack it in the loop.
Register the builtins as globals if you genuinely need them in the template, for example in a reporting template you cannot change the view for.
# Standalone Jinja
from jinja2 import Environment, PackageLoader
env = Environment(loader=PackageLoader("myapp"))
env.globals["zip"] = zip
env.globals["enumerate"] = enumerate
# Flask
app.jinja_env.globals.update(zip=zip, enumerate=enumerate)Modify globals before any template is loaded. With both registered, the fully nested form works: {% for i, (a, b) in enumerate(zip(x, y)) %} renders as you would expect.
{% for i in range(a|length) %}{{ a[i] }} {{ b[i] }}{% endfor %} works with no configuration, since range is a Jinja global.set with multiple targets
The set tag takes tuple targets, so you can assign several names at once or destructure a value from the context.
{% set a, b = 1, 2 %}{{ a }}{{ b }} {# 12 #}
{% set low, high = bounds %}{{ low }}-{{ high }}Loop scope, and why your counter stays at zero
Jinja’s documentation states it directly: it is not possible to set variables inside a block and have them show up outside of it, and this applies to loops. So the obvious accumulator does nothing.
{% set total = 0 %}
{% for i in [1, 2] %}{% set total = total + i %}{% endfor %}
{{ total }} {# renders 0, not 3 #}The fix, available since Jinja 2.10, is a namespace object. Assignments to its attributes do propagate out of the loop.
{% set ns = namespace(total=0, found=false) %}
{% for i in [1, 2, 3] %}
{% set ns.total = ns.total + i %}
{% if i == 2 %}{% set ns.found = true %}{% endif %}
{% endfor %}
{{ ns.total }} {{ ns.found }} {# 6 True #}Note that the attribute notation in a set tag is only allowed for namespace objects. You cannot use it to poke a value into an arbitrary object from the context.
Grouping, batching and the loop variable
Several filters hand you tuples ready to unpack, which is often cleaner than nesting loops by hand. groupby sorts the values first, so exactly one group comes back per unique key.
{% for city, rows in users|groupby("city") %}
<h3>{{ city }}</h3>
{% for u in rows %}{{ u.name }} {% endfor %}
{% endfor %}
{% for row in items|batch(3, " ") %}
<tr>{% for cell in row %}<td>{{ cell }}</td>{% endfor %}</tr>
{% endfor %}The loop object carries more than an index, and several of its attributes replace bookkeeping variables you would otherwise need a namespace for.
| Attribute | What it gives you |
|---|---|
loop.index / loop.index0 | Position, one indexed and zero indexed |
loop.first / loop.last | Booleans for the edges of the sequence |
loop.previtem / loop.nextitem | Neighboring items, undefined at the ends |
loop.changed(value) | True when the value differs from the previous call, ideal for section headers |
loop.cycle("odd", "even") | Rotates through the arguments each iteration |
Troubleshooting
not enough values to unpack (expected 2, got 1). You are iterating a mapping directly, or a list of plain strings. Add .items() or the items filter for a mapping.
too many values to unpack (expected 2). Your rows have three elements and your target has two. Jinja will not silently drop the extra, so either add a name or slice in Python.
‘zip’ is undefined. Register it on the environment, or zip in the view. Turning on StrictUndefined makes this class of mistake fail loudly instead of rendering an empty string.
A counter set in a loop reads back as its initial value. Loop scope. Switch to namespace().
Groups come out in the wrong order. groupby sorts by the grouping key first. If you need source order, sort the data in Python and group there.
Frequently asked questions
Can I unpack in a nested loop?
Yes, and each loop gets its own loop object. To reach the outer one from inside, bind it first with {% set outer_loop = loop %} before the inner loop starts, then refer to outer_loop.index. Otherwise loop always refers to the innermost loop.
What is the difference between the items filter and calling .items()?
Behaviorally almost nothing in Python. The filter is portable to Jinja implementations in other languages whose mapping type has no .items() method, and it returns an empty iterator rather than raising when handed an undefined value.
How do I iterate two lists together?
Preferably zip them in the view and pass a list of tuples. If you must do it in the template, either register zip on the environment globals or index both lists with {% for i in range(a|length) %}, since range is available by default.
Does namespace work in older versions?
Namespace objects arrived in Jinja 2.10. On anything older you have to restructure: compute the aggregate in Python, or use a filter such as sum or selectattr combined with length to get the number without accumulating in the template.
Is there a for else in Jinja?
Yes, and it is unlike Python’s. The {% else %} block in a Jinja for loop runs when the sequence was empty, which makes it a tidy way to render an empty state message without a separate length check.
The bottom line
Unpacking itself is Python’s rules with Python’s error messages. The parts that are genuinely different are the missing globals and the loop scope, and both have clean answers: loop.index replaces enumerate, zipping belongs in the view, and namespace() exists for the rare case where you must accumulate inside a loop.
When something behaves oddly, check the two references directly: the Jinja template designer documentation for loops and filters, and the API documentation for the environment globals. If you also work in Django templates, our guide to marking strings as safe in Django covers the escaping model in the other engine, and for the underlying data shaping our notes on joining two vectors and on explaining a solution clearly are worth a look.
