Rendering to Custom Writers with FRender
November 8, 2025 ยท View on GitHub
The FRender method enables rendering Liquid templates directly to any io.Writer implementation, providing fine-grained control over output handling. This is particularly useful for performance optimization, resource limiting, and security constraints.
Table of Contents
Basic Usage
The simplest use of FRender writes template output to any io.Writer:
engine := liquid.NewEngine()
template, err := engine.ParseTemplate([]byte(`<h1>{{ page.title }}</h1>`))
if err != nil {
log.Fatal(err)
}
bindings := map[string]any{
"page": map[string]string{"title": "Introduction"},
}
var buf bytes.Buffer
err = template.FRender(&buf, bindings)
if err != nil {
log.Fatal(err)
}
fmt.Println(buf.String())
// Output: <h1>Introduction</h1>
Use Cases
Direct File Writing
Avoid unnecessary memory allocation by rendering large templates directly to files:
engine := liquid.NewEngine()
template, err := engine.ParseTemplate(sourceBytes)
if err != nil {
log.Fatal(err)
}
file, err := os.Create("output.html")
if err != nil {
log.Fatal(err)
}
defer file.Close()
// Stream directly to file without intermediate buffers
err = template.FRender(file, bindings)
if err != nil {
log.Fatal(err)
}
Context-Based Cancellation
Prevent runaway template rendering by implementing cancellation via context:
// CancelWriter wraps an io.Writer with context cancellation support
type CancelWriter struct {
ctx context.Context
w io.Writer
}
func (cw *CancelWriter) Write(p []byte) (n int, err error) {
select {
case <-cw.ctx.Done():
return 0, cw.ctx.Err()
default:
return cw.w.Write(p)
}
}
func renderWithTimeout(template *liquid.Template, bindings liquid.Bindings, timeout time.Duration) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var buf bytes.Buffer
cw := &CancelWriter{ctx: ctx, w: &buf}
err := template.FRender(cw, bindings)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return "", fmt.Errorf("template rendering exceeded %v timeout", timeout)
}
return "", err
}
return buf.String(), nil
}
// Usage
engine := liquid.NewEngine()
template, _ := engine.ParseTemplate([]byte(`{% for i in (1..1000000) %}{{ i }}{% endfor %}`))
result, err := renderWithTimeout(template, liquid.Bindings{}, 100*time.Millisecond)
if err != nil {
log.Printf("Rendering stopped: %v", err)
}
This is crucial when rendering untrusted templates that might contain deeply nested loops or expensive operations.
Limiting Output Size
Protect against excessive output from untrusted templates:
// LimitWriter enforces a maximum output size
type LimitWriter struct {
w io.Writer
written int64
maxBytes int64
}
var ErrOutputLimitExceeded = errors.New("output size limit exceeded")
func NewLimitWriter(w io.Writer, maxBytes int64) *LimitWriter {
return &LimitWriter{w: w, maxBytes: maxBytes}
}
func (lw *LimitWriter) Write(p []byte) (n int, err error) {
if lw.written+int64(len(p)) > lw.maxBytes {
return 0, ErrOutputLimitExceeded
}
n, err = lw.w.Write(p)
lw.written += int64(n)
return n, err
}
func renderWithSizeLimit(template *liquid.Template, bindings liquid.Bindings, maxBytes int64) (string, error) {
var buf bytes.Buffer
lw := NewLimitWriter(&buf, maxBytes)
err := template.FRender(lw, bindings)
if err != nil {
if errors.Is(err, ErrOutputLimitExceeded) {
return "", fmt.Errorf("template output exceeded %d bytes", maxBytes)
}
return "", err
}
return buf.String(), nil
}
// Usage - limit untrusted template output to 1MB
result, err := renderWithSizeLimit(template, bindings, 1024*1024)
if err != nil {
log.Printf("Rendering failed: %v", err)
}
Custom Output Transformation
Transform output on-the-fly without post-processing:
// UpperCaseWriter converts all output to uppercase
type UpperCaseWriter struct {
w io.Writer
}
func (uc *UpperCaseWriter) Write(p []byte) (n int, err error) {
upper := bytes.ToUpper(p)
return uc.w.Write(upper)
}
// MinifyWriter could strip whitespace, compress, etc.
type MinifyWriter struct {
w io.Writer
}
func (mw *MinifyWriter) Write(p []byte) (n int, err error) {
// Remove extra whitespace
compressed := regexp.MustCompile(`\s+`).ReplaceAll(p, []byte(" "))
_, err = mw.w.Write(compressed)
return len(p), err // Return original length for proper accounting
}
// Usage
var buf bytes.Buffer
upperWriter := &UpperCaseWriter{w: &buf}
template.FRender(upperWriter, bindings)
API Reference
Template.FRender
func (t *Template) FRender(w io.Writer, vars Bindings) SourceError
Executes the template with the specified variable bindings and writes output to w.
Parameters:
w: Any type implementingio.Writerinterfacevars: Variable bindings (typicallymap[string]any)
Returns:
SourceError: Error with source location information, ornilon success
Error Handling:
FRender returns errors from:
- Template execution errors (undefined variables, filter errors, etc.)
- Writer errors (disk full, context cancellation, custom limits, etc.)
Both error types are returned as SourceError when possible, providing line number information for template-related issues.
Engine.ParseAndFRender
func (e *Engine) ParseAndFRender(w io.Writer, source []byte, b Bindings) SourceError
Convenience method that parses a template and immediately renders it to a writer.
Example:
engine := liquid.NewEngine()
var buf bytes.Buffer
err := engine.ParseAndFRender(&buf, []byte(`{{ greeting }}`), liquid.Bindings{
"greeting": "Hello, World!",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(buf.String())
Comparison with Render Methods
| Method | Return Type | Use Case |
|---|---|---|
Render(vars) | ([]byte, error) | Small templates, need byte slice |
RenderString(vars) | (string, error) | Small templates, need string |
FRender(w, vars) | error | Large output, streaming, custom handling |
When to use FRender:
- Template output > 1MB (avoid memory allocation)
- Writing to files or network connections
- Need cancellation or resource limits
- Want custom output transformation
- Rendering untrusted templates
When to use Render/RenderString:
- Small templates with predictable output
- Need the result as a value for further processing
- Simpler code for straightforward use cases
Performance Considerations
FRender can significantly improve performance for large templates:
// Memory-inefficient for large output
data, _ := template.Render(bindings)
file.Write(data) // Entire output buffered in memory
// Memory-efficient streaming
file, _ := os.Create("output.html")
template.FRender(file, bindings) // Streams directly to disk
For a 100MB template output:
Render()approach: ~100MB memory usageFRender()approach: ~4KB memory usage (typical buffer size)
Security Best Practices
When rendering untrusted templates, always use FRender with protective wrappers:
type SafeWriter struct {
ctx context.Context
w io.Writer
written int64
maxBytes int64
}
func (sw *SafeWriter) Write(p []byte) (n int, err error) {
// Check context cancellation
select {
case <-sw.ctx.Done():
return 0, sw.ctx.Err()
default:
}
// Check size limit
if sw.written+int64(len(p)) > sw.maxBytes {
return 0, ErrOutputLimitExceeded
}
n, err = sw.w.Write(p)
sw.written += int64(n)
return n, err
}
func renderUntrusted(template *liquid.Template, bindings liquid.Bindings) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var buf bytes.Buffer
safeWriter := &SafeWriter{
ctx: ctx,
w: &buf,
maxBytes: 10 * 1024 * 1024, // 10MB limit
}
err := template.FRender(safeWriter, bindings)
return buf.String(), err
}
This approach protects against:
- Infinite loops or deeply nested iterations
- Excessive memory consumption
- DoS attacks via template complexity