# How to generate Python docstrings and type hints from an API spec

> Generate a Python client whose help() text and type hints come from the OpenAPI document, then measure which descriptions survived, are empty, or repeat the name.

Source: https://voxgig.com/howto/python-docstrings-and-type-hints-from-a-spec

- Audience: api-producer
- Level: intermediate
- Languages: python
- Verified: 2026-09-25
- Published: 2026-09-25

## Short answer

Run `openapi-python-client` for the operations and `datamodel-code-generator` for plain dataclasses, both from the same OpenAPI document, so help() and the IDE tooltip carry the text the document carries. Then read the output before shipping it: parameter descriptions are taken from the parameter's schema, not the parameter, and a description that repeats the name is copied faithfully into every docstring.

---
## You will need

Python 3.12, an OpenAPI 3 document with `description` fields, and a docstring style. The generated
code and the hand-written comparison here use the
[Google style](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings),
because both generators emit its `Args:` and `Attributes:` sections. The
[numpydoc format](https://numpydoc.readthedocs.io/en/latest/format.html), the other common
choice, is not used here because neither generator writes it. Verified 2026-09-25 against Python 3.12.3,
`openapi-python-client` 0.29.1, `datamodel-code-generator` 0.83.0 and `ruff` 0.16.9. The runtime hints
follow [PEP 604](https://peps.python.org/pep-0604/), so a nullable string is `str | None`.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [`datamodel-code-generator`](https://datamodel-code-generator.koxudaxi.dev/) | You want the models as dataclasses, Pydantic, or TypedDict, with the schema text on them | Models only, no operations, and attribute docstrings that `help()` never shows | You need the request functions generated too |
| [Hand-written docstrings](https://peps.python.org/pep-0257/) | A small client, and prose that explains rather than restates the field | Every rename in the document is an edit nobody is reminded to make | The document changes more than once a quarter |
| [`openapi-python-client`](https://github.com/openapi-generators/openapi-python-client#readme) | A whole client, one function per operation, docstrings and hints from the document | Descriptions read like the document, empty `Args` entries where the document put them elsewhere, and `httpx` plus `attrs` at runtime | You cannot take the runtime dependencies, or the prose has to read well |

The generators win on currency and lose on prose, and they lose in a specific way. A generated
docstring is exactly as good as the sentence in the document, so a document that says `The id.`
produces a client that says `The id.` fifty times. The hand-written client reads better on the
day it is written and gives you no signal when it stops being true.

## Generate once and read what came out

Both generators run from one script, into a `generated/` directory the checks below read with
the standard library alone. The script pins the versions and puts the virtual environment first on
the PATH, because `openapi-python-client` runs `ruff` after generating and takes whichever copy it
finds first.

```bash title="generate.sh"
PATH="$VENV/bin:$PATH" "$VENV/bin/openapi-python-client" generate \
  --path openapi.json --output-path generated/opc --meta none --overwrite
rm -rf generated/opc/.ruff_cache

"$VENV/bin/datamodel-codegen" \
  --input openapi.json --input-file-type openapi \
  --output generated/models.py \
  --output-model-type dataclasses.dataclass \
  --use-schema-description --use-field-description \
  --target-python-version 3.12 --formatters builtin --disable-timestamp
```

The document has three operations and five schemas. Two of its descriptions are written to be
useful, and two are written the way most descriptions are.

```json title="openapi.json"
          { "name": "site", "in": "query", "required": false, "description": "Only meters installed at this site, such as hq or depot.", "schema": { "type": "string" } },
          { "name": "limit", "in": "query", "required": false, "description": "The limit.", "schema": { "type": "integer", "default": 50, "minimum": 1, "maximum": 200 } }
```

`datamodel-code-generator` turns each schema into a dataclass with the schema description as the
class docstring and each property description as a string literal under its field.

```python title="generated/models.py"
@dataclass
class Meter:
    """
    A physical meter and where it is installed.
    """

    id: str
    """
    The id.
    """
    site: str
    """
    Name of the site the meter is installed at.
    """
    kind: Kind
    """
    What the meter measures.
    """
    decommissioned_at: str | None
    """
    When the meter was taken out of service, or null while it is still in service.
    """
    installed_at: str | None = None
    """
    When the meter was installed. Absent for meters imported from the legacy system.
    """
```

Read the last two fields together. `decommissioned_at` is required and nullable in the document,
`installed_at` is optional, and the dataclass gives both the same hint and differs only in the
default. `openapi-python-client` keeps the distinction, with `datetime.datetime | None` for the
first and `datetime.datetime | Unset = UNSET` for the second, at the cost of a sentinel type your
callers have to learn.

## Put the description where the generator reads it

Every parameter entry in the generated `Args:` sections came out without a description. The
document describes `site` and `limit` on the
[Parameter Object](https://spec.openapis.org/oas/v3.1.0.html#parameter-object), which is where
the specification puts a parameter's description. `openapi-python-client` 0.29.1 reads the
description from the parameter's `schema` instead. Move the sentence into the schema and it
appears; leave it where the specification says and the tooltip shows `site (str | Unset):` and
nothing after the colon. That was measured by generating twice, once with each placement.

```bash
python3 help_demo.py
```

```text output
== hand-written: help(MetersClient.get_meter)
Python Library Documentation: function get_meter in module meters_client

get_meter(self, meter_id: 'str') -> 'Meter'
    Load one meter by id.

    Args:
        meter_id: The identifier from a previous ``list_meters`` call or
            from the meter's label, such as ``mtr_01HZX4``.

    Returns:
        The meter, including ``decommissioned_at`` when it has been
        taken out of service.

    Raises:
        ApiError: If no meter has that id (status 404), or on any other
            error document.

== datamodel-codegen: help(models.Meter), first lines
Python Library Documentation: class Meter in module models

class Meter(builtins.object)
 |  Meter(id: 'str', site: 'str', kind: 'Kind', decommissioned_at: 'str | None', installed_at: 'str | None' = None) -> None
 |
 |  A physical meter and where it is installed.
 |
 |  Methods defined here:

== openapi-python-client: generated/opc/api/default/get_meter.py, sync
sync(meter_id: str, *, client: AuthenticatedClient | Client) -> Error | Meter | None
    Load one meter

 Loads one meter by id, including the date it was decommissioned if it has been.

Args:
    meter_id (str):

Raises:
    errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
    httpx.TimeoutException: If the request takes longer than Client.timeout.

Returns:
    Error | Meter
```

Three things in that output are the whole comparison. The hand-written entry explains where a
`meter_id` comes from, which no document field says. The dataclass shows its class docstring and
not one of the five attribute descriptions, because [`help()`](https://docs.python.org/3/builtins/functions.html#help)
renders what [`pydoc`](https://docs.python.org/3/library/pydoc.html) finds in `__doc__`, and a
string literal under a field is not in `__doc__`.[^1] The generated function carries the
operation's summary and description and an `Args:` entry with nothing in it.

The third block was read from the source with [`ast`](https://docs.python.org/3/library/ast.html)
rather than imported, because the generated module imports `httpx` and this page's checks run
with the standard library only. `help()` prints the same docstring under the same signature.

## Count what the docstrings say

`docstrings.py` parses each file with `ast`, reads the `Args:` and `Attributes:` sections, and
gives every parameter one of four verdicts. The one worth automating is the third.

```python title="docstrings.py"
def repeats_the_name(name: str, description: str) -> bool:
    """True when a description is made only of the parameter's own words."""
    words = {w for w in re.findall(r"[a-z0-9]+", description.lower())} - FILLER
    return bool(words) and words <= set(name.lower().split("_"))
```

```bash
python3 docstrings.py
```

```text output
generated with datamodel-code-generator 0.83.0, openapi-python-client 0.29.1, ruff 0.16.9

file                                         params  described  empty  name only  undocumented  attr docstrings
generated/opc/api/default/get_meter.py       8       0          4      0          4             0
generated/opc/api/default/list_meters.py     12      0          8      0          4             0
generated/opc/api/default/record_reading.py  12      4          4      0          4             0
generated/opc/models/meter.py                6       4          0      1          1             0
generated/opc/models/reading.py              6       3          0      2          1             0
generated/models.py                          17      14         0      3          0             17
meters_client.py                             12      12         0      0          0             0

empty: asyncio.limit, asyncio.meter_id, asyncio.site, asyncio_detailed.limit, asyncio_detailed.meter_id, asyncio_detailed.site, sync.limit, sync.meter_id, sync.site, sync_detailed.limit, sync_detailed.meter_id, sync_detailed.site
name only: Meter.id, Reading.id, Reading.meter_id
undocumented: asyncio.client, asyncio_detailed.client, from_dict.src_dict, sync.client, sync_detailed.client

openapi.json descriptions that only repeat the name: Meter.id, Reading.id, Reading.meter_id, list_meters(limit), get_meter(meter_id), record_reading(meter_id)
```

The last line is the one to act on. Every generated file downstream repeats the six descriptions
in the document that add nothing to the name they describe. `The meter id.` is not
documentation, it is the name with spaces in it, and a generator cannot tell the difference. Fix
the sentence in the document and both generators pick it up on the next run; fix it in the output
and the next run puts it back. The check follows local `$ref`s and shared path parameters,
so it reads your document too.

`record_reading` scores four described parameters because its body properties carry real
sentences in the schema, which is where the generator looks. `limit` is empty because its
docstring entry reads `Default: 50.` and nothing else, which the report treats as no description.

## Lint the docstrings, not the prose

[ruff](https://docs.astral.sh/ruff/rules/#pydocstyle-d) carries the pydocstyle rules, and its
[`convention`](https://docs.astral.sh/ruff/settings/#lint_pydocstyle_convention) setting turns
off the rules that fight the chosen style. This configuration is the whole of it.

```toml title="pydocstyle.toml"
target-version = "py312"

[lint]
select = ["D"]

[lint.pydocstyle]
convention = "google"
```

The file is not named `ruff.toml` on purpose. `openapi-python-client` runs `ruff check --fix-only`
and `ruff format` on its own output, ruff discovers the nearest configuration file, and a
`ruff.toml` in the parent directory changed the generated code. The config is passed with
`--config pydocstyle.toml` instead. ruff is not installed on the runner that checks this page, so
its findings are recorded here rather than captured. On the three operation modules, 39 findings.
Twelve are [D417](https://docs.astral.sh/ruff/rules/undocumented-param/), for `client`, which
has no entry, and the empty `site` or `meter_id` entry. Another 12 are D415 because the summaries
end without a period, 12 are D202 for the blank line after each docstring, and 3 are D100 for
the missing module docstring.[^2] On `generated/models.py`, 12 findings, ten about the shape of
the triple-quoted blocks and two for missing docstrings. On `meters_client.py`, none.

D417 misses `limit`. Its entry reads `Default: 50.`, which is text, so the rule is satisfied and
the report is not. A linter checks that something was written, and only a reader checks
that it says anything.

## Check it worked

Thirteen tests pin the verdicts and exercise the hand-written client against a fake opener and a
server that never answers. A regeneration with a different generator version fails here rather
than in a tooltip.

```python title="test_docstrings.py"
    def test_parameter_descriptions_do_not_reach_the_args_section(self):
        rep = report(Path("generated/opc/api/default/get_meter.py"))
        empty = {f"{f.where}.{f.name}" for f in rep.findings if f.verdict == "empty"}
        self.assertEqual(empty, {"sync.meter_id", "sync_detailed.meter_id", "asyncio.meter_id", "asyncio_detailed.meter_id"})
```

```bash
python3 test_docstrings.py
```

```text output
test_model_attributes_carry_the_schema_descriptions_noise_included (__main__.GeneratedClient.test_model_attributes_carry_the_schema_descriptions_noise_included) ... ok
test_parameter_descriptions_do_not_reach_the_args_section (__main__.GeneratedClient.test_parameter_descriptions_do_not_reach_the_args_section) ... ok
test_dataclasses_carry_attribute_docstrings_that_help_does_not_show (__main__.GeneratedModels.test_dataclasses_carry_attribute_docstrings_that_help_does_not_show) ... ok
test_every_parameter_is_described (__main__.HandWritten.test_every_parameter_is_described) ... ok
test_a_server_that_never_answers_is_given_up_on (__main__.HandWrittenClient.test_a_server_that_never_answers_is_given_up_on) ... ok
test_a_slash_in_a_meter_id_stays_inside_one_path_segment (__main__.HandWrittenClient.test_a_slash_in_a_meter_id_stays_inside_one_path_segment) ... ok
test_absent_and_null_both_become_none_and_a_reading_is_posted (__main__.HandWrittenClient.test_absent_and_null_both_become_none_and_a_reading_is_posted) ... ok
test_an_error_document_and_a_bare_error_both_raise_api_error (__main__.HandWrittenClient.test_an_error_document_and_a_bare_error_both_raise_api_error) ... ok
test_a_description_made_of_the_name_is_noise (__main__.RepeatsTheName.test_a_description_made_of_the_name_is_noise) ... ok
test_a_description_with_a_new_word_is_not (__main__.RepeatsTheName.test_a_description_with_a_new_word_is_not) ... ok
test_an_empty_description_is_not_noise_either (__main__.RepeatsTheName.test_an_empty_description_is_not_noise_either) ... ok
test_the_noise_starts_in_the_document (__main__.Spec.test_the_noise_starts_in_the_document) ... ok
test_the_report_reads_shared_and_referenced_parameters (__main__.Spec.test_the_report_reads_shared_and_referenced_parameters) ... ok
```

The test that matters most is the first. It asserts the empty entries are exactly the four path
parameters. A generator release that starts reading the Parameter Object description turns it
red, and the page gets re-verified instead of staying wrong.

## When it goes wrong

Every `Args:` entry is empty. The descriptions sit on the Parameter Object and the generator reads
the schema. Copy each sentence into the parameter's `schema`, which the specification allows, or
accept empty entries for path and query parameters until the generator changes.

The dataclass hints say `str | None` for a field that is never null. `installed_at` is optional,
not nullable, and the `datamodel-code-generator` dataclass output spells both the same way with a
default of `None`. If the difference matters to callers, use the `openapi-python-client` models,
whose `Unset` sentinel keeps it, or write the distinction into the field's description.

A hand edit to a docstring disappears. Both generators overwrite their output on every run, and
`openapi-python-client` refuses to write into an existing directory without `--overwrite`, which
is the last warning you get. Edit the document, or use the generator's `custom-template-path`
option, and keep the output directory out of the places people edit.

The document's descriptions are one word each. The report's last line names them. Rewrite those
before regenerating, because a generator multiplies a bad sentence by the number of places it is
used.

## When not to do this

Do not generate docstrings from a document whose descriptions were never written for a reader.
A schema that says `The id.` on every id produces a client that says it on every model and every
function, and the generated prose is then a liability rather than an asset. Write the sentences in
the document first, which is where they belong anyway, and run the report on the document before
running the generators.

Do not hand-write docstrings for a client of an API that changes monthly. The hand-written
`get_meter` on this page reads better than either generated one, and every rename in the document is a
silent edit somebody has to remember. The comparison holds only while someone is looking.

Do not ship the `datamodel-code-generator` attribute docstrings as the only documentation of a field.
They are a convention that editors read and `help()` does not, so a caller at a REPL sees the
class docstring and nothing about the fields.

Do not treat a green pydocstyle run as evidence the docstrings say anything. D417 accepts
`Default: 50.` as a description.

## Related how-tos

- [Decide between generating and hand-writing a client](/howto/generate-or-hand-write-a-small-api-client)

- [Ship a changelog inside your published package](/howto/ship-a-changelog-inside-the-package)

## Last verified

Verified 2026-09-25 against Python 3.12.3, `openapi-python-client` 0.29.1,
`datamodel-code-generator` 0.83.0 and `ruff` 0.16.9. Every output block is what the command preceding it printed, run in the
page's code directory with the standard library. The generation itself is not repeated by the
checks: `generate.sh` was run once, twice in fact, to confirm that two runs produce the same bytes,
and its output is committed under `generated/`. The ruff figures are from a local run and are
quoted, not captured.

[^1]: [PEP 257](https://peps.python.org/pep-0257/) names the convention: a string literal
directly after a simple assignment at the top level of a module, class, or `__init__` is an
"attribute docstring". The PEP says in the same breath that such strings are not accessible as
runtime attributes and may be extracted by software tools. The proposal to make them real,
[PEP 224](https://peps.python.org/pep-0224/), was written in August 2000 for Python 2.1 and
rejected. The name survived the rejection, which is how a docstring came to exist that `help()`
has never read.

[^2]: The ruff documentation's own example of the
[`convention`](https://docs.astral.sh/ruff/settings/#lint_pydocstyle_convention) setting selects
every `D` rule, sets the Google convention, and then ignores D417. The comment beside it explains
that the rule requires documentation for every function parameter. The one rule that would have
noticed the empty entries on this page is the one the worked example shows how to switch off.