# How to format generated code as the last step of generation

> Format generated TypeScript, Go, and Python in memory with the consuming project's settings, before the generator writes, so no run needs a formatting commit.

Source: https://voxgig.com/howto/format-generated-code-as-the-last-step

- Audience: platform-team
- Level: beginner
- Languages: typescript, go, python
- Verified: 2026-10-21
- Published: 2026-10-20

## Short answer

Format generated code with Prettier, go/format, or Ruff inside the generator, after rendering and before writing, using the settings the project's own tools read, so a regeneration never needs a formatting commit. With Prettier, pass `resolveConfig(outPath, { editorconfig: true })` and `filepath: outPath` to `format`. In Go, run `format.Source` on the rendered bytes. In Python, pipe the source through `ruff format --stdin-filename`. Then run the check mode of each formatter and expect zero changes.

---
## You will need

A generator that writes files to disk, and a formatter for each language it emits. Here those
are Prettier from npm for TypeScript, the Go toolchain for `go/format`, and Ruff from PyPI for
Python. Verified 2026-10-21 against Node 22.22.0, Prettier 3.9.10 and 2.8.8, Go 1.24.7, Python
3.13.16, and Ruff 0.17.1. The sample renders one API model into a client in each language, formats it, and checks
it. It does not run Biome or a pre-commit hook; those two rows come from their documentation.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Biome](https://biomejs.dev/formatter/) | The consuming project already formats JavaScript or TypeScript with Biome | A second formatter to pin, with defaults that differ from those of Prettier, starting with tab indentation | The project formats with Prettier, whose settings files Biome does not read at format time |
| [`gofmt` and `go/format`](https://pkg.go.dev/go/format) | The generator is written in Go and emits Go | Output follows the Go release that compiled the generator, which the package's own overview calls fragile for pre-submit checks | A pre-submit check needs one fixed style, where a pinned `gofmt` binary is steadier |
| [pre-commit in the consuming repository](https://pre-commit.com/) | Many repositories consume the output, and each already pins its own formatter hooks | The diff you ship depends on hook versions you never see, and unformatted output reaches every reviewer first | People read the generated files in review before any hook runs |
| [Prettier](https://prettier.io/docs/api) | The generator runs in Node and emits TypeScript, JavaScript, JSON, or Markdown | One more dependency to pin, and its output moves when a major version changes a default | The project formats with Biome, or the generator does not run in Node |
| [Ruff](https://docs.astral.sh/ruff/formatter/) | The generator emits Python for a project that follows Black's style | A child process per file, because the package installs a binary and exports no formatting function | The project pins Black and its own configuration for it |

Formatting inside the generator pins the formatter with the generator, so every consumer receives
the same bytes, at the cost of one more dependency to keep current. Leaving it to pre-commit or CI
in the consuming repository respects that repository's settings, but the diff you ship then
depends on formatter versions you never see. Biome reads `biome.json` rather than `.prettierrc`,
and its [migration guide](https://biomejs.dev/installation/migrate-eslint-prettier/) writes every
option out explicitly because its defaults differ from those of Prettier.

## Format the string before it reaches the disk

Put the formatter between the template and `writeFile`. The template renders content with no
attention to layout, and the formatter turns that string into the bytes the project's own tools
would produce. The file is written once, already formatted, so no unformatted copy ever sits on
disk for a watcher, a test, or a commit to pick up.

```js title="generate.mjs"
// Resolve the configuration the Prettier CLI and an editor would find for this path, then
// format with that path as filepath. editorconfig: true is the CLI's default and not the API's.
export async function formatFor(path, source) {
  const config = await prettier.resolveConfig(path, { editorconfig: true })
  return prettier.format(source, { ...config, filepath: path })
}

// Format in memory and write once. A template that renders invalid TypeScript makes format
// throw, so a broken generator fails here and leaves no half-formatted file behind.
export async function generate(model, outPath) {
  const formatted = await formatFor(outPath, render(model))
  await mkdir(dirname(outPath), { recursive: true })
  await writeFile(outPath, formatted)
  return formatted
}
```

`resolveConfig` searches upward from the output path, as the CLI does, so it finds the consuming
project's `.prettierrc.json`. The [CLI](https://prettier.io/docs/cli) also reads `.editorconfig`
unless told not to, while the API reads it only when asked.[^1] `filepath` lets Prettier infer
the TypeScript parser from the extension.

```bash
node generate.mjs && npx prettier --check generated/client.ts
```

```text output
wrote generated/client.ts: 32 lines
prettier 3.9.10, config .prettierrc.json and .editorconfig
Checking formatting...
All matched files use Prettier code style!
```

The check exits 0 straight after generation, so the file needs no second pass. Its four-space
indentation comes from `.editorconfig`, and its single quotes and missing semicolons come from
`.prettierrc.json`.

## Format Go in process with go/format

`format.Source` parses the rendered bytes and prints them in canonical `gofmt` style, with the
imports sorted. [`gofmt`](https://pkg.go.dev/cmd/gofmt) takes flags for listing, diffing,
rewriting, and simplifying but no style options, so there is no configuration to resolve.

```go title="main.go"
// Generate renders the model and formats the result in memory. A template that renders invalid
// Go makes format.Source return an error, and nothing is written.
func Generate(m Model) ([]byte, error) {
	var buf bytes.Buffer
	if err := client.Execute(&buf, m); err != nil {
		return nil, err
	}
	formatted, err := format.Source(buf.Bytes())
	if err != nil {
		return nil, fmt.Errorf("generated Go does not parse: %w", err)
	}
	return formatted, nil
}
```

The template behind it writes the import list on one line with semicolons, and each function body
on a single line. Both are legal Go, and `format.Source` lays them out the way `gofmt` would.

```bash
go run . && test -z "$(gofmt -l generated)" && echo "gofmt -l lists no files"
```

```text output
wrote generated/client.go: 70 lines
gofmt -l lists no files
```

`gofmt -l` prints the name of every file whose formatting differs, and `test -z` turns an empty
list into success. The output carries the formatting of the Go release that compiled the
generator, which the package documents as a reason to prefer a pinned binary for checks.[^2]

## Pipe the Python output through `ruff format`

The `ruff` package on PyPI installs a binary and exports no formatting function, so the Python
generator runs it as a child process on standard input. `--stdin-filename` names the output path,
and Ruff starts its search for [configuration](https://docs.astral.sh/ruff/configuration/) from
there.

```py title="generate.py"
def format_for(path, source):
    # The ruff package ships a binary and exports no formatting function, so the pass is a
    # subprocess on stdin. --stdin-filename makes Ruff find ruff.toml from the output path.
    result = subprocess.run(
        [sys.executable, '-m', 'ruff', 'format', '--stdin-filename', str(path), '-'],
        input=source,
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(f'ruff could not format {path}: {result.stderr.strip()}')
    return result.stdout
```

```bash
PYTHONPATH=pydeps python3 generate.py && PYTHONPATH=pydeps python3 -m ruff format --check generated
```

```text output
wrote generated/client.py: 31 lines
1 file already formatted
```

Ruff's formatter leaves `import json, os` on one line, because splitting it is lint rule E401,
fixed by `ruff check --fix` rather than by formatting, so the template writes one import per line
itself. Black checks the syntax tree after formatting and leaves such a line alone too, and Ruff
measures how closely the two agree.[^3]

## Check it worked

Run the suite. It regenerates each client, runs the check mode of each formatter over the
result, and feeds all three generators a model that renders invalid code.

```js title="generate.test.mjs"
test('the Go client is gofmt clean, and invalid Go writes nothing', () => {
  execFileSync('go', ['run', '.'], { encoding: 'utf8' })
  assert.equal(execFileSync('gofmt', ['-l', 'generated'], { encoding: 'utf8' }), '')
  const outPath = join(brokenDir, 'client.go')
  const run = spawnSync('go', ['run', '.', '-model', brokenModel, '-out', outPath], {
    encoding: 'utf8',
  })
  assert.equal(run.status, 1)
  assert.match(run.stderr, /generated Go does not parse/)
  assert.equal(existsSync(outPath), false)
})
```

```bash
node --test generate.test.mjs
```

```text output
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 856.540218
```

`# pass 8` and `# fail 0` mean all three tools accept their output unchanged. A model whose
operation name contains a space makes every generator exit with an error and write no file. A
broken template therefore cannot leave half a client behind for someone to commit.

## When it goes wrong

Saving a generated file in the editor changes its layout. The generator called Prettier without the
output path, or without `editorconfig: true`, so it formatted with settings the CLI never uses.
This script formats one string three ways and asks the CLI about each result.

```bash
node pitfall.mjs && npx prettier --check pitfall
```

```text output
format(source) with no parser and no filepath: No parser and no file path given, couldn't infer a parser.
pitfall/parser-only.ts          indent 2, quotes ", semicolons yes
pitfall/no-editorconfig.ts      indent 2, quotes ', semicolons no
pitfall/as-the-cli-reads-it.ts  indent 4, quotes ', semicolons no
Checking formatting...
[warn] pitfall/no-editorconfig.ts
[warn] pitfall/parser-only.ts
[warn] Code style issues found in 2 files. Run Prettier with --write to fix.
```

Prettier throws when it has neither a parser nor a path. The other two calls succeed and produce
valid TypeScript, but the CLI rejects both files: one read no configuration, and one skipped
`.editorconfig`. Ruff has the same trap. Without `--stdin-filename` it looks for configuration
from the working directory, so a generator started elsewhere formats with defaults, as the
seventh test shows.

A consuming repository's CI rejects the file after either side upgrades its formatter. Formatter
output changes between major versions, so a file formatted under one major can fail the other's
check. Here is
the same string and configuration through two major versions of Prettier.

```bash
node drift.mjs
```

```text output
prettier 2.8.8 and 3.9.10, generated/client.ts
line 5
  2.8.8  |        private readonly fetchImpl: typeof fetch = fetch
  3.9.10 |        private readonly fetchImpl: typeof fetch = fetch,
line 19
  2.8.8  |        params: Record<string, string | number>
  3.9.10 |        params: Record<string, string | number>,
```

Both lines are trailing commas, because the default for
[`trailingComma`](https://prettier.io/docs/options) changed between those majors.[^4] Set every
option the output depends on explicitly in the configuration file, `trailingComma` included, so a
change of default cannot move it, and pin the formatter in the generator's lockfile. Explicit
settings close the gap that defaults leave, but not the layout rules a major version changes on
its own.

## When not to do this

Do not format inside the generator when its output lands in many repositories that each format
on commit with their own settings. Every consumer formats the file again anyway, so the pass in
the generator adds a dependency without changing what they see. Ship output that parses, and leave
layout to their hooks.

Do not replace the in-memory pass with `prettier --write` over the output directory. A directory
pass also rewrites files the generator did not produce, such as a hand-written module beside the
generated ones, and the unformatted bytes sit on disk until it runs.

Do not expect a formatter to fix nondeterminism. It normalizes layout, so map order, timestamps,
and absolute paths survive formatting and still differ between two runs.

Do not format output that no person reads, such as a minified bundle or a wire-format fixture.
The pass costs time on every run, which pays off only when a person reads the output.

## Related how-tos

- [Make a Handlebars template produce byte-identical output](/howto/handlebars-byte-identical-output-every-run)

- [Run a codemod across every generated file with jscodeshift](/howto/jscodeshift-codemod-across-generated-files)

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

## Last verified

Verified 2026-10-21 against Node 22.22.0, Prettier 3.9.10 and 2.8.8, Go 1.24.7, Python 3.13.16,
and Ruff 0.17.1. Every output block is what the command preceding it printed. Biome, Black, and
pre-commit were read from their documentation, not run, and the consuming project is modeled by the sample
directory's own `.prettierrc.json`, `.editorconfig`, and `ruff.toml`.

[^1]: Prettier documents the setting twice, from opposite ends. The
[CLI reference](https://prettier.io/docs/cli) lists `--no-editorconfig`, a flag for switching the
file off, which makes reading it the default. The [API reference](https://prettier.io/docs/api)
describes `options.editorconfig` as something that applies when it is true. Whether
`.editorconfig` counts therefore depends on which entrance a caller used.

[^2]: The standard library's formatting package recommends something other than itself. Its
[overview](https://pkg.go.dev/go/format) says that formatting of Go source changes over time, so
tools relying on consistent formatting should run a specific version of the `gofmt` binary. Its
example is a pre-submit check that calls the package directly and so behaves differently with
each developer's Go version. A generator built on the package carries the formatting of whichever
Go release compiled it.

[^3]: Ruff's [formatter documentation](https://docs.astral.sh/ruff/formatter/) describes it as a
drop-in replacement for Black and reports that more than 99.9% of lines in Django and Zulip come
out identical. Black's own [FAQ](https://black.readthedocs.io/en/stable/faq.html), asked whether
Black has an API, answers "Not yet" and says a simple one is being planned. The same page gives
the style a calendar: output is stable across the releases of one year, and the first release of
each year changes it.

[^4]: The trailing comma has had three defaults in three major versions. Before 2.0, Prettier
avoided trailing commas where it could, which kept output compatible with environments as old as
IE8. The [2.0 release notes](https://prettier.io/blog/2020/03/21/2.0.0) changed the default to
`es5`, after an initiative that had been open for over three years, and added that the default
might become `all` in a future major version. The
[3.0 release notes](https://prettier.io/blog/2023/07/05/3.0.0) made that change and gave the
reason: Internet Explorer, the last browser to reject trailing commas in function calls, lost
support on June 15, 2022. The two trailing commas that `drift.mjs` reports are therefore a decision
about Internet Explorer.