Jinja trim_blocks Swallows the Newline After a Trailing Comment and Joins Two Config Lines

A Jinja comment is meant to vanish from the output. Put one at the end of a config line and, with the settings most config generators use, it takes the line break with it.

The render succeeds. Nothing is logged. The next command is simply on the end of the previous one.

I generate FortiGate configuration from Jinja templates and push the result to FortiManager as a CLI script. This is a short note on a quiet way to break that, because the same thing applies to any line-based config: Cisco, Junos, Arista, FortiOS.

Everything below was run on Jinja 3.0.3 with Python 3. I have not sent the broken output to a device, and I would not.

The setup

Config templates are almost always rendered with two options on:

from jinja2 import Environment, StrictUndefined

env = Environment(
    undefined=StrictUndefined,
    trim_blocks=True,
    lstrip_blocks=True,
)

trim_blocks removes the first newline after a block tag, so {% if %} and {% endif %} lines do not leave blank lines behind. lstrip_blocks strips the leading whitespace before a block tag, so you can indent tags for readability. Without them, a templated config is full of empty lines.

The template

An ordinary template with a note on the end of one line:

config system ntp
    set ntpsync enable {# keep sync on #}
    set type custom
    set syncinterval 60
end

What it renders

With both options off, the comment disappears and the line breaks stay put:

config system ntp
    set ntpsync enable 
    set type custom
    set syncinterval 60
end

With trim_blocks=True, the output is:

config system ntp
    set ntpsync enable     set type custom
    set syncinterval 60
end

set ntpsync enable and set type custom are now one line. I confirmed it is trim_blocks doing it: trim_blocks=True on its own reproduces it, and lstrip_blocks=True on its own does not.

The option is documented as removing the first newline after a block. A comment tag gets the same treatment. When the comment sits on its own line that is exactly what you want, because the whole line disappears cleanly. When the comment follows content, the newline you lose is the one that ends your command.

StrictUndefined does not help. It guards against missing variables, and nothing here is missing. There is also no exception, so a pipeline that only checks that the render did not fail will pass this straight through.

Three fixes

1. Put the comment on its own line. This is the one I use as a rule:

config system ntp
    {# keep sync on #}
    set ntpsync enable
    set type custom
    set syncinterval 60
end

lstrip_blocks removes the indentation in front of the comment and trim_blocks removes the newline after it, so the comment leaves no trace:

config system ntp
    set ntpsync enable
    set type custom
    set syncinterval 60
end

2. Add a blank line after the trailing comment. trim_blocks removes the first newline and the second one ends your command. The blank line is consumed, so the output is correct, but it looks like a typo to the next person editing the template.

3. End the comment with +#}. The plus sign switches off the newline trimming for that one tag:

    set ntpsync enable {# keep sync on +#}

The output keeps its line breaks. The space before the comment stays as trailing whitespace on the rendered line, which is harmless in most places and untidy in a diff.

A cheap check

Two habits catch this class of mistake.

The first is to search your templates for a comment that follows content on the same line:

grep -rnE '\{#.*#\}[[:space:]]*$' templates | grep -vE ':[0-9]+:[[:space:]]*\{#'

The first pattern finds lines ending in a comment. The second grep drops lines where the comment is the first thing on the line, which are the safe ones. On my templates today this returns nothing.

The second is a check on the rendered output. In FortiOS CLI a line carries one set, so a line with two is a joined line:

import re

def joined_lines(rendered: str) -> list[str]:
    return [
        line for line in rendered.splitlines()
        if len(re.findall(r"\bset\b", line)) > 1
    ]

Against the two renders above, it flags the broken one and passes the fixed one:

guard flags: ['    set ntpsync enable     set type custom']
guard flags: []

It will not catch every possible join. It is a smoke test for the commonest one, and it runs in milliseconds.

The stronger habit is to read the diff before anything is installed. My pipeline stages a script and previews the change before install, which is where a joined line would be expected to show up, and it is a good reason never to skip the preview on a template change that looks trivial.

The summary

  1. With trim_blocks=True, a Jinja comment removes the newline that follows it, the same as a block tag.
  2. A trailing comment on a config line therefore joins that line to the next one. The render succeeds and StrictUndefined does not object.
  3. Keep comments on their own line. Use +#} or a blank line only when you have a reason to.
  4. Search templates for trailing comments, and add a render check that flags lines holding two commands.

I tested on Jinja 3.0.3. If you run a different version, the reproduction takes a minute and is worth doing before you rely on any of this.