Naming Operation IDs for Readable SDK Methods
This page sits under Client SDK Generation Pipelines in the API Design Fundamentals & Architecture reference. operationId never travels on the wire, which is exactly why it gets neglected — and why the resulting SDK reads like api.postV2InvoicesInvoiceIdVoid() instead of invoices.void(). This page defines a naming rule, shows what each generator does with it, and covers the rename problem.
The decision trigger
| Symptom | Cause |
|---|---|
Generated method named invoicesInvoiceIdGet |
no operationId; the generator derived one from the path |
Method reads invoices.invoicesList() |
operationId repeats the tag it lives under |
| Generation fails with “duplicate operation id” | two operations share an operationId |
Python SDK exposes getInvoice in camelCase |
generator could not transform a non-standard casing |
| Upgrading the SDK breaks a consumer’s build | an operationId was renamed without a major bump |
| Two methods differ only by trailing digits | generator disambiguated colliding derived names |
Spec snippet: the naming rule
The rule is three parts: verb + Resource, lowerCamelCase, and never repeat the tag.
# openapi.yaml (OpenAPI 3.1.0)
paths:
/invoices:
get:
tags: [invoices]
operationId: listInvoices # → invoices.listInvoices()
post:
tags: [invoices]
operationId: createInvoice # → invoices.createInvoice()
/invoices/{invoiceId}:
get:
tags: [invoices]
operationId: getInvoice # → invoices.getInvoice()
patch:
tags: [invoices]
operationId: updateInvoice
delete:
tags: [invoices]
operationId: deleteInvoice
/invoices/{invoiceId}/void:
post:
tags: [invoices]
operationId: voidInvoice # domain verb, not "postInvoiceVoid"
/invoices/{invoiceId}/line-items:
get:
tags: [invoices]
operationId: listInvoiceLineItems # nested resource keeps its parent
The verbs are worth standardising across the whole document: list, get, create, update, replace, delete for CRUD, and a domain verb (void, cancel, refund, retry) for state transitions. A reader who learns the vocabulary on one resource can predict every other resource’s method names — which is the entire point of naming discipline.
Step-by-step
Step 1 — Choose lowerCamelCase and let generators transform it
Write listInvoices once; each generator converts to the idiomatic form for its language. Writing list_invoices instead does not produce nicer Python — it produces list_invoices in Python and list_invoices in TypeScript, because most generators only transform from camelCase reliably.
operationId |
TypeScript | Python | Go | Java |
|---|---|---|---|---|
listInvoices |
listInvoices() |
list_invoices() |
ListInvoices() |
listInvoices() |
list_invoices |
list_invoices() |
list_invoices() |
ListInvoices() |
listInvoices() |
Invoices_List |
invoicesList() |
invoices_list() |
InvoicesList() |
invoicesList() |
Step 2 — Drop tag repetition
With tags: [invoices], the generated call is already namespaced. listInvoices gives invoices.listInvoices() which is acceptable; invoicesList gives invoices.invoicesList() which stutters. If your generator emits a flat client with no namespacing, the redundancy becomes useful — decide once, document it, and lint for it.
Step 3 — Enforce with lint
# .spectral.yaml — operationId governance
extends: ["spectral:oas"]
rules:
operation-operationId: error
operation-operationId-unique: error
operationid-lower-camel:
description: operationId must be lowerCamelCase (verb + Resource).
severity: error
given: "$.paths[*][get,put,post,delete,patch,head,options].operationId"
then:
function: pattern
functionOptions:
match: "^[a-z][a-zA-Z0-9]*$"
operationid-known-verb:
description: operationId must start with an approved verb.
severity: warn
given: "$.paths[*][get,put,post,delete,patch].operationId"
then:
function: pattern
functionOptions:
match: "^(list|get|create|update|replace|delete|void|cancel|refund|retry|export|import|search)"
Step 4 — Audit an existing document before you commit to the names
# Print every operation and the method name each generator would produce.
python3 - <<'PY'
import yaml, re, sys
doc = yaml.safe_load(open("openapi.yaml"))
def snake(s): return re.sub(r'(?<!^)(?=[A-Z])', '_', s).lower()
print(f'{"path":42} {"method":7} {"operationId":26} {"python":26}')
for path, item in doc.get("paths", {}).items():
for method, op in item.items():
if method not in {"get","put","post","delete","patch"}:
continue
oid = op.get("operationId", "<MISSING>")
print(f'{path:42} {method:7} {oid:26} {snake(oid) if oid != "<MISSING>" else "":26}')
PY
Run this before publishing a first SDK. Renaming afterwards is a breaking change for every consumer, which is a much more expensive conversation than a spreadsheet review now.
Step 5 — Handle renames as breaking changes
The generator will not produce the alias for you — add it in the hand-written wrapper layer:
// sdk/typescript/index.ts — keep the old name compiling for one minor cycle
export class Invoices {
voidInvoice(id: string) { return this.api.voidInvoice({ invoiceId: id }); }
/** @deprecated Renamed to voidInvoice(); removed in 5.0. */
cancelInvoice(id: string) { return this.voidInvoice(id); }
}
Applied consistently, the verb vocabulary makes the whole SDK predictable — a reader who learns one resource can guess every other resource’s methods:
Standard compliance
| Rule | Clause | Consequence |
|---|---|---|
operationId is optional |
OpenAPI 3.1 §4.8.10 | Generators fall back to derived names |
| Must be unique across the document | OpenAPI 3.1 §4.8.10 | Duplicates make the document invalid |
| Case-sensitive | OpenAPI 3.1 §4.8.10 | getInvoice ≠ getinvoice |
Used by Link objects (operationId field) |
OpenAPI 3.1 §4.8.20 | Renaming breaks documented links |
| No wire-level meaning | OpenAPI 3.1 §4.8.10 | Never a runtime API break |
The Link object row is the one that surprises people: if your responses declare links using operationId, a rename silently breaks the link resolution in documentation tools and HATEOAS-style clients, without any generator complaining.
SDK / codegen downstream effect
// Before — no operationId on POST /invoices/{invoiceId}/void
- await api.invoicesInvoiceIdVoidPost({ invoiceId: id });
+ // After — operationId: voidInvoice, tags: [invoices]
+ await client.invoices.voidInvoice({ invoiceId: id });
The derived name is not merely ugly. It encodes the URL structure into consumer code, so a later path change — /invoices/{id}/void to /invoices/{id}/voids — renames the method and breaks every caller, even though the SDK is supposed to insulate them from URL shape. An explicit operationId decouples the two, which is the same decoupling argument that URL Path vs Header Versioning makes about version identifiers.
Common mistakes
| Mistake | Correct approach |
|---|---|
Omitting operationId and accepting derived names |
Require it in lint on every operation |
Invoices_List or invoices-list styles |
Use lowerCamelCase and let generators transform |
Repeating the tag: invoicesListInvoices |
Name for how it reads after the namespace |
| Reusing an id across two operations | Enforce uniqueness in CI; the document is invalid otherwise |
| Renaming in a minor release | Ship an alias, deprecate, remove at the next major |
HTTP-verb-derived names like postInvoice |
Use the domain verb: createInvoice, voidInvoice |
FAQ
Is changing an operationId a breaking change?
Not for the HTTP API — operationId is document metadata and never appears on the wire, so a running integration that speaks raw HTTP notices nothing. It is breaking for consumers of your generated SDK, because the method they call disappears and their build fails on upgrade. Treat it accordingly: ship the new name alongside the old, mark the old one deprecated in the wrapper layer, and remove it at the next major SDK release. If your responses use Link objects that reference the id, update those in the same change.
Should the operationId include the resource name if there is already a tag?
No, assuming your generator namespaces by tag — which most do. operationId: listInvoices under tags: [invoices] produces invoices.listInvoices(), which reads well; operationId: invoicesList produces invoices.invoicesList(), which stutters at every call site. The exception is a generator configured to emit one flat client class, where the tag never appears and the resource name in the id is what disambiguates. Pick the configuration first, then the naming convention that fits it, and lint for that convention.
What happens if two operations share an operationId?
The document is invalid: OpenAPI requires uniqueness across the entire document, not merely within a path. Tooling behaviour then varies in unhelpful ways — some generators abort, some emit both methods with a numeric suffix, and some silently generate only the last one, producing an SDK that is missing an endpoint nobody notices until a caller needs it. Add operation-operationId-unique to your lint rules so the failure happens in CI with a clear message instead of in a generated client.
Related
- Client SDK Generation Pipelines — up-link: the pipeline that consumes these names
- API Design Fundamentals & Architecture — up-link: the parent reference for contract-first design
- OpenAPI Generator vs Kiota vs Speakeasy — how each generator transforms these ids
- Writing Custom Spectral Rules — implementing the naming rules as lint checks
- Semver Ranges for Generated SDK Clients — releasing a rename without breaking consumers