Introduction
Creating a comma-separated list from a collection of items is one of the most common Jinja2 templating tasks. Whether generating configuration files, SQL statements, JSON payloads, or CSV output, you need to place a delimiter between elements without a trailing comma after the last one. This article covers every approach — loop.last, the join filter, custom delimiters, and real-world Ansible template patterns.
Method 1: The join Filter (Recommended)
The simplest and most Pythonic approach — use the join filter:
{{ my_list | join(', ') }}
Ansible Playbook Example
---
- name: Comma-separated list demo
hosts: localhost
vars:
fruits:
- apple
- banana
- cherry
tasks:
- name: Create comma-separated string
ansible.builtin.debug:
msg: "{{ fruits | join(', ') }}"
Output:
ok: [localhost] => {
"msg": "apple, banana, cherry"
}
With Type Conversion
When list elements are not all strings:
vars:
ports: [80, 443, 8080]
tasks:
- name: Join integers as strings
ansible.builtin.debug:
msg: "{{ ports | map('string') | join(', ') }}"
# Output: "80, 443, 8080"
Method 2: loop.last in Jinja2 Templates
For more complex formatting in template files, use loop.last:
{% for item in my_list %}
{{ item }}{% if not loop.last %}, {% endif %}
{% endfor %}
Template File Example
Template (templates/allowed_hosts.conf.j2):
# Generated by Ansible - do not edit
allowed_hosts = {% for host in allowed_hosts %}{{ host }}{% if not loop.last %}, {% endif %}{% endfor %}
Playbook:
- name: Generate config
ansible.builtin.template:
src: templates/allowed_hosts.conf.j2
dest: /etc/myapp/allowed_hosts.conf
vars:
allowed_hosts:
- 192.168.1.10
- 192.168.1.11
- 10.0.0.5
Output file:
# Generated by Ansible - do not edit
allowed_hosts = 192.168.1.10, 192.168.1.11, 10.0.0.5
Method 3: loop.first for Leading Delimiters
Sometimes you need the first item without a delimiter and all subsequent items with one:
{% for item in items %}{% if not loop.first %}, {% endif %}{{ item }}{% endfor %}
This produces identical output to loop.last but is useful when the delimiter logic is more natural at the start.
Available Loop Variables
Jinja2 provides several useful variables inside {% for %} loops:
| Variable | Description |
|---|---|
loop.index | Current iteration (1-indexed) |
loop.index0 | Current iteration (0-indexed) |
loop.revindex | Iterations remaining (1-indexed) |
loop.first | True if first iteration |
loop.last | True if last iteration |
loop.length | Total number of items |
loop.previtem | Previous item (Jinja2 2.10+) |
loop.nextitem | Next item (Jinja2 2.10+) |
Real-World Examples
SQL IN Clause
SELECT * FROM users WHERE id IN (
{% for id in user_ids %}{{ id }}{% if not loop.last %}, {% endif %}{% endfor %}
);
vars:
user_ids: [101, 205, 342]
# Output: SELECT * FROM users WHERE id IN (101, 205, 342);
Nginx upstream Block
upstream backend {
{% for server in backend_servers %}
server {{ server.host }}:{{ server.port }}{% if not loop.last %};{% endif %}
{% endfor %}
}
Comma-Separated with Newlines
{% for item in items %}
"{{ item }}"{% if not loop.last %},{% endif %}
{% endfor %}
Output:
"apple",
"banana",
"cherry"
JSON Array Generation
[
{% for user in users %}
{
"name": "{{ user.name }}",
"email": "{{ user.email }}"
}{% if not loop.last %},{% endif %}
{% endfor %}
]
CSV Output
- name: Generate CSV
ansible.builtin.copy:
content: |
name,email,role
{% for user in users %}
{{ user.name }},{{ user.email }},{{ user.role }}
{% endfor %}
dest: /tmp/users.csv
HAProxy Backend Config
backend webservers
balance roundrobin
{% for server in web_backends %}
server {{ server.name }} {{ server.ip }}:{{ server.port }} check{% if not loop.last %}
{% endif %}
{% endfor %}
Firewall Rules
- name: Generate firewall rule
ansible.builtin.debug:
msg: "Allow ports: {{ allowed_ports | join(', ') }}"
vars:
allowed_ports: [22, 80, 443, 8080]
# Output: "Allow ports: 22, 80, 443, 8080"
Advanced Patterns
Joining with Different Delimiters
# Comma-separated
msg: "{{ items | join(', ') }}"
# Semicolon-separated
msg: "{{ items | join('; ') }}"
# Newline-separated
msg: "{{ items | join('\n') }}"
# Pipe-separated
msg: "{{ items | join(' | ') }}"
# Space-separated
msg: "{{ items | join(' ') }}"
Joining Object Attributes
vars:
servers:
- name: web1
ip: 10.0.0.1
- name: web2
ip: 10.0.0.2
tasks:
- name: Join server names
ansible.builtin.debug:
msg: "{{ servers | map(attribute='name') | join(', ') }}"
# Output: "web1, web2"
- name: Join IPs
ansible.builtin.debug:
msg: "{{ servers | map(attribute='ip') | join(', ') }}"
# Output: "10.0.0.1, 10.0.0.2"
Filtering Before Joining
vars:
services:
- name: nginx
enabled: true
- name: apache
enabled: false
- name: haproxy
enabled: true
tasks:
- name: Join only enabled services
ansible.builtin.debug:
msg: "{{ services | selectattr('enabled') | map(attribute='name') | join(', ') }}"
# Output: "nginx, haproxy"
Quoting Each Element
# Wrap each element in quotes
msg: "{{ items | map('quote') | join(', ') }}"
# Output: "'apple', 'banana', 'cherry'"
# Double quotes
msg: >-
{{ items | map('regex_replace', '^(.*)$', '"\\1"') | join(', ') }}
# Output: "apple", "banana", "cherry"
Handling Empty Lists
# join on empty list returns empty string
msg: "{{ [] | join(', ') }}"
# Output: ""
# Provide a default
msg: "{{ my_list | join(', ') | default('none', true) }}"
join vs loop.last: When to Use Which
| Approach | Best For |
|---|---|
join filter | Simple inline strings, playbook tasks, one-liners |
loop.last | Template files, multi-line output, complex formatting per item |
Rule of thumb: If you can solve it with join, use join. It's shorter, cleaner, and less error-prone. Use loop.last when each element needs complex per-item formatting.
Common Mistakes
Trailing Comma
{# ❌ Trailing comma on last element #}
{% for item in items %}
{{ item }},
{% endfor %}
{# ✅ No trailing comma #}
{% for item in items %}
{{ item }}{% if not loop.last %},{% endif %}
{% endfor %}
Extra Whitespace
{# ❌ Produces extra newlines #}
{% for item in items %}
{{ item }}{% if not loop.last %}, {% endif %}
{% endfor %}
{# ✅ Use whitespace control #}
{%- for item in items -%}
{{ item }}{% if not loop.last %}, {% endif %}
{%- endfor -%}
Related Articles
- Ansible Jinja2 Templates Guide
- Ansible Filter Plugins Guide
- Ansible map vs selectattr vs json_query
- Ansible template Module Guide
Conclusion
For simple comma-separated lists, use {{ items | join(', ') }} — it's the cleanest approach. For complex per-element formatting in template files, use loop.last or loop.first. Combine with map, selectattr, and default filters for advanced list processing. The join filter handles the majority of real-world use cases in Ansible playbooks.