# How to write operationIds that generate clean SDK method names

> Name every operation with a unique lowercase verb-plus-noun id, audit the ids in CI, rename the bad ones with an overlay, and diff the methods two generators make.

Source: https://voxgig.com/howto/operationids-that-generate-clean-sdk-method-names

- Audience: api-producer
- Level: intermediate
- Verified: 2026-10-15
- Published: 2026-10-15

## Short answer

Give every operation an operationId in `snake_case`, verb then noun, such as `list_branches`, and keep it unique even once case is ignored. Audit the ids in CI, rename with an OpenAPI Overlay and the `openapi-format` casing pass, then generate with two generators and diff the method lists. Generators that name methods from operationId follow the rename. sdkgen and Kiota name calls from the path, so the path has to read well too.

---
## You will need

Node 22 or later and an OpenAPI 3 document, ideally inside an authoring workflow that already
lints it. Verified 2026-10-15 against Node 22.22.0, `openapi-format` 1.34.0,
`@hey-api/openapi-ts` 0.99.0 and `@voxgig/apidef` 8.25.2. The
[OpenAPI 3.0.3 specification](https://spec.openapis.org/oas/v3.0.3#operation-object) requires
each operationId to be unique and case-sensitive, and recommends common programming naming
conventions without naming one.[^1]

Voxgig maintains sdkgen. This page compares how it names methods with OpenAPI Generator,
Speakeasy, and Kiota.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Kiota](https://learn.microsoft.com/en-us/openapi/kiota/request-builders) | Deep path hierarchies, and callers who navigate by path segment | Call names come from path segments and HTTP verbs, so an operationId convention buys you nothing here | Your callers expect verb names your team chose |
| [OpenAPI Generator](https://openapi-generator.tech/docs/customization/) | The widest language coverage, and ids you already control | A duplicate inside one tag gets `_0` appended with only a log warning | You want naming decided by a model rather than by each id |
| [sdkgen](https://voxgig.com/sdk) | Entity-shaped clients whose paths already name the resource | Reads an operationId only when a path has no segment to name an entity, so the paths carry the naming | Your paths are actions or RPC verbs rather than resources |
| [Speakeasy](https://www.speakeasy.com/docs/sdks/customize/methods) | Ids you control, grouped by tag or `x-speakeasy-group` | Method names track the operationId unless `x-speakeasy-name-override` decouples them, so a rename breaks callers | You cannot add vendor extensions to the published document |

OpenAPI Generator and Speakeasy turn each id into a method, so a bad id ships as a bad method and a
renamed id is a breaking change. Speakeasy files each method under a
[namespace named after its tag](https://www.speakeasy.com/docs/sdks/customize/structure/namespaces).
Kiota and sdkgen build names from the path, which makes the ids nearly irrelevant to them and the
path segments the thing to get right. One convention serves both
groups: `snake_case` verb plus noun, on paths that name resources.

## Find the ids that would become bad names

The audit asks of each id whether it exists, is unique, is not a framework default, is
`snake_case`, and starts with a verb. Uniqueness is checked twice: as written, then with case
and punctuation folded away. The framework check compares the id with its own path and method,
so `get_branch_head` and `create_post` pass it.

```js title="ids.mjs"
// FastAPI's default is the function name, then the path with every non-word
// character turned into `_`, then the method: `create_branch_repos__repo__branches_post`.
// So the id ends with its own operation's path and method, and a doubled
// underscore usually marks it. An id that merely ends in a method name, such
// as `get_branch_head` or `create_post`, is not one. The comparison is folded,
// so the mark survives a casing pass that rewrote the id.
export function frameworkDefault({ id, method, path }) {
  const tail = folded(path)
  return id.includes('__') || (tail !== '' && folded(id).endsWith(tail + method.toLowerCase()))
}

/** The problems with one operation's id, given every id in the document. */
export function problems(op, exact, fold) {
  const { id, unread } = op
  if (unread) return ['in another file, bundle first']
  if (id === undefined || id === '') return ['missing']
  if (typeof id !== 'string') return ['not a string']
  const found = []
  if (exact.get(id) > 1) found.push('duplicate')
  else if (fold.get(folded(id)) > 1) found.push('collides after re-casing')
  if (frameworkDefault(op)) found.push('framework default')
  if (!SNAKE.test(id)) found.push('not snake_case')
  else if (!VERBS.includes(id.split('_')[0])) found.push('no verb first')
  return found
}
```

Run it on the sample document, a small repository-hosting API with branches and tags:

```bash
node ids.mjs api.json
```

```text output
api.json: 6 operations, 6 with a problem
  GET    /repos/{repo}/branches           listBranches                              duplicate, not snake_case
  POST   /repos/{repo}/branches           create_branch_repos__repo__branches_post  framework default, not snake_case
  GET    /repos/{repo}/branches/{branch}  GetBranch                                 not snake_case
  PATCH  /repos/{repo}/branches/{branch}  (none)                                    missing
  DELETE /repos/{repo}/branches/{branch}  delete-branch                             not snake_case
  GET    /repos/{repo}/tags               listBranches                              duplicate, not snake_case
```

The POST id is FastAPI's default. Its
[`generate_unique_id`](https://github.com/fastapi/fastapi/blob/master/fastapi/utils.py)
joins the function name, the path with every non-word character replaced by an underscore, and the
method.[^2] The audit exits 1 here, so the same command can gate a pull request.

## Rename with an overlay, then fix the case

A casing tool can turn `GetBranch` into `get_branch`, but it cannot know that the tags endpoint
means `list_tags`, because that is a change of words rather than of case. Put the word changes in an
[OpenAPI Overlay](https://spec.openapis.org/overlay/v1.0.0.html), a reviewed file of targeted
edits, and leave case to a rule.

```json title="rename.overlay.json"
    {
      "target": "$.paths['/repos/{repo}/branches'].post",
      "description": "FastAPI's default id, function name plus path plus method",
      "update": { "operationId": "create_branch" }
    },
```

[`openapi-format`](https://github.com/thim81/openapi-format) applies
the overlay first and the `--casingFile` rule after it, in one run. The casing file holds one rule,
`"operationId": "snake_case"`.

```bash
npx openapi-format api.json --no-sort --overlayFile rename.overlay.json --casingFile casing.json -o api.clean.json && node ids.mjs api.clean.json
```

```text output
api.clean.json: 6 operations, 0 with a problem
  GET    /repos/{repo}/branches           list_branches                             ok
  POST   /repos/{repo}/branches           create_branch                             ok
  GET    /repos/{repo}/branches/{branch}  get_branch                                ok
  PATCH  /repos/{repo}/branches/{branch}  update_branch                             ok
  DELETE /repos/{repo}/branches/{branch}  delete_branch                             ok
  GET    /repos/{repo}/tags               list_tags                                 ok
```

Keep the overlay beside the code that emits the document. A framework writes its defaults again on
every export, and the overlay re-applies to each one.

## Generate twice and diff the method lists

The audit says the ids are clean. The generators decide what that is worth. `methods.mjs` runs
[`@hey-api/openapi-ts`](https://heyapi.dev/docs/openapi/typescript/plugins/sdk), which names
functions from operationId, and `@voxgig/apidef`, the model step inside sdkgen, which names them from
the path. Both run on the document before and after the rename.

```bash
node methods.mjs
```

```text output
operationId-driven, @hey-api/openapi-ts 0.99.0, api.json -> api.clean.json
  GET    /repos/{repo}/branches           listBranches                       -> listBranches
  POST   /repos/{repo}/branches           createBranchReposRepoBranchesPost  -> createBranch
  GET    /repos/{repo}/branches/{branch}  getBranch                          -> getBranch
  PATCH  /repos/{repo}/branches/{branch}  patchReposByRepoBranchesByBranch   -> updateBranch
  DELETE /repos/{repo}/branches/{branch}  deleteBranch                       -> deleteBranch
  GET    /repos/{repo}/tags               listBranches2                      -> listTags
  3 of 6 method names changed

path-driven, @voxgig/apidef 8.25.2 (the sdkgen model step), api.json -> api.clean.json
  GET    /repos/{repo}/branches           branch.list                        -> branch.list
  POST   /repos/{repo}/branches           branch.create                      -> branch.create
  GET    /repos/{repo}/branches/{branch}  branch.load                        -> branch.load
  PATCH  /repos/{repo}/branches/{branch}  branch.update                      -> branch.update
  DELETE /repos/{repo}/branches/{branch}  branch.remove                      -> branch.remove
  GET    /repos/{repo}/tags               tag.list                           -> tag.list
  0 of 6 method names changed
```

The three names that changed are the three the overlay renamed. The casing pass changed no
TypeScript method, because the generator re-cases every id into `camelCase` itself. The missing id
became `patchReposByRepoBranchesByBranch`, a name the generator built from the method and path. The
copied id became `listBranches2`.

The path-driven column did not move. sdkgen read the same six operations as one `branch` entity
with five operations and one `tag` entity with one. Paths and methods were all it read. For it, the
rename was a no-op, and a path such as `/repos/{repo}/branches` is what produced `branch.list`.

## Check it worked

Twelve tests run, covering the audit, the rename pass, and both generators.

```bash
node --test ids.test.mjs
```

```text output
1..12
# tests 12
# suites 0
# pass 12
# fail 0
# cancelled 0
# skipped 0
# todo 0
```

One test regenerates `api.clean.json` from the committed overlay and casing file and compares it
with the committed copy, so the rename cannot drift from its record. Another asserts that the
casing pass alone leaves `create_branch_repos_repo_branches_post`, the duplicate, and the missing
id in place.

## When it goes wrong

The specification accepts `listBranches` beside `list_branches`, because it treats the value as
case-sensitive. The generator re-cases both to `listBranches` and names the second `listBranches2`,
and by default it prints no warning. Its opt-in validator, `parser.validate_EXPERIMENTAL`, stops on
an exact duplicate but compares ids as written, so it passes this pair; a test pins all three. The
audit folds case before counting for this reason. OpenAPI Generator appends `_0` to a duplicate
inside one tag and logs a warning, in its
[`DefaultCodegen`](https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java).[^3]
Speakeasy's linter reports a collision as an error, under its
[`generator-duplicate-operation-name`](https://www.speakeasy.com/docs/sdks/prep-openapi/linting)
rule.

The casing pass invents words. `listOAuthApps` converts to `list_o_auth_apps`, because `camelCase`
hides where an acronym ends; a test pins that too. Write the id in `snake_case` at the source, where
the word boundaries are explicit, rather than converting it afterwards.

A path with no literal segment, such as `GET /` or `GET /{check}`, takes its entity name from the
operationId in sdkgen. The model step in apidef 8.25.2 falls back to the id there, ahead of the
response schema's title, and the sample's `root.json` produces `get_service_status.load`. That fallback is the one
place the model step uses the id to name anything.

## When not to do this

Do not rename the ids behind an SDK you have already published as a minor release. In OpenAPI
Generator and Speakeasy the method name is the id, so the rename is a breaking change. Add
`x-speakeasy-name-override` or OpenAPI Generator's
[`--operation-id-name-mappings`](https://openapi-generator.tech/docs/customization/) instead, and
rename on the next major version.

Do not fill missing ids from a template. The `openapi-format` generate option named the PATCH
`patch_branches` in the tests, a method and a plural that describe neither the intent nor the
single branch it changes.

Do not expect a clean operationId convention to fix sdkgen or Kiota output. Both name calls from
the path, so a path whose segments name no resource produces a poor client whatever its id says. Fix the path, or
accept that the convention serves the id-driven generators only.

## Related how-tos

- [Derive a noun-verb command tree from an OpenAPI document](/howto/derive-a-command-tree-from-operation-ids)

- [Enforce a naming convention across 40 microservice specs](/howto/enforce-naming-conventions-across-many-specs)

- [Generate your first SDK with sdkgen](/howto/first-sdk-with-sdkgen)

## Last verified

Verified 2026-10-15 against Node 22.22.0, `openapi-format` 1.34.0, `@hey-api/openapi-ts` 0.99.0
and `@voxgig/apidef` 8.25.2. Every output block is what the command preceding it printed. OpenAPI
Generator, Speakeasy, and Kiota were not run; their behavior is taken from the source and
documentation linked on this page. The sdkgen names come from `@voxgig/apidef` run on its own, the
step of an sdkgen generate that classifies the paths.

[^1]: The [3.0.3 text](https://spec.openapis.org/oas/v3.0.3#operation-object) asks for an id that
is unique among all operations and case-sensitive. Taken together, the two rules permit
`listBranches` and `list_branches` in one document. It then recommends following common
programming naming conventions, a phrase that names no convention, which leaves the choice to
whichever generator reads the id next.

[^2]: FastAPI's [guide to generating clients](https://fastapi.tiangolo.com/advanced/generate-clients/)
shows the method its own default produces, `createItemItemsPost`, and concedes that it does not
read cleanly. It also says why the default looks that way: the specification requires unique ids,
and a function name plus a path plus a method is unique by construction. Readability arrives further
down that guide, as a custom `generate_unique_id_function` that the reader writes.

[^3]: The loop is in
[`DefaultCodegen.addOperationToGroup`](https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java).
Its counter starts at zero, so a second `listBranches` becomes `listBranches_0` and a third becomes
`listBranches_1`. It compares only the operations already filed under the same tag. Two identical
ids under two different tags pass through unchanged, into two different API classes.