Skip to content

Generating a Python SDK from an OpenAPI Specification

If your API ships an OpenAPI spec, OpenAPI Generator can build the Python client from it. You don't have to hand-write models and endpoint wrappers.

Most tutorials stop at generate. After that you're still choosing generator options, picking a spec with enough surface area to matter, locking dependencies, running smoke tests, and keeping the repo sane when the spec changes again.

This walkthrough uses a compact OpenAPI 3.1 example. I use Astral uv for lock, sync, test, and build because that's what I run locally. The generator config and generate steps are the same with pip or Poetry.

Prerequisites

You need three things:

Start with a Useful Specification

Start with an API specification that'll actually exercise the generator: tags, pagination, request bodies, enums, timestamps, and a reusable error schema, not one of the generic "Pet Store" examples.

Here is a compact OpenAPI 3.1 example:

openapi: 3.1.0
info:
  title: Dispatch API
  version: 1.0.0
  summary: A compact task-tracking API for SDK generation examples.
  description: |
    This example stays small enough for a blog post while still looking like a real API.
    It includes pagination, reusable schemas, enums, timestamps, bearer auth, and a
    structured error response.
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
servers:
  - url: https://api.example.com/v1
    description: Production
tags:
  - name: Tasks
    description: Manage tracked work items.
paths:
  /tasks:
    get:
      tags: [Tasks]
      operationId: listTasks
      summary: List tasks
      parameters:
        - name: status
          in: query
          required: false
          description: Filter tasks by workflow state.
          schema:
            $ref: "#/components/schemas/TaskStatus"
        - name: limit
          in: query
          required: false
          description: Maximum number of tasks to return.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: cursor
          in: query
          required: false
          description: Opaque pagination cursor from a previous response.
          schema:
            type: string
      responses:
        "200":
          description: A page of tasks.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TaskListResponse"
        "401":
          description: Authentication failed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      tags: [Tasks]
      operationId: createTask
      summary: Create a task
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateTaskRequest"
      responses:
        "201":
          description: Task created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Task"
        "400":
          description: Invalid request body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /tasks/{taskId}:
    get:
      tags: [Tasks]
      operationId: getTask
      summary: Get a task by ID
      parameters:
        - $ref: "#/components/parameters/TaskId"
      responses:
        "200":
          description: The requested task.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Task"
        "404":
          description: Task not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    patch:
      tags: [Tasks]
      operationId: updateTask
      summary: Update a task
      parameters:
        - $ref: "#/components/parameters/TaskId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateTaskRequest"
      responses:
        "200":
          description: Task updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Task"
        "404":
          description: Task not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  parameters:
    TaskId:
      name: taskId
      in: path
      required: true
      description: Stable task identifier.
      schema:
        type: string
        format: uuid
  schemas:
    TaskStatus:
      type: string
      enum:
        - queued
        - running
        - completed
        - failed
    Task:
      type: object
      required:
        - id
        - title
        - status
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
          minLength: 1
          maxLength: 120
        description:
          type:
            - string
            - "null"
        status:
          $ref: "#/components/schemas/TaskStatus"
        labels:
          type: array
          items:
            type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    CreateTaskRequest:
      type: object
      required:
        - title
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 120
        description:
          type:
            - string
            - "null"
        labels:
          type: array
          items:
            type: string
    UpdateTaskRequest:
      type: object
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 120
        description:
          type:
            - string
            - "null"
        status:
          $ref: "#/components/schemas/TaskStatus"
        labels:
          type: array
          items:
            type: string
    TaskListResponse:
      type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Task"
        nextCursor:
          type:
            - string
            - "null"
    Error:
      type: object
      required:
        - code
        - message
      properties:
        code:
          type: string
        message:
          type: string
        requestId:
          type:
            - string
            - "null"
security:
  - bearerAuth: []

This example is small enough to walk through without pasting pages of YAML.

Configure the Python Generator

Create a generator configuration file and make the build system choice explicit:

{
  "packageName": "python_dispatch_sdk",
  "projectName": "pythn-dispatch-sdk",
  "packageVersion": "1.0.0",
  "packageUrl": "https://example.com/dispatch-sdk",
  "description": "Python SDK for the Dispatch API.",
  "author": "The Authors",
  "authorEmail": "[email protected]",
  "license": "MIT",
  "library": "httpx",
  "buildSystem": "hatchling",
  "hideGenerationTimestamp": true
}

In that configuration, these fields matter most:

  • packageName: the import name, so use snake_case
  • projectName: the distribution name, so use hyphens
  • library: httpx: the default I'd pick today unless you need urllib3 (see Requests vs. HTTPX)
  • buildSystem: hatchling: keeps the generated pyproject.toml aligned with current Python packaging practice
  • hideGenerationTimestamp: true: removes a useless source of churn from regenerated files

Don't flip disallowAdditionalPropertiesIfNotPresent without reading the docs first. OpenAPI Generator's Python generator docs call true the old behavior and false the spec-compliant one. It's a compatibility switch, not a free hardening flag.

Generate the SDK

With the spec and config in place, generate the client:

openapi-generator-cli generate \
  -i snippets/example-openapi-3.1-dispatch-api.yaml \
  -g python \
  -o ./client \
  -c snippets/openapi-generator-config-example.json

That produces a Python package, API classes, models, tests, and a pyproject.toml. The point is to treat that output as generated source, not something you hand-edit line by line.

When the upstream specification changes, rerun the same command and review the diff. If you're hand-editing generated files, you'll fight every regeneration.

Manage the Generated Package with uv

The generator hands you a pyproject.toml. Below I use uv to compile a lockfile, sync the environment, run tests, and build artifacts. Swap in pip or Poetry if that's what you already use.

Lock the generated dependencies:

cd client
uv pip compile pyproject.toml -o requirements.txt --upgrade

Create a local environment and sync it exactly to the lockfile:

uv venv
source .venv/bin/activate
uv pip sync requirements.txt

On Windows, activate with .venv\Scripts\activate instead.

Install the generated package plus test tools:

uv pip install -e . pytest pytest-cov
python -m pytest test -v

uv pip compile writes a lock you can review in requirements.txt. uv pip sync installs exactly that lock instead of whatever was already on the machine.

Build the Distribution

Once the smoke tests pass, build the package artifacts:

uv build --no-sources

That writes the source distribution and wheel into dist/.

Keep Your Repository Clean

Generated SDK repositories can collect a lot of junk unless you make the boundaries explicit.

At a minimum, ignore local environments, caches, build output, and generator metadata:

.venv/
__pycache__/
*.py[cod]
dist/
build/
.pytest_cache/
.coverage
htmlcov/
.openapi-generator/
git_push.sh

I'd also rewrite the generated README.md before publishing. The scaffold is fine for a first pass, but it's not documentation you should put your name on.

Optional: Template Overrides

If you need custom copyright or SPDX headers, use template overrides instead of editing generated files after the fact:

openapi-generator-cli author template -g python -o ./templates

From there, edit the copied Mustache templates and regenerate with -t ./templates. Keep the override directory small. The more templates you fork, the more work you'll inherit on generator upgrades.

Here is the generated workflow in one pass:

OpenAPI Python SDK Workflow Demo

Let the generator handle the scaffolding.

The specification quality, package boundaries, and how you ship releases are where the maintenance cost actually lives.

References