A Loader resolves a template name to its source text. The preferred
engine API takes a loader via WithLoader(...):
engine := template.New(
template.WithLoader(loader),
template.WithFormat(template.FormatHTML),
)The package ships four loader constructors, each suited to a different
use case. Most real projects wire several together with
NewChainLoader to build an override chain (user > theme > builtin).
Use the most restrictive loader that matches the job:
| Need | Recommended loader | Why |
|---|---|---|
| Templates on local disk | NewDirLoader(dir) |
Default choice. Sandboxed with os.Root, blocks symlink escape |
| Templates embedded in the binary | NewFSLoader(embed.FS) |
The embedded FS is already the trust boundary |
| In-memory templates for tests | NewMemoryLoader(map[string]string) |
Small, explicit, deterministic |
| User overrides layered over theme/builtin templates | NewChainLoader(...) |
Clear precedence with distinct cache keys |
| Real directory with deliberate symlink following | NewFSLoader(os.DirFS(dir)) |
Escape hatch only; explicitly non-sandboxed |
Recommended default for production HTML rendering:
loader, err := template.NewDirLoader("./templates")
if err != nil {
log.Fatal(err)
}
engine := template.New(
template.WithLoader(loader),
template.WithFormat(template.FormatHTML),
)If you are unsure, start with NewDirLoader. Reach for NewFSLoader
only when the filesystem itself is already the boundary, such as
embed.FS, fstest.MapFS, or another intentionally constrained
fs.FS.
type Loader interface {
Open(name string) (source string, resolved string, err error)
}source— the raw template textresolved— a stable, unique key used for the engine's compile cache, alias mapping, in-flight compile dedup, and circular-reference detection. For simple loaders this is usually just the input name;ChainLoaderprepends a layer index.- Errors:
ErrInvalidTemplateName— path failed validation (fs.ValidPath, no backslash, no NUL)ErrTemplateNotFound— name is valid but not present
All built-in loaders call ValidateName(name) first. Custom loaders
must too.
In-memory map. Intended for tests and small pre-registered sets:
loader := template.NewMemoryLoader(map[string]string{
"base.html": `<html>{% block body %}{% endblock %}</html>`,
"child.html": `{% extends "base.html" %}{% block body %}hi{% endblock %}`,
})
engine := template.New(
template.WithLoader(loader),
template.WithFormat(template.FormatHTML),
template.WithLayout(),
)
out, _ := engine.Render("child.html", nil)
// <html>hi</html>The input map is copied on construction — later mutations do not affect the loader.
Wraps any fs.FS:
//go:embed themes/default/*
var themeFS embed.FS
loader := template.NewFSLoader(themeFS)
engine := template.New(
template.WithLoader(loader),
template.WithFormat(template.FormatHTML),
)Works with:
embed.FS— production theme assets compiled into the binaryfstest.MapFS— test doublesarchive/zip.Reader— loading templates from a zip archiveos.DirFS— explicitly non-sandboxed local directory (see "Dev workflows" below)- Any custom
fs.FSimplementation
embed.FS/fstest.MapFS/ zip →NewFSLoader(no sandbox needed, the FS itself is the boundary)- Real directory on disk with untrusted template names →
NewDirLoader(usesos.Rootto block symlink escape) - Real directory on disk where you want symlinks to follow
(theme dev, monorepo sharing) →
NewFSLoader(os.DirFS(dir))— you are explicitly opting into the non-sandboxed primitive and taking responsibility for the environment.
Treat NewFSLoader(os.DirFS(...)) as an advanced workflow tool, not as
the default local-filesystem choice.
Local directory, sandboxed by os.Root (Go 1.24+):
loader, err := template.NewDirLoader("./templates")
if err != nil {
log.Fatal(err) // directory doesn't exist or is unreadable
}
engine := template.New(
template.WithLoader(loader),
template.WithFormat(template.FormatHTML),
)- All template names go through
ValidateName: no.., no absolute paths, no backslash, no NUL. - All file access goes through
os.Root.Open, which refuses to follow symbolic links that point outside the root. This closes path traversal attacks even when the template name comes from untrusted input (frontmatter, URL params, database rows). - TOCTOU is closed by
os.Rootusingopenat2/O_NOFOLLOWprimitives — not "check then open".
If your workflow deliberately relies on symbolic links (theme dev, monorepo theme sharing), use the documented escape hatch:
loader := template.NewFSLoader(os.DirFS("./templates"))os.DirFS does not sandbox symlinks — the Go standard library
documents this explicitly. By writing it out at the call site, you
make the tradeoff visible to reviewers.
Consults a list of loaders in order, returning the first hit:
user, _ := template.NewDirLoader("./templates") // writable user overrides
theme, _ := template.NewDirLoader("./themes/default") // the active theme
//go:embed themes/default/*
var defaultThemeFS embed.FS
builtin := template.NewFSLoader(defaultThemeFS) // fallback baked into the binary
loader := template.NewChainLoader(user, theme, builtin)When the user requests layouts/blog.html:
user.Open("layouts/blog.html")— if present, returned immediately- Otherwise
theme.Open("layouts/blog.html") - Otherwise
builtin.Open("layouts/blog.html") - Otherwise
ErrTemplateNotFound
Same-name files in different layers do not collide in the cache:
the ChainLoader prepends the layer index (layer0:, layer1:, ...)
to the resolved name. The engine caches by resolved, not by the input
name, so even on case-insensitive filesystems (macOS HFS+/APFS),
templates with the same visible name in different layers get distinct
cache entries.
NewChainLoader() (no arguments) is a valid loader — it simply
returns ErrTemplateNotFound for every request. Useful as a placeholder
during tests.
The Loader interface is open. Any type that implements
Open(name string) (string, string, error) and honors the
ValidateName contract qualifies.
type httpLoader struct {
baseURL string
client *http.Client
}
func (l *httpLoader) Open(name string) (string, string, error) {
if err := template.ValidateName(name); err != nil {
return "", "", err
}
resp, err := l.client.Get(l.baseURL + "/" + name)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", "", fmt.Errorf("%w: %q", template.ErrTemplateNotFound, name)
}
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("http %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", err
}
return string(body), "http:" + name, nil
}Caveats for remote loaders:
- Error on network failure — don't swallow as "not found" unless the HTTP status is actually 404.
- Prepend a scheme/host prefix to the resolved name so different
backends get distinct cache keys in a
ChainLoader. - Consider a timeout; the calling
Engine.Renderwill block onOpen. - Remember that
Engine.Loadcaches the compiled template byresolved— the HTTP request happens only once per resolved template identity untilEngine.Reset()is called. Concurrent callers that hit the sameresolvedname also share the same in-flight compile.
type dbLoader struct {
db *sql.DB
}
func (l *dbLoader) Open(name string) (string, string, error) {
if err := template.ValidateName(name); err != nil {
return "", "", err
}
var src string
err := l.db.QueryRow("SELECT source FROM templates WHERE name = ?", name).Scan(&src)
if errors.Is(err, sql.ErrNoRows) {
return "", "", fmt.Errorf("%w: %q", template.ErrTemplateNotFound, name)
}
if err != nil {
return "", "", err
}
return src, "db:" + name, nil
}Engine caches compiled templates indefinitely. To pick up on-disk
changes, call engine.Reset() after the file watcher fires:
watcher, _ := fsnotify.NewWatcher()
watcher.Add("./templates")
go func() {
for event := range watcher.Events {
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove) != 0 {
engine.Reset()
}
}
}()Engine.Reset() clears the cache; in-flight renders finish using their
already-loaded templates, and the next Render call recompiles.
Do not call Reset from request-handler hot paths — it forces every
template to recompile on next access.