Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Changelog

## Unreleased

### Performance

- **Execution context**: Child contexts (created by `{% for %}`, `{% with %}`, `{% macro %}`, and `block.Super`) no longer copy the parent's private data — they layer over it via a scope chain. The user-supplied context and set globals are no longer merged into a fresh map on each `Execute`; they are resolved through a read-only view. This removes the per-nesting-level copy and the per-`Execute` merge. Rendering output is unchanged.
- **Macro-clash check**: The per-`Execute` scan that compares context keys against exported macro names is now skipped when the template exports no macros (the common case).
- **`TemplateSet.SkipContextValidation`** (new, default `false`): when set, skips the per-`Execute` check that every context key is a valid identifier. The check scales with context size and only guards against keys that cannot be referenced in a template anyway. Enable it in hot paths that render with large, trusted contexts. With a 1000-key context this drops per-`Execute` context handling from ~19µs to ~0.65µs.

### Backwards-Incompatible Changes

- **`ExecutionContext.Private`** changed from `Context` (a map) to `Scope`. Custom tags must use methods instead of indexing:
- `ctx.Private[k]` → `ctx.Private.Get(k)` (returns `(any, bool)`)
- `ctx.Private[k] = v` → `ctx.Private.Set(k, v)`
- `delete(ctx.Private, k)` → `ctx.Private.Delete(k)`
- `for k, v := range ctx.Private` → `ctx.Private.Range(func(k string, v any) bool { ... })`
- `ctx.Private.Update(m)` → call `ctx.Private.Set` per entry
- **`ExecutionContext.Public`** changed from `Context` (a map) to `PublicContext`, a read-only view over the user context and set globals:
- `ctx.Public[k]` → `ctx.Public.Get(k)` (returns `(any, bool)`; includes globals)
- Globals are no longer present as plain entries in `Public`; reach them via `Get`/`Range`.
- `Public` has no setter (template data is read-only by contract).

## v7.0.0-alpha.2

This release brings pongo2 significantly closer to Django template behavior.
Expand Down
63 changes: 35 additions & 28 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ func (c Context) Update(other Context) Context {
// to create scoped child contexts within tags.
//
// Context hierarchy:
// - Public: User data (READ-ONLY)
// - Private: Scoped engine data (copied per child context)
// - Public: User data + set globals (READ-ONLY view)
// - Private: Scoped engine data (layered per child context, not copied)
// - Shared: Global state (same instance across all contexts)
type ExecutionContext struct {
// The template being executed (provides config, inheritance, and TemplateSet access).
Expand All @@ -77,13 +77,24 @@ type ExecutionContext struct {
// The |safe filter bypasses escaping.
Autoescape bool

// User-provided data from Execute(). Treat as READ-ONLY to avoid side effects.
Public Context
// User-provided data from Execute() overlaying the set's globals. Read-only:
// access via Public.Get/Has/Range, never by indexing. The caller's map is
// used directly (not copied), so it must not be mutated during rendering.
Public PublicContext

// parent links a child execution context to the one it was derived from,
// forming the scope chain walked by Private. nil for a root context.
parent *ExecutionContext

// privateVars holds this context's own private layer. Allocated lazily on
// first write. Parent layers are reached through parent, never copied.
privateVars Context

// Engine-managed scoped data (e.g., "forloop" from {% for %}, variables
// from {% set %}, or macros). Child contexts receive a copy, enabling
// isolated modifications.
Private Context
// from {% set %}, or macros). A child context layers its own data over the
// parent's without copying; writes touch only the child's layer. Access via
// Private.Get/Set/Delete/Range, never by indexing.
Private Scope

// Data shared across all contexts during a single render. Use for cross-scope
// tag communication.
Expand All @@ -102,41 +113,37 @@ var pongo2MetaContext = Context{
}

func newExecutionContext(tpl *Template, ctx Context) *ExecutionContext {
privateCtx := make(Context)

// Make the pongo2-related funcs/vars available to the context
privateCtx["pongo2"] = pongo2MetaContext

return &ExecutionContext{
newctx := &ExecutionContext{
template: tpl,

Public: ctx,
Private: privateCtx,
Shared: make(Context),
Autoescape: tpl.set.autoescape,
tagState: make(map[any]any),
// Make the pongo2-related funcs/vars available to the context
privateVars: Context{"pongo2": pongo2MetaContext},
Public: PublicContext{vars: ctx, globals: tpl.set.Globals},
Shared: make(Context),
Autoescape: tpl.set.autoescape,
tagState: make(map[any]any),
}
newctx.Private = Scope{ec: newctx}
return newctx
}

// NewChildExecutionContext creates a new execution context that inherits from
// a parent context. The child context shares the same Public context and Shared
// context as the parent, but gets its own Private context (pre-populated with
// copies of the parent's private data). This is useful for custom tags that need
// to create isolated scopes while maintaining access to the template's data.
// a parent context. The child shares the same Public and Shared context as the
// parent, and layers a fresh, empty Private scope over the parent's (parent data
// is reached by walking the scope chain, never copied). Writes to the child's
// Private touch only the child's layer, leaving the parent untouched. Useful for
// custom tags that need an isolated scope while keeping access to outer data.
func NewChildExecutionContext(parent *ExecutionContext) *ExecutionContext {
newctx := &ExecutionContext{
template: parent.template,
parent: parent,

Public: parent.Public,
Private: make(Context),
Shared: parent.Shared,
Autoescape: parent.Autoescape,
tagState: parent.tagState,
}
newctx.Shared = parent.Shared

// Copy all existing private items
newctx.Private.Update(parent.Private)

newctx.Private = Scope{ec: newctx}
return newctx
}

Expand Down
28 changes: 28 additions & 0 deletions context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,31 @@ func TestCheckForValidIdentifiersWithEmptyKey(t *testing.T) {
t.Error("expected error for empty key, got nil")
}
}

func TestSkipContextValidation(t *testing.T) {
ctx := Context{
"valid_key": "rendered",
"in-valid-key": "ignored", // not a valid identifier
}

set := NewSet("skip-validation", &DummyLoader{})
tpl, err := set.FromString("{{ valid_key }}")
if err != nil {
t.Fatalf("FromString: %v", err)
}

// Default: validation enabled -> the invalid key triggers an error.
if _, err := tpl.Execute(ctx); err == nil {
t.Error("expected validation error for invalid context key, got nil")
}

// Opt-out: validation skipped -> renders without error.
set.SkipContextValidation = true
out, err := tpl.Execute(ctx)
if err != nil {
t.Fatalf("unexpected error with SkipContextValidation: %v", err)
}
if out != "rendered" {
t.Errorf("got %q, want %q", out, "rendered")
}
}
8 changes: 4 additions & 4 deletions docs/custom-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,11 +369,11 @@ Access template context during execution:

```go
func (node *myNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.TemplateWriter) error {
// Read from public context (user-provided)
user := ctx.Public["user"]
// Read from public context (user-provided + set globals; read-only)
user, _ := ctx.Public.Get("user")

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

// Check autoescape setting
if ctx.Autoescape {
Expand All @@ -398,7 +398,7 @@ func (node *myNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.Template
childCtx := pongo2.NewChildExecutionContext(ctx)

// Add scoped variables
childCtx.Private["loop_var"] = someValue
childCtx.Private.Set("loop_var", someValue)

// Execute wrapped content with child context
return node.wrapper.Execute(childCtx, writer)
Expand Down
30 changes: 14 additions & 16 deletions docs/write_tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,8 @@ type ExecutionContext struct {
macroDepth int // Recursion depth for macros

Autoescape bool // HTML auto-escaping enabled
Public Context // User-provided context (read-only by convention)
Private Context // Internal variables (forloop, macro args, etc.)
Public PublicContext // User context + set globals (read-only view)
Private Scope // Internal variables (forloop, macro args, etc.)
Shared Context // Shared across all templates in execution
}
```
Expand All @@ -310,13 +310,13 @@ type ExecutionContext struct {

```go
func (node *myNode) Execute(ctx *ExecutionContext, writer TemplateWriter) error {
// Read from public context (user-provided data)
if user, ok := ctx.Public["user"]; ok {
// Read from public context (user-provided data + set globals)
if user, ok := ctx.Public.Get("user"); ok {
// Use user data
}

// Read from private context (internal variables)
if counter, ok := ctx.Private["forloop"]; ok {
if counter, ok := ctx.Private.Get("forloop"); ok {
// Inside a for loop
}

Expand All @@ -334,16 +334,14 @@ func (node *myNode) Execute(ctx *ExecutionContext, writer TemplateWriter) error
```go
func (node *myNode) Execute(ctx *ExecutionContext, writer TemplateWriter) error {
// Set private variable (for internal use)
ctx.Private["my_counter"] = 0
ctx.Private.Set("my_counter", 0)

// Set shared variable (available in included templates)
ctx.Shared["breadcrumbs"] = breadcrumbList

// Update context with map
ctx.Private.Update(pongo2.Context{
"item": currentItem,
"index": currentIndex,
})
// Set multiple private variables
ctx.Private.Set("item", currentItem)
ctx.Private.Set("index", currentIndex)

return nil
}
Expand All @@ -359,7 +357,7 @@ func (node *myNode) Execute(ctx *ExecutionContext, writer TemplateWriter) error
childCtx := pongo2.NewChildExecutionContext(ctx)

// Add scoped variables (only visible in child)
childCtx.Private["scoped_var"] = someValue
childCtx.Private.Set("scoped_var", someValue)

// Execute wrapped content with child context
err := node.wrapper.Execute(childCtx, writer)
Expand Down Expand Up @@ -719,17 +717,17 @@ func (node *tagEachNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.Tem
childCtx := pongo2.NewChildExecutionContext(ctx)

// Set item variable
childCtx.Private[node.itemName] = listVal.Index(i).Interface()
childCtx.Private.Set(node.itemName, listVal.Index(i).Interface())

// Set loop metadata
childCtx.Private["eachloop"] = &eachLoop{
childCtx.Private.Set("eachloop", &eachLoop{
Counter: i + 1,
Counter0: i,
First: i == 0,
Last: i == length-1,
Revcounter: length - i,
Revcounter0: length - i - 1,
}
})

// Execute body
err := node.wrapper.Execute(childCtx, writer)
Expand Down Expand Up @@ -1256,7 +1254,7 @@ Prevent variable leakage:
```go
func (node *myNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.TemplateWriter) error {
childCtx := pongo2.NewChildExecutionContext(ctx)
childCtx.Private["local_var"] = value
childCtx.Private.Set("local_var", value)

return node.wrapper.Execute(childCtx, writer)
// local_var is not visible in parent context
Expand Down
51 changes: 51 additions & 0 deletions pongo2_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,57 @@ func BenchmarkExecuteComplex(b *testing.B) {
}
}

// BenchmarkExecuteLargeContext renders a trivial template against a large input
// context. The template references only a handful of the keys, so any time or
// allocations that scale with the map size come from per-Execute context
// handling rather than from rendering. This isolates the cost that the old code
// paid by merging/copying the entire input map on every Execute.
func BenchmarkExecuteLargeContext(b *testing.B) {
const numKeys = 1000

ctx := make(pongo2.Context, numKeys)
for i := 0; i < numKeys; i++ {
ctx["key_"+strconv.Itoa(i)] = i
}

set := pongo2.NewSet("bench", pongo2.MustNewLocalFileSystemLoader(""))
tpl, err := set.FromString("{{ key_0 }}-{{ key_1 }}-{{ key_999 }}")
if err != nil {
b.Fatal(err)
}

for b.Loop() {
if err := tpl.ExecuteWriterUnbuffered(ctx, io.Discard); err != nil {
b.Fatal(err)
}
}
}

// BenchmarkExecuteLargeContextSkipValidation is BenchmarkExecuteLargeContext with
// per-Execute context validation disabled, showing the cost of validating every
// key on every render against a large context.
func BenchmarkExecuteLargeContextSkipValidation(b *testing.B) {
const numKeys = 1000

ctx := make(pongo2.Context, numKeys)
for i := 0; i < numKeys; i++ {
ctx["key_"+strconv.Itoa(i)] = i
}

set := pongo2.NewSet("bench", pongo2.MustNewLocalFileSystemLoader(""))
set.SkipContextValidation = true
tpl, err := set.FromString("{{ key_0 }}-{{ key_1 }}-{{ key_999 }}")
if err != nil {
b.Fatal(err)
}

for b.Loop() {
if err := tpl.ExecuteWriterUnbuffered(ctx, io.Discard); err != nil {
b.Fatal(err)
}
}
}

func BenchmarkCompileAndExecuteComplex(b *testing.B) {
set := pongo2.NewSet("bench", pongo2.MustNewLocalFileSystemLoader(""))
buf, err := os.ReadFile("template_tests/complex.tpl")
Expand Down
3 changes: 2 additions & 1 deletion pongo2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ func TestImplicitExecCtx(t *testing.T) {
res, err := tpl.Execute(pongo2.Context{
"Value": val,
"ImplicitExec": func(ctx *pongo2.ExecutionContext) string {
return ctx.Public["Value"].(string)
v, _ := ctx.Public.Get("Value")
return v.(string)
},
})
if err != nil {
Expand Down
Loading