How Pydantic V2 leverages Rust's Superpowers
by
Samuel Colvin
PyCon US April 2023
https://us.pycon.org/2023/schedule/presentation/39/
Today
- What is Pydantic, why do people seem to like it?
- Trouble in paradise
- Rust to the rescue - Good, Bad, Ugly
- Examples of how Rust helps Pydantic V2 solve your problems
I won't:
- Do the "
hello_world()
Python extension in Rust" thing (there are lots of other great resources for that, and PyO3's docs are great)
Pydantic
from datetime import datetime
from pydantic import BaseModel
class Talk(BaseModel):
title: str
when: datetime | None = None
mistakes: list[str]
Just type hints get you:
- Validation
- Coercion/tranformation
- Serialization
- JSON Schema
You people seemed to like it:
- 58m downloads/mo
- used by all FAANG companies
- 12% of pro web developers
30s - understand
3m - useful
300hr - usable
But there's a problem...
Pydantic V2
Priorities for V2:
- Performance - it was good, but could be better - think of the penguins!
- Strict Mode - live up to the name
- Composability - you don't always want a model
- Maintainability - I maintain Pydantic so I want maintaining Pydantic to be fun
Sad penguin, no snow
What would it look like if we started from scratch?
What about Rust?
The obvious advantages...
- Performance
- Multithreading - no GIL
- Reusing high quality rust libraries
- More explicit error handling
(maybe) Less obviously advantages:
- Virtually zero cost customisation, even in hot code
- Arguably easier to maintain - the compiler picks up more of mistake
Rust - the good
But perhaps most pertinent to Pydantic...
from pydantic import BaseModel
class Qualification(BaseModel):
name: str
description: str
required: bool
value: int
class Student(BaseModel):
id: int
name: str
qualifications: list[Qualification]
friends: list[int]
[
...,
...,
...,
...,
...,
...,
...,
...,
...,
...,
...,
...,
]
Rust loves this
- Deeply recursive code - no stack frames
- Small modular components
How Rust?
What does that tree look like?
class Talk(BaseModel):
title: Annotated[
str,
Maxlen(100)
]
attendance: PosInt
when: datetime | None = None
mistakes: list[
tuple[timedelta, str]
]
ModelValidator {
cls: Talk,
validator: TypeDictValidator [
Field {
key: "title",
validator: StrValidator { max_len: 100 },
},
Field {
key: "attendance",
validator: IntValidator { min: 0 },
},
Field {
key: "when",
validator: UnionValidator [
DateTimeValidator {},
NoneValidator {},
],
default: None,
},
Field {
key: "mistakes",
validator: ListValidator {
item_validator: TupleValidator [
TimedeltaValidator {},
StrValidator {},
],
},
},
],
}
Python Interface to Rust
from pydantic_core import SchemaValidator
class Talk:
...
talk_validator = SchemaValidator({
'type': 'model',
'cls': Talk,
'schema': {
'type': 'typed-dict',
'fields': {
'title': {'schema': {'type': 'str', 'max_length': 100}},
'attendance': {'schema': {'type': 'int', 'ge': 0}},
'when': {
'schema': {
'type': 'default',
'schema': {'type': 'nullable', 'schema': {'type': 'datetime'}},
'default': None,
}
},
'mistakes': {
'schema': {
'type': 'list',
'items_schema': {
'type': 'tuple',
'mode': 'positional',
'items_schema': [{'type': 'timedelta'}, {'type': 'str'}]
}
}
},
},
}
})
some_data = {
'title': "How Pydantic V2 leverages Rust's Superpowers",
'attendance': '100',
'when': '2023-04-22T12:15:00',
'mistakes': [
('00:00:00', 'Screen mirroring confusion'),
('00:00:30', 'Forgot to turn on the mic'),
('00:25:00', 'Too short'),
('00:40:00', 'Too long!'),
],
}
talk = talk_validator.validate_python(some_data)
print(talk.mistakes)
"""
[
(datetime.timedelta(0), 'Screen mirroring confusion'),
(datetime.timedelta(seconds=30), 'Forgot to turn on the mic'),
(datetime.timedelta(seconds=1500), 'Too short'),
(datetime.timedelta(seconds=2400), 'Too long!')
]
"""
class Talk(BaseModel):
title: Annotated[
str,
Maxlen(100)
]
attendance: PosInt
when: datetime | None = None
mistakes: list[
tuple[timedelta, str]
]
Pydantic V2 Architecture
Read type hints
construct a "core schema"
pydantic
(pure python)
pydantic-core
(binary + stubs + core-schema)
process core schema
return SchemaValidator
Receive data
call schema_validator(data)
run validator
return the result of validation
Rust - the bad
from __future__ import annotations
from pydantic import BaseModel
class Foo(BaseModel):
a: int
f: list[Foo]
f = {'a': 1, 'f': []}
f['f'].append(f)
Foo(**f)
fn main() {
main();
}
RecursionError is bad, but no RecursionError is worse!
Also no multiple ownership.
Rust - the ugly
class Box:
def __init__(self, width):
self.width = width
def area(self):
return self.width ** 2
def __str__(self):
return f'Box: {self.width}'
box = Box(42)
print(f'{box}, area {box.area()}')
use std::fmt;
struct Box {
width: i64,
}
impl fmt::Display for Box {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Box: {}", self.width)
}
}
impl Box {
fn new(width: i64) -> Self {
Self { width }
}
fn area(&self) -> i64 {
self.width * self.width
}
}
fn main() {
let b = Box::new(42);
println!("{b}, area {}", b.area());
}
Rust is significantly more verbose.
Pydantic V2
Examples
Performance
import timeit
from pydantic import BaseModel, __version__
class Model(BaseModel):
name: str
age: int
friends: list[int]
settings: dict[str, float]
data = {
'name': 'John',
'age': 42,
'friends': list(range(200)),
'settings': {f'v_{i}': i / 2.0 for i in range(50)}
}
t = timeit.timeit(
'Model(**data)',
globals={'data': data, 'Model': Model},
number=10_000,
)
print(f'version={__version__} time taken {t * 100:.2f}us')
version=1.10.4 time taken 179.81us
version=2.0a3 time taken 7.99us
22.5x speedup
Strict Mode
from pydantic import BaseModel, ValidationError
class Model(BaseModel):
model_config = dict(strict=True)
age: int
friends: tuple[int, int]
try:
Model(age='42', friends=[1, 2])
except ValidationError as e:
print(e)
"""
2 validation errors for Model
age
Input should be a valid integer [type=int_type,
input_value='42', input_type=str]
friends
Input should be a valid tuple [type=tuple_type,
input_value=[1, 2], input_type=list]
"""
print(Model(age=42, friends=(1, 2)))
#> age=42 friends=(1, 2)
AKA Pedant mode.
Builtin JSON parsing
from pydantic import BaseModel
class Model(BaseModel):
model_config = dict(strict=True)
age: int
friends: tuple[int, int]
print(Model.model_validate_json('{"age": 1, "friends": [1, 2]}'))
#> age=1 friends=(1, 2)
If you're going to be a pedant, you better be right.
Also gives us:
- Big performance improvement without 3rd party parsing library
- Custom Errors (WIP)
- Line numbers in errors (in future)
Wrap Validators
from pydantic import BaseModel, field_validator
class Model(BaseModel):
x: int
@field_validator('x', mode='wrap')
def validate_x(cls, v, handler):
if v == 'one':
return 1
try:
return handler(v)
except ValueError:
return -999
print(Model(x='one'))
#> x=1
print(Model(x=2))
#> x=2
print(Model(x='three'))
#> x=-999
- Logic before
- Logic after
- Catch errors - new error, or default
AKA "The Onion"
Recursive Models
from __future__ import annotations
from pydantic import BaseModel, Field, ValidationError
class Branch(BaseModel):
length: float
branches: list[Branch] = Field(default_factory=list)
print(Branch(length=1, branches=[{'length': 2}]))
#> length=1.0 branches=[Branch(length=2.0, branches=[])]
b = {'length': 1, 'branches': []}
b['branches'].append(b)
try:
Branch.model_validate(b)
except ValidationError as e:
print(e)
"""
1 validation error for Branch
branches.0
Recursion error - cyclic reference detected
[type=recursion_loop,
input_value={'length': 1, 'branches': [{...}]},
input_type=dict]
"""
Alias Paths
from pydantic import BaseModel, Field, AliasPath, AliasChoices
class MyModel(BaseModel):
a: int = Field(validation_alias=AliasPath('foo', 1, 'bar'))
b: str = Field(validation_alias=AliasChoices('x', 'y'))
m = MyModel.model_validate(
{
'foo': [{'bar': 0}, {'bar': 1}],
'y': 'Y',
}
)
print(m)
#> a=1 b='Y'
Generics
from typing import Generic, TypeVar
from pydantic import BaseModel
DataT = TypeVar('DataT')
class Response(BaseModel, Generic[DataT]):
error: int | None = None
data: DataT | None = None
class Profile(BaseModel):
name: str
email: str
def my_profile_view(id: int) -> Response[Profile]:
if id == 42:
return Response[Profile](data={'name': 'John', 'email': 'john@example.com'})
else:
return Response[Profile](error=404)
print(my_profile_view(42))
#> error=None data=Profile(name='John', email='john@example.com')
Favorite = tuple[int, str]
def my_favorites_view() -> Response[list[Favorite]]:
return Response[list[Favorite]](data=[(1, 'a'), (2, 'b')])
Serialisation
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
class Profile(BaseModel):
account_id: int
user: User
user = User(name='Alice', age=1)
print(Profile(account_id=1, user=user).model_dump())
#> {'account_id': 1, 'user': {'name': 'Alice', 'age': 1}}
class AuthUser(User):
password: str
auth_user = AuthUser(name='Bob', age=2, password='very secret')
print(Profile(account_id=2, user=auth_user).model_dump())
#> {'account_id': 2, 'user': {'name': 'Bob', 'age': 2}}
Solving the "don't ask the type" problem.
Without BaseModel
from dataclasses import dataclass
from pydantic import AnalyzedType
@dataclass
class Foo:
a: int
b: int
@dataclass
class Bar:
c: int
d: int
x = AnalyzedType(Foo | Bar)
d = x.validate_json('{"a": 1, "b": 2}')
print(d)
#> Foo(a=1, b=2)
print(x.dump_json(d))
#> b'{"a":1,"b":2}'
BaseModel is still here and widely used, but no longer essentials.
Enter AnalysedType.
AnalysedType
from pydantic import AnalyzedType
x = AnalyzedType(TheType)
d = x.validate_python(...)
d = x.validate_json(...)
x.dump_python(d)
x.dump_json(d)
x.json_schema()
What the hell do we call it?
- AnalyzedType
-
PydanticTypeWrapper
-
TypeValidator - misnomer, but maybe that's okay?
-
Change the API... the fact that we can't a good name suggests it's a mistake
Thank you
Twitter: @pydantic & @samuel_colvin
GitHub: /pydantic & /samuelcolvin
Docs: docs.pydantic.dev
We need your help:
- Try pydantic V2 alpha before we release V2!
- Applications using Pydantic (without FastAPI)
- Are you using Pydantic to process lots of data - if so we'd love to chat to you about the commercial platform we're building
Open space at 3pm today in 251E
Not Rust vs. Python
But rather: Python as the user* interface for Rust.
(* by user, I mean "application developer")
I'd love to see a generation of libraries for Python (and other high level languages) built in Rust.
Rust
TLS
Routing
HTTP parsing
Validation
DB query
Serializing
Rust/C
Python
Application Logic
HTTPS request lifecycle:
100% of Developer time
=
1% of CPU cycles
...
Ok, some actual Rust...
Pydantic V2
#[enum_dispatch(CombinedValidator)]
trait Validator {
const EXPECTED_TYPE: &'static str;
fn build(schema: &PyDict, config: Option<&PyDict>) -> PyResult<CombinedValidator>;
fn validate(&self, input: &impl Input, extra: &Extra) -> ValResult<PyObject>;
}
#[enum_dispatch]
enum CombinedValidator {
Int(IntValidator),
Str(StrValidator),
TypedDict(TypedDictValidator),
Union(UnionValidator),
TaggedUnion(TaggedUnionValidator),
Nullable(NullableValidator),
// ... and 43 more
}
fn build_validator(schema: &PyDict, config: Option<&PyDict>) -> PyResult<CombinedValidator> {
let schema_type: &str = schema.get_as_req("type")?;
// really this is a clever macro to avoid the duplication
match schema_type {
IntValidator::EXPECTED_TYPE => IntValidator::build(schema, config),
StrValidator::EXPECTED_TYPE => StrValidator::build(schema, config),
TypedDictValidator::EXPECTED_TYPE => TypedDictValidator::build(schema, config),
UnionValidator::EXPECTED_TYPE => UnionValidator::build(schema, config),
TaggedUnionValidator::EXPECTED_TYPE => TaggedUnionValidator::build(schema, config),
NullableValidator::EXPECTED_TYPE => NullableValidator::build(schema, config),
// ... and 43 more
}
}
trait Input<'a> {
fn is_none(&self) -> bool;
fn strict_str(&'a self) -> ValResult<&'a str>;
fn lax_str(&'a self) -> ValResult<&'a str>;
fn validate_date(&self, strict: bool) -> ValResult<PyDatetime>;
fn strict_date(&self) -> ValResult<PyDatetime>;
// ... and 53 more
}
impl<'a> Input<'a> for PyAny {
// ...
}
impl<'a> Input<'a> for JsonInput {
// ...
}
#[pyclass]
struct SchemaValidator {
validator: CombinedValidator,
}
#[pymethods]
impl SchemaValidator {
#[new]
fn py_new(schema: &PyDict, config: Option<&PyDict>) -> PyResult<Self> {
// We also do magic/evil schema validation using pydantic-core itself
let validator = build_validator(schema, config)?;
Ok(SchemaValidator { validator })
}
fn validate_python(&self, input: &PyAny, strict: Option<bool>) -> PyResult<PyObject> {
self.validator.validate(input, &Extra::new(strict))
}
fn validate_json(
&self,
input_string: &PyString,
strict: Option<bool>,
) -> PyResult<PyObject> {
let input = parse_string(input_string)?;
self.validator.validate(&input, &Extra::new(strict))
}
}
PyCon US | How Pydantic V2 leverages Rust's Superpowers
By Samuel Colvin
PyCon US | How Pydantic V2 leverages Rust's Superpowers
- 3,572