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.

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:

VariableDescription
loop.indexCurrent iteration (1-indexed)
loop.index0Current iteration (0-indexed)
loop.revindexIterations remaining (1-indexed)
loop.firstTrue if first iteration
loop.lastTrue if last iteration
loop.lengthTotal number of items
loop.previtemPrevious item (Jinja2 2.10+)
loop.nextitemNext 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

ApproachBest For
join filterSimple inline strings, playbook tasks, one-liners
loop.lastTemplate 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 -%}

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.