The text/template package produces any kind of text output: config files, emails, code, plain HTML you write yourself. The moment user-supplied data lands in an HTML page, though, text/template becomes a liability. A customer review that contains <script>steal()</script> will render as a working script tag, and the attacker has a stored cross-site scripting hole. The html/template package is the standard library's answer to that problem. It speaks the same syntax as text/template, but it watches where each value is going to land in the output and escapes it for that exact context.
When a product detail page reflects customer text back to the browser, every place that text appears is a potential injection point. The body of a paragraph needs HTML entity escaping. An attribute like title="..." needs attribute escaping, which is different. A URL inside <a href="..."> needs URL escaping. A snippet inside <script> needs JavaScript escaping. A value inside <style> needs CSS escaping. Each context has its own escape rules, and the wrong escape in the wrong place either breaks rendering or fails to stop an attacker.
text/template does none of that. It writes whatever string you give it, verbatim. The output below is what a naive store gets when it renders a review with text/template:
That output, served to a browser, runs the script. Every customer that loads the product page sends their cookies to the attacker's server. The fix is to swap one import.
Same template, same data, totally different output. The HTML special characters are now entities, so the browser displays the text literally instead of executing it. Nothing in the template source changed. The package noticed that {{.Body}} sits inside an HTML element body and applied the right escape function automatically.
html/template is a drop-in replacement for text/template for HTML output. Same actions ({{if}}, {{range}}, {{with}}, {{block}}, {{template}}, {{define}}), same pipelines, same FuncMap, same Must, ParseFiles, ParseGlob, ParseFS. If you're producing HTML and you used text/template, you've shipped a vulnerability. If you're producing anything else (emails, config, code), text/template is still the right choice. Some HTML email pipelines also use html/template to get auto-escaping; that's fine too.
The mechanism html/template adds on top of text/template is called contextual auto-escaping. The parser walks the template text and tracks the lexical context at every action. When it reaches {{.Body}}, it already knows whether you're inside an element body, an attribute, a URL, a script, or a stylesheet, and it picks the escape function that fits.
The diagram shows one rule: the same input value produces different output depending on where it lands. The parser doesn't ask you what to do. It infers the right escape from the surrounding HTML, attribute, URL, script, or style context, and applies it on every execution.
Here's the same idea as a runnable example. The same UserInput string flows into five different places, and you can read the output to see how each context treats it.
Five different outputs from one input. The body and attribute both got HTML entity encoding, but the attribute version is fine because the surrounding quotes are now safe. The URL got percent-encoded. The JavaScript context got the value wrapped in quotes and string-escaped, so it's a valid JS string literal, not executable code. The CSS context was replaced with ZgotmplZ, a sentinel the package emits when it sees something it cannot safely include. Whoever wrote a font-family expects a font name; <script> is not a font name, so the package refuses.
Cost: Contextual escaping happens once at parse time. The parser figures out the right escape function for every action and bakes it in. Execution is roughly the same speed as text/template. The fixed overhead is in Parse, so the same rule applies: parse once at startup, reuse the *Template for every request.
The same template can branch on different contexts within one render. If you put {{.Path}} inside a <form action="..."> the URL escaper kicks in; if you put it inside <p> it's the HTML escaper. You don't pick. The parser does.
The package supports a fixed set of contexts. The table below covers the ones that come up in practical templates.
| Context | Where it triggers | What gets applied |
|---|---|---|
| HTML element body | Between tags: <p>{{.}}</p> | HTML entity escape (< to <, etc.) |
| HTML attribute (quoted) | <a title="{{.}}"> | HTML attribute escape (entities + quote handling) |
| HTML attribute (unquoted) | <a class={{.}}> | Stricter escape: spaces and quotes become entities |
| URL or URL fragment | <a href="{{.}}">, <img src="{{.}}"> | URL escape; rejects javascript: and dangerous schemes |
| JavaScript | Inside <script>...</script> | JS string or value escape, depending on what the action returns |
| JavaScript string | Inside "..." or '...' in a script | JS string escape (< to <, etc.) |
| CSS | Inside <style>...</style> | CSS escape; rejects unrecognized values with ZgotmplZ |
| Comments | Inside <!-- ... --> | The action is dropped entirely; comments cannot contain dynamic data |
A few of these behaviors are worth calling out:
href and src contexts don't just escape characters. They also block dangerous URL schemes. If you pass javascript:alert(1) into an href, the rendered output starts with #ZgotmplZ so the browser never navigates to it.<script>, if your value is a Go string the package emits a JSON-encoded string literal, complete with surrounding quotes and Unicode escapes for <, >, &, and '. If your value is a struct or map, the same encoder turns it into a JSON object. That's why var q = {{.UserInput}}; in the example above ended up as var q = "...";, not var q = ...; without quotes.ZgotmplZ sentinel ("Zgot-empty-Z", a recognizable garbage string the team chose deliberately) is the package's way of saying "I refuse to emit this in this context." Seeing it in rendered output is a sign that either the input is wrong or the template is in a context the package can't reason about safely.The same template renders a normal URL exactly as written and refuses to emit the javascript: one. If a real attacker submits a product link with that scheme, the browser navigates to #ZgotmplZ (a fragment that does nothing) instead of executing JavaScript.
Sometimes you actually do want to emit raw HTML. A product description rendered from Markdown to HTML by a trusted Markdown library is the typical case. The HTML is already escaped by that library against the input that produced it, and you don't want html/template to double-escape every tag.
For these cases, html/template defines a set of safe types. A safe type is a named string type that tells the package "I have verified this content is safe in this context. Emit it as-is."
| Type | Context it bypasses escaping for |
|---|---|
template.HTML | HTML element body and most HTML contexts |
template.HTMLAttr | Full HTML attribute, including the name and =value |
template.JS | JavaScript value (object, expression) |
template.JSStr | JavaScript string content (without the surrounding quotes) |
template.URL | URL in href, src, etc. |
template.CSS | CSS value |
template.Srcset | Image srcset attribute |
Here's template.HTML in use. The pipeline that produced the description is responsible for guaranteeing the bytes are HTML-safe; html/template takes the type at its word.
The <strong> is preserved because the description field is declared as template.HTML. The product Name is a plain string, so it would have been escaped if it contained HTML.
The pattern that separates safe usage from a security hole is: a safe type at a function or template boundary should always come from a source that proves the bytes are valid for that context. That source is either a constant in your code, a library whose contract is "I emit safe HTML", or a sanitizer that strips dangerous constructs from user input. Form values, query parameters, request bodies, database fields that were once form values, JSON fields parsed from a public API, anything that originates from the outside world is not safe and must stay a plain string so the package can escape it.
template.URL deserves its own note. Wrapping a user-submitted link in template.URL bypasses the scheme check, which is the only thing standing between you and a javascript: URL. Don't do that. If you need to allow arbitrary URLs (and the user clicks them), let the package's URL context do its work on a plain string. If you genuinely have a constant URL or one assembled from trusted parts, template.URL is fine.
Cost: Safe types are zero-cost at execution because they short-circuit escaping. The cost is reviewer time. Every template.HTML(x) in your codebase is a place a reviewer has to verify is safe. Keep them rare and put them next to the function that produced the bytes.
Layouts in html/template work the same way as in text/template. A template set is a collection of named templates that can call each other through {{template "name" .}} or {{block "name" .}}. Parsing several files into one set gives you a base layout plus partials that override {{block}} regions.
The base layout below defines the page shell. The page-specific template fills in the title and content blocks.
Mallory's payload didn't execute. The block was rendered inside an HTML element body, so the package escaped it. Crucially, {{range .Reviews}} doesn't change the context. Inside the loop, {{.Body}} is still in an HTML body, so the escaper still runs.
Two practical points about layouts. First, define your layout as the first parsed template so it becomes the default. When you call t.Execute(w, data), the package renders the template named t.Name(), which is whatever the topmost call to template.New("layout") set. Second, partials that override blocks must be parsed into the same set; otherwise the {{block}} falls back to its default content.
A web binary that ships with embedded templates is the common deployment story. The embed.FS filesystem plugs into html/template through ParseFS, the same way it does for text/template. The advantage is the same: the binary is self-contained, no template files at runtime, no relative-path issues, no missing-file surprises in production.
The structure below is a typical layout. The templates/ directory sits next to the Go source and contains the base layout plus page partials.
The Go side is short. The //go:embed directive bundles every .html file under templates/ into the binary, and template.ParseFS walks the same filesystem to build the template set.
ExecuteTemplate picks which named template to run by name, which is what you want when one parsed set contains layout plus several pages. tmpl is built once at package init and reused for every request, so there's no per-request parse cost.
Cost: Re-parsing on every request will tank your throughput. Parse once at startup and store the *Template in a package variable, behind an init function, or inside a struct that the handler closes over. The Go runtime caches nothing for you here.
In development, parsing on every request is sometimes preferred so you don't have to restart the server to see template changes. A common pattern is to swap parse-on-startup for parse-on-request behind an environment flag, then fall back to embed in production.
The package handles the hard part. Most XSS bugs in Go applications come from sidestepping it, not from a flaw in the escaper.
Using `text/template` for HTML output. The two packages have identical APIs. Importing the wrong one is easy and the compiler won't complain. A quick search for "text/template" in any HTTP-serving codebase is worth doing once.
Wrapping user input in `template.HTML`. This is the most common stored XSS pattern in Go web apps. A field of type template.HTML gets populated from a form, a database column that was once a form, or a JSON body. The value reaches the template and skips every escaper. Customer input never goes into a safe type without first passing through a sanitizer designed for that context.
Building HTML by string concatenation outside the template. A handler that builds a snippet with fmt.Sprintf("<p>%s</p>", body) and then wraps it in template.HTML(snippet) has the same problem as the previous bullet. The escaper isn't running on the dynamic part. Either put the dynamic part inside a template action and let the package handle it, or run it through template.HTMLEscapeString first.
Trusting the context after a `<script>` block. Inside <script>, the package emits values as JS literals. If your code then does string concatenation in the script (var s = "prefix" + {{.X}};), the value of .X is escaped as a JS expression, not as a JS string. Make the template explicit: put the action inside a string literal in the script (var s = "prefix" + {{.X}}; where .X is a string, the package will quote it for you, but you should still review the generated JS).
Treating attribute values like body values. Quoted attributes get an HTML attribute escape. Unquoted attributes get a stricter rule that includes whitespace and quotes. Either way is fine, but mixing them in a way the parser can't follow (a quote inside a quoted attribute that has another action right after) can trigger the package to emit ZgotmplZ or to refuse to compile the template. Stick to fully-quoted attributes.
Mixing safe types across contexts. A template.URL is safe in a href, not in a <script>. A template.JS is safe in a script, not in an href. The package will still escape a template.URL if it lands inside a body context (treating it like a normal value). Don't rely on cross-context magic; type your safe values for one specific context.
User-typed search queries reflected in the result page are one of the most common XSS vectors in e-commerce. The query lands in three places: the page title, an <input value="..."> so the user can see what they searched for, and a "no results" message. Each is a different context.
If an attacker visits /search?q=<img src=x onerror=alert(1)>, the rendered page contains the same query string in all three places, each one escaped for its context: the <title> gets HTML escaping, the value="..." gets attribute escaping, and the <p> gets HTML body escaping. None of them executes the script. The same code with text/template would have shipped three XSS vectors from a single query parameter.
10 quizzes