Linter Usage

djLint includes many rules to check the style and validity of your templates. Take full advantage of the linter by configuring it to use a preset profile for the template language of your choice.

djlint /path/to/templates --lint

# with custom extensions
djlint /path/to/templates -e html.dj --profile=django

# or to file
djlint /path/to/this.html.j2  --profile=jinja

Enabling or Disabling Rules

Most rules are enabled by default. Rules can be disabled in the command line with the --ignore flag. Rules can be enabled with the --include flag.

For example:

djlint . --lint --include=H006,H017 --ignore=H013,H015

This can also be done through the Configuration file.

Rules

Code Meaning Default
T001 Variables should be wrapped in whitespace. Ex: {{ this }} ✔️
T002 Double quotes should be used in tags. Ex {% extends "this.html" %} ✔️
T003 Endblock should have name. Ex: {% endblock body %}. -
D004 (Django) Static urls should follow {% static path/to/file %} pattern. ✔️
J004 (Jinja) Static urls should follow {{ url_for('static'..) }} pattern. ✔️
H005 Html tag should have a non-empty lang attribute. ✔️
H006 img tag should have height and width attributes. -
H007 <!DOCTYPE ... > should be present before the html tag. ✔️
H008 Attributes should be double quoted. ✔️
H009 Tag names should be lowercase. ✔️
H010 Attribute names should be lowercase. ✔️
H011 Attribute values should be quoted. ✔️
H012 There should be no spaces around attribute =. ✔️
H013 img tag should have alt attributes. ✔️
H014 More blank lines than the configuration keeps. ✔️
H015 Follow h tags with a line break. ✔️
H016 Missing title tag in html. ✔️
H017 Void tags should be self closing (conflicts with: H018). -
D018 (Django) Internal links should use the {% url ... %} pattern. ✔️
H018 Void tags are self closing by nature and must end with “>”, not “/>” (conflicts with: H017). -
J018 (Jinja) Internal links should use the {% url ... %} pattern. ✔️
H019 Replace javascript:abc() with on_ event and real url. ✔️
H020 Empty tag pair found. Consider removing. ✔️
H021 Inline styles should be avoided. ✔️
H022 Use HTTPS for external links. ✔️
H023 Do not use entity references. ✔️
H024 Omit type on scripts and styles. ✔️
H025 Tag seems to be an orphan. ✔️
H026 Empty id and class tags can be removed. ✔️
T027 Unclosed string found in template syntax. ✔️
T028 Consider using spaceless tags inside attribute values. {%- if/for -%} -
H029 Consider using lowercase form method values. ✔️
H030 Consider adding a meta description. ✔️
T032 Extra whitespace found in template tags. ✔️
H033 Extra whitespace found in form action. ✔️
T034 Did you intend to use {% … %} instead of {% … }%? ✔️
H036 Avoid use of br tags. ✔️
H037 Duplicate attribute found. ✔️
T038 Block tag has no matching end tag. ✔️
T039 Unclosed template tag found. ✔️
T040 Missing or empty template name in extends or include tag. ✔️
H041 Tag is closed in a different template block than it was opened. ✔️
T041 Extends tag should be the first tag in the template. ✔️
H042 Label for attribute has no matching element id in this file. ✔️
T042 Content outside a block is not rendered in a template that extends another. ✔️
H043 Button tag should have a type attribute. ✔️
T043 Block name is used more than once in the template. ✔️
H044 Thead should not mix th and td cells. ✔️
T044 Output tag holds a statement keyword; use a block tag. ✔️
H045 Iframe tag should have a title attribute. ✔️
T045 Template tag inside an html comment still runs; use a template comment to disable it. ✔️
H046 Tabindex should not be positive. ✔️
H047 Aria-hidden should not be set on a focusable element. ✔️
H048 Aria attribute is not one the specification defines. ✔️
H049 Viewport should not stop the page being zoomed. ✔️
H050 Element is obsolete and should be replaced. ✔️
H051 Role is not one ARIA defines for markup. ✔️
H052 Meta refresh should not reload or redirect the page on a timer. ✔️
H053 Id is used more than once in the file. ✔️
H054 Interactive element should not be nested inside another. ✔️
H055 Lang attribute should be a language tag such as en or pt-BR. ✔️
H056 Src should not be empty. ✔️
H057 Video should have a captions track. ✔️

Code Patterns

The first letter of a code follows the pattern:

  • D: applies specifically to Django
  • H: applies to html
  • J: applies specifically to Jinja
  • M: applies specifically to Handlebars
  • N: applies specifically to Nunjucks
  • T: applies generally to templates

Rule Details

T001

Variables should be wrapped in a whitespace.

Template syntax like {{user.name}} without inner padding is harder to scan and diff, and inconsistent spacing across a codebase makes grep-based refactors (searching for a variable or tag) unreliable because the same expression exists in multiple spellings. Both Django and Jinja style guides write {{ var }} and {% tag %} with single spaces.

Not applied to the handlebars and golang profiles.

Don’t:

{{user.name}}

Do:

{{ user.name }}

T002

Double quotes should be used in tags.

Mixing single and double quotes in template tags ({% extends %}, {% include %}, {% with %}, {% trans %}, {% now %}) makes the same template name appear in two spellings, so searches and bulk renames miss half the occurrences. Standardizing on double quotes keeps tag arguments consistent with HTML attribute quoting in the rest of the file.

Single quotes inside HTML attribute values (e.g. <span title="{% trans 'x' %}">) are not flagged, since the attribute’s double quotes force single quotes there.

With quote_style = "single" the rule asks for single quotes instead, and the formatter writes them.

--reformat rewrites these quotes for you, so a finding is never hand work.

Don’t:

{% extends 'base.html' %}

Do:

{% extends "base.html" %}

T003

Endblock should have name. Ex: {% endblock body %}.

When a {% block %} spans many lines or blocks are nested, a bare {% endblock %} gives no clue which block it closes, so it is easy to end the wrong one while editing; child templates then override the wrong content. Naming the endblock documents the pairing and lets both djLint and Django (which raises TemplateSyntaxError on a mismatched endblock name) catch a block closed in the wrong place. Pairing errors (unclosed blocks, orphan endblocks and mismatched names) are correctness checks handled by T038.

Off by default; enable with --include=T003. --name-endblocks writes the name for you, so a finding is never hand work.

A name is not required when the block opens and closes on the same line, e.g. {% block title %}``{% endblock %}.

Don’t:

{% block content %}
<p>hello</p>
{% endblock %}

Do:

{% block content %}
<p>hello</p>
{% endblock content %}

D004

(Django) Static urls should follow {% static path/to/file %} pattern.

Hardcoding /static/ paths bypasses Django’s {% static %} tag, so templates break when STATIC_URL changes (e.g. moving assets to a CDN or a subpath deployment) and never pick up hashed filenames from ManifestStaticFilesStorage, causing 404s or stale cached assets in production. The rule looks for the literal /static/ prefix, so a project serving its static files from another path is not covered.

Don’t:

<link rel="stylesheet" href="/static/css/style.css">

Do:

<link rel="stylesheet" href="{% static 'css/style.css' %}">

J004

(Jinja) Static urls should follow {{ url_for('static'..) }} pattern.

Hardcoding /static/ paths bypasses Flask/Jinja’s url_for(‘static’, …), so assets 404 when the app is mounted under a URL prefix or the static folder/host is changed, and cache-busting query strings added by the framework are lost. The rule looks for the literal /static/ prefix, so a project serving its static files from another path is not covered.

Don’t:

<link rel="stylesheet" href="/static/css/style.css">

Do:

<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">

H005

Html tag should have a non-empty lang attribute.

Without a lang attribute on <html>, screen readers guess the pronunciation rules and may read the page in the wrong language, and browsers cannot correctly offer translation, hyphenation, or locale-aware quotation marks. Declaring the page language is WCAG 2.1 success criterion 3.1.1 (Level A).

lang="" and a valueless lang both say the language is unknown, so they are reported the same as a missing attribute.

Don’t:

<!DOCTYPE html>
<html>
</html>

Do:

<!DOCTYPE html>
<html lang="en">
</html>

H006

Img tag should have height and width attributes.

Off by default; enable with --include=H006.

When an <img> has no width and height, the browser cannot reserve space before the image downloads, so surrounding content jumps as images load. This layout shift degrades Cumulative Layout Shift (a Core Web Vitals metric) and can make users mis-click while the page settles.

Don’t:

<img src="cat.png" alt="Cat">

Do:

<img src="cat.png" alt="Cat" width="120" height="80">

H007

<!DOCTYPE ... > should be present before the html tag.

Without a <!DOCTYPE> before the <html> tag, browsers render the page in quirks mode, emulating legacy box-model and layout behavior, so CSS renders inconsistently across browsers. Template tags and comments before the doctype are fine; only the <html> tag itself must be preceded by it.

Don’t:

<html lang="en">
</html>

Do:

<!DOCTYPE html>
<html lang="en">
</html>

H008

Attributes should be double quoted.

Mixed quote styles make attribute values harder to scan and grep for, and single-quoted values break as soon as the content contains an apostrophe. Double quotes are the convention used by HTML specs, formatters, and most style guides, so standardizing on them keeps templates consistent with the wider ecosystem.

Don’t:

<div class='content'></div>

Do:

<div class="content"></div>

H009

Tag names should be lowercase.

HTML parsers accept uppercase tag names, but XHTML and XML serializations are case-sensitive and reject them, and mixed casing makes text search and diff review unreliable (grepping for <h1> misses <H1>). Lowercase tag names keep templates portable and consistent.

Don’t:

<H1>Welcome</H1>

Do:

<h1>Welcome</h1>

H010

Attribute names should be lowercase.

Uppercase attribute names are invalid in XHTML/XML serializations and defeat text search across templates (grepping for src= misses SRC=). The DOM normalizes HTML attribute names to lowercase anyway, so uppercase spellings add inconsistency with no benefit.

Don’t:

<img SRC="cat.png" alt="Cat" width="120" height="80">

Do:

<img src="cat.png" alt="Cat" width="120" height="80">

H011

Attribute values should be quoted.

Unquoted attribute values end at the first whitespace, so a value like class=btn primary silently drops everything after the space (the browser treats “primary” as a separate boolean attribute). Values that come from template variables are especially fragile: any rendered space, “=”, or “>” corrupts the tag. Quoting makes the value boundary explicit and safe.

Don’t:

<div class=test></div>

Do:

<div class="test"></div>

H012

There should be no spaces around attribute =.

With spaces around “=”, the tag reads as three separate tokens, and it is one edit away from breaking apart: a line wrap or truncation in the middle leaves a bare boolean attribute plus stray text. Keeping name=“value” contiguous is also what simple text tooling (grep, search-and-replace) assumes, so mixed spacing makes attributes hard to find and refactor reliably.

Don’t:

<div class = "test"></div>

Do:

<div class="test"></div>

H013

Img tag should have an alt attribute.

Without an alt attribute, screen readers announce the image’s file name or nothing at all, failing WCAG 1.1.1 (Non-text Content). The alt text is also what users see when the image fails to load. Decorative images should carry an explicit empty alt=“” so assistive technology knows to skip them; that also satisfies this rule.

A valueless alt is the same as alt="", the decorative image case, so it is accepted.

Don’t:

<img src="cat.jpg" height="200" width="300">

Do:

<img src="cat.jpg" height="200" width="300" alt="A sleeping cat">

H014

Found extra blank lines.

Runs of blank lines have no effect on the rendered page (HTML collapses whitespace) but bloat templates and create noisy diffs when neighboring lines change. djLint’s formatter removes them entirely by default (keeping at most max_blank_lines blank lines, which defaults to 0), so leftover runs indicate unformatted code.

Don’t:

<div>one</div>


<p>two</p>

Do:

<div>one</div>

<p>two</p>

H015

Follow h tags with a line break.

Headings are block-level landmarks that define the document outline; cramming the next element onto the same line as the closing h tag hides that structure in the source and makes edits to either element show up as changes to both in diffs. A line break after each heading keeps the template’s visual structure aligned with the rendered outline.

Don’t:

<h1>Heading</h1><p>Intro text.</p>

Do:

<h1>Heading</h1>
<p>Intro text.</p>

H016

Missing title tag in html.

The HTML spec requires a title element in every document. Without one, browser tabs, bookmarks, and history show a raw URL instead of a page name, search engines lose the primary label for the page, and screen-reader users lose the first thing announced on load, failing WCAG 2.4.2 (Page Titled, Level A).

Only fires on files containing a complete <html></html> document, so partials and child templates that extend a base are never flagged. SPA shells that set the title client-side still need a static <title>: it is what appears on first paint, in crawlers, and when JavaScript fails.

Don’t:

<html lang="en">
<body>Content</body>
</html>

Do:

<html lang="en">
<head>
<title>My page</title>
</head>
<body>Content</body>
</html>

H017

Void tags should be self closing.

Templates that must also parse as XML/XHTML (or feed XML-based tooling) reject void elements written without a closing slash, and mixing <br> with <br /> across a codebase produces inconsistent diffs. This rule enforces the XHTML-style convention so every void element is closed the same way.

Off by default; enable with --include=H017. Mutually exclusive with H018; enable only one of the two conventions.

Don’t:

<br>
<meta charset="utf-8">

Do:

<br />
<meta charset="utf-8" />

D018

(Django) Internal links should use the {% url ... %} pattern.

Hardcoded internal URLs silently go stale when a route’s path changes in urls.py, producing broken links and dead form actions that no test on the URLconf will catch. {% url %} resolves the path from the route name, so renaming a path updates every link at once.

Don’t:

<a href="/accounts/login">Login</a>

Do:

<a href="{% url 'login' %}">Login</a>

H018

Void tags are self closing by nature and must end with ">", not "/>"

In the HTML living standard the trailing slash on a void element has no meaning (the parser ignores it), so writing <br /> implies XML-style self-closing behavior HTML does not have and can mislead readers into adding slashes to non-void tags, where a stray / is silently dropped and masks unclosed-tag bugs. This rule enforces plain > endings on void elements.

Off by default; enable with --include=H018. Mutually exclusive with H017; enable only one of the two conventions. SVG <path /> is exempt, since SVG is XML and requires the slash.

Don’t:

<br />
<meta charset="utf-8" />

Do:

<br>
<meta charset="utf-8">

J018

(Jinja) Internal links should use the {{ url_for() ... }} pattern.

Hardcoded internal URLs break silently when a route’s path changes or the app is mounted under a prefix, leaving dead links and form actions posting to 404s. url_for() builds the URL from the endpoint name, so route changes propagate to every template automatically.

Don’t:

<a href="/accounts/login">Login</a>

Do:

<a href="{{ url_for('login') }}">Login</a>

H019

Replace 'javascript:abc()' with on_ event and real url.

javascript: URLs break middle-click and open-in-new-tab, do nothing when JavaScript is disabled or fails to load, are blocked by strict Content Security Policies, and are a classic XSS injection sink. Use a real URL for the href and attach the behavior with an event handler instead. Under a strict CSP, inline on* handlers are blocked as well: the onclick shown is the minimal in-template fix; prefer attaching the listener with addEventListener from a script file.

Don’t:

<a href="javascript:openPopup()">Open popup</a>

Do:

<a href="{% url 'popup' %}" onclick="openPopup(event)">Open popup</a>

H020

Empty tag pair found. Consider removing.

An empty tag pair renders no content but still creates a DOM node that can pick up margins, borders, or flex/grid gaps from stylesheets, producing phantom spacing that is hard to trace; it is usually leftover markup from an earlier edit. Tags that are legitimately empty in normal markup (td, th, li, dt, dd, slot) are exempt. Tags carrying any attribute (JS mount points like <div id="app">``</div>, icon-font elements like <i class="fa fa-user">``</i>) are not flagged either; only fully attribute-less empty pairs match.

Don’t:

<p>Saved.</p>
<span> </span>

Do:

<p>Saved.</p>

H021

Inline styles should be avoided.

Inline styles carry higher specificity than any stylesheet selector, so overriding them later requires !important; they are blocked by Content Security Policies without ‘unsafe-inline’ in style-src; and they scatter presentation across templates, so a theme or design change means editing markup instead of one stylesheet. Move the declaration to a CSS class. One legitimate exception: HTML email templates, where many email clients strip <style> blocks and inline styles are the standard technique; exclude your email template directories or disable this rule for them.

Don’t:

<div style="color: red;">Wrong username or password.</div>

Do:

<div class="error">Wrong username or password.</div>

H022

Use HTTPS for external links.

Plain http:// subresources on a page served over HTTPS are mixed content: browsers block scripts, stylesheets, and iframes outright and auto-upgrade or warn on images. An <a> link to an http:// page is not mixed content, but it still sends visitors over an unencrypted connection open to interception and tampering. References to internal hosts that genuinely have no TLS will be flagged too; silence those spots with a {# djlint:off H022 #} block rather than disabling the rule.

Don’t:

<a href="http://example.com">Example</a>

Do:

<a href="https://example.com">Example</a>

H023

Do not use entity references.

HTML5 documents are UTF-8, so the literal character works everywhere and is what reviewers actually read; a typo in an entity reference (e.g. &mdsah;) is not caught by the browser and renders verbatim as broken text. djLint allows the entities that carry syntax (&lt;, &gt;, &amp;, &quot;, &apos;, and the braces &lbrace; and &rbrace; together with &percnt;, &num; and &dollar;, which written out would complete a template delimiter) and those naming a character that is invisible, and so cannot be reviewed as a literal: the spaces (&nbsp;, &thinsp;, &hairsp;), the joiners and marks (&zwnj;, &zwj;, &lrm;, &rlm;) and &shy;, in named, decimal and hex form alike.

--reformat rewrites the entity as the character for you, so a finding is never hand work. An entity written inside a template tag is part of the tag rather than of the page, so neither the rule nor the formatter touches it.

Don’t:

<p>Dates 1900 &mdash; 2000</p>

Do:

<p>Dates 1900 — 2000</p>

H024

Omit type on scripts and styles.

text/javascript and text/css are the HTML5 defaults for <script> and <style>, so the attribute is dead weight the browser ignores; the WHATWG spec explicitly says to omit it. Dropping it also avoids stale MIME strings that break the element when copied onto module scripts (where type=“module” actually matters).

Don’t:

<script type="text/javascript" src="app.js">

Do:

<script src="app.js"></script>

H025

Tag seems to be an orphan.

A tag without its matching opening or closing tag forces the browser’s error recovery to guess where the element ends, so following markup gets swallowed into the wrong element; layout, CSS selectors, and JavaScript DOM queries then break silently and differently across browsers. H025 also reports an <ol> or <ul> opened inside a <p>: the HTML parser closes the paragraph before the list, so the markup never nests the way it is written.

Don’t:

<div>
  <p>Hello</p>

Do:

<div>
  <p>Hello</p>
</div>

H026

Empty id and class tags can be removed.

No class or id selector matches an empty attribute, and an empty id is invalid HTML (the id value must not be the empty string). An attribute presence selector such as div[class] does still match it, so removing one is visible to a stylesheet written that way. It usually signals a template bug where a variable was meant to be interpolated, so removing or filling it keeps that bug from hiding in plain sight.

Don’t:

<div id="" class="">content</div>

Do:

<div>content</div>

T027

Unclosed string found in template syntax.

A quote that is opened but never closed inside {% ... %} or {{ ... }} makes the template engine mis-parse the tag: Django and Jinja either raise a TemplateSyntaxError at render time or silently swallow the rest of the tag’s arguments as string content, so the page 500s or renders with missing arguments.

Don’t:

{% trans "Welcome %}

Do:

{% trans "Welcome" %}

T028

Consider using spaceless tags inside attribute values. {%- if/for -%}

Off by default; enable with --include=T028.

The whitespace a spaceless tag strips is whitespace that renders, so apply this only where the attribute has none to lose. alt="{%- if brand -%}Acme{%- endif -%} logo" renders as Acmelogo, and an svg d="M12 {%- if big -%}20{%- endif -%} 4Z" becomes a different path. This is why the rule is opt in.

Template tags inside an attribute value emit the whitespace and newlines around them into the rendered attribute, so an href or src built with plain {% if %}/{% for %} tags can contain stray spaces and produce broken URLs. Jinja/Nunjucks whitespace-control tags ({%- ... -%}) strip that surrounding whitespace so the attribute renders as one clean value. The class attribute is exempt, since extra whitespace between class names is harmless.

Not applied to the django profile: Django template tags do not support {%- -%} whitespace control.

Don’t:

<a href="{% if x %}/home{% endif %}"></a>

Do:

<a href="{%- if x -%}/home{%- endif -%}"></a>

H029

Consider using lowercase form method values.

The HTML spec defines the form method keywords as lowercase (get, post); browsers only accept uppercase variants through case-insensitive fallback matching. Keeping the canonical lowercase form makes templates consistent and greppable and avoids complaints from strict validators and XHTML-based toolchains.

Don’t:

<form method="POST"></form>

Do:

<form method="post"></form>

H030

Consider adding a meta description.

Search engines use the meta description as the snippet under your page title in results; without one they synthesize a snippet from arbitrary page text, which hurts click-through rates and produces poor link previews when the page is shared.

Only fires on files containing a complete <html></html> document. The snippet argument applies to publicly indexed pages; for auth-gated or intranet apps this rule is commonly disabled.

Don’t:

<html lang="en">
  <head><title>Home</title></head>
  <body>Welcome</body>
</html>

Do:

<html lang="en">
  <head>
    <title>Home</title>
    <meta name="description" content="A short summary of this page.">
  </head>
  <body>Welcome</body>
</html>

T032

Extra whitespace found in template tags.

Runs of spaces or tabs between the arguments of a template tag are invisible noise: they hide real differences in diffs, can make it hard to spot a missing argument, and drift from the single-space style djLint’s formatter produces, causing needless reformat churn. Whitespace inside quoted strings is preserved and not flagged.

Don’t:

{% static  'css/style.css' %}

Do:

{% static 'css/style.css' %}

H033

Extra whitespace found in form action.

Leading or trailing whitespace inside a form’s action value becomes part of the rendered URL. Browsers strip it when parsing, but non-browser clients and tests hitting the literal value may not, and around a {% url %} tag the stray space almost always signals a typo that renders a submission URL which fails server-side route matching.

Don’t:

<form action="{% url 'search' %} " method="get">
    <button>Search</button>
</form>

Do:

<form action="{% url 'search' %}" method="get">
    <button>Search</button>
</form>

T034

Did you intend to use {% ... %} instead of {% ... }%?

}% is almost always a typo for %}. The template engine does not recognize }% as a tag delimiter, so the tag is never parsed: the raw {% … }% text leaks into the rendered HTML, or the engine raises a syntax error when it hits the unclosed tag.

Don’t:

{% include "footer.html" }%

Do:

{% include "footer.html" %}

H036

Do not use br tags for spacing.

The html specification allows <br> only for a line break that is part of the content itself, as in a postal address or a poem, and that use is left alone. What is reported is the presentational use the specification rules out: a run of two or more breaks, which is vertical space, and a break against the inside edge of a block element, which renders nothing its own margin would not. Both break text reflow at narrow widths, and a screen reader announces a forced break where there is nothing to announce.

Don’t:

<p>Shipping is free.<br><br>Delivery takes 3 days.</p>

Do:

<p>Shipping is free.</p>
<p>Delivery takes 3 days.</p>

H037

Duplicate attribute found.

Duplicate attributes are invalid HTML, and browsers keep only the first occurrence and silently drop the rest, so the second class or style value never takes effect, which hides real bugs. The check is template-aware: an attribute repeated in mutually exclusive branches ({% if %}/{% else %}) is not flagged, since only one copy can render.

Don’t:

<div class="card" id="profile" class="active">...</div>

Do:

<div class="card active" id="profile">...</div>

T038

Block tag has no matching end tag.

A block tag such as {% if %}, {% for %} or {% macro %} without its matching end tag is a hard TemplateSyntaxError in Django and Jinja: the page fails to render at request time, which this rule catches before deploy. It also flags orphan end tags with no opening tag and incorrectly interleaved blocks (e.g. {% if %}``{% for %}``{% endif %}).

{% block %}/{% endblock %} pairing and endblock-name mismatches are checked by this rule; T003 (off by default) additionally demands a name on every multi-line {% endblock %}. Custom block tags registered via custom_blocks are also checked, including their self-closing / %} form.

Don’t:

{% if user.is_authenticated %}
<p>Welcome back!</p>

Do:

{% if user.is_authenticated %}
<p>Welcome back!</p>
{% endif %}

T039

Unclosed template tag found.

A template tag opened with {{ or {% but never closed with the matching }} or %} is not parsed as a tag: Django/Jinja either raise a TemplateSyntaxError or render the raw brace characters into the page, and everything up to the next delimiter can be silently swallowed. These typos (a single missing brace, a mismatched delimiter) are easy to miss in review because the template may still partially render.

Don’t:

<p>{{ user.name }</p>

Do:

<p>{{ user.name }}</p>

T040

Missing or empty template name in extends or include tag.

An {% extends %} or {% include %} tag with a missing, empty, or whitespace-only template name has nothing to load: Django raises TemplateSyntaxError when the name is missing entirely, and TemplateDoesNotExist at render time when it is empty, so the page 500s in production even though the template file itself looks syntactically plausible.

Don’t:

{% extends "" %}

Do:

{% extends "base.html" %}

H041

Tag is closed in a different template block than it was opened.

When an HTML tag is opened in one {% block %} but closed in another, a child template that overrides only one of those blocks inherits half of the element, producing unbalanced markup in the rendered page; browsers then auto-close or re-nest elements unpredictably, breaking layout and CSS selectors far from the template that was actually edited. Keeping each element opened and closed within the same block makes every block safe to override independently.

Don’t:

{% block content %}
<div class="wrapper">
{% endblock content %}
{% block footer %}
</div>
{% endblock footer %}

Do:

{% block content %}
<div class="wrapper">
</div>
{% endblock content %}
{% block footer %}
{% endblock footer %}

T041

Extends tag should be the first tag in the template.

Django refuses to compile a template in which another tag comes before {% extends %}, and text written before it is rendered, so it leaks into the page ahead of everything the parent template produces. Jinja renders that text too, and nunjucks drops it, so in every engine the template does not do what it looks like it does.

A {# #} comment renders nothing and does not count, and neither does anything inside a {% comment %}, {% raw %} or {% verbatim %} block, named or not, a {# djlint:off #} region in any of its three spellings, or yaml front matter. An html comment does count: the engine writes it into the page ahead of the parent template’s doctype, which is the leak this rule is about. So do a {% blocktrans %} block, a {% filter %} block and a <?php ?> block, each of which renders. Only the first {% extends %} is checked; a second one is an error of its own. On the jinja and nunjucks profiles a branch tag before it does not count, since jinja documents {% if x %}{% extends "a.html" %}{% else %}{% extends "b.html" %}{% endif %} as the way to choose a parent; django reads the rest of the template into the {% extends %} and then rejects the {% endif %}, so on that profile a branch tag counts.

Not applied to the handlebars, golang, liquid and angular profiles.

Don’t:

{% load static %}
{% extends "base.html" %}

Do:

{% extends "base.html" %}
{% load static %}

H042

Label for attribute has no matching element id in this file.

The check runs only on files it can analyze soundly: if the file contains anything that could render an id this file never shows (a {{ ... }} output such as a form widget, an {% include %} or {% extends %}, or an unrecognized template tag), the rule stays silent for that file. Where it does run, a report is a real broken association.

Don’t:

<label for="email">Email</label>
<input id="username">

Do:

<label for="email">Email</label>
<input id="email">

T042

Content outside a block is not rendered in a template that extends another.

Once a template extends another, the parent decides what is output and the child only fills the parent’s blocks. Text or html written after {% extends %} and outside every {% block %} is silently discarded at render time, so a paragraph that looks fine in the source never reaches the page.

A template tag there still runs, so {% load %}, {% set %} and an {% if %} wrapped around a block are left alone, as are {# #} and {% comment %} comments, html comments, {% raw %} and {% verbatim %} blocks, and the body of a {% macro %}, a block form {% set %}, a {% partialdef %} or an {% addtoblock %}, which is captured rather than output. The text of a {% blocktrans %} is reported, since a {% blocktrans %} is not a {% block %}. Only content after the extends tag is considered, and each run of it is reported once, at its start.

The engine reads template tags before html, so an {% extends %} written inside an html comment or a <pre> still runs and still makes the file a child; only {# #}, {% comment %}, {% raw %} and {% verbatim %} really hide one. Text inside an html comment reaches no reader either way and is not reported, but the tags written there are still read.

Not applied to the handlebars, golang, liquid and angular profiles.

Don’t:

{% extends "base.html" %}
<p>This paragraph is never shown.</p>
{% block content %}
<h1>Welcome</h1>
{% endblock %}

Do:

{% extends "base.html" %}
{% block content %}
<h1>Welcome</h1>
<p>This paragraph is shown.</p>
{% endblock %}

H043

Button tag should have a type attribute.

A <button> with no type defaults to submit, so a button written to run a script also submits the form around it and the page reloads. Writing the type out prevents that.

Don’t:

<form>
  <button onclick="preview()">Preview</button>
</form>

Do:

<form>
  <button type="button" onclick="preview()">Preview</button>
</form>

T043

Block name is used more than once in the template.

Django, Jinja and Nunjucks all refuse to parse a template that names two blocks the same, so the page fails to load at all. The engines do not care that the two blocks sit in different branches of an {% if %}, so each block name has to be unique across the whole template, whether the blocks are side by side or one is nested in another.

Only {% block %} counts: a {% blocktrans %} is not a block and a {% endblock name %} merely names the block it closes. Names are compared as written, since the engines treat Content and content as two blocks.

Only what the engine itself never parses is skipped: a template comment, a {% comment %} block, a {% raw %} or {% verbatim %} body, and a djlint:off region. A block written inside an html comment, a <script>, <style>, <pre> or <textarea> body, or a {% filter %} body is still counted, since the engine reads all of those and raises on the duplicate name all the same.

An {% embed %} opens a scope of its own: the blocks in it fill the embedded template rather than this one, so two embeds of the same partial may each write {% block body %}. A name repeated inside a single embed is still reported.

Not applied to the handlebars, golang, liquid and angular profiles.

Don’t:

{% block content %}{% endblock %}
{% block content %}{% endblock %}

Do:

{% block content %}{% endblock %}
{% block sidebar %}{% endblock %}

H044

Thead should not mix th and td cells.

A row is judged on its own, so the explanation row of td that the html specification places in a thead beside the row of headers is not a mixture. An empty td opening the row is the corner cell of a table with headers down its first column, which is the markup the W3C accessibility tutorial asks for, so it is skipped.

A th and a td carry different meaning to a screen reader and usually different css, so one stray cell in a header row reads as data and is styled unlike the columns beside it. The mixture is legal html, which is what makes it hard to spot.

Don’t:

<thead>
  <tr>
    <th>Name</th>
    <td>Size</td>
  </tr>
</thead>

Do:

<thead>
  <tr>
    <th>Name</th>
    <th>Size</th>
  </tr>
</thead>

T044

Output tag holds a statement keyword; use a block tag.

Not applied to the golang, handlebars and angular profiles.

An output tag prints a value, and if, for, url, include and the rest are statements that belong in a block tag. Django, Jinja and Nunjucks all reject {{ if x }} with a syntax error, and a closing keyword on its own, such as {{ endif }}, is read as a variable that renders nothing while the block it was meant to close stays open, so the page either fails to load or shows what the condition should have hidden.

A bare keyword is an ordinary variable name, so {{ url }}, {{ url|default:"/" }} and {{ set.name }} are not reported. Only a keyword given an argument is, along with a closing or branch keyword on its own such as {{ endif }} or {{ else }}. A control keyword such as if or for cannot begin an expression, so whatever comes after one is an argument, an operator included, and {{ if -1 > count }} is reported. The rest of the names double as variables and functions, and there only a quoted string or a word counts as an argument, so an expression that happens to start with one of them is left alone: {{ url ~ "/x" }}, {{ url if url else "#" }}, {{ url ? url : '#' }}, {{ block ('title') }}, {{ block .super }} and {{ filter [0] }} are all quiet.

The body of a {% raw %} or {% verbatim %} block is text rather than template syntax and is skipped, the named form {% verbatim vueapp %}...{% endverbatim vueapp %} included, so a Vue or Handlebars {{ }} protected that way is not reported. A {{ }} written inside a quoted argument of a block tag, as in {% trans "Write {{ if x }} instead" %}, is text the engine prints and is left alone too.

Don’t:

{{ if user.is_active }}

Do:

{% if user.is_active %}

H045

Iframe tag should have a title attribute.

A screen reader announces an iframe by its accessible name. Without one it reads out the frame’s url, or nothing at all, and there is no way to tell what the embedded page is before entering it. WCAG puts this under 4.1.2 Name, Role, Value, and axe and html-validate both ship the check by default.

The name can come from title, aria-label or aria-labelledby, and any one of the three satisfies the rule. A name written by a template tag counts, so a frame titled per page is not reported.

Don’t:

<iframe src="/report/"></iframe>

Do:

<iframe src="/report/" title="Quarterly report"></iframe>

T045

Template tag inside an html comment still runs; use a template comment to disable it.

An html comment hides markup from the browser, not from the template engine. <!-- {% include "debug.html" %} --> still renders the file, and <!-- {% if debug %}...{% endif %} --> still evaluates, in Django, Jinja, Nunjucks, Handlebars and Go alike, so a tag commented out this way keeps running, and whatever it writes lands inside the comment or, if it holds -->, breaks out of it. Only a template comment, {# #} in Django and Jinja, {{! }} in Handlebars or {{/* */}} in Go, stops a tag from running.

Only a statement tag is reported: {% %} under the profiles that have it, a handlebars section, close or partial, and a Go keyword such as {{if}} or {{end}}. A value printed into a comment, as in <!-- built {{ version }} -->, is a deliberate use and is left alone, as is a tag inside a template comment, a {% comment %} block or a raw block, and a conditional comment for Internet Explorer, <!--[if IE]> ... <![endif]-->, whose body is markup for the browser it names.

A bare Go keyword is an ordinary variable name in every other engine, so <!-- period {{ start }} to {{ end }} --> and <!-- Template: {{ template }} --> are values under Django, Jinja, Nunjucks, Handlebars and Liquid, and read as statements only under --profile golang. One carrying a Go operand, as {{ if .X }} and {{ template "footer" . }} do, is a statement no other engine prints and is reported under any profile. {% is likewise read only where the engine has it, so it is text under --profile handlebars and --profile golang, and an opening no closing brace follows, as in <!-- battery at 50{% charge -->, is the prose it looks like. A conditional comment is one written as such, opening on <!--[if and closed by its <![endif]--> in any case; one left unclosed is the ordinary comment a browser reads it as, and the tags in it are reported.

Don’t:

<!-- {% include "banner.html" %} -->

Do:

{# {% include "banner.html" %} #}

H046

Tabindex should not be positive.

A positive tabindex pulls an element to the front of the tab order, ahead of everything that has none. One of them rearranges the whole page for a keyboard user, and the order then has to be kept by hand in every template that adds a control. WCAG covers this under 2.4.3 Focus Order.

0 puts an element in the tab order at the place the document gives it, and -1 takes it out while leaving it focusable from script. Neither is reported, and neither is a value written by a template tag, whose number is not known here.

Don’t:

<input tabindex="1">

Do:

<input tabindex="0">

H047

Aria-hidden should not be set on a focusable element.

aria-hidden="true" takes an element out of the accessibility tree but leaves it in the tab order, so a keyboard user still lands on it and hears nothing announced. Hiding a decorative icon is the ordinary use of the attribute and is not reported; only an element that takes focus by itself is.

An element counts as focusable when it is a button, select, textarea, iframe or summary, an a or area with an href, an input that is not hidden, an audio or video with controls, or anything carrying contenteditable or a tabindex of 0 or more. A disabled control, or one with tabindex="-1", is already out of the tab order and is left alone.

Don’t:

<button aria-hidden="true">Close</button>

Do:

<button type="button" aria-label="Close"><span aria-hidden="true">x</span></button>

H048

Aria attribute is not one the specification defines.

A misspelled aria attribute does nothing at all. No browser warns, no screen reader reports it, and the markup keeps the appearance of having been made accessible, so aria-lable can sit in a template for years while the control it was meant to name stays unnamed.

The names the rule knows are the ones ARIA defines. A binding written by a framework is not a plain aria name, so :aria-label, v-bind:aria-label and [attr.aria-label] are left alone.

Don’t:

<button type="button" aria-lable="Close">x</button>

Do:

<button type="button" aria-label="Close">x</button>

H049

Viewport should not stop the page being zoomed.

user-scalable=no, and a maximum-scale below 2, stop a page being enlarged on a phone, which for many people is the only way to read it. WCAG asks for 200% under 1.4.4 Resize Text. Browsers increasingly ignore the restriction, but the tag still turns zoom off wherever it is honoured.

The name and the content are found whichever order the two are written in.

Don’t:

<meta name="viewport" content="width=device-width, user-scalable=no">

Do:

<meta name="viewport" content="width=device-width, initial-scale=1">

H050

Element is obsolete and should be replaced.

<center>, <font>, <big>, <strike> and <tt> were dropped when css took over presentation, and <marquee>, <blink>, <nobr> and <spacer> were never standard at all. Html no longer defines any of them, so nothing guarantees how a browser lays them out, and a stylesheet cannot target them the way it targets a class.

The elements the rule knows are acronym, applet, basefont, bgsound, big, blink, center, dir, font, frame, frameset, isindex, keygen, marquee, menuitem, nobr, noembed, noframes, plaintext, spacer, strike, tt and xmp. Only the opening tag is reported, so each element is named once, and a custom element whose name merely starts with one, such as <font-picker>, is left alone.

Don’t:

<center><font color="red">Warning</font></center>

Do:

<p class="warning">Warning</p>

H051

Role is not one ARIA defines for markup.

A role no specification names is dropped outright and the element keeps whatever meaning it already had, so role="buton" leaves a <div> a <div>: nothing to a screen reader, while the markup looks as though it had been given a purpose. Nothing warns about it, which is what makes the typo worth catching.

The names the rule knows are the roles ARIA defines for authors, together with those DPUB-ARIA (doc-chapter and the rest) and GRAPHICS-ARIA (graphics-symbol and the rest) add. ARIA’s abstract roles, such as landmark and sectionhead, are reported: the specification says they exist to build its ontology and must not be written in markup.

A role may hold several names, as a fallback list, and each is checked. A value holding template syntax is unknowable and is left alone, and so is a binding written by a framework, as in :role or [attr.role].

Don’t:

<div role="buton">Save</div>

Do:

<button type="button">Save</button>

H052

Meta refresh should not reload or redirect the page on a timer.

A timed refresh moves the page out from under whoever is reading it. Someone who reads slowly, is using a screen reader, or has simply been interrupted loses their place with no warning and no way to stop it, which is a failure of WCAG 2.2.1 Timing Adjustable, and a refresh that reloads the same page throws away whatever was typed into it.

A delay of zero is an immediate redirect rather than a timer, which WCAG allows where a server redirect cannot be used, so it is not reported. The two attributes are found whichever order they are written in.

Don’t:

<meta http-equiv="refresh" content="30">

Do:

<meta http-equiv="refresh" content="0; url=/next-page">

H053

Id is used more than once in the file.

An id names one element. A second element carrying the same id breaks getElementById, <label for>, fragment links and aria-labelledby: the browser takes the first and silently ignores the rest, so a label, a link or a script lands on the wrong element without any warning.

Two ids in exclusive branches of one {% if %}...{% else %}...{% endif %} are never both rendered, so they are not reported. A {% for %} loop or a {% block %} is not a branch: an id inside one and the same id outside it both render, and the later one is reported. A value written by a template tag is unknowable and is left alone, and so is an empty value. Ids are compared exactly, as the browser does, so save and Save are two ids.

Branches are read in whichever language the file is written in, and in any whitespace the language allows between the delimiters and the tag name: {% elif %} and {% else %}, liquid’s {% elsif %} and {% when %}, go’s {{ else }}, a mako % else: line statement, and handlebars’ {{else}}, its chained {{else if x}} and the inverse section of any {{#helper}} block, custom helpers included. The contents of a <template> are a document fragment of their own that getElementById never reaches, so an id there is compared only with the ids of that same <template>: two templates may each hold a row of the same id, while two of them inside one template are still reported. An id inside a comment, a {% raw %} or {% verbatim %} body, or a djlint:off region is not read at all.

Don’t:

<button type="submit" id="submit">Save</button>
<button type="submit" id="submit">Save and continue</button>

Do:

<button type="submit" id="submit">Save</button>
<button type="button" id="cancel">Cancel</button>

H054

Interactive element should not be nested inside another.

Html forbids interactive content inside <a> and <button>. A button inside a link, or a link inside a button, is invalid markup that browsers repair each in their own way, and a screen reader or keyboard user is handed one control that behaves like two. axe reports the same thing as “nested-interactive”.

The containers watched are an <a> with an href and a <button>, and the controls reported inside them are a link with an href, button, input, select and textarea. An <a> without an href is not interactive and is left alone on either side, as is a hidden input or one whose type a template tag writes. The content of a <template> is not rendered where it is written, so a control inside one is not nested in the link around it, though nesting written inside the template is still reported.

An attribute is read where a tag writes one and nowhere else: the href of {% if not href %} names a variable, the one in {# href="{{ url }}" #} is commented out and the one in data-attr=href belongs to another attribute, while the href an {% if %} writes between its own tags is an attribute like any other. A link or button left open ends with the element around it, as it would in a browser, so one typo does not report the rest of the file; one left open at the top level has nothing around it to end it and does run to the end of the file, where H025 reports the orphan as well.

Don’t:

<a href="/cart"><button>Add</button></a>

Do:

<a href="/cart" class="button">Add</a>

H055

Lang attribute should be a language tag such as en or pt-BR.

H005 asks for a lang on <html>, but a value such as lang="english" or lang="en_US" satisfies it while naming no language a browser knows. A screen reader then falls back to its default voice, and translation and hyphenation pick the wrong rules or none. The value has to be a BCP 47 tag: two or three letters, then any number of subtags of one to eight letters or digits, each after a hyphen, as in en, pt-BR or zh-Hant-TW.

Only the <html> tag is checked, matching H005. An empty value is left to that rule, while a value that is only whitespace is reported here, since H005 reads it as a value. A value written by a template tag, as in lang="{{ LANGUAGE_CODE }}", or by a php short echo, as in lang="<?= $lang ?>", is unknowable and is left alone, but a value that merely starts with $ or {, as in lang="$LANG", is read as written. xml:lang or data-lang is not read as lang, and a > written inside a template tag ahead of the attribute, as in {% if a > b %}, does not hide it.

Don’t:

<html lang="english">

Do:

<html lang="en">

H056

Src should not be empty.

The html specification says an empty src is invalid, and warns that a browser resolves it against the document’s own url, so <img src=""> fetches the page again as an image and <script src=""></script> fetches it as a script. It is usually a placeholder a script was meant to fill in, and the fix is to drop the attribute, or hold the value in a data attribute, until there is a real one. A src written with no value at all, as in <img src>, is empty too and is reported.

Only img, script, iframe, embed, source, track, audio and video are checked, since those are the elements that fetch what src names. Only a value a browser reads as empty is reported, so an unquoted value such as <img src=/static/logo.png> is left alone, as is a value that is only whitespace and a value written by a template tag, and srcset and data-src are different attributes that the rule does not judge.

Don’t:

<img src="" alt="Logo">

Do:

<img src="{% static 'logo.png' %}" alt="Logo">

H057

Video should have a captions track.

A video with sound carries its speech only in the audio, so a deaf or hard-of-hearing viewer gets nothing from it without captions, which WCAG 1.2.2 Captions (Prerecorded) requires. A <track> with a kind of captions or subtitles inside the <video> satisfies the rule, and so does a <track> with no kind at all, since subtitles is the default.

A muted video has no audio to caption and is not reported. Nor is a video whose opening tag or body holds a template tag, since the tracks, or the muted attribute, may be written by the template where djLint cannot see them. A template tag means one a template language opens, {{, {%, {# or ${, so a jQuery handler such as onclick="$(this).play()", an Alpine $refs, a css brace and a price written in the fallback text are the ordinary text they look like and the video is still read.

Only markup counts as markup. muted has to be the attribute, so <video class=muted> is a class named muted and is reported, and a <track> that is commented out or written inside an attribute value is text rather than a track. A </video> inside a value ends nothing, and the element is judged where its real end tag closes it, so a <video> left open is left to H025.

Don’t:

<video controls src="talk.mp4"></video>

Do:

<video controls src="talk.mp4">
  <track kind="captions" src="talk.vtt" srclang="en">
</video>

{% endraw %}

Adding Rules

We welcome pull requests with new rules!

A good rule consists of

  • Name
  • Code
  • Message - Message to display when error is found.
  • Flags - Regex flags. Defaults to re.DOTALL. ex: re.I|re.M
  • Patterns - regex expressions that will find the error.
  • Exclude - Optional list of profiles to exclude rule from.

Please include a test to validate the rule.

Custom Rules

You can add custom rules just for your project by creating a .djlint_rules.yaml alongside your pyproject.toml. Rules can be added to this files and djLint will pick them up. A rules file in another location can be given with the --rules CLI option.

Pattern Rules

You can add rules that fails if one of the regex pattern has a match:

- rule:
    name: T001
    message: Find Trichotillomania
    flags: re.DOTALL|re.I
    patterns:
      - Trichotillomania

Python module Rules

You can add rules that import and execute a custom python function:

- rule:
    name: T001
    message: Found the 'bad' word
    python_module: your_package.your_module

The specified python_module must contain a run() function that will be executed on every checked file. It must accept the following arguments:

  • rule: The dict that represent your rule in .djlint_rules.yaml. You will typically use this variable to access the rule name and message.
  • config: The DJLint configuration object.
  • html: The full html content of the file.
  • filepath: Path to the file that we are currently checking.
  • line_ends: List of line start and end character position that you can use with djlint.lint.get_line() to get line numbers from a character position. See the example.
  • *args, **kwargs: We might add other arguments in the future, so you should include those two arguments to reduce the risk of failure on djLint upgrade.

It must return a list of dict, one for each errors, with the following keys:

  • code: Code name of the rule that report the error (typically rule['name'])
  • line: Line number and character number on this line, separated by a ‘:’ as a string.
    For example "2:3" means that the error has been found on line 2, character 3
  • match: The part of the content that contains the error
  • message: The message that will be printed to signal the error (typically rule['message'])
from typing import Any, Dict, List
from djlint.settings import Config
from djlint.lint import get_line
import re


def run(
    rule: Dict[str, Any],
    config: Config,
    html: str,
    filepath: str,
    line_ends: List[Dict[str, int]],
    *args: Any,
    **kwargs: Any,
) -> List[Dict[str, str]]:
    """
    Rule that fails if if the html file contains 'bad'. This is just an example, in
    reality it's much simpler to do that with "pattern rule".
    """
    errors: List[Dict[str, str]] = []
    for match in re.finditer(r"bad", html):
        errors.append({
            "code": rule["name"],
            "line": get_line(match.start(), line_ends),
            "match": match.group().strip()[:20],
            "message": rule["message"],
        })
    return errors
Edit this page Updated Sep 16, 2026