npdlint
A fast, modular, structurally-aware linter for numpydoc-style docstrings.
It finds its own files, keeps every check as a separate rule, and lets you vary
the rules by the kind of thing being documented, so a @property, a private
helper and a public function can each be held to a different standard.
$ npdlint check
src/shapes.py:12:5: PT01 Property docstring does not match the type-line form (<type>: <summary>)
src/shapes.py:40:1: PR04 Parameter "radius" has no type
Found 2 issues in 18 files
Why
numpydoc lint has the right checks but an awkward shape: it takes a list of
files rather than a project, every check lives inside one function, and the only
way to vary behaviour is a regex over dotted names. That pushes projects into
shell pipelines like fd . src/ -E __init__.py --extension py | xargs numpydoc lint.
This tool keeps numpydoc's checks and error codes, and adds:
- Discovery. Point it at a directory, or nothing at all. It honours
.gitignore. - Rules as objects. Each check is a class with a code, a name, and the kinds of object it applies to. Third-party rules load from entry points or a local file.
- Scope blocks. Select and ignore rules by kind, decorator, visibility, qualified name, path, and more.
- Property conventions. Built-in support for the
type: summaryform that numpydoc cannot express. - A drop-in migration. An existing
[tool.numpydoc_validation]table is read as-is, so adopting it changes no configuration on day one. - Dataclass awareness. Fields of a
@dataclassare the constructor's parameters, inherited ones included, so documenting them is not an error.
Install
pip install npdlint
Usage
npdlint check [PATHS...] # lint; PATHS defaults to the configured include
npdlint rule PR # describe rules, by code or prefix
npdlint explain src/x.py:42 # show which rules apply there, and why
check is the default command, so npdlint src works too.
Exit codes are 0 when clean, 1 when violations were found, and 2 when the tool could not run.
Output formats
--output-format takes concise (default), full, json, github, or
pylint. Use github in CI to get inline annotations on the pull request.
--statistics prints counts per rule, which is the quickest way to decide what
to ignore when adopting the tool on an existing codebase.
Migrating from numpydoc
An existing [tool.numpydoc_validation] table is read as-is, so a project
already configured for numpydoc lint runs without touching its configuration:
$ npdlint check
The table is interpreted with numpydoc's semantics, not this tool's.
| numpydoc setting | Behaviour |
|---|---|
checks |
An allow-list, unless it contains "all", in which case it is every numpydoc check minus the ones listed |
exclude |
Regular expressions searched against the object's numpydoc name, which is rooted at the file stem rather than the package path |
exclude_files |
Regular expressions anchored at the start of the file path |
override_<CODE> |
Suppresses one check for an object when the pattern is found in its docstring |
setup.cfg with a [tool:numpydoc_validation] section works too, and as in
numpydoc it is only consulted when there is no pyproject.toml.
checks = ["all"] means the 37 checks numpydoc defines and no more, so
adopting this tool never silently turns on rules a project has not asked for.
The PT and DS rules are opt-in.
Anything written in [tool.npdlint] wins over the legacy table, so a
project can migrate one setting at a time. Set numpydoc-compat = false to
ignore the legacy table entirely.
The same three features exist natively, under clearer names:
[tool.npdlint]
exclude-object-patterns = ['\.__repr__$'] # regex on the object name
exclude-file-patterns = ['^generated/'] # regex on the path, anchored
[tool.npdlint.overrides]
SS05 = ['^Process ', '^Access '] # regex found in the docstring
Note that exclude in [tool.npdlint] is a gitignore-style glob, not
a regular expression. exclude-file-patterns is the regex equivalent.
What is left in the workflow
File selection that used to live in the shell moves into configuration:
[tool.npdlint]
include = ["mypackage"]
extend-exclude = ["mypackage/vendored.py"]
Then the CI step is one line with no project-specific data in it.
Configuration
Everything lives in pyproject.toml under [tool.npdlint]. The tool was
called numpydoc-linter to begin with, and [tool.numpydoc-linter] is still
read when no [tool.npdlint] table is present, with a warning on stderr
naming the file. Where both are present the current name wins outright, since
merging the two would make the effective configuration impossible to read off
the file.
[tool.npdlint]
include = ["src"]
extend-exclude = ["**/tests/**"]
select = ["ALL"]
ignore = ["ES01", "SA01", "EX01"]
[tool.npdlint.per-file-ignores]
"**/__init__.py" = ["GL08"]
# Private helpers are not part of the public API, so they need no docstring.
[[tool.npdlint.scope]]
match = { private = true }
skip = true
# Properties read as attributes, so document them as "type: summary".
[[tool.npdlint.scope]]
match = { kind = "property" }
extend-ignore = ["PR", "RT", "ES01"]
extend-select = ["PT"]
property-form = "type-line"
# Dunder methods only need to exist.
[[tool.npdlint.scope]]
match = { kind = "any-method", dunder = true }
select = ["GL08"]
Excluding files
Directories like .git, .venv, build and __pycache__ are excluded by
default, and .gitignore is honoured. Following ruff, extend-exclude adds to
the built-in list while exclude replaces it, so reach for extend-exclude
unless you really mean to walk everything.
Selecting rules
Selectors follow ruff. ALL matches everything, PR matches a family, PR0 a
partial code, and PR04 one rule. Where two selectors both match, the more
specific wins; at equal specificity an ignore beats a select.
Scope blocks
Blocks are evaluated top to bottom. Each matching block layers its overrides on
the result of the previous one, so ordering is explicit rather than a precedence
puzzle. Within a block the order is: skip, select, ignore, extend-select,
extend-ignore.
A match table may combine any of these, and all of them must hold:
| Key | Type | Matches on |
|---|---|---|
kind |
string or list | module, class, function, method, property, setter, classmethod, staticmethod, or the groups callable, any-method, any |
name |
regex | the bare name |
qualname |
regex | the dotted name from the module root |
path |
glob or list | the file, relative to the project root |
decorator |
string or list | any decorator, by dotted name |
parent-kind |
string or list | the kind of the enclosing object |
in-all |
bool | membership of the module's __all__ |
private |
bool | name starts with one underscore |
dunder |
bool | name is __like_this__ |
abstract |
bool | decorated @abstractmethod |
stub |
bool | body is only pass, ..., or raise NotImplementedError |
overload |
bool | decorated @overload |
override |
bool | decorated @override |
async |
bool | declared async def |
generator |
bool | contains a yield |
returns-value |
bool | contains a return <expr> |
has-docstring |
bool | has a docstring at all |
dataclass |
bool | a decorator synthesises the constructor |
nested |
bool | defined inside a function body |
Run npdlint explain path.py:42 when a rule fires and you expected it not to.
It prints the target's flags and every layer of the resolution.
Property forms
property-form selects what a @property docstring should look like.
| Form | Shape |
|---|---|
returns-section |
plain numpydoc, with a Returns section; the default |
type-line |
float: The radius in metres. |
summary-only |
a summary with no type |
{ regex = "..." } |
your own, with optional type and summary groups |
PT02 compares a captured type group against the return annotation, so
int: ... on a method annotated -> str is reported.
Dataclasses
A @dataclass has no __init__ in the syntax tree, so numpydoc sees an empty
signature and calls every documented field an unknown parameter. This linter
derives the constructor the decorator will synthesise: annotated attributes in
order, ClassVar and field(init=False) excluded, InitVar unwrapped, and
fields inherited from a base class in the same file merged in first. Where a
base class lives in another module its fields cannot be seen, so documented
names that are not accounted for are left alone rather than reported.
A field is a constructor parameter and an attribute at the same time, and real
code documents it in any of three places. All three count: the Parameters
section, the Attributes section, and an inline attribute docstring, which is
a bare string literal directly below the field.
@dataclass
class Point:
"""
A point.
Attributes
----------
x : int
The x.
"""
x: int
y: int
"""The y."""
Set dataclass-fields-section = "parameters" to accept only the Parameters
section, as numpydoc would. Names documented under Attributes are not treated
as declared parameters, so a property or ClassVar described there is not
reported as an unknown parameter and has no position to be out of order.
attrs and pydantic dataclasses are recognised too; add more with
dataclass-decorators.
Stub files
.pyi files are not linted by default: a stub carries the signatures while the
implementation carries the docstrings. Set include-stubs = true to lint them.
Private parameters
By default every parameter needs documenting, as numpydoc requires. Set
private-parameters = "ignore" to exempt underscore-prefixed ones, which is
useful for dataclasses that carry private caches. The exemption is symmetric:
an exempt parameter is neither required nor rejected.
Suppressing inline
# noqa: PR04,RT01 suppresses those rules for the object it sits on, and a bare
# noqa suppresses everything. numpydoc's # numpydoc ignore=PR04 spelling is
accepted too, so existing suppressions keep working.
The comment may go anywhere in the signature, which matters because the natural
place for it is rarely the def line:
@overload
def f(
x: int,
) -> int: # numpydoc ignore=GL08
...
The docstring's closing line works as well. A comment inside the body does not
count, which is where this differs from numpydoc: numpydoc attaches a
suppression to the most recent def it has seen, so a comment buried deep in a
function body silently suppresses that function's checks.
Suppressions that suppress nothing
A suppression outlives the problem it was written for. The docstring gets its
Returns section, nobody deletes the # numpydoc ignore=RT01 above it, and
from then on the comment is both useless and misleading: the next reader takes
it as evidence that the rule still fires there, and it hides the rule if the
docstring later regresses.
NQ01 reports a comment that did no work, and NQ02 one that names a rule
code no rule answers to:
src/geometry.py:41:23: NQ01 Unnecessary suppression comment: RT01 was not reported for this object
src/geometry.py:88:19: NQ01 Unnecessary suppression comment: it applies to no documented object
src/geometry.py:96:19: NQ02 Suppression comment names unknown rule code RT09
Both are on by default, and both are ordinary rules: ignore = ["NQ"] in
[tool.npdlint], a per-file-ignores entry, or --ignore NQ on the
command line turns them off. They are the only rules that judge the file
rather than an object in it, so scope blocks do not apply to them.
What counts as unnecessary is deliberately conservative, because the cost of a false positive is a user deleting a comment they needed:
- A rule your configuration already disabled is never judged. Running with
a narrowed
--select, askipping scope block,exclude-object-patterns, or anoverridespattern leaves the comments for those rules alone, so a partial run cannot condemn them. # noqais shared with other linters, so only codes registered here are judged in one.# noqa: F401is ruff's business, and a bare# noqais left alone entirely.# numpydoc ignoreis addressed to this tool alone, so everything in one is judged, a bare# numpydoc ignoreincluded.- Only the unused codes are named.
# numpydoc ignore=GL08,RT01on an undocumented function reportsRT01and says nothing aboutGL08. - A comment can exempt itself, by naming the rule:
# numpydoc ignore=RT01,NQ01keeps a suppression you want to hold on to.
A comment that applies to no documented object is reported too. That is the
other half of the divergence above: numpydoc would attach a suppression
written in a function body to the function, and this linter does not, so
NQ01 names the comments that stopped working when you migrated rather than
letting them fail quietly.
Continuous integration
Replacing a fd | xargs numpydoc lint pipeline:
- uses: actions/setup-python@v5
- run: pip install npdlint
- run: npdlint check --output-format github
What used to be workflow inputs, such as the package directory, extra
excludes, and the __init__.py exemption, move into pyproject.toml, so the
workflow step carries no project-specific data.
Or as a pre-commit hook:
repos:
- repo: https://github.com/ucgmsim/numpydoc-linter
rev: v0.1.0
hooks:
- id: npdlint
Rules
Codes GL, SS, ES, PR, RT, YD, SA and EX are ports of numpydoc's
checks and keep its codes and wording, so existing ignore lists carry over
unchanged. PT covers property forms, DS01 reports a docstring the parser
cannot read, and NQ reports suppression comments that are not doing anything.
Run npdlint rule for the full list.
Differences from numpydoc
A differential test lints a shared corpus with both tools and requires them to agree, so every difference below is deliberate and pinned down by a test.
-
GL08on__init__. numpydoc documents that a properly formatted class docstring silences this for an undocumented constructor. Its AST hook gets this wrong in both directions, reporting it when the class does document the parameters and skipping it when the class has no docstring at all. This linter implements the documented behaviour. -
PR01andPR02on dataclasses. See above. numpydoc's own test suite contains a dataclass annotated "As param1 is not documented this class should also raise PR01", which numpydoc does not raise and this linter does. -
YD01onyield from. numpydoc's generator check looks only for a bareyieldstatement among a function's direct children, so it missesyield fromand any yield inside a branch or loop. -
Nested objects. numpydoc's visitor stops at anything that is not a module, class or function, so a function defined inside an
ifor aforis never checked while one defined directly in a body is. This linter is consistent. To keep numpydoc's effective behaviour, skip them:[[tool.npdlint.scope]] match = { nested = true } skip = true
-
Malformed docstrings. A docstring that breaks the parser makes numpydoc abandon the whole file. This linter reports
DS01and carries on. -
Suppression syntax. A bare
# noqaor# numpydoc ignore, with no codes, suppresses everything on its line here; numpydoc recognises only# numpydoc ignore=CODE. TheNQrules above have no numpydoc equivalent, and a[tool.numpydoc_validation]table never enables them.
Performance
On 1076 files of third-party Python, with every rule enabled:
| Tool | Wall clock |
|---|---|
numpydoc lint, 1 process |
30.1 s |
npdlint, 1 process |
7.5 s |
npdlint, 8 processes |
3.0 s |
numpydoc also failed to parse 4 of those files.
Writing your own rules
Point at a local file, or ship a package that advertises the
npdlint.rules entry point.
[tool.npdlint]
plugins = ["tools/doc_rules.py"]
from npdlint.rules import BaseRule, registry
from npdlint.targets import Kind
@registry.register
class SummaryMentionsUnits(BaseRule):
code = "X001"
name = "summary-mentions-units"
summary = "Physical quantities should state their units."
kinds = frozenset({Kind.PROPERTY})
def check(self, target, ctx):
doc = target.docstring
if "metres" not in doc.summary and target.name.endswith("_m"):
yield self.diagnostic(target, "summary should state the units")
Subclass BaseFileRule instead for a check on the file as a whole. It runs
once, after every object has been checked, and implements check_file(ctx)
rather than check; ctx.source is the parsed file and self.at(ctx, line, col, message) builds the diagnostic. The NQ rules are written this way.
Licence
BSD 3-Clause. src/npdlint/_vendor/docscrape.py is vendored from
numpydoc under the same licence; its copyright notice is kept alongside it.
Release files for npdlint 1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| npdlint-1.tar.gz | 117.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| npdlint-1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 198.5 kB
Release files / npdlint-1.tar.gz
| Download URL | npdlint-1.tar.gz |
|---|---|
| Size | 117.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f1b2d5c6ae86f2bfb98d21cfb2d3efacc933890cb43a65229095176c328e4ea7
|
|
BLAKE2b-256 checksum How to use checksums |
0d8d205859bdef95c515cca6d306b633f86a017167351d445601c69b1dcf7c52
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.
Transparency logRelease files / npdlint-1-py3-none-any.whl
| Download URL | npdlint-1-py3-none-any.whl |
|---|---|
| Size | 80.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
215bea1f515c9c6be6f80565663ba1f2ea0a7ecc0b2ba6cf0f384484979575ed
|
|
BLAKE2b-256 checksum How to use checksums |
1ba5c06c8fb9d23eb05dcf21395f5397ec2564da870d821c661d3b6f3c6b90a3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.
Transparency log