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
  • Dummy Models — generate fake model instances with random data for testing

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

Dummy Models

dummy generates an instance of any Pydantic BaseModel populated with random data. This is useful for testing — round-trip serialization, factory fixtures, property-based tests — anywhere you need a valid model instance without hand-writing every field.

Usage

from pydantic import BaseModel
from pydantic_plus_plus import dummy

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

user = dummy(User)
user.name   # "kR4tBx2m"
user.age    # 37

Seed for Reproducibility

Pass a seed for deterministic output — same seed, same instance every time:

first = dummy(User, seed=42)
second = dummy(User, seed=42)
assert first.model_dump() == second.model_dump()  # always True

Constraint Awareness

Generated values respect field constraints. A field with ge=0, le=100 will produce a value in that range; a string with min_length=3 will never be shorter:

class Config(BaseModel):
    name: str = Field(min_length=3, max_length=10)
    value: int = Field(ge=0, le=100)

config = dummy(Config, seed=1)
len(config.name)  # between 3 and 10
config.value      # between 0 and 100

Collection constraints (min_length, max_length on list, set, dict, tuple) are also respected.

Nested Models

Nested BaseModel fields are recursively populated:

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

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

user = dummy(User, seed=1)
user.address.street  # "xN7kLm2p"
user.address.city    # "Bq4RtYwS"

Self-referential models and circular references are handled automatically — collections return empty and optional fields return None at the recursion boundary.

Using Defaults

By default, dummy generates fresh values for every field, ignoring defaults. Use set_defaults to keep defaults instead:

class User(BaseModel):
    name: str
    role: str = "viewer"
    tags: list[str] = Field(default_factory=list)

user = dummy(User, set_defaults=True)
user.role  # "viewer"
user.tags  # []

Pass a set of field names to apply this selectively:

user = dummy(User, set_defaults={"role"})
user.role  # "viewer" — uses default
user.tags  # ["kR4tBx2m", "Qp3nYwLs"] — generated

Setting None

By default, Optional fields receive generated values. Use set_nones to set them to None instead:

class Profile(BaseModel):
    name: str
    bio: Optional[str] = None
    avatar: Optional[str] = None

profile = dummy(Profile, set_nones=True)
profile.name    # "xN7kLm2p" — required, still generated
profile.bio     # None
profile.avatar  # None

Pass a set of field names to apply this selectively:

profile = dummy(Profile, set_nones={"bio"})
profile.bio     # None
profile.avatar  # "Bq4RtYwS" — generated

When both set_nones and set_defaults target the same field, set_nones takes precedence.

Supported Types

dummy handles the full range of common Pydantic field types:

Type Generation
str Random alphanumeric string (respects min_length, max_length)
int, float, Decimal Random number (respects ge, gt, le, lt, multiple_of)
bool Random True or False
bytes Random bytes
datetime, date, time Random value in a reasonable range
UUID Random UUID
Enum Random member
Literal["a", "b"] Random choice from the literal values
list[T], set[T], frozenset[T] 1–3 generated elements (respects min_length, max_length)
dict[K, V] 1–3 generated entries (respects min_length, max_length)
tuple[T, U, V] Each position generated
tuple[T, ...] 1–3 generated elements (respects min_length, max_length)
Optional[T] Generated T value (non-None by default)
Union[A, B] Random choice of member, then generated
BaseModel Recursive generation

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.4.0.tar.gz (108.1 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.4.0-py3-none-any.whl (21.5 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: pydantic_plus_plus-0.4.0.tar.gz
  • Upload date:
  • Size: 108.1 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.4.0.tar.gz
Algorithm Hash digest
SHA256 4eb1357263f9dbf6b3d46a8d808d566e64c233b581080ea1c25581b35ce958bc
MD5 44b2ece400afe2a643e645e89b73646c
BLAKE2b-256 59b5180f752cd20b2ba80eabc7028f180a66fec2501e93018e4c5df150810f04

See more details on using hashes here.

Provenance

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

File metadata

File hashes

Hashes for pydantic_plus_plus-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 abe4da427f7a7d067f3df33f2cfa20bdfbc77c2ee42a7337708ee11c5c708fd2
MD5 9c8901ace8fe109113b6b2a92f3efb29
BLAKE2b-256 b8056c7e2a1be44626e3510cc6eefd0637c80eaba4455ebb2f2d8863db2c92db

See more details on using hashes here.

Provenance

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