Custom Extensions

February 8, 2026 ยท View on GitHub

pongo2 allows you to extend the template engine with custom filters and tags.

Custom Filters

Filters transform variable values. They are functions with the signature:

type FilterFunction func(in *Value, param *Value) (out *Value, err error)

Registering a Filter

func init() {
    pongo2.RegisterFilter("double", filterDouble)
}

func filterDouble(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, error) {
    return pongo2.AsValue(in.Integer() * 2), nil
}

Usage in template:

{{ 21|double }}  {# Output: 42 #}

Filter with Parameter

func init() {
    pongo2.RegisterFilter("multiply", filterMultiply)
}

func filterMultiply(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, error) {
    return pongo2.AsValue(in.Integer() * param.Integer()), nil
}

Usage:

{{ 7|multiply:6 }}  {# Output: 42 #}

Returning Errors

func filterDivide(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, error) {
    divisor := param.Integer()
    if divisor == 0 {
        return nil, &pongo2.Error{
            Sender:    "filter:divide",
            OrigError: errors.New("division by zero"),
        }
    }
    return pongo2.AsValue(in.Integer() / divisor), nil
}

Working with the Value Type

The Value type wraps Go values and provides helper methods:

func myFilter(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, error) {
    // Type checking
    if in.IsString() { /* ... */ }
    if in.IsInteger() { /* ... */ }
    if in.IsFloat() { /* ... */ }
    if in.IsBool() { /* ... */ }
    if in.IsNil() { /* ... */ }
    if in.IsNumber() { /* ... */ } // Integer or Float

    // Type conversion
    s := in.String()      // Convert to string
    i := in.Integer()     // Convert to int
    f := in.Float()       // Convert to float64
    b := in.Bool()        // Convert to bool

    // Collection operations
    if in.CanSlice() {
        length := in.Len()
        first := in.Index(0)
        slice := in.Slice(0, 5)
    }

    // Check truthiness (for conditionals)
    if in.IsTrue() { /* ... */ }

    // Get underlying interface{}
    raw := in.Interface()

    // Return values
    return pongo2.AsValue("result"), nil       // Regular value
    return pongo2.AsSafeValue("<b>html</b>"), nil  // Safe (no escaping)
}

Replacing Built-in Filters

func init() {
    // Override the built-in upper filter
    pongo2.ReplaceFilter("upper", myUpperFilter)
}

Check if Filter Exists

if pongo2.BuiltinFilterExists("myfilter") {
    // Filter is registered
}

Apply Filter Programmatically

value := pongo2.AsValue("hello")
param := pongo2.AsValue(nil)

result, err := pongo2.ApplyFilter("upper", value, param)
// result.String() == "HELLO"

// Panic version
result = pongo2.MustApplyFilter("upper", value, param)

Custom Tags

Tags are more complex than filters. They can:

  • Access and modify the parser
  • Wrap content (block tags)
  • Execute logic during rendering

Tag Parser Function

type TagParser func(doc *Parser, start *Token, arguments *Parser) (INodeTag, error)

Parameters:

  • doc - The document parser (for parsing nested content)
  • start - The token containing the tag name
  • arguments - Parser for the tag's arguments

Simple Tag Example

A tag that outputs the current time:

func init() {
    pongo2.RegisterTag("current_time", tagCurrentTimeParser)
}

type tagCurrentTimeNode struct {
    format string
}

func (node *tagCurrentTimeNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.TemplateWriter) error {
    writer.WriteString(time.Now().Format(node.format))
    return nil
}

func tagCurrentTimeParser(doc *pongo2.Parser, start *pongo2.Token, arguments *pongo2.Parser) (pongo2.INodeTag, error) {
    node := &tagCurrentTimeNode{
        format: time.RFC3339, // default format
    }

    // Parse optional format argument
    if formatToken := arguments.MatchType(pongo2.TokenString); formatToken != nil {
        node.format = formatToken.Val
    }

    // Check for extra arguments
    if arguments.Remaining() > 0 {
        return nil, arguments.Error("Malformed current_time tag", nil)
    }

    return node, nil
}

Usage:

{% current_time %}
{% current_time "2006-01-02" %}

Block Tag Example

A tag that wraps content:

func init() {
    pongo2.RegisterTag("uppercase", tagUppercaseParser)
}

type tagUppercaseNode struct {
    wrapper *pongo2.NodeWrapper
}

func (node *tagUppercaseNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.TemplateWriter) error {
    // Capture the block content
    var buf bytes.Buffer
    err := node.wrapper.Execute(ctx, &buf)
    if err != nil {
        return err
    }

    // Transform and output
    writer.WriteString(strings.ToUpper(buf.String()))
    return nil
}

func tagUppercaseParser(doc *pongo2.Parser, start *pongo2.Token, arguments *pongo2.Parser) (pongo2.INodeTag, error) {
    node := &tagUppercaseNode{}

    // Parse until enduppercase
    wrapper, endargs, err := doc.WrapUntilTag("enduppercase")
    if err != nil {
        return nil, err
    }
    node.wrapper = wrapper

    // enduppercase shouldn't have arguments
    if endargs.Count() > 0 {
        return nil, endargs.Error("enduppercase takes no arguments", nil)
    }

    return node, nil
}

Usage:

{% uppercase %}
  hello world
{% enduppercase %}
{# Output: HELLO WORLD #}

Tag with Expression Arguments

Parse expressions that can contain variables:

type tagRepeatNode struct {
    countExpr pongo2.IEvaluator
    wrapper   *pongo2.NodeWrapper
}

func (node *tagRepeatNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.TemplateWriter) error {
    // Evaluate the count expression
    countVal, err := node.countExpr.Evaluate(ctx)
    if err != nil {
        return err
    }

    count := countVal.Integer()
    for i := 0; i < count; i++ {
        err := node.wrapper.Execute(ctx, writer)
        if err != nil {
            return err
        }
    }
    return nil
}

func tagRepeatParser(doc *pongo2.Parser, start *pongo2.Token, arguments *pongo2.Parser) (pongo2.INodeTag, error) {
    node := &tagRepeatNode{}

    // Parse the count expression
    countExpr, err := arguments.ParseExpression()
    if err != nil {
        return nil, err
    }
    node.countExpr = countExpr

    // Parse until endrepeat
    wrapper, _, err := doc.WrapUntilTag("endrepeat")
    if err != nil {
        return nil, err
    }
    node.wrapper = wrapper

    return node, nil
}

Usage:

{% repeat 3 %}Hello {% endrepeat %}
{# Output: Hello Hello Hello #}

{% repeat count %}Item {% endrepeat %}

Tag with Multiple End Tags

Handle tags like if/elif/else/endif:

wrapper, endargs, err := doc.WrapUntilTag("elif", "else", "endif")
if err != nil {
    return nil, err
}

switch wrapper.Endtag {
case "elif":
    // Parse elif condition and continue
case "else":
    // Parse else block
case "endif":
    // Done
}

Parser Methods

Common methods for parsing arguments:

// Match specific token type and value
if token := arguments.Match(pongo2.TokenKeyword, "as"); token != nil {
    // Matched "as" keyword
}

// Match any of several values
if token := arguments.MatchOne(pongo2.TokenIdentifier, "asc", "desc"); token != nil {
    // Matched either "asc" or "desc"
}

// Match by type only
if token := arguments.MatchType(pongo2.TokenString); token != nil {
    value := token.Val
}
if token := arguments.MatchType(pongo2.TokenNumber); token != nil {
    // ...
}
if token := arguments.MatchType(pongo2.TokenIdentifier); token != nil {
    name := token.Val
}

// Parse a full expression
expr, err := arguments.ParseExpression()

// Check remaining arguments
if arguments.Remaining() > 0 {
    return nil, arguments.Error("Too many arguments", nil)
}

// Peek without consuming
if arguments.Peek(pongo2.TokenSymbol, "=") != nil {
    // Next token is "="
}

Execution Context

Access template context during execution:

func (node *myNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.TemplateWriter) error {
    // Read from public context (user-provided)
    user := ctx.Public["user"]

    // Read/write private context (internal use)
    ctx.Private["my_counter"] = 0

    // Check autoescape setting
    if ctx.Autoescape {
        // HTML escaping is enabled
    }

    // Log debug messages (only when Debug=true)
    ctx.Logf("Processing item %d", itemNum)

    // Create error with template location
    return ctx.Error("Something went wrong", node.token)
}

Creating Child Contexts

For tags that create new variable scopes:

func (node *myNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.TemplateWriter) error {
    // Create child context (inherits parent's variables)
    childCtx := pongo2.NewChildExecutionContext(ctx)

    // Add scoped variables
    childCtx.Private["loop_var"] = someValue

    // Execute wrapped content with child context
    return node.wrapper.Execute(childCtx, writer)
}

Replacing Built-in Tags

func init() {
    pongo2.ReplaceTag("for", myCustomForParser)
}

Complete Example: Cache Tag

A tag that caches rendered content:

package main

import (
    "bytes"
    "sync"
    "time"

    "github.com/flosch/pongo2/v7"
)

var (
    cache      = make(map[string]cacheEntry)
    cacheMutex sync.RWMutex
)

type cacheEntry struct {
    content   string
    expiresAt time.Time
}

type tagCacheNode struct {
    key      string
    duration time.Duration
    wrapper  *pongo2.NodeWrapper
}

func (node *tagCacheNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.TemplateWriter) error {
    // Check cache
    cacheMutex.RLock()
    entry, exists := cache[node.key]
    cacheMutex.RUnlock()

    if exists && time.Now().Before(entry.expiresAt) {
        writer.WriteString(entry.content)
        return nil
    }

    // Render content
    var buf bytes.Buffer
    err := node.wrapper.Execute(ctx, &buf)
    if err != nil {
        return err
    }

    content := buf.String()

    // Store in cache
    cacheMutex.Lock()
    cache[node.key] = cacheEntry{
        content:   content,
        expiresAt: time.Now().Add(node.duration),
    }
    cacheMutex.Unlock()

    writer.WriteString(content)
    return nil
}

func tagCacheParser(doc *pongo2.Parser, start *pongo2.Token, arguments *pongo2.Parser) (pongo2.INodeTag, error) {
    node := &tagCacheNode{
        duration: 5 * time.Minute, // default
    }

    // Parse cache key
    keyToken := arguments.MatchType(pongo2.TokenString)
    if keyToken == nil {
        return nil, arguments.Error("cache tag requires a key string", nil)
    }
    node.key = keyToken.Val

    // Parse optional duration
    if durationToken := arguments.MatchType(pongo2.TokenNumber); durationToken != nil {
        seconds := pongo2.AsValue(durationToken.Val).Integer()
        node.duration = time.Duration(seconds) * time.Second
    }

    // Parse content
    wrapper, _, err := doc.WrapUntilTag("endcache")
    if err != nil {
        return nil, err
    }
    node.wrapper = wrapper

    return node, nil
}

func init() {
    pongo2.RegisterTag("cache", tagCacheParser)
}

Usage:

{% cache "sidebar" 300 %}
  <div class="sidebar">
    {{ expensive_query() }}
  </div>
{% endcache %}