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-Nonevalue is provided. - Serialization — custom type serializers defined via
Annotated(e.g., UUID-to-string) work as expected. - JSON Schema —
model_json_schema()produces a valid schema with all properties defaulting tonull. - Validation —
model_validate(),model_validate_json(), andmodel_dump_json()all work.
What Partials Don't Inherit
Partial models are standalone classes that subclass PartialBaseModel, not the original model. This means:
@field_validatorand@model_validatordecorators on the original are not inherited. This is intentional — validators that enforce required-field invariants would reject the partial data.isinstance(patch, User)isFalse. Useisinstance(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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a833f8fe4a575d537543f9cd491ecdadbe43895b4d895d296b4efb11f0886d68
|
|
| MD5 |
b2730eafc1205a6f2a934b6545033b14
|
|
| BLAKE2b-256 |
631db467332970e8ad6f48ea1d930082dff17e05cd1bc4f9dec1daf5bba933d5
|
Provenance
The following attestation bundles were made for pydantic_plus_plus-0.2.0.tar.gz:
Publisher:
release.yml on andonimichael/pydantic-plus-plus
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pydantic_plus_plus-0.2.0.tar.gz -
Subject digest:
a833f8fe4a575d537543f9cd491ecdadbe43895b4d895d296b4efb11f0886d68 - Sigstore transparency entry: 1191603241
- Sigstore integration time:
-
Permalink:
andonimichael/pydantic-plus-plus@106d80ccba36d08394918131960b3c2fdf860e2d -
Branch / Tag:
refs/heads/main - Owner: https://github.com/andonimichael
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@106d80ccba36d08394918131960b3c2fdf860e2d -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file pydantic_plus_plus-0.2.0-py3-none-any.whl.
File metadata
- Download URL: pydantic_plus_plus-0.2.0-py3-none-any.whl
- Upload date:
- Size: 9.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d63c1c884fbff68aed20751d4dfee8bddce9c296b851547c762bbb7a00f3c58f
|
|
| MD5 |
0a49d6358cb9b09bbbdd67b2898fbd84
|
|
| BLAKE2b-256 |
1c57c20bf9ff0ff4ebf6afe5b2a463b2f4421f6c89a7ac68c924d4e58ef9be9b
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pydantic_plus_plus-0.2.0-py3-none-any.whl -
Subject digest:
d63c1c884fbff68aed20751d4dfee8bddce9c296b851547c762bbb7a00f3c58f - Sigstore transparency entry: 1191603247
- Sigstore integration time:
-
Permalink:
andonimichael/pydantic-plus-plus@106d80ccba36d08394918131960b3c2fdf860e2d -
Branch / Tag:
refs/heads/main - Owner: https://github.com/andonimichael
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@106d80ccba36d08394918131960b3c2fdf860e2d -
Trigger Event:
workflow_dispatch
-
Statement type: