annotated-types turns typing.Annotated into a shared constraint vocabulary
Reusable constraint types to use with typing.Annotated
At a glance
- What is it?
- annotated-types is a small MIT licensed Python library of metadata objects, such as Gt, Len and Timezone, that libraries and applications can read from typing.Annotated annotations. It performs no runtime validation itself, which is both its speed advantage and its main trap for anyone expecting errors at call time.
- Who is it for?
- annotated-types fits teams that annotate types once and want several libraries to agree on what Gt, Len or Timezone mean, and it costs nothing at runtime because it validates nothing. It does not fit a project that expects a single library to enforce constraints at call time, since enforcement belongs to whatever library consumes the annotations.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly Python, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
A vocabulary for metadata that no library owns
PEP-593 added typing.Annotated as a way to attach context specific metadata to an existing type, with the rule that Annotated[T, x] should be treated as T by any tool that has no special logic for x. That rule leaves a gap. Two libraries can both read Annotated metadata and still disagree about what a bound means, or one library can ignore metadata entirely. annotated-types closes that gap by shipping the metadata objects themselves as a standalone, dependency free package.
The objects cover the constraints that show up over and over in schemas: lower and upper bounds on scalars, length limits on collections, a predicate marker for arbitrary runtime checks, timezone rules for datetime values, and units for quantities. The package describes how it intends each object to be interpreted, and it names the objects that implement the same thing differently.
What it deliberately does not do is validate. The documentation states that the package avoids runtime checks for performance, and that downstream implementors may raise an error, emit a warning, silently ignore a metadata item, or do anything else when they meet metadata they cannot use. So this is a protocol package, not a validator. If you are looking for something that rejects a bad value when you call a function, this is the wrong tool.
Bounds that do not demand the annotated type
Gt, Ge, Lt and Le express inclusive and exclusive bounds on orderable values. The values need not be numbers. They can be dates, times, strings or sets, and the boundary itself does not have to share the type of the annotated value, because the requirement is only that the two can be compared. The documentation gives Annotated[int, Gt(1.5)] as valid, which means an integer x such that x > 1.5.
Interval(gt, ge, lt, le) compresses an upper and a lower bound into one object. None attributes are ignored, and any attribute that is not None is read according to the single bound rules above.
That flexibility is also the trap. Because the boundary type is unconstrained, a typo such as a string passed where a number is expected fails later, in someone else's validator, with a message that will not point back to the annotation. The package does warn against constructing invalid combinations such as MultipleOf("non-numeric") or Annotated[int, Len(3)], and it says downstream implementors may raise, warn or ignore when they meet one. Read that as permission to be wrong quietly.
Len changed its upper bound in v0.4.0
Len, MinLen and MaxLen cover size limits. Len() implies that min_length is less than or equal to len(value) and len(value) is less than or equal to max_length, with both bounds inclusive. MinLen(x) is the same as Len(min_length=x) and MaxLen(y) is the same as Len(max_length=y). All three work with any type that supports len(value), so Annotated[str, MaxLen(10)] and Annotated[list, MinLen(3)] are both ordinary uses.
The version history matters here. In v0.4.0, min_inclusive was renamed to min_length with no change of meaning, while max_exclusive was renamed to max_length and the upper bound became inclusive instead of exclusive. The same change removed the recommendation that slices be read as Len, because a slice upper bound and a Len upper bound carry different meanings. A project that pinned an old annotated-types and one on v0.8.0 can therefore disagree about the same annotation, and the rename will show up as an attribute error in whichever version does not know the new name.
For an adapter that reads these objects generically, the safe move is to accept both spellings rather than assume min_length and max_length exist.
MultipleOf carries two incompatible readings
MultipleOf(multiple_of=x) is the one object in the package with a documented ambiguity. It might be read with Python semantics, where value % multiple_of == 0, or with JSON Schema semantics, where int(value / multiple_of) == value / multiple_of. The two agree on small positive integers and part company elsewhere.
The documentation warns that very large or non-integer numbers make it easy to cause silent data corruption through floating-point imprecision, and it asks libraries to document which interpretation they implement. That request is aimed at the consumer, not at the user of the library, so a reader of a given validation library cannot assume the answer. It is also the sharpest practical difference between using this package and writing your own constraint objects, because your own object would have exactly one meaning by construction.
If you adopt the package, treat MultipleOf as an area where you need to check the consuming library's behaviour by hand, not an area where the shared vocabulary has settled the question for you.
Timezone and Unit leave the interpretation to other libraries
Timezone expresses which timezones are allowed for a datetime or a time value. Timezone(None) requires a naive datetime, Timezone[...] with the literal ellipsis allows any timezone aware datetime, and you can pass a specific timezone string or a tzinfo object such as Timezone(timezone.utc) or Timezone("Africa/Abidjan") to restrict the value to one zone. The documentation calls pinning a single timezone a symptom of fragile design, which is a fair warning for anyone whose business logic quietly assumes one zone.
Since v0.x.x, Timezone accepts tzinfo objects rather than timezone specifically, which extends it to zoneinfo and to third party timezone libraries.
Unit(unit: str) marks a numeric value as the magnitude of a quantity with the given unit, so Annotated[float, Unit("m/s")] is a velocity in meters per second. The package makes no attempt to parse or validate the unit string, and leaves that to downstream libraries such as pint or astropy.units. The import is small:
from annotated_types import UnitBoth of these objects show the same design pattern: the metadata names an intent, and the library that understands units or timezones decides how much of it to enforce.
Installing it and annotating a class in one sitting
Installation is a single pip command, and the package supports Python 3.10 and later according to its classifiers.
pip install annotated-typesBecause the library holds no validation logic of its own, your first real use is writing annotations, not running a check. The documentation's example attaches three constraints to a class body, mixing a numeric bound, a predicate on list items and a length range on the list itself.
from typing import Annotated
from annotated_types import Gt, Len, Predicate
class MyClass:
age: Annotated[int, Gt(18)] # Valid: 19, 20, ...
# Invalid: 17, 18, "19", 19.0, ...
factors: list[Annotated[int, Predicate(is_prime)]] # Valid: 2, 3, 5, 7, 11, ...
# Invalid: 4, 8, -2, 5.0, "prime", ...
my_list: Annotated[list[int], Len(0, 10)] # Valid: [], [10, 20, 30, 40, 50]
# Invalid: (1, 2), ["abc"], [0] * 20The comments list what a validator is expected to accept and reject. Note the third line of invalid cases for my_list: a tuple is invalid even though its length is in range, because the annotation says list. That is the level of detail the annotations can carry, and it is exactly the detail that disappears if no library is reading them.
The Predicate marker is the escape hatch for rules no fixed constraint type can express. It names a callable that a consuming library is expected to call for each item.
A small repository with a strict toolchain
The source tree is narrow on purpose. The repository holds the annotated_types package, a tests directory, a Makefile, a pyproject.toml and a requirements directory, with no runtime dependencies of its own. Build backend is hatchling, and the version is read from annotated_types/__init__.py rather than being written in the configuration.
The project uses pre-commit for hooks and a Makefile with conventional targets for install, format, lint, test and a combined all target that runs lint, mypy and the coverage report. Pytest is configured with filterwarnings set to error, so any warning raised during the test run fails the suite. Coverage tracks the annotated_types source with branch coverage enabled, and flake8 runs with a 120 character line limit.
The licence is MIT, which matters more than usual here because the objects are meant to be embedded in other people's type annotations and sometimes read by code you do not control. An MIT licence lets a consumer vendor, modify and redistribute the objects with attribution. It says nothing about whether your data satisfies the constraint, which is the part people forget when they treat an annotation as enforcement.
Releases move in small steps: v0.6.0 on 2023-10-06, v0.7.0 on 2024-05-20 and v0.8.0 on 2026-07-23, with the last push on 2026-10-21. The version history is where the Len semantics change lives, so it is worth reading before a major bump rather than after.
Editorial conclusion
annotated-types fits teams that annotate types once and want several libraries to agree on what Gt, Len or Timezone mean, and it costs nothing at runtime because it validates nothing. It does not fit a project that expects a single library to enforce constraints at call time, since enforcement belongs to whatever library consumes the annotations. Before adopting it, check the upper bound semantics of Len in your dependency versions, decide whether MultipleOf means modulo or JSON Schema division, and confirm that the library you rely on actually reads these metadata objects rather than ignoring them.
Frequently asked questions
Does annotated-types validate values at runtime?
No. The documentation states that the package avoids runtime checks for performance and that downstream implementors decide what to do with the metadata. Enforcement depends on the library that reads your annotations.
What is the difference between Len and MaxLen in annotated-types?
Len carries both a minimum and a maximum length with inclusive bounds, while MaxLen(y) is equivalent to Len(max_length=y) and sets only the upper limit. The upper bound became inclusive in v0.4.0, and the earlier max_exclusive name was removed.
How should I interpret MultipleOf in annotated-types?
The documentation describes two possible readings, Python modulo semantics and JSON Schema division semantics, and asks libraries to document which one they implement. With very large or non-integer values the two can diverge through floating-point imprecision, so check the consuming library before relying on it.
Can I use annotated-types with a library that does not support Annotated?
The package documents an alternative for that case, suggesting implementors may treat functools.partial(operator.le, 1.5) as equivalent to Gt(1.5) so users avoid a runtime dependency on annotated-types. The same tradeoff applies to the other constraint objects.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/annotated-types-annotated-types)Was this useful?