Skip to main content

Pydantic++ | Utilities to improve on the core Pydantic library

Project description

Pydantic++

CI Release PyPI version

Pydantic++ is a suite of utilities to improve upon the core Pydantic library. We stand on the shoulders of giants here. Huge kudos to the Pydantic team for all their hard work building an amazing product.

Utilities

  • Partial Models — type-safe "partial" Pydantic models where some or all fields are optional

Installation

pip install pydantic-plus-plus

Requires Python 3.10+ and Pydantic 2.0+.

If you use mypy for type checking, please also add our plugin to your mypy configuration.

[tool.mypy]
plugins = ["pydantic.mypy", "pydantic_plus_plus.mypy"]

Partial Models

partial creates a PartialBaseModel, a variant of the given Pydantic BaseModel where every field is Optional with a default of None. This is the type-safe way to represent partial updates (PATCH payloads, upsert data, sparse sync records) without manually duplicating each model with every field made optional.

Usage

from pydantic import BaseModel
from pydantic_plus_plus import partial

class User(BaseModel):
    name: str
    age: int
    email: str

PartialUser = partial(User)

The resulting PartialUser class accepts any subset of fields:

patch = PartialUser(name="Alice")
patch.name   # "Alice"
patch.age    # None
patch.email  # None

Use model_dump(exclude_unset=True) to get only the fields that were explicitly provided — the standard pattern for database partial updates:

patch = PartialUser(name="Alice", age=31)
patch.model_dump(exclude_unset=True)
# {"name": "Alice", "age": 31}

Selective Partials

Pass field names to partial() to make only those fields optional. Non-selected fields keep their original behavior:

class User(BaseModel):
    name: str
    age: int
    email: str = "user@example.com"
    address: Address

PartialUser = partial(User, "name", "email")

patch = PartialUser(age=30, address=some_address)
patch.name   # None — selected, defaults to None
patch.age    # 30 — not selected, still required
patch.email  # None — selected, defaults to None (original default replaced)

Use dot-notation to target nested fields. The parent field stays required, but the nested model becomes a partial with only the specified fields optional:

PartialUser = partial(User, "address.city")

patch = PartialUser(
    name="Alice",
    age=30,
    address={"street": "123 1st Ave", "state": "NY", "zip_code": "10001"},
)
patch.address.city    # None — selected, optional
patch.address.street  # "123 1st Ave" — not selected, still required

Use "*" as a wildcard to make all fields of a nested model optional:

PartialUser = partial(User, "address.*")

patch = PartialUser(name="Alice", age=30, address={})
patch.address.city    # None
patch.address.street  # None

These options can be freely combined.

Recursive Partials

By default, nested BaseModel fields are also partialized. This means you can provide incomplete nested data:

class Address(BaseModel):
    street: str
    city: str
    state: str
    zip_code: str

class User(BaseModel):
    name: str
    address: Address

PartialUser = partial(User)

patch = PartialUser(address={"city": "New York City"})

Disable this with recursive=False to require complete nested models when provided:

PartialUser = partial(User, recursive=False)

patch = PartialUser(address=Address(
    street="123 1st Ave",
    city="New York",
    state="NY",
    zip_code="10001",
))

Recursive partialization handles list[Model], dict[str, Model], Optional[Model], and self-referencing models.

Applying Partials

Use .apply() to deep-merge a partial into an existing model instance. Only explicitly-set fields are merged; everything else is preserved:

user = User(
    name="Alice",
    age=30,
    email="alice@example.com",
    address=Address(street="123 1st Ave", city="New York", state="NY", zip_code="10001"),
)

patch = PartialUser(age=31, address={"city": "New York City"})
updated = patch.apply(user)

updated.name             # "Alice" — preserved
updated.age              # 31 — updated
updated.address.street   # "123 1st Ave" — preserved
updated.address.city     # "New York City" — updated

.apply() returns a new instance of the original model type. The original is never mutated. The field selection controls which fields are optional in the constructor — .apply() merges all explicitly-set fields regardless of the selection.

What Partials Preserve

  • Field metadata — descriptions, constraints (min_length, ge, etc.), and aliases are carried over. Constraints are enforced when a non-None value is provided.
  • Serialization — custom type serializers defined via Annotated (e.g., UUID-to-string) work as expected.
  • JSON Schemamodel_json_schema() produces a valid schema with all properties defaulting to null.
  • Validationmodel_validate(), model_validate_json(), and model_dump_json() all work.

What Partials Don't Inherit

Partial models are standalone classes that subclass PartialBaseModel, not the original model. This means:

  • @field_validator and @model_validator decorators on the original are not inherited. This is intentional — validators that enforce required-field invariants would reject the partial data.
  • isinstance(patch, User) is False. Use isinstance(patch, PartialBaseModel) instead.

Alternative Syntax

You can also use the class method:

PartialUser = PartialBaseModel.from_model(User)

Both syntaxes return the same cached class.

Caching

Partial classes are cached. Calling partial() with the same arguments returns the same class:

partial(User) is partial(User)                          # True
partial(User, recursive=False) is partial(User)         # False
partial(User, "name") is partial(User, "name")          # True
partial(User, "name", "email") is partial(User, "email", "name")  # True (order independent)
partial(User, "name") is partial(User, "email")         # False

Mypy Plugin

Pydantic++ ships a mypy plugin that gives partial models full type safety. Without it, mypy sees partial(User) as returning a generic type and can't validate field access or constructor arguments.

With the plugin enabled, mypy understands that PartialUser has name: str | None, age: int | None, etc., and will type-check constructor calls and attribute access accordingly.

Setup

Add the plugin to your mypy configuration:

# pyproject.toml
[tool.mypy]
plugins = ["pydantic.mypy", "pydantic_plus_plus.mypy"]

The plugin works alongside the standard Pydantic mypy plugin. Order does not matter.

What the Plugin Enables

Without the plugin:

PartialUser = partial(User)

def update(p: PartialUser) -> None:  # mypy error: not valid as a type
    p.model_dump()                   # mypy error: has no attribute "model_dump"

With the plugin, both lines type-check cleanly — no # type: ignore needed.

Comparable Packages

Why Pydantic++ over pydantic-partial?

Pydantic++ offers a similar feature set, but takes a different philosophical approach than pydantic-partial that is more aligned with SOLID Object Oriented design principles.

Here's how the two differ:

Pydantic++ pydantic-partial
Partial models All fields optional All fields optional
Selective fields Yes Yes
Dot-notation nesting Yes Yes
Wildcard nesting Yes Yes
Field metadata preservation Yes Yes
Caching Yes Yes
Recursive by default Yes No (opt-in)
Mypy plugin Yes No
.apply() deep merge Yes No
Standalone partial classes Yes — partials don't subclass the original No — partials subclass the original

Philosophical Differences

pydantic-partial's partials subclass the original model, so isinstance(patch, User) is True. This violates the Liskov Substitution Principle — a partial User weakens the preconditions of the original (required fields become optional), meaning code that expects a complete User can silently receive one with None fields. In addition to breaking LSP, this also causes problems for type checkers because the subclass relationship tells type checkers the partial is a User.

Pydantic++ takes a different philosophical approach, where partials are standalone classes. This preserves LSP. Additionally, because isinstance(patch, User) is False, the type checker can enforce the boundary between partial and complete data. Users must go through .apply() to produce a real User, making the conversion explicit and safe.

Mypy plugin

This is a well known limitation of pydantic-partial, which their README calls out as "a massive amount of work while being kind of a bad hack." Pydantic++ ships a mypy plugin that synthesizes full TypeInfo objects, giving users field-level type checking, constructor validation, and IDE autocompletion with zero # type: ignore comments.

.apply() deep merge

pydantic-partial creates partial models but has no built-in way to merge a partial back into an existing instance. Pydantic++ provides .apply() which recursively merges only explicitly-set fields, preserving untouched nested data.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pydantic_plus_plus-0.3.0.tar.gz (99.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pydantic_plus_plus-0.3.0-py3-none-any.whl (14.9 kB view details)

Uploaded Python 3

File details

Details for the file pydantic_plus_plus-0.3.0.tar.gz.

File metadata

  • Download URL: pydantic_plus_plus-0.3.0.tar.gz
  • Upload date:
  • Size: 99.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for pydantic_plus_plus-0.3.0.tar.gz
Algorithm Hash digest
SHA256 4536a857c45a5c1e0a38daff0304ef175669b67bc0e9f97fb61b6658b4eea69f
MD5 9281995ebc1a7790bef3d861c5771de5
BLAKE2b-256 c42226a776bd570cf3c4a520e6dc7700a62458f2ac3470ea100ae2684d764f7f

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydantic_plus_plus-0.3.0.tar.gz:

Publisher: release.yml on andonimichael/pydantic-plus-plus

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pydantic_plus_plus-0.3.0-py3-none-any.whl.

File metadata

File hashes

Hashes for pydantic_plus_plus-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d59ca00eaeb16f289ab01d2af547446b99bdfa1dd19b8d58b813986cb93384bf
MD5 44cc833090ae3d55ab8d872c0f10d690
BLAKE2b-256 f5213a81c815bc454c759b3242880dbb8bacf1dcdad8647769c19d7519082a6e

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydantic_plus_plus-0.3.0-py3-none-any.whl:

Publisher: release.yml on andonimichael/pydantic-plus-plus

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page