From d445759f342ce64f66632ce79598f202ed7fa0dc Mon Sep 17 00:00:00 2001 From: Daniel Richter Date: Sun, 7 Jun 2026 16:12:48 +0000 Subject: [PATCH 1/3] perf(context): replace per-Execute/per-child map copies with a scope chain Child execution contexts (created by {% for %}, {% with %}, {% macro %}, and block.Super) previously copied the entire parent private map on every creation, and each Execute merged the user context and set globals into a fresh map. Both copies scaled O(N) with map size and ran on every render. Replace both with a non-copying scope chain: - ExecutionContext.Private changes from Context (map) to Scope, a method-only handle backed by the ExecutionContext itself (no per-scope allocation). A child layers its own data over the parent via the context chain; lookups walk outward. The own layer is allocated lazily on first write. Delete uses a tombstone to mask a parent key without mutating the parent, so delete and range semantics are preserved. - ExecutionContext.Public changes from Context (map) to PublicContext, a read-only view over the user context and set globals. The user's map is used directly (never copied); globals are a resolution fallback reached through Get/Range. Method-only access means callers cannot bypass globals via direct indexing, so resolution output is unchanged. Rendering output is identical. The only peripheral change: globals are no longer re-validated or macro-clash-checked on every Execute (only the user context is). Measured (BenchmarkExecuteLargeContext, 1000-key input map, template uses 3): before: ~71us/op 82,720 B/op 21 allocs/op after: ~29us/op 672 B/op 15 allocs/op Context handling no longer scales with input-map size. Nested/block-heavy templates render ~27% faster with ~25% less memory. BREAKING CHANGE: custom tags must use methods instead of map indexing on ExecutionContext.Private/Public (ctx.Private[k] -> ctx.Private.Get/Set, etc.). See CHANGELOG.md. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 19 +++++ context.go | 63 +++++++++-------- docs/custom-extensions.md | 8 +-- docs/write_tags.md | 30 ++++---- pongo2_template_test.go | 26 +++++++ pongo2_test.go | 3 +- scope.go | 145 ++++++++++++++++++++++++++++++++++++++ tags_block.go | 8 +-- tags_cycle.go | 2 +- tags_for.go | 8 +-- tags_import.go | 4 +- tags_include.go | 7 +- tags_macro.go | 39 +++++----- tags_set.go | 2 +- tags_ssi.go | 7 +- tags_widthratio.go | 2 +- tags_with.go | 2 +- template.go | 41 +++++------ variable.go | 9 ++- 19 files changed, 311 insertions(+), 114 deletions(-) create mode 100644 scope.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b687606..3547b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # 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. + +### 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. diff --git a/context.go b/context.go index 4892be4..2a797a0 100644 --- a/context.go +++ b/context.go @@ -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). @@ -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. @@ -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 } diff --git a/docs/custom-extensions.md b/docs/custom-extensions.md index ed3b1fe..8b6852c 100644 --- a/docs/custom-extensions.md +++ b/docs/custom-extensions.md @@ -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 { @@ -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) diff --git a/docs/write_tags.md b/docs/write_tags.md index 8179d77..e2365fe 100644 --- a/docs/write_tags.md +++ b/docs/write_tags.md @@ -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 } ``` @@ -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 } @@ -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 } @@ -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) @@ -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) @@ -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 diff --git a/pongo2_template_test.go b/pongo2_template_test.go index b8335e8..a5bb7b3 100644 --- a/pongo2_template_test.go +++ b/pongo2_template_test.go @@ -667,6 +667,32 @@ 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) + } + } +} + func BenchmarkCompileAndExecuteComplex(b *testing.B) { set := pongo2.NewSet("bench", pongo2.MustNewLocalFileSystemLoader("")) buf, err := os.ReadFile("template_tests/complex.tpl") diff --git a/pongo2_test.go b/pongo2_test.go index 35eb624..2bed194 100644 --- a/pongo2_test.go +++ b/pongo2_test.go @@ -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 { diff --git a/scope.go b/scope.go new file mode 100644 index 0000000..64309ea --- /dev/null +++ b/scope.go @@ -0,0 +1,145 @@ +package pongo2 + +// tombstone marks a key as deleted within a child Scope. It masks a value +// inherited from a parent Scope without mutating the parent, so the deletion is +// only visible to the child and its descendants. +var tombstone = &struct{}{} + +// Scope is a chain of variable layers representing nested template scopes (for +// example the body of a {% for %} or {% with %} block). Each child layers its +// own variables over its parent without copying, so lookups walk outward from +// the innermost scope to the outermost. Writes only ever touch the innermost +// (own) layer; a parent scope is never modified by its children. +// +// Scope is the type of ExecutionContext.Private. It is a lightweight handle onto +// its owning ExecutionContext and the chain of parents — there is no separate +// per-scope allocation. Access it through the Get/Set/Delete/Has/Range methods +// rather than indexing a map. +type Scope struct { + ec *ExecutionContext +} + +// Get returns the value bound to key, searching this scope and then its parents. +// The second return value reports whether the key was found. A key masked by +// Delete in a closer scope is reported as not found. +func (s Scope) Get(key string) (any, bool) { + for c := s.ec; c != nil; c = c.parent { + if v, ok := c.privateVars[key]; ok { + if v == tombstone { + return nil, false + } + return v, true + } + } + return nil, false +} + +// Has reports whether key is bound in this scope or any parent. +func (s Scope) Has(key string) bool { + _, ok := s.Get(key) + return ok +} + +// Set binds key to value in this scope's own layer, shadowing any binding +// inherited from a parent. Parent scopes are never modified. The own layer is +// allocated lazily on first write. +func (s Scope) Set(key string, value any) { + if s.ec.privateVars == nil { + s.ec.privateVars = make(Context) + } + s.ec.privateVars[key] = value +} + +// Delete removes key from this scope's view. If the key lives only in this +// scope's own layer it is removed outright; if it is inherited from a parent it +// is masked with a tombstone so the parent remains untouched. +func (s Scope) Delete(key string) { + delete(s.ec.privateVars, key) + if s.ec.parent != nil { + if _, ok := (Scope{ec: s.ec.parent}).Get(key); ok { + if s.ec.privateVars == nil { + s.ec.privateVars = make(Context) + } + s.ec.privateVars[key] = tombstone + } + } +} + +// Range calls fn for each key visible in this scope, with closer scopes +// shadowing parents and tombstoned keys skipped. Iteration stops early if fn +// returns false. +func (s Scope) Range(fn func(key string, value any) bool) { + for key, value := range s.flatten() { + if !fn(key, value) { + return + } + } +} + +// flatten collapses the scope chain into a single map, with closer scopes +// overriding parents and tombstoned keys removed. Used by tags that need a +// full snapshot of the private context (for example {% include %}). +func (s Scope) flatten() Context { + return flattenScope(s.ec) +} + +func flattenScope(ec *ExecutionContext) Context { + if ec == nil { + return make(Context) + } + out := flattenScope(ec.parent) + for key, value := range ec.privateVars { + if value == tombstone { + delete(out, key) + continue + } + out[key] = value + } + return out +} + +// PublicContext is a read-only view of the data available to a template: the +// user-supplied context overlaying the template set's globals. It is read-only +// by design — a template must not mutate caller-provided data — so it exposes +// no setter. Globals are reached only through Get/Has/Range, so callers cannot +// bypass them by indexing a map directly. +type PublicContext struct { + vars Context // user-supplied context (used directly, never copied) + globals Context // template set globals +} + +// Get returns the value bound to key, checking the user context first and then +// the set globals. The second return value reports whether key was found. +func (p PublicContext) Get(key string) (any, bool) { + if v, ok := p.vars[key]; ok { + return v, true + } + if v, ok := p.globals[key]; ok { + return v, true + } + return nil, false +} + +// Has reports whether key is available in the user context or globals. +func (p PublicContext) Has(key string) bool { + _, ok := p.Get(key) + return ok +} + +// Range calls fn for each key available, with user-context entries shadowing +// globals of the same name. Iteration stops early if fn returns false. +func (p PublicContext) Range(fn func(key string, value any) bool) { + for key, value := range p.globals { + if _, shadowed := p.vars[key]; shadowed { + continue + } + if !fn(key, value) { + return + } + } + for key, value := range p.vars { + if !fn(key, value) { + return + } + } +} diff --git a/tags_block.go b/tags_block.go index 0bf66b9..83eb426 100644 --- a/tags_block.go +++ b/tags_block.go @@ -79,10 +79,10 @@ func (node *tagBlockNode) Execute(ctx *ExecutionContext, writer TemplateWriter) } blockWrapper := blockWrappers[lenBlockWrappers-1] - ctx.Private["block"] = tagBlockInformation{ + ctx.Private.Set("block", tagBlockInformation{ ctx: ctx, wrappers: blockWrappers[0 : lenBlockWrappers-1], - } + }) err := blockWrapper.Execute(ctx, writer) if err != nil { return err @@ -109,10 +109,10 @@ func (t tagBlockInformation) Super() (*Value, error) { } superCtx := NewChildExecutionContext(t.ctx) - superCtx.Private["block"] = tagBlockInformation{ + superCtx.Private.Set("block", tagBlockInformation{ ctx: t.ctx, wrappers: t.wrappers[0 : lenWrappers-1], - } + }) blockWrapper := t.wrappers[lenWrappers-1] buf := bytes.NewBufferString("") diff --git a/tags_cycle.go b/tags_cycle.go index 6e82862..a045180 100644 --- a/tags_cycle.go +++ b/tags_cycle.go @@ -106,7 +106,7 @@ func (node *tagCycleNode) Execute(ctx *ExecutionContext, writer TemplateWriter) } if node.asName != "" { - ctx.Private[node.asName] = cycleValue + ctx.Private.Set(node.asName, cycleValue) } if !node.silent { // Apply autoescape like Django's render_value_in_context diff --git a/tags_for.go b/tags_for.go index b082d3e..1d2d54c 100644 --- a/tags_for.go +++ b/tags_for.go @@ -80,7 +80,7 @@ type tagForLoopInformation struct { func (node *tagForNode) Execute(ctx *ExecutionContext, writer TemplateWriter) (forError error) { // Backup forloop (as parentloop in public context), key-name and value-name forCtx := NewChildExecutionContext(ctx) - parentloop := forCtx.Private["forloop"] + parentloop, _ := forCtx.Private.Get("forloop") // Create loop struct loopInfo := &tagForLoopInformation{ @@ -93,7 +93,7 @@ func (node *tagForNode) Execute(ctx *ExecutionContext, writer TemplateWriter) (f } // Register loopInfo in public context - forCtx.Private["forloop"] = loopInfo + forCtx.Private.Set("forloop", loopInfo) obj, err := node.objectEvaluator.Evaluate(forCtx) if err != nil { @@ -104,9 +104,9 @@ func (node *tagForNode) Execute(ctx *ExecutionContext, writer TemplateWriter) (f // There's something to iterate over (correct type and at least 1 item) // Update loop infos and public context - forCtx.Private[node.key] = key + forCtx.Private.Set(node.key, key) if value != nil && node.value != "" { - forCtx.Private[node.value] = value + forCtx.Private.Set(node.value, value) } loopInfo.Counter = idx + 1 loopInfo.Counter0 = idx diff --git a/tags_import.go b/tags_import.go index 97fc92f..577a6cd 100644 --- a/tags_import.go +++ b/tags_import.go @@ -46,9 +46,9 @@ type tagImportNode struct { func (node *tagImportNode) Execute(ctx *ExecutionContext, writer TemplateWriter) error { for name, macro := range node.macros { func(name string, macro *tagMacroNode) { - ctx.Private[name] = func(args ...*Value) (*Value, error) { + ctx.Private.Set(name, func(args ...*Value) (*Value, error) { return macro.call(ctx, args...) - } + }) }(name, macro) } return nil diff --git a/tags_include.go b/tags_include.go index 57a540a..d817632 100644 --- a/tags_include.go +++ b/tags_include.go @@ -53,8 +53,11 @@ func (node *tagIncludeNode) Execute(ctx *ExecutionContext, writer TemplateWriter // Fill the context with all data from the parent if !node.only { - includeCtx.Update(ctx.Public) - includeCtx.Update(ctx.Private) + ctx.Public.Range(func(key string, value any) bool { + includeCtx[key] = value + return true + }) + includeCtx.Update(ctx.Private.flatten()) } // Put all custom with-pairs into the context diff --git a/tags_macro.go b/tags_macro.go index 59c75ea..a148e77 100644 --- a/tags_macro.go +++ b/tags_macro.go @@ -69,7 +69,7 @@ type tagMacroNode struct { // Execute registers the macro as a callable function in the private context. // The macro can then be called like {{ macro_name(args) }}. func (node *tagMacroNode) Execute(ctx *ExecutionContext, writer TemplateWriter) error { - ctx.Private[node.name] = func(args ...*Value) (*Value, error) { + ctx.Private.Set(node.name, func(args ...*Value) (*Value, error) { ctx.macroDepth++ defer func() { ctx.macroDepth-- @@ -80,7 +80,7 @@ func (node *tagMacroNode) Execute(ctx *ExecutionContext, writer TemplateWriter) } return node.call(ctx, args...) - } + }) return nil } @@ -88,22 +88,24 @@ func (node *tagMacroNode) Execute(ctx *ExecutionContext, writer TemplateWriter) // call executes the macro body with the provided arguments and returns the // rendered output as a safe value. It creates an isolated context for execution. func (node *tagMacroNode) call(ctx *ExecutionContext, args ...*Value) (*Value, error) { - argsCtx := make(Context) + // Make a context for the macro execution. Declared arguments are written + // directly into its private scope (no intermediate map). Default values are + // evaluated against the caller's context. + macroCtx := NewChildExecutionContext(ctx) for k, v := range node.args { if v == nil { - // User did not provided a default value - argsCtx[k] = nil - } else { - // Evaluate the default value - valueExpr, err := v.Evaluate(ctx) - if err != nil { - ctx.Logf(err.Error()) - return AsSafeValue(""), err - } - - argsCtx[k] = valueExpr.Interface() + // User did not provide a default value + macroCtx.Private.Set(k, nil) + continue + } + // Evaluate the default value + valueExpr, err := v.Evaluate(ctx) + if err != nil { + ctx.Logf(err.Error()) + return AsSafeValue(""), err } + macroCtx.Private.Set(k, valueExpr.Interface()) } if len(args) > len(node.argsOrder) { @@ -114,14 +116,9 @@ func (node *tagMacroNode) call(ctx *ExecutionContext, args ...*Value) (*Value, e return AsSafeValue(""), err } - // Make a context for the macro execution - macroCtx := NewChildExecutionContext(ctx) - - // Register all arguments in the private context - macroCtx.Private.Update(argsCtx) - + // Override with the positional arguments passed by the caller. for idx, argValue := range args { - macroCtx.Private[node.argsOrder[idx]] = argValue.Interface() + macroCtx.Private.Set(node.argsOrder[idx], argValue.Interface()) } var b bytes.Buffer diff --git a/tags_set.go b/tags_set.go index 2b7bd0e..3f4c077 100644 --- a/tags_set.go +++ b/tags_set.go @@ -44,7 +44,7 @@ func (node *tagSetNode) Execute(ctx *ExecutionContext, writer TemplateWriter) er return err } - ctx.Private[node.name] = value + ctx.Private.Set(node.name, value) return nil } diff --git a/tags_ssi.go b/tags_ssi.go index 287fc68..3c337cc 100644 --- a/tags_ssi.go +++ b/tags_ssi.go @@ -38,8 +38,11 @@ func (node *tagSSINode) Execute(ctx *ExecutionContext, writer TemplateWriter) er if node.template != nil { // Execute the template within the current context includeCtx := make(Context) - includeCtx.Update(ctx.Public) - includeCtx.Update(ctx.Private) + ctx.Public.Range(func(key string, value any) bool { + includeCtx[key] = value + return true + }) + includeCtx.Update(ctx.Private.flatten()) err := node.template.execute(includeCtx, writer) if err != nil { diff --git a/tags_widthratio.go b/tags_widthratio.go index 4410788..ce59d0b 100644 --- a/tags_widthratio.go +++ b/tags_widthratio.go @@ -80,7 +80,7 @@ func (node *tagWidthratioNode) Execute(ctx *ExecutionContext, writer TemplateWri return err } } else { - ctx.Private[node.ctxName] = value + ctx.Private.Set(node.ctxName, value) } return nil diff --git a/tags_with.go b/tags_with.go index 17a5b59..a375632 100644 --- a/tags_with.go +++ b/tags_with.go @@ -57,7 +57,7 @@ func (node *tagWithNode) Execute(ctx *ExecutionContext, writer TemplateWriter) e if err != nil { return err } - withctx.Private[key] = val + withctx.Private.Set(key, val) } return node.wrapper.Execute(withctx, writer) diff --git a/template.go b/template.go index e92cd9e..980e49a 100644 --- a/template.go +++ b/template.go @@ -230,36 +230,31 @@ func (tpl *Template) newContextForExecution(context Context) (*Template, *Execut parent = parent.parent } - // Create context if none is given - newContext := make(Context) - newContext.Update(tpl.set.Globals) - - if context != nil { - newContext.Update(context) - - if len(newContext) > 0 { - // Check for context name syntax - err := newContext.checkForValidIdentifiers() - if err != nil { - return parent, nil, err - } + // The user-supplied context is used directly (no copy); the set's globals + // are layered behind it by the execution context's Public view rather than + // merged into a fresh map. + if len(context) > 0 { + // Check for context name syntax + err := context.checkForValidIdentifiers() + if err != nil { + return parent, nil, err + } - // Check for clashes with macro names - for k := range newContext { - _, has := tpl.exportedMacros[k] - if has { - return parent, nil, &Error{ - Filename: tpl.name, - Sender: "execution", - OrigError: fmt.Errorf("context key name '%s' clashes with macro '%s'", k, k), - } + // Check for clashes with macro names + for k := range context { + _, has := tpl.exportedMacros[k] + if has { + return parent, nil, &Error{ + Filename: tpl.name, + Sender: "execution", + OrigError: fmt.Errorf("context key name '%s' clashes with macro '%s'", k, k), } } } } // Create operational context - ctx := newExecutionContext(parent, newContext) + ctx := newExecutionContext(parent, context) return parent, ctx, nil } diff --git a/variable.go b/variable.go index ed83be2..4325271 100644 --- a/variable.go +++ b/variable.go @@ -295,11 +295,14 @@ func (vr *variableResolver) resolveArrayDefinition(ctx *ExecutionContext) (*Valu } // lookupInitialValue looks up the first part of the variable in the context. +// Private (scoped engine data) takes precedence over Public (user data and the +// set's globals). func (vr *variableResolver) lookupInitialValue(ctx *ExecutionContext) reflect.Value { - val, inPrivate := ctx.Private[vr.parts[0].s] - if !inPrivate { - val = ctx.Public[vr.parts[0].s] + key := vr.parts[0].s + if val, ok := ctx.Private.Get(key); ok { + return reflect.ValueOf(val) } + val, _ := ctx.Public.Get(key) return reflect.ValueOf(val) } From 976636e93fe0792b14c29d8491bcc331c09c55e8 Mon Sep 17 00:00:00 2001 From: Daniel Richter Date: Sun, 7 Jun 2026 16:15:01 +0000 Subject: [PATCH 2/3] perf(template): skip macro-clash scan when no exported macros The per-Execute clash check iterated the entire input context comparing each key against the template's exported macros. When the template exports no macros (the common case) there is nothing to clash with, so the O(N) scan is pure overhead. Guard it on len(tpl.exportedMacros) > 0. BenchmarkExecuteLargeContext (1000-key map, no exported macros): ~29us -> ~19us. Co-Authored-By: Claude Opus 4.8 --- template.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/template.go b/template.go index 980e49a..437d351 100644 --- a/template.go +++ b/template.go @@ -240,14 +240,17 @@ func (tpl *Template) newContextForExecution(context Context) (*Template, *Execut return parent, nil, err } - // Check for clashes with macro names - for k := range context { - _, has := tpl.exportedMacros[k] - if has { - return parent, nil, &Error{ - Filename: tpl.name, - Sender: "execution", - OrigError: fmt.Errorf("context key name '%s' clashes with macro '%s'", k, k), + // Check for clashes with macro names. Skip the scan entirely when the + // template exports no macros — there is nothing to clash with. + if len(tpl.exportedMacros) > 0 { + for k := range context { + _, has := tpl.exportedMacros[k] + if has { + return parent, nil, &Error{ + Filename: tpl.name, + Sender: "execution", + OrigError: fmt.Errorf("context key name '%s' clashes with macro '%s'", k, k), + } } } } From a8d3f0c590c306635ea0cbce5ff7dc91c5ed3eb8 Mon Sep 17 00:00:00 2001 From: Daniel Richter Date: Sun, 7 Jun 2026 16:18:05 +0000 Subject: [PATCH 3/3] perf(template): add SkipContextValidation opt-out for hot paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-Execute checkForValidIdentifiers scan byte-checks every key in the user context on every render. It is a usability guard — an invalid identifier key cannot be referenced from a template anyway — and its cost scales with the context size, which is wasteful for hot paths that render large, trusted maps. Add TemplateSet.SkipContextValidation (default false, validation enabled). When set, the check is skipped. Behavior is unchanged by default. BenchmarkExecuteLargeContext (1000-key map): ~19us/op with validation, ~0.65us/op with SkipContextValidation. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ context_test.go | 28 ++++++++++++++++++++++++++++ pongo2_template_test.go | 25 +++++++++++++++++++++++++ template.go | 10 ++++++---- template_sets.go | 7 +++++++ 5 files changed, 68 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3547b6b..bb0e97f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### 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 diff --git a/context_test.go b/context_test.go index 50163db..cf4790a 100644 --- a/context_test.go +++ b/context_test.go @@ -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") + } +} diff --git a/pongo2_template_test.go b/pongo2_template_test.go index a5bb7b3..a9909bd 100644 --- a/pongo2_template_test.go +++ b/pongo2_template_test.go @@ -693,6 +693,31 @@ func BenchmarkExecuteLargeContext(b *testing.B) { } } +// 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") diff --git a/template.go b/template.go index 437d351..bbcea1f 100644 --- a/template.go +++ b/template.go @@ -234,10 +234,12 @@ func (tpl *Template) newContextForExecution(context Context) (*Template, *Execut // are layered behind it by the execution context's Public view rather than // merged into a fresh map. if len(context) > 0 { - // Check for context name syntax - err := context.checkForValidIdentifiers() - if err != nil { - return parent, nil, err + // Check for context name syntax (unless the set opts out for performance). + if !tpl.set.SkipContextValidation { + err := context.checkForValidIdentifiers() + if err != nil { + return parent, nil, err + } } // Check for clashes with macro names. Skip the scan entirely when the diff --git a/template_sets.go b/template_sets.go index 016d976..a130984 100644 --- a/template_sets.go +++ b/template_sets.go @@ -42,6 +42,13 @@ type TemplateSet struct { // When true (default), string output will be escaped for safety. autoescape bool + // SkipContextValidation disables the per-Execute check that every key in the + // user-supplied context is a valid identifier. The check is a usability guard + // (an invalid key cannot be referenced in a template anyway) and scales with + // the size of the context map. Set this to true in hot paths that render with + // large contexts and trust their keys. Default false (validation enabled). + SkipContextValidation bool + // Options allow you to change the behavior of template-engine. // You can change the options before calling the Execute method. Options *Options