OpenAPI Best Practices for Writing the Spec File

OpenAPI best practices for the spec document itself: stable operationIds, tags, reusable components, named examples, servers, security schemes, and linting.

On this page

OpenAPI best practices are mostly about the document, not the API behind it. Give every operation a unique, readable operationId, because code generators turn it into a method name. Declare tags at the top level so docs group operations in a sensible order. Move anything used twice into components and point to it with $ref. Add named examples that match their schemas. List real servers, declare authentication once in securitySchemes, and run a linter on every pull request so these rules hold after the first author moves on. This guide covers each of those with the OpenAPI 3.1 rules behind them and a worked before and after example.

Resource naming, HTTP methods, and status codes are design questions with their own guide, REST API design best practices. Everything here assumes the design is settled and asks how to write it down well.

Fill in info and servers first

The info object needs only title and version, and version trips people up. The OpenAPI Specification defines it as the version of the OpenAPI document, distinct from both the OpenAPI Specification version and the API implementation version. Bump it whenever the document changes, so a client team can see that the spec they generated from is stale. It does not have to match the /v1 in your paths; the API versioning guide covers how to version the API itself.

If servers is missing or empty, the specification says the default is a single server with the URL /. Interactive docs then send try-it requests to whatever host serves the docs page, which is rarely your API. List each environment a reader may call, with a description, and put the sandbox before production if you want readers to experiment safely. Server variables can template the URL, for example a region or tenant subdomain, and each variable must have a default. Path items and operations can override servers, which is useful when one endpoint, such as file upload, lives on a different host.

YAMLservers.yaml
servers:
  - url: https://sandbox.api.example.com
    description: Sandbox with test data
  - url: https://{region}.api.example.com
    description: Production
    variables:
      region:
        enum: [eu, us]
        default: eu

Give every operation a stable operationId

The specification requires operationId to be unique among all operations, treats it as case-sensitive, and recommends common programming naming conventions because tools may use it to identify the operation. Generators for TypeScript, Python, Go, and other languages take it as the function name. Without it, each generator invents a name from the method and path, and you get names like getV1InvoicesId that change when the path changes.

operationIdProblemBetter
(missing)Generated name depends on the tool and the pathlistInvoices
get_v1_invoices_idEncodes the path and version, so it changes when they changegetInvoice
invoiceSays nothing about the actiongetInvoice or updateInvoice
GetInvoice and getInvoice in one fileUnique by case only, so easy to misread, and a generator that changes casing can collide themPick one casing for the whole document
handleRequest2Meaningless to a reader of the SDKvoidInvoice
One convention that works: lowerCamelCase verb plus singular or plural noun, such as listInvoices, getInvoice, createInvoice.

Treat operationIds as public names. Renaming one renames a method in every generated SDK, which breaks client code even though no HTTP request changed. If a name is wrong, add the new operation, mark the old one deprecated: true, and remove it in the next major version. Links and some tools also refer to operations by operationId, so a rename can break references inside the document too.

Group operations with declared tags

Docs renderers commonly group operations by tag in the navigation, and some code generators use the tag to split the client into classes such as InvoicesApi and CustomersApi. Declare every tag in the top-level tags array with a description. The specification says tools can use the order of that array, and that tags used by operations but not declared there may be ordered randomly or by the tool's own logic. Give each operation one primary tag. An operation with three tags can appear under each of them in the sidebar and in each generated class.

Reuse definitions through components

The components object holds reusable schemas, responses, parameters, examples, request bodies, headers, security schemes, links, callbacks, and, in 3.1, path items. Keys must match the pattern ^[a-zA-Z0-9\.\-_]+$, so a schema named Invoice Line fails and InvoiceLine works. The reason to reuse is drift. Two inline copies of the same object are edited at different times by different people, and they stop agreeing. In the before example below, one invoice schema types total as an integer and the other as a number, which is exactly that drift.

  • Extract an object into components/schemas as soon as two operations use it. Generators produce one named type per component, so Invoice becomes one class instead of InvoicesGet200ResponseItem and InvoicesIdGet200Response.
  • Put shared parameters, such as a page size limit or a path ID, in components/parameters. A parameter used by every operation on a path can sit at the path item level instead of being repeated under each method.
  • Keep shared response definitions in components/responses. The error handling guide shows this for problem details bodies.
  • Leave one-off request bodies inline. A component used once adds a jump for the reader and no consistency benefit.
  • Split very large documents into several files with relative $ref paths only when your tooling resolves them. Some tools accept only a single bundled file.

Write examples readers and tools can use

Examples are what most readers look at first, and mock servers and docs tools render them directly. In a media type or parameter, example and examples are mutually exclusive. Prefer examples, the map of named Example objects, when a response has more than one interesting state, such as a paid invoice and a draft with no total. Each named example gets a summary that docs tools show in a dropdown.

In OpenAPI 3.1 the example keyword inside a Schema Object is deprecated in favor of the JSON Schema examples keyword, which takes an array. Whichever you use, the values should validate against the schema. The specification says media type examples SHOULD match the schema, and JSON Schema says the same for examples, but neither is enforced, so stale examples are common. An example with totalCents as "129.00" when the schema says integer teaches every reader the wrong type.

Declare security once with securitySchemes

Define each authentication method under components/securitySchemes. The allowed types in 3.1 are apiKey, http, mutualTLS, oauth2, and openIdConnect. For bearer tokens use type: http with scheme: bearer; bearerFormat: JWT is a documentation hint and changes nothing at runtime. Then apply security in one of three ways.

  1. Top-level security applies to every operation. Use it when almost every endpoint needs the same credentials.
  2. Operation-level security overrides the top-level value. An empty array, security: [], removes authentication from that one operation, which is how you mark a public health check or login endpoint.
  3. An empty object inside the array, security: [{}, {bearerAuth: []}], makes authentication optional, for endpoints that return more data to signed-in callers.

Entries in the security array are alternatives: a request needs to satisfy only one of them. Two schemes inside a single entry are both required. Mixing those up is how a spec ends up saying that an API key alone is enough for an admin endpoint. For OAuth2, list the scopes each operation needs in its security requirement so readers and gateways can see them per endpoint. The authentication example shows a full securitySchemes block.

Before and after: one small spec

AvoidBefore: valid OpenAPI, hard to use

YAML
openapi: 3.1.0
info:
  title: Invoices API
  version: "1"
paths:
  /v1/invoices:
    get:
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string }
                    total: { type: integer }
                    status: { type: string }
  /v1/invoices/{id}:
    get:
      summary: Get invoice
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  total: { type: number }
                  status: { type: string }
        "404":
          description: Not found

No servers, so try-it calls go to /. No operationIds, tags, security, or examples. The invoice object is written twice and the copies disagree on the type of total. The list operation has no summary.

PreferAfter: the same two operations, written for tools

YAML
openapi: 3.1.0
info:
  title: Invoices API
  version: 1.4.0
  description: Create, list, and void customer invoices.
servers:
  - url: https://api.example.com
    description: Production
  - url: https://sandbox.api.example.com
    description: Sandbox with test data
tags:
  - name: Invoices
    description: Billing documents sent to customers
security:
  - bearerAuth: []
paths:
  /v1/invoices:
    get:
      tags: [Invoices]
      operationId: listInvoices
      summary: List invoices
      parameters:
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: A page of invoices
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Invoice"
        "401":
          $ref: "#/components/responses/Problem"
  /v1/invoices/{id}:
    parameters:
      - $ref: "#/components/parameters/InvoiceId"
    get:
      tags: [Invoices]
      operationId: getInvoice
      summary: Get one invoice
      responses:
        "200":
          description: The invoice
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
              examples:
                paid:
                  summary: A paid invoice
                  value:
                    id: inv_1042
                    totalCents: 12900
                    currency: EUR
                    status: paid
                draft:
                  summary: A draft with no total yet
                  value:
                    id: inv_1043
                    totalCents: 0
                    currency: EUR
                    status: draft
        "404":
          $ref: "#/components/responses/Problem"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  parameters:
    InvoiceId:
      name: id
      in: path
      required: true
      description: Invoice identifier, for example inv_1042
      schema:
        type: string
    Limit:
      name: limit
      in: query
      description: Page size
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
  schemas:
    Invoice:
      type: object
      required: [id, totalCents, currency, status]
      properties:
        id:
          type: string
        totalCents:
          type: integer
          description: Amount in the smallest currency unit
        currency:
          type: string
          pattern: "^[A-Z]{3}$"
        status:
          type: string
          enum: [draft, open, paid, void]
  responses:
    Problem:
      description: Error in problem details format
      content:
        application/problem+json:
          schema:
            type: object
            properties:
              title:
                type: string
              status:
                type: integer

Servers list both environments. Each operation has a tag, an operationId, and a summary. Invoice is defined once, the ID parameter sits at the path level, two named examples show different states, and bearer auth applies to every operation.

We ran both files through APIForge's design score. The before version scored 90 out of 100, losing 5 points for documentation because the list operation has no summary and 5 for error handling because the list operation declares no 4xx or default response. The after version scored 100. Adding operationIds, tags, servers, security, examples, and the shared Invoice schema did not change the number. The score grades API design from paths, methods, response codes, and operation descriptions. It does not read authoring details, so a spec can score 100 and still generate an SDK full of getV1InvoicesId methods.

Lint the spec on every pull request

Rules that live only in a style guide stop being followed. Put them in CI so a pull request that breaks one fails before review. A useful first rule set checks things a reviewer misses when reading a diff.

A lint pipeline for an OpenAPI repository

  1. Parse and resolve every $ref

    Fail on YAML syntax errors and on any $ref that points to nothing. A broken reference often renders as an empty schema in docs instead of an error.

  2. Check authoring rules

    Every operation has an operationId, a summary, and exactly one tag; operationIds are unique; every tag is declared at the top level; servers is not empty; every operation is covered by a security requirement or an explicit security: [].

  3. Validate examples against their schemas

    Run each example value through a JSON Schema validator so stale examples fail the build instead of misleading readers.

  4. Score the design

    Run a design check for naming, methods, versioning, and documented responses, and fail the build if the score drops below the value on the main branch.

APIForge covers the first and last steps in the browser. The OpenAPI validator parses YAML or JSON, resolves $ref pointers, and lists the operations it found, and the API quality score grades the design across seven categories. The middle steps need a rule-based linter you can configure; the APIForge vs Spectral comparison explains where each fits. Before a release, walk through the OpenAPI production checklist, and if the validator rejects your file, common OpenAPI errors lists the usual causes.

Frequently asked questions

No, the specification makes it optional, but when present it must be unique across the document and it is case-sensitive. Code generators use it as the method name, so leaving it out gives you generated names that change whenever a path changes. Add one to every operation.

In a media type or parameter, use examples when you want several named cases and example for a single value; you cannot use both on the same object. Inside a schema in OpenAPI 3.1, the example keyword is deprecated, so use the JSON Schema examples array instead.

Declare the default security at the top level, then set security: [] on the public operation. The empty array removes the top-level requirement for that operation only. Use security: [{}, {bearerAuth: []}] if authentication should be optional rather than absent.

Sources

  1. OpenAPI Specification 3.1.0: Operation Object (operationId, tags, security)OpenAPI Initiative
  2. OpenAPI Specification 3.1.0: Components ObjectOpenAPI Initiative
  3. OpenAPI Specification 3.1.0: Security Scheme ObjectOpenAPI Initiative
  4. JSON Schema annotations: examples and deprecatedjson-schema.org