# Swagger 2.0 to OpenAPI 3.x migration: a field-tested checklist

A surprising number of production APIs still publish Swagger 2.0 documents. Some are frozen services nobody dares touch; more often the spec is generated by an old plugin — springfox instead of springdoc, an unmaintained flask-restplus, a swagger-node-express setup — and the document has simply never been converted. The automatic migration tools work well enough to feel safe, which is exactly when teams ship a converted spec that misdescribes nullability, bodies, and auth. This is the checklist we use to get a 2.0 document all the way to OpenAPI 3.2 with the semantics intact.

## Step 0: decide the target

Do not migrate to 3.0.3 in 2026. If you are touching the document, target 3.2 (or 3.1 if a specific downstream tool has not caught up — check your renderer, code generator, and gateway first). The 3.0 stop adds a second migration later, and the `nullable` keyword it requires is already a legacy construct you would immediately have to remove.

## Step 1: take an inventory before converting

Run through the source document and list the features that convert badly:

-   Every `consumes` / `produces` declaration and where they differ per operation.
-   Every `body` and `formData` parameter (these become request bodies and are the biggest structural change).
-   Every `nullable: x-nullable` vendor extension and every field that can actually be null in responses.
-   `securityDefinitions` types, especially `basic` and `apiKey` locations.
-   `collectionFormat` on array parameters.
-   Global `definitions` with circular or polymorphic refs (`discriminator`).
-   Example payloads kept in wikis or tests, because converted examples are where drift hides.

Ten minutes of inventory saves a day of "the generated SDK does not match reality."

## Step 2: run the mechanical conversion

The standard tool is swagger2openapi (maintained as `swagger2openapi` / the online editor converters). Run it against the file and keep both versions:

``` bash
npx swagger2openapi --outfile openapi-3.yaml swagger-2.json
```

It handles the bulk renames: `swagger: "2.0"` becomes `openapi: 3.x`, `definitions` moves under `components.schemas`, `securityDefinitions` under `components.securitySchemes`, and `parameters` reshapes. Treat the output as a draft. The rest of this checklist is the review.

## Step 3: request bodies are the number-one break

In 2.0, a request body was just another parameter with `in: body`. In 3.x it is a dedicated `requestBody` object keyed by media type:

``` yaml
# 2.0
parameters:
  - in: body
    name: body
    required: true
    schema:
      $ref: '#/definitions/ProjectCreate'

# 3.2
requestBody:
  required: true
  content:
    application/json:
      schema:
        $ref: '#/components/schemas/ProjectCreate'
```

Watch for three conversion failures: `formData` parameters that must become `multipart/form-data` or `application/x-www-form-urlencoded` content (converters sometimes emit an empty JSON body instead), file uploads (`type: file` becomes a binary string schema under the right media type), and per-operation `consumes` that override the global default — each needs its own content map.

## Step 4: responses get content too

2.0 responses had a direct `schema`. In 3.x that schema lives under `content.<media type>`:

``` yaml
responses:
  '200':
    description: A project
    content:
      application/json:
        schema:
          $ref: '#/components/schemas/Project'
```

If the API serves both JSON and CSV, this is where you finally document both properly — the old `produces` array flattened that distinction.

## Step 5: nullability done right

2.0 had no null type; teams used the vendor extension `x-nullable`, or nothing at all. The 3.0 converter emits `nullable: true`. In 3.2 you must convert again to the JSON Schema form:

``` yaml
# converted to 3.0 style (do not keep)
refundedAt:
  type: string
  nullable: true

# 3.2 target
refundedAt:
  type: [string, "null"]
  format: date-time
```

Do not stop at find-and-replace. Check the actual API behavior against logs: fields that are omitted are not the same as fields returned as `null`, and optional-but-non-null is different from nullable. This is the single most common semantic lie in migrated specs, and it generates wrong SDK types (pointer vs union vs value).

## Step 6: security schemes

``` yaml
# 2.0
securityDefinitions:
  apiKey:
    type: apiKey
    in: header
    name: X-API-Key
  appAuth:
    type: basic

# 3.2
securitySchemes:
  apiKey:
    type: apiKey
    in: header
    name: X-API-Key
  appAuth:
    type: http
    scheme: basic
```

`type: basic` becomes `type: http, scheme: basic`; OAuth2 flows restructure into a `flows` object with explicit authorization/token/refresh URLs per flow; `in: query` API keys convert mechanically but should trigger a security review (query-string secrets leak into logs).

## Step 7: parameters and arrays

-   `collectionFormat: csv|ssv|tsv|pipes` becomes `style: form|spaceDelimited|pipeDelimited` with `explode`. Multi-valued query parameters are a frequent source of client bugs; verify the generated client actually serializes the way the server expects.
-   Path parameters now require `required: true` explicitly and validators enforce it.
-   `exclusiveMinimum: true` (a boolean in 2.0-era JSON Schema dialect) becomes a numeric value under draft 2020-12.

## Step 8: examples and descriptions

2.0 allowed a single `example` on schemas; 3.x adds a media-type-level `examples` map (named examples with summaries). Migration is the moment to replace the classic `"string"` and `123` placeholders with realistic payloads — every mock and SDK downstream consumes them. Also move any `x-*` vendor extensions deliberately: keep the ones your tooling uses, and check whether 3.1/3.2 now covers them natively (webhooks, content encodings).

## Step 9: validate against the real service

A converted document that validates syntactically can still be fiction. Two checks close the gap:

1.  **Diff against traffic.** Replay recorded requests/responses (an HAR export works) and validate payloads against the converted schemas. Nullability and missing required fields show up immediately.
2.  **Generate a client and call the API.** Regenerate the TypeScript or Java client, run the smoke tests, and compare against the old client. A field typed wrong is obvious the moment code compiles differently.

If the service exists but the spec was neglected, consider scanning the codebase with an AST-based extractor and merging its findings into the converted document — the scan catches endpoints added to the code but never to the 2.0 file, which is nearly always some.

## Step 10: wire the new document into the lifecycle

A migration that ends with a file in a wiki will rot again by next quarter. On the day of cutover:

-   Put the spec in git with a lint/validation step in CI.
-   Add breaking-change detection so future reviews show the diff.
-   Render docs, generate mocks, and — if agents touch this API — serve the document as an MCP endpoint.
-   Pin the OpenAPI version in the document and in every consumer's toolchain.

The migration is complete when the spec is consumed, not when it validates.

Powerduck opens both 2.0 and 3.x documents, assists the conversion review with explicit gap flags, and then drives mocks, scenario tests, docs, and MCP from the finished 3.2 file; the [demo](https://www.powerduck.com/app/?ref=powerduck.com) has a sample spec to test the result against.

**What to read next:** [OpenAPI 3.2 in 2026: what changed for SSE and AI agents](https://www.powerduck.com/blog/openapi-3-2-what-changed-sse-ai/) covers the target version in depth, and [detect breaking API changes in CI](https://www.powerduck.com/blog/detect-breaking-api-changes-openapi-diff-ci/) sets up the post-migration gate.

