How do you decide whether a Go service's templates ship inside the binary via embed and ParseFS or load from disk at runtime?
answer
- who must change it without a release
- one artefact or two to roll back
- when does a bad template fail
- the parse function takes an fs.FS
- copy that changes weekly is data
basics
~20 sDefault to embedding: //go:embed with template.ParseFS makes one self-contained artefact and turns a missing file into a build failure. Choose runtime loading only when someone outside the release process must change markup without a redeploy, and price that in.
solid answer
~50 sI treat it as a release decision, not a coding preference. Embedding with `//go:embed` and `template.ParseFS` makes the artefact self-contained: templates are versioned with the code, a missing file fails the build, the binary needs no volume, and a rollback is one artefact. Reading from disk buys exactly one thing - changing markup without shipping a binary - and costs a second artefact to deploy and roll back, plus a path by which a bad edit reaches production without passing CI. So the real question is who owns template changes. If engineers own them, embed, parse once at startup under `template.Must`, and assert the defined set in a test. If non-engineers need to change copy independently, the answer is usually data rather than a hot-reloading template directory. Either way I keep the seam: the parse function takes an `fs.FS`, so a dev build can pass `os.DirFS`.
code
go · 13 lines//go:embed layouts/*.html pages/*.html
var embedded embed.FS
func parse(fsys fs.FS) (*template.Template, error) {
return template.ParseFS(fsys, "layouts/*.html", "pages/*.html")
}
func source(dir string) fs.FS {
if dir == "" {
return embedded
}
return os.DirFS(dir)
}go deeper
Know both mechanisms exist: //go:embed filling an embed.FS that ParseFS reads, and the same files read from the filesystem at runtime through os.DirFS or ParseGlob.
Explain what changes with each: where a missing or malformed template surfaces, what has to be deployed and rolled back, and why the parse function should take an fs.FS so both work unchanged.
Argue the operational side — one artefact to roll back, no drift between replicas, and, if reload is genuinely required, building a new set and swapping it rather than mutating one that is being executed.
Own the policy and its escape hatch: say who may change rendered output without a release, what that costs the on-call rotation, what you would move into data instead, and what would make you change the decision.
## Frame it as a release question Both mechanisms are two lines of code, so the choice is not technical taste — it is a policy about what constitutes a deployable unit and who is allowed to change rendered output. State that first in an interview, then walk the consequences. **Embedded.** A `//go:embed` directive above a variable of type `embed.FS` captures the matching files into the binary at build time. `template.ParseFS(files, "layouts/*.html", "pages/*.html")` then reads them from that filesystem. The patterns are resolved by the compiler and cannot reach outside the package directory, and a pattern matching nothing is a build error. **From disk.** `os.DirFS(dir)` returns an `fs.FS` rooted at a directory, so the very same `ParseFS` call works unchanged; or you use `ParseGlob` directly. Now the templates are a separate thing that has to arrive on the machine. Because both are an `fs.FS`, the sane engineering shape is to write one parse function taking an `fs.FS` and choose the source at startup. That keeps the decision reversible and one flag wide. ## Where each choice moves the failure The useful axis is *when a broken template is discovered*: - **Build time** — an embed pattern that matches no files. Discovered by CI, blocks the release, costs nothing. - **Startup** — a malformed action, when parsing happens in an init or a package-level `template.Must`. Discovered by the deploy, on one instance, before it takes traffic; the rollout halts. - **First render of one page** — a name that does not exist, or a lazily parsed set. Discovered by a user, possibly weeks later, on the one page nobody visits. Embedding plus parse-at-startup plus a test over `DefinedTemplates` pushes almost everything to the first two rows. Runtime loading pushes the *whole* class down at least one row, because the files on the box are no longer the files CI tested. ## Operational cost of the disk option - **Two artefacts, two rollbacks.** A binary at version N with a template directory at version N-1 is a real state, and it is not one anybody tests. - **Drift across replicas.** If templates can be edited in place, three replicas can render three different pages, and the difference is invisible to every health check you have. - **A bypass of review.** The value people actually want is "fix the typo now". What they get is a channel that skips the build, the tests and the diff. - **Reload has to be done correctly.** Never mutate the live set: parse a whole new set, and only if it parses cleanly swap the pointer handlers read — an `atomic.Pointer` holding the current `*template.Template` is enough. Reparsing per request is both wasteful and a mutation of shared state. ## When disk loading is genuinely right - Templates are supplied by tenants or operators as data the product is *about*, not part of your source. Then they were never build inputs; treat them as untrusted input with size limits and a strict allowlist of what they may reference, and lean on `html/template`'s contextual escaping. - A tool whose entire purpose is to render a directory the user controls — a site generator's own theme directory, for instance. - Local development, where reload is a real productivity gain. This is the case the `fs.FS` seam serves, without changing what production does. ## The organisational answer When someone asks for disk loading, the request underneath is almost always "we cannot ship fast enough". The stronger answer is usually to fix that: a small, well-tested release path with a quick rollback beats a side channel. And if the copy really does change several times a week, it should not be in a template at all — move it into data the service reads, so a wording change is a row edit that needs no build and no deploy. Embed by default. Parse at startup under `Must`. Assert the parsed name set in a test. Keep the `fs.FS` seam so a dev build reloads and so the decision can be revisited without a rewrite. Be explicit about what would overrule you — a product whose templates belong to its users — because that is what distinguishes a policy from a habit.
- If you do reload templates from disk, how do you do it safely while serving?Never mutate the live set. Parse an entirely new set from the directory, and only if it parses cleanly swap the pointer handlers read — an atomic.Pointer holding the current template value is enough. That keeps a broken edit from taking the service down and avoids changing a set that goroutines are executing.
- Where does an embedding mistake surface?At build time. A //go:embed pattern that matches no file fails the build, and the patterns cannot reach outside the package directory, so a moved template directory breaks CI rather than production. Parsing at startup under template.Must then moves the remaining class of error — a malformed action — to process start, where a rollout will catch it.
- How would you let someone fix a wording mistake during an incident without disk loading?Make the normal path fast: a small release with a tested rollback beats a side channel that skips review. If the wording changes often enough that this keeps coming up, it belongs in data the service reads rather than in a template, so a change is a row edit with no build and no deploy at all.
- Does embedding templates change anything about escaping or rendering?No. html/template escapes according to the syntactic context of each action, and it makes no difference whether the bytes came from an embedded filesystem or from the disk. The choice affects packaging, deployment and when failures surface — not the semantics of the render.
saying these in an interview costs you the question
- Treats it as a style choice rather than a release decision
- Reparses templates on every request to get reload
- Mutates the live template set when a file changes
- Assumes disk templates stay identical across replicas
- Embeds the files but still parses lazily inside the handler