How to Make Django Raise an Error for Missing Template Variables

Who dropped the {{ variable }} ?

It’s all too easy to forget to pass a variable to your template, or make a typo in a variable name. Unfortunately, it can be quite hard to debug such mistakes, since Django’s default behaviour is to ignore the problem and render an empty string.

In this post we’ll look at several techniques to check for missing template variables:

  1. Using the string_if_invalid option - the built-in option, but a bit cumbersome.
  2. With a Logging Filter that Raises Exceptions - this is my preferred technique. But it could break templates in your project that rely on the default missing variable beahviour.
  3. With a Logging Filter That Promotes Messages to Errors - you can use this to phase in the above exception-raising behaviour.
  4. With Jinja’s StrictUndefined - if you can switch your templates to render with Jinja, you can enable this option, as well as gain some other advantages.

Alright, there’s plenty of soil to till, so let’s dig in!

With The string_if_invalid Option

The DTL has an option called string_if_invalid. Templates render this for missing variables, and it defaults to the empty string. The docs have more detail on how this works.

You can configure it in your settings like so (but don’t copy this example):

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": True,
        "OPTIONS": {
            "string_if_invalid": "😱 MISSING VARIABLE %s 😱",
        },
    }
]

Using some emoji and shouty upper case will help the output stand out when manually testing pages. Django will replace the %s with the name of the missing variable, which can help track down the problem.

Setting the opiton unconditionally like this is not recommended, since it can appear in production as well. Instead, you generally want to set the option during development and tests, for example:

import sys

testing = sys.argv[1:2] == ["test"]

...

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": True,
        "OPTIONS": {
            "string_if_invalid": (
                "😱 MISSING VARIABLE %s 😱" if DEBUG or testing else ""
            ),
        },
    }
]

The testing variable here checks if the running command is test, so it’s looking for Django’s testing framework. If you’re using pytest you can instead use:

testing = "pytest" in sys.modules

There are many other ways to handle settings though, such as overriding some settings during tests - adapt as required.

With the option set, you can see it in action by loading a page with a bad variable. For example, take this index view:

from django.shortcuts import render


def index(request):
    return render(request, "index.html", {"nome": "Adam"})

nome is a typo of name, which is what the template is looking for:

<p>Hi {{ name }}!</p>

When loading the page in development you’d see:

Hi 😱 MISSING VARIABLE name 😱!

In theory you can then spot this when checking your pages. You might miss it though, or the missing variables might be in non-visible parts of the page, such as HTML attributes like <a href="{{ url }}>. It’s thus useful to check for string_if_invalid in tests…

Checking in Tests

In tests, you can check for missing variables by searching for your string_if_invalid value (without the %s). You could do this in every test with a string search, like:

from http import HTTPStatus

from django.test import TestCase


class IndexTests(TestCase):
    def test_success(self):
        response = self.client.get("/")

        self.assertEqual(response.status_code, HTTPStatus.OK)
        self.assertNotIn("😱 MISSING VARIABLE", response.content.decode())

With the above typo, this fails like:

$ ./manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_success (example.core.tests.test_views.IndexTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../example/core/tests/test_views.py", line 11, in test_success
    self.assertNotIn("😱 MISSING VARIABLE", response.content.decode())
AssertionError: '😱 MISSING VARIABLE' unexpectedly found in '<p>Hi 😱 MISSING VARIABLE name 😱!</p>\n'

----------------------------------------------------------------------
Ran 1 test in 0.014s

FAILED (failures=1)
Destroying test database for alias 'default'...

Okay, that’s usable. But it would be annoying to have to add the assertion to every test that renders a template.

You can make this check always run by creating a custom test client sublcass that checks for every response. Then, you can create custom test case classes that use your custom client class via the client_class attribute:

import re

from django import test


class CustomClient(test.Client):
    def request(self, *args, **kwargs):
        response = super().request(*args, **kwargs)

        # Check for missing template variables
        if not response.streaming:
            missing_vars = re.findall(
                r"😱 MISSING VARIABLE (.*?) 😱".encode(), response.content
            )
            if missing_vars:
                raise AssertionError(
                    "Missing template variables: "
                    + ",".join(v.decode() for v in missing_vars)
                )

        return response


class TestCaseMixin:
    client_class = CustomClient


class SimpleTestCase(TestCaseMixin, test.SimpleTestCase):
    pass


class TestCase(TestCaseMixin, test.TestCase):
    pass


class TransactionTestCase(TestCaseMixin, test.TransactionTestCase):
    pass

Your project can then use your custom test case classes everywhere:

from http import HTTPStatus

from example.test import TestCase


class IndexTests(TestCase):
    def test_success(self):
        response = self.client.get("/")

        self.assertEqual(response.status_code, HTTPStatus.OK)

Running this test now fails with a nice clear message listing all missing variables:

$ ./manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_success (example.core.tests.test_views.IndexTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/.../example/core/tests/test_views.py", line 8, in test_success
    response = self.client.get("/")
  File "/.../venv/lib/python3.10/site-packages/django/test/client.py", line 836, in get
    response = super().get(path, data=data, secure=secure, **extra)
  File "/.../venv/lib/python3.10/site-packages/django/test/client.py", line 424, in get
    return self.generic(
  File "/.../venv/lib/python3.10/site-packages/django/test/client.py", line 541, in generic
    return self.request(**r)
  File "/.../example/test.py", line 16, in request
    raise AssertionError(
AssertionError: Missing template variables: name

----------------------------------------------------------------------
Ran 1 test in 0.014s

FAILED (failures=1)
Destroying test database for alias 'default'...

Fan-tabby-tastic!

This approach will cover test for views, but it won’t cover other situations where you might render templates, for example for custom widgets, or emails. Adapt the check to include it for other rendering paths as appropriate.

With a Logging Filter that Raises Exceptions

The string_if_invalid option is simple and sometimes sufficient, but it has many drawbacks:

A second option that I’ve used is to hook into the DTL’s “missing variable” logging. The django.template logger logs debug-level messages for each missing variable. With a custom logging filter, we can act on those log messages, and even transform them into exceptions.

You can add a custom logging filter to the logger in the LOGGING setting, as per Django’s configring logging docs. For example:

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "filters": {
        "mising_variable_error": {
            "()": "example.logging.MissingVariableErrorFilter",
        },
    },
    "loggers": {
        "django.template": {
            "level": "DEBUG",
            "filters": ["mising_variable_error"],
        },
    },
}

This sets the level for the django.template logger to DEBUG, and adds a custom filter example.logging.MissingVariableErrorFilter. Here’s an example filter class which you might want to adapt:

import logging


class MissingVariableError(Exception):
    """
    A variable was missing from a template. Used as an alternative to
    django.template.base.VariableDoesNotExist, because that exception has some
    meaning within the template engine.
    """


class MissingVariableErrorFilter(logging.Filter):
    """
    Take log messages from Django for missing template variables and turn them
    into exceptions.
    """

    ignored_prefixes = (
        "admin/",
        "auth/",
        "debug_toolbar/",
        "django/",
        "wagtail/",
        "wagtailadmin/",
        "wagtailblog/",
        "wagtailembeds/",
        "wagtailimages/",
        "wagtailsites/",
        "wagtailusers/",
    )

    def filter(self, record):
        if record.msg.startswith("Exception while resolving variable "):
            variable_name, template_name = record.args
            if not template_name.startswith(self.ignored_prefixes):
                raise MissingVariableError(
                    f"{variable_name!r} missing in {template_name!r}"
                ) from None
        return False

Let’s deconstruct this code like cheesecake at a fancy restaurant…

Update (2022-03-31): As discovered by Jamie Matthews on Twitter, some expressions like {% if not var %} catch all exceptions, so this approach won’t show you every missing variable. The next logging-as-error technique will work for all cases, but it’s not so useful during development or testing.

Allllrighty then. When you hit a template with a missing variable in debug mode, it looks like this:

Django debug page for MissingVariableError

Brilliant. Django even shows you where in the template the problem lies.

To check the filter is hooked up correctly, and that it continues to work in the future, add some delicious tests:

from django.template import Context, Template
from django.test import SimpleTestCase

from example.logging import MissingVariableError


class MissingVariableErrorFilterTests(SimpleTestCase):
    def test_missing_variable(self):
        template = Template("Hi {{ name }}", name="index.html")
        context = Context({"nome": "Adam"})

        with self.assertRaises(MissingVariableError) as cm:
            template.render(context)

        self.assertEqual(str(cm.exception), "'name' missing in 'index.html'")

    def test_ignored_prefix(self):
        template = Template("Hi {{ name }}", name="admin/index.html")
        context = Context({"nome": "Adam"})

        result = template.render(context)

        self.assertEqual(result, "Hi ")

Abracadabra:

$ ./manage.py test example.test_logging
Found 2 test(s).
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.004s

OK

Yeet that into production and deal with the consequences… or, use the next technique…

With a Logging Filter That Promotes Messages to Errors

The above approach may be a bit harsh for existing projects. You may have many templates that depend on the default missing variable behaviour. Or you might not have sufficient test coverage to ensure that you can fix all missing variables if they raise exceptions.

Instead of raising exceptions, you can modify the logging to instead use the “error” level to make missing variables obvious. The default “debug” level likely doesn’t show up in any of your monitoring software, but error records should. For example, the default configuration for Sentry captures all logged errors.

Here’s a version of the template filter that doesn’t raise exceptions, and instead changes non-ignored records to use the ERROR level:

import logging


class MissingVariableErrorFilter(logging.Filter):
    """
    Take log messages from Django for missing template variables and turn them
    into exceptions.
    """

    ignored_prefixes = (
        "admin/",
        "auth/",
        "debug_toolbar/",
        "django/",
        "wagtail/",
        "wagtailadmin/",
        "wagtailblog/",
        "wagtailembeds/",
        "wagtailimages/",
        "wagtailsites/",
        "wagtailusers/",
    )

    def filter(self, record):
        if record.msg.startswith("Exception while resolving variable "):
            variable_name, template_name = record.args
            if not template_name.startswith(self.ignored_prefixes):
                record.level = logging.ERROR
                return True
        return False

…and corresponding tests:

import logging

from django.template import Context, Template
from django.test import SimpleTestCase


class MissingVariableErrorFilterTests(SimpleTestCase):
    def test_missing_variable(self):
        template = Template("Hi {{ name }}", name="index.html")
        context = Context({"nome": "Adam"})

        with self.assertLogs("django.template", logging.DEBUG) as cm:
            result = template.render(context)

        self.assertEqual(result, "Hi ")
        self.assertEqual(len(cm.records), 1)
        self.assertEqual(cm.records[0].level, logging.ERROR)
        self.assertEqual(
            cm.records[0].getMessage(),
            "Exception while resolving variable 'name' in template 'index.html'.",
        )

    def test_ignored_prefix(self):
        template = Template("Hi {{ name }}", name="admin/index.html")
        context = Context({"nome": "Adam"})

        with self.assertNoLogs("django.template", logging.DEBUG):
            result = template.render(context)

        self.assertEqual(result, "Hi ")

Nice one.

You could deploy this, and then fix missing variables as they’re discovered. Then, when you’re confident enough, switch to the exception-raising version as above. On very large projects you could even combine the approaches, and raise exceptions for a list of prefixes that you gradually expand, iteratively making your templates stricter.

With Jinja’s StrictUndefined

Django has built-in support to use Jinja as an alternative template engine. This can provide a pretty meaningful performance boost, more flexible syntax, and some nice features like macros. It also has more options for handling missing variables.

Jinja turns missing variables into instances of the Jinja environment’s undefined type. The behaviour of this class then affects what happens for use of the variable. There several undefined types built-in to Jinja - here are the most relevant:

By default Django’s Jinja backend sets the undefined type to DebugUndefined when settings.DEBUG is enabled, and Undefined otherwise. This gives you a little extra help for finding missing variables in development, without breaking your design in production.

But, we’re here to make things stricter! We can do so with… StrictUndefined.

You can set this in your settings:

from jinja2 import StrictUndefined

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        # ...
    },
    {
        "BACKEND": "django.template.backends.jinja2.Jinja2",
        "DIRS": [BASE_DIR / "templates"],
        "OPTIONS": {
            "undefined": StrictUndefined,
        },
    },
]

Note that Jinja is the second template engine here. For most projects, you need the DTL as your default engine, since Django’s admin and many third party packages depend on it.

After setting undefined, every missing variable error will raise UndefinedError:

Django debug page showing Jinja UndefinedError

Jinja stack traces show the responsible line in the template, so here the bottom of the stack trace shows:

/.../templates/index.html, line 1, in top-level template code

    <p>Hi {{ name }}!</p>

Slick!

Fin

That’s it for our grand tour of missing variable techniques. I hope you can use one to make your templates easier to work with.

😱 MISSING VARIABLE {{ witty_sign_off }} 😱

—Adam


Improve your Django develompent experience with my new book.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: