Originally published in 2017 for Django 1.11, updated for modern Django versions

Django's template system is powerful out of the box, but sometimes you need functionality that goes beyond the built-in tags and filters. Custom template tags allow you to extend Django's templating capabilities with your own reusable components, making your templates cleaner and your code more maintainable.

In this guide, we'll explore how to build custom template tags from simple filters to complex inclusion tags, with practical examples you can use in your projects today.

Why custom template tags?

Before diving into implementation, let's understand when and why you'd want to create custom template tags:

  • Reusability: Avoid repeating complex template logic across multiple templates
  • Performance: Move heavy processing from templates to Python code
  • Maintainability: Centralise complex display logic in one place
  • Readability: Make templates more semantic and easier to understand

Setting up your template tags

Directory structure

First, create the proper directory structure in your Django app:

myapp/
├── __init__.py
├── models.py
├── views.py
└── templatetags/
    ├── __init__.py
    └── myapp_tags.py

The templatetags directory must contain an __init__.py file to be recognized as a Python package. The naming convention for your tags file is typically {app_name}_tags.py.

Basic setup

Create your template tags file (myapp/templatetags/myapp_tags.py):

from django import template
from django.utils.safestring import mark_safe
from django.utils.html import format_html
from django.db.models import Count
from datetime import datetime, timedelta
import re

register = template.Library()

The register object is your gateway to registering custom tags and filters with Django's template system.

Building custom filters

Filters are the simplest type of custom template tags. They take a value, transform it, and return the result.

Simple filter example

Let's start with a basic filter that truncates text and adds an ellipsis:

@register.filter
def smart_truncate(value, length=50):
    """
    Truncates a string to a given length and adds ellipsis if truncated.
    Usage: {{ post.content|smart_truncate:100 }}
    """
    if not value:
        return ""
    
    if len(value) <= length:
        return value
    
    # Find the last space before the length limit
    truncated = value[:length].rsplit(' ', 1)[0]
    return f"{truncated}..."

@register.filter
def reading_time(value):
    """
    Calculates estimated reading time for text content.
    Usage: {{ post.content|reading_time }}
    """
    if not value:
        return "0 min read"
    
    words = len(value.split())
    minutes = max(1, round(words / 200))  # Average 200 words per minute
    return f"{minutes} min read"

Advanced filter with arguments

Here's a more complex filter that formats numbers with custom suffixes:

@register.filter
def format_count(value, singular_plural=None):
    """
    Formats large numbers with K, M suffixes and handles pluralization.
    Usage: {{ view_count|format_count:"view,views" }}
    """
    try:
        num = int(value)
    except (ValueError, TypeError):
        return value
    
    if num >= 1000000:
        formatted = f"{num / 1000000:.1f}M"
    elif num >= 1000:
        formatted = f"{num / 1000:.1f}K"
    else:
        formatted = str(num)
    
    if singular_plural:
        singular, plural = singular_plural.split(',')
        word = singular if num == 1 else plural
        return f"{formatted} {word}"
    
    return formatted

Simple tags

Simple tags are more powerful than filters as they can access the template context and perform complex operations.

Basic simple tag

@register.simple_tag
def current_time(format_string='%B %d, %Y'):
    """
    Returns the current time formatted according to the given string.
    Usage: {% current_time "%Y-%m-%d %H:%M" %}
    """
    return datetime.now().strftime(format_string)

@register.simple_tag
def multiply(value, multiplier):
    """
    Multiplies two values together.
    Usage: {% multiply product.price 1.2 %}
    """
    try:
        return float(value) * float(multiplier)
    except (ValueError, TypeError):
        return 0

Context-aware simple tag

Simple tags can access the template context, making them incredibly powerful:

@register.simple_tag(takes_context=True)
def user_display_name(context):
    """
    Returns appropriate display name for the current user.
    Usage: {% user_display_name %}
    """
    user = context.get('user')
    if not user or not user.is_authenticated:
        return "Guest"
    
    if user.get_full_name():
        return user.get_full_name()
    
    return user.username

@register.simple_tag(takes_context=True)
def query_string(context, **kwargs):
    """
    Modifies current query string with new parameters.
    Usage: {% query_string page=2 sort="name" %}
    """
    request = context.get('request')
    if not request:
        return ''
    
    query_dict = request.GET.copy()
    
    for key, value in kwargs.items():
        if value is None:
            query_dict.pop(key, None)
        else:
            query_dict[key] = value
    
    return query_dict.urlencode()

Inclusion tags

Inclusion tags are perfect for rendering complex HTML snippets with data processing. They render a template and return the result.

Basic inclusion tag

@register.inclusion_tag('myapp/tags/user_badge.html')
def user_badge(user, size='medium'):
    """
    Renders a user badge with avatar and info.
    Usage: {% user_badge user "large" %}
    """
    return {
        'user': user,
        'size': size,
        'avatar_url': user.profile.avatar.url if hasattr(user, 'profile') and user.profile.avatar else '/static/img/default-avatar.png',
        'is_online': user.last_login and user.last_login > datetime.now() - timedelta(minutes=15)
    }

Create the corresponding template (myapp/templates/myapp/tags/user_badge.html):

<div class="user-badge user-badge--{{ size }}">
    <img src="{{ avatar_url }}" alt="{{ user.username }}" class="user-badge__avatar">
    <div class="user-badge__info">
        <span class="user-badge__name">{{ user.get_full_name|default:user.username }}</span>
        {% if is_online %}
            <span class="user-badge__status user-badge__status--online">Online</span>
        {% endif %}
    </div>
</div>

Advanced inclusion tag with database queries

@register.inclusion_tag('myapp/tags/popular_posts.html', takes_context=True)
def popular_posts(context, count=5, days=7):
    """
    Shows popular posts from the last N days.
    Usage: {% popular_posts count=10 days=30 %}
    """
    from myapp.models import Post
    
    cutoff_date = datetime.now() - timedelta(days=days)
    
    posts = Post.objects.filter(
        created_at__gte=cutoff_date,
        is_published=True
    ).annotate(
        total_engagement=Count('likes') + Count('comments')
    ).order_by('-total_engagement')[:count]
    
    return {
        'posts': posts,
        'count': count,
        'days': days,
        'request': context.get('request'),  # Pass request for URL building
    }

Template (myapp/templates/myapp/tags/popular_posts.html):

<div class="popular-posts">
    <h3 class="popular-posts__title">
        Popular Posts (Last {{ days }} days)
    </h3>
    
    {% if posts %}
        <ul class="popular-posts__list">
            {% for post in posts %}
                <li class="popular-posts__item">
                    <a href="{{ post.get_absolute_url }}" class="popular-posts__link">
                        {{ post.title }}
                    </a>
                    <span class="popular-posts__engagement">
                        {{ post.total_engagement }} interactions
                    </span>
                </li>
            {% endfor %}
        </ul>
    {% else %}
        <p class="popular-posts__empty">No popular posts found.</p>
    {% endif %}
</div>

Assignment tags (setting variables)

Sometimes you need to set variables in your template for later use:

@register.simple_tag
def get_popular_tags(count=10):
    """
    Gets popular tags and returns them.
    Usage: {% get_popular_tags 5 as popular_tags %}
    """
    from myapp.models import Tag
    
    return Tag.objects.annotate(
        post_count=Count('posts')
    ).filter(post_count__gt=0).order_by('-post_count')[:count]

@register.simple_tag(takes_context=True)
def get_related_posts(context, post, count=3):
    """
    Gets posts related to the current post.
    Usage: {% get_related_posts post 5 as related_posts %}
    """
    from myapp.models import Post
    
    return Post.objects.filter(
        tags__in=post.tags.all(),
        is_published=True
    ).exclude(
        id=post.id
    ).distinct().order_by('-created_at')[:count]

Usage in templates:

{% load myapp_tags %}

{% get_popular_tags 5 as popular_tags %}
{% if popular_tags %}
    <div class="tag-cloud">
        {% for tag in popular_tags %}
            <span class="tag tag--popular">{{ tag.name }} ({{ tag.post_count }})</span>
        {% endfor %}
    </div>
{% endif %}

{% get_related_posts post 3 as related_posts %}
{% if related_posts %}
    <h3>Related Posts</h3>
    {% for related_post in related_posts %}
        {% user_badge related_post.author %}
    {% endfor %}
{% endif %}

Advanced techniques

Custom tag with parser

For complex syntax, you can create tags that parse their own arguments:

@register.tag
def cache_bust(parser, token):
    """
    Adds cache-busting parameter to static files.
    Usage: {% cache_bust "css/style.css" %}
    """
    try:
        tag_name, file_path = token.split_contents()
    except ValueError:
        raise template.TemplateSyntaxError(
            f"{token.contents.split()[0]} tag requires exactly one argument"
        )
    
    if not (file_path[0] == file_path[-1] and file_path[0] in ('"', "'")):
        raise template.TemplateSyntaxError(
            f"{token.contents.split()[0]} tag's argument should be in quotes"
        )
    
    return CacheBustNode(file_path[1:-1])

class CacheBustNode(template.Node):
    def __init__(self, file_path):
        self.file_path = file_path
    
    def render(self, context):
        import os
        from django.conf import settings
        
        file_full_path = os.path.join(settings.STATIC_ROOT or settings.STATICFILES_DIRS[0], self.file_path)
        
        try:
            timestamp = str(int(os.path.getmtime(file_full_path)))
        except (OSError, IndexError):
            timestamp = '1'
        
        return f"{settings.STATIC_URL}{self.file_path}?v={timestamp}"

Error handling and safety

Always include proper error handling in your custom tags:

@register.filter
def safe_divide(value, divisor):
    """
    Safely divides two numbers, returning 0 for division by zero.
    Usage: {{ total|safe_divide:count }}
    """
    try:
        return float(value) / float(divisor)
    except (ValueError, TypeError, ZeroDivisionError):
        return 0

@register.simple_tag
def get_object_or_none(model_class, **kwargs):
    """
    Gets an object or returns None if not found.
    Usage: {% get_object_or_none "myapp.Post" slug="hello-world" as post %}
    """
    try:
        from django.apps import apps
        model = apps.get_model(model_class)
        return model.objects.get(**kwargs)
    except (model.DoesNotExist, LookupError, ValueError):
        return None

Using your custom tags

Loading in templates

{% load myapp_tags %}

<!DOCTYPE html>
<html>
<head>
    <title>{{ post.title }}</title>
    {% cache_bust "css/style.css" %}
</head>
<body>
    <article>
        <h1>{{ post.title }}</h1>
        <p class="post-meta">
            By {% user_badge post.author %}
            • {{ post.content|reading_time }}
            • {% current_time %}
        </p>
        
        <div class="post-content">
            {{ post.content|smart_truncate:200 }}
        </div>
        
        <div class="post-stats">
            Views: {{ post.view_count|format_count:"view,views" }}
        </div>
    </article>
    
    <aside>
        {% popular_posts count=5 days=30 %}
    </aside>
</body>
</html>

Testing your custom tags

Don't forget to test your custom template tags:

# tests/test_template_tags.py
from django.test import TestCase
from django.template import Context, Template
from django.contrib.auth.models import User
from myapp.templatetags.myapp_tags import smart_truncate, reading_time

class CustomTagsTest(TestCase):
    def test_smart_truncate_filter(self):
        """Test the smart_truncate filter."""
        text = "This is a long text that should be truncated properly"
        result = smart_truncate(text, 20)
        self.assertTrue(result.endswith('...'))
        self.assertTrue(len(result) <= 23)  # 20 + 3 for ellipsis
    
    def test_reading_time_filter(self):
        """Test the reading_time filter."""
        text = "word " * 200  # 200 words
        result = reading_time(text)
        self.assertEqual(result, "1 min read")
    
    def test_user_badge_inclusion_tag(self):
        """Test the user_badge inclusion tag."""
        user = User.objects.create_user(
            username='testuser',
            first_name='Test',
            last_name='User'
        )
        
        template = Template("{% load myapp_tags %}{% user_badge user %}")
        context = Context({'user': user})
        rendered = template.render(context)
        
        self.assertIn('Test User', rendered)
        self.assertIn('user-badge', rendered)

Performance considerations

  • Cache expensive operations: If your tag performs database queries or complex calculations, consider caching the results
  • Use select_related and prefetch_related: Optimise database queries in your tags
  • Limit query scope: Don't fetch more data than you need
  • Consider using @cached_property for expensive computations in your Node classes

Best practices

  1. Name your tags clearly: Use descriptive names that indicate what the tag does
  2. Handle edge cases: Always account for None values, empty strings, and invalid input
  3. Document your tags: Include docstrings with usage examples
  4. Keep templates in a consistent location: Use app/templates/app/tags/ for inclusion tag templates
  5. Use appropriate tag types: Filters for simple transformations, simple tags for calculations, inclusion tags for HTML rendering
  6. Test thoroughly: Write tests for all your custom tags
  7. Consider security: Use mark_safe and format_html appropriately to prevent XSS attacks

Conclusion

Custom template tags are a powerful way to extend Django's templating system and create reusable, maintainable components. Whether you need simple filters for data formatting or complex inclusion tags for rendering dynamic content, Django's template tag system provides the flexibility to build exactly what you need.

Start with simple filters and work your way up to more complex tags as your needs grow. Remember to always test your tags and consider performance implications, especially when dealing with database queries.

With these tools in your Django toolkit, you can create cleaner, more maintainable templates that provide a better experience for both developers and users.


This guide covers Django 2.0+ syntax with backward compatibility notes for Django 1.11. For the latest Django features and best practices, always refer to the official Django documentation.