# How to make a Handlebars template produce byte-identical output

> Sort the model, keep helpers pure, normalise line endings and compile strict, so a Handlebars template renders the same bytes everywhere, checked with sha256sum.

Source: https://voxgig.com/howto/handlebars-byte-identical-output-every-run

- Audience: library-maintainer
- Level: intermediate
- Languages: typescript
- Verified: 2026-09-25
- Published: 2026-09-25

## Short answer

Compile with `strict: true`, `noEscape: true` and `preventIndent: true`, and register only helpers that are pure functions of their arguments. Sort every object in the model before rendering, because `{{#each}}` walks keys in the order they were inserted. Normalise the rendered string to LF with one trailing newline before writing it, and pin the template files to `eol=lf` in `.gitattributes`. Two renders then hash the same under `sha256sum`.

---
## You will need

Node 22 or later, and a Handlebars template that generates source code rather than HTML.
Verified 2026-09-25 against Node 22.22.2, `handlebars` 4.7.9, `mustache` 4.2.0 and `ejs` 6.0.1,
all installed from npm in the sample directory. `sha256sum` comes with
[GNU `coreutils`](https://www.gnu.org/software/coreutils/manual/html_node/sha2-utilities.html),[^1]
and any other SHA-256 tool prints the same digest.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [EJS](https://ejs.co/#docs) | You want JavaScript in the template and will police it in review | Any expression runs, so a `Date.now()` in a template is legal and invisible until the diff | The template is edited by people who should not be writing JavaScript |
| [Handlebars](https://handlebarsjs.com/api-reference/compilation.html) | Helpers you register, a strict mode, and whitespace you control with `~` | Every helper is yours to keep pure, and `{{#each}}` over an object follows the key order it was handed | You need loops with logic the built-in helpers cannot express |
| [mustache.js](https://mustache.github.io/mustache.5.html) | Logic-less templates shared with Mustache implementations in other languages | No helpers, so formatting moves into the view, and a function placed in the view still runs | You need whitespace control finer than the standalone-line rule |

All three engines are deterministic when the model is sorted and nothing in the render reads a
clock. What differs is who enforces that. EJS enforces nothing, and buys you any loop you can
write. Handlebars enforces the split between template and helper, and buys you whitespace
control. mustache.js removes helpers, and buys you a template another language can render,
at the price of doing every sort and every join in the view.

## Sort the model, because the template will not

`{{#each}}` over an object visits keys in the order
[ECMAScript defines](https://tc39.es/ecma262/multipage/ordinary-and-exotic-objects-behaviours.html#sec-ordinaryownpropertykeys)
for own property keys: integer keys first in ascending numeric order, then string keys in the
order they were created.[^2] A model parsed from an OpenAPI document on one machine and from a
cached copy on another can differ in that order and in nothing else.

```js title="render.mjs"
// Sort every object's keys, recursively, and leave arrays alone. An object is a set and an
// array is a sequence: parameter order is part of the signature the file declares.
export function sortModel(value) {
  if (Array.isArray(value)) return value.map(sortModel)
  if (value && typeof value === 'object') {
    return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortModel(value[k])]))
  }
  return value
}
```

The rule in the comment is the one that matters. `types` and `operations` are maps, so their
order carries no meaning and gets sorted. `params` is an array, because
`listOrders(status, limit)` and `listOrders(limit, status)` are different functions. Sort a
sequence and you have changed the generated API, deterministically.

## Compile with the three options that matter

Handlebars compiles templates for HTML by default. Three
[compile options](https://handlebarsjs.com/api-reference/compilation.html) turn it into a
source generator.

```js title="render.mjs"
// strict: a missing field throws instead of rendering as an empty string.
// noEscape: generated TypeScript is not HTML, so `Record<string, string>` stays as typed.
// preventIndent: a partial's indentation comes from the partial file, not from the call site.
export const OPTIONS = { strict: true, noEscape: true, preventIndent: true }
```

`strict` is the one that finds bugs. Without it, an operation with no `returns` renders
`Promise<>` and the TypeScript compiler reports the problem three steps later. With it, the
render throws `"returns" not defined in [object Object]`, naming the field. It does not check
a helper's arguments, so a missing `params` reaches `signature` as `undefined`, and the helpers
below check their own. `noEscape` keeps `Record<string, string>` from becoming
`Record&lt;string, string&gt;`, which is what every angle bracket in a generated type turns
into otherwise. `preventIndent` stops an indented partial call from indenting every line the
partial emits, so the partial file owns its own layout.

The method partial is called once per operation, and the tilde in `{{~/each}}` is
[whitespace control](https://handlebarsjs.com/guide/expressions.html#whitespace-control): it
removes the newline the loop would otherwise leave after each method.

```handlebars title="templates/client.hbs"
export class {{service}}Client {
  constructor(private readonly baseUrl = '{{baseUrl}}') {}
{{#each operations}}

{{> method name=@key op=this}}
{{~/each}}
```

The partial carries its own two-space indentation, which `preventIndent` leaves alone.

```handlebars title="templates/method.hbs"
  {{name}}({{signature op.params}}): Promise<{{op.returns}}> {
    return this.request('{{op.method}}', '{{op.path}}', {{args op.params}});
  }
```

## Keep every helper a function of its arguments

A helper runs at render time with whatever the process can see, so a helper is where a clock
gets in. `Date.now()`, `Math.random()`, `os.hostname()`, `process.pid` and the current
working directory all produce output that is correct and different every time.

```js title="render.mjs"
// One line ending, and exactly one at the end. A template checked out with CRLF renders CRLF,
// so the output is normalised rather than the checkout trusted.
export function normalise(text) {
  const lf = text.replace(/\r\n?/g, '\n')
  let end = lf.length
  while (end > 0 && lf[end - 1] === '\n') end--
  return `${lf.slice(0, end)}\n`
}

// Every helper is a pure function of its arguments. No clock, no random source, no host name.
// strict does not check a helper's arguments, so a missing list arrives here as undefined, and
// each helper checks its own and names the field.
const list = (params) => {
  if (!Array.isArray(params)) throw new Error(`"params" must be an array, got ${typeof params}`)
  return params
}
export const helpers = {
  signature: (params) => list(params).map((p) => `${p.name}: ${p.type}`).join(', '),
  args: (params) => (list(params).length ? `{ ${params.map((p) => p.name).join(', ')} }` : '{}'),
}
```

`normalise` is the other half of the line-ending fix. The `.gitattributes` below stops git
from converting the template on checkout, and the normalisation stops a template that arrived
with CRLF anyway, through a zip file or an editor setting, from rendering CRLF. The
[`gitattributes` documentation](https://git-scm.com/docs/gitattributes) sets `eol=crlf` as the
default on Windows when `text` is set and `core.autocrlf` is not,[^3] so the file is not
optional.

```text title=".gitattributes"
# Templates render whatever line ending they were checked out with. Pin them, so a
# checkout on Windows with core.autocrlf=true renders the same bytes as one on Linux.
*.hbs text eol=lf
*.mustache text eol=lf
*.ejs text eol=lf
*.json text eol=lf
```

The render itself is one line: sort, compile with the options, normalise.

```js title="render.mjs"
export function render(data, { template = readFileSync(here('templates/client.hbs'), 'utf8'), engine = createEngine(), options = OPTIONS } = {}) {
  return normalise(engine.compile(template, options)(sortModel(data)))
}
```

## Render twice and compare the digests

Two renders of the same model, hashed with `sha256sum`.

```bash
node render.mjs out/a.ts && node render.mjs out/b.ts && sha256sum out/a.ts out/b.ts
```

```text output
3932e839e675734e26f64449329e8546e4c9fc2919ac35ad0329acd1d31f9739  out/a.ts
3932e839e675734e26f64449329e8546e4c9fc2919ac35ad0329acd1d31f9739  out/b.ts
```

Run the same command on a second machine and compare the digests. Two runs on one machine
prove the helpers are pure. Two machines prove the line endings and the key order are under
control, because those are the two things a second machine changes.

## Check it worked

The test suite pins each cause of drift separately, so a regression names itself.

```js title="render.test.mjs"
test('a template checked out with CRLF renders the same bytes', () => {
  const crlf = TEMPLATE.replace(/\n/g, '\r\n')
  assert.notEqual(crlf, TEMPLATE)
  const out = render(model, { template: crlf })
  assert.equal(out, render(model))
  assert.ok(!out.includes('\r'))
})
```

```bash
node --test render.test.mjs
```

```text output
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 165.332555
```

The eighth test registers a helper that calls `Math.random()` and asserts that two renders
differ. A test that only proves the good path passes cannot tell you the guard is doing
anything.

## When it goes wrong

Each safeguard removed in turn against the same model and template.

```bash
node pitfall.mjs
```

```text output
keys reversed, no sort         different
keys reversed, sortModel       identical
CRLF template, as rendered     different, 30 carriage returns
CRLF template, normalised      identical
helper calls Math.random()     different
noEscape false renders         Record&lt;string, string&gt;
noEscape true renders          Record<string, string>
preventIndent false            "    first\n    second\n"
preventIndent true             "    first\nsecond\n"
```

The output differs and the file compiles. Reversed keys produce a valid client whose
interfaces are in a different order. A CRLF template produces a valid client with 30 carriage
returns in it. The build is green in both cases, which is why the check has to be a digest and
not a compiler.

The digest matches on your machine and differs in CI. Look at what the helper can see: an
environment variable, a `package.json` version read from disk, the path of the checkout.
Anything read from outside the model is an input the digest does not know about, so pass it in
through the model or leave it out.

The digest matches, but the diff is still noisy. The comparison is being made after a formatter
ran on one side and not the other. Format inside the generator or in neither place, and
compare the bytes you wrote.

## What the other two engines change

The same interfaces rendered through Handlebars, mustache.js and EJS, twice each, from the
sorted model.

```bash
node compare.mjs
```

```text output
handlebars 4.7.9 | mustache 4.2.0 | ejs 6.0.1

engine        pure view    impure value   default output for "Record<string, string>"
Handlebars    identical    different      Record&lt;string, string&gt;
mustache.js   identical    different      Record&lt;string, string&gt;
EJS           identical    different      Record&lt;string, string&gt;

all three rendered the same 195 bytes from the sorted view: true
```

Three things to read off that. All three engines escape HTML by default, so each needs its
own switch: `noEscape` here, the triple mustache `{{{type}}}` or an overridden `Mustache.escape`
in mustache.js, and `<%-` in EJS. All three render a different value when a random source is
reachable, and mustache.js reaches it through a
[lambda](https://mustache.github.io/mustache.5.html), a function placed in the view. So no
helpers does not mean no impurity; it means impurity in a different file. And mustache.js iterates
arrays only, so the sort that Handlebars lets you forget is one you cannot skip: the view has
to be built as sorted arrays before the template sees it.

## When not to do this

Do not sort arrays. A parameter list, a middleware chain and a list of `enum` members are all
sequences, and sorting them changes the program the file describes while making the digest
look stable.

Do not set `noEscape` on a template that produces HTML or Markdown that a browser will render.
The option exists because generated source code is not HTML, and it removes the one defence
against a field value that contains a tag.

Do not put a build id, a timestamp or a host name into the generated file to make it
traceable. Put it in the commit message or in a sidecar file the digest does not cover. A file
that carries the time it was written is different every time it is written, by construction.

Do not trust `.gitattributes` alone. It governs checkouts through git and nothing else, so a
template that reaches the build through a zip, a copy or a container image can still carry
CRLF. Normalise the output and keep the attribute file, because each covers what the other
does not.

Do not turn on `strict` in a template that relies on `{{^missing}}` inverse sections. The
compile options documentation says strict mode disables them unless the field is present in
the source object, so the render throws where the template meant to branch.

## Related how-tos

- [Preview a regeneration as a diff before writing files](/howto/preview-a-regeneration-as-a-diff)

- [Generate a code section with code instead of a template](/howto/generate-a-code-section-with-a-component)

## Last verified

Verified 2026-09-25 against Node 22.22.2, `handlebars` 4.7.9, `mustache` 4.2.0 and `ejs` 6.0.1,
installed from npm in the sample directory. Every output block is what the command preceding it
printed. The two-machine claim was checked between two runs in one checkout and one run from a
copy with CRLF templates, not on a second operating system.

[^1]: The `coreutils` manual files `sha256sum` under "sha2 utilities" and opens the entry by
calling it a legacy interface to the more modern `cksum`. The same
[page](https://www.gnu.org/software/coreutils/manual/html_node/sha2-utilities.html) says the
four SHA-2 commands print the untagged output format, which is the two-space form shown earlier.
It does not say what to call a tool that is legacy and still the one everyone types.

[^2]: [ECMAScript](https://tc39.es/ecma262/multipage/ordinary-and-exotic-objects-behaviours.html#sec-ordinaryownpropertykeys)
specifies `OrdinaryOwnPropertyKeys` in three passes: array indices in ascending numeric order,
then string keys in ascending order of creation, then symbols in the same order. So a model
keyed by numeric ids sorts itself and a model keyed by names does not, and a template cannot
tell which kind it was handed. The keys `2` and `10` come out as 2 then 10 whatever order they
were added in, which is the one place insertion order loses to arithmetic.

[^3]: The [`gitattributes`](https://git-scm.com/docs/gitattributes) manual says that when `text`
is set and neither `core.eol` nor `core.autocrlf` is, the default is `eol=crlf` on Windows and
`eol=lf` on all other platforms. A generated file's bytes therefore depend on the operating
system that checked out its template, unless somebody writes down that they should not.