Skip to main content

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

Project description

Pydantic++

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.

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}

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)

# Only update the city — street, state, zip_code are not required
patch = PartialUser(address={"city": "New York City"})

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

PartialUser = partial(User, recursive=False)

# This now requires all Address fields
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.

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 by (model, recursive) pair. Calling partial(User) multiple times returns the same class:

partial(User) is partial(User)           # True
partial(User, recursive=False) is partial(User, recursive=True)  # 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 Planned model_as_partial("name", "email")
Dot-notation nesting Planned model_as_partial("address.city")
Wildcard nesting Planned model_as_partial("address.*")
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.2.0.tar.gz (93.8 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.2.0-py3-none-any.whl (9.8 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: pydantic_plus_plus-0.2.0.tar.gz
  • Upload date:
  • Size: 93.8 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.2.0.tar.gz
Algorithm Hash digest
SHA256 a833f8fe4a575d537543f9cd491ecdadbe43895b4d895d296b4efb11f0886d68
MD5 b2730eafc1205a6f2a934b6545033b14
BLAKE2b-256 631db467332970e8ad6f48ea1d930082dff17e05cd1bc4f9dec1daf5bba933d5

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydantic_plus_plus-0.2.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.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for pydantic_plus_plus-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d63c1c884fbff68aed20751d4dfee8bddce9c296b851547c762bbb7a00f3c58f
MD5 0a49d6358cb9b09bbbdd67b2898fbd84
BLAKE2b-256 1c57c20bf9ff0ff4ebf6afe5b2a463b2f4421f6c89a7ac68c924d4e58ef9be9b

See more details on using hashes here.

Provenance

The following attestation bundles were made for pydantic_plus_plus-0.2.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