Skip to content

Graph-object binding: attribute access costs 11x-18x vs plain pydantic; descriptor binding is a faster strict superset with no syntax change #107

Description

@simontaurus

Summary

Reading any attribute of a LinkedBaseModel (including plain, non-reference fields) costs roughly 11x (pydantic v1) to 18x (pydantic v2) more than the equivalent plain pydantic model, because attribute access is intercepted unconditionally. Writes, linked-field access and query construction are slower still.

This is fixable without changing the declaration syntax. Installing a data descriptor per x-oold-range field keeps today's exact model syntax (standard annotations, plain List[...] for to-many), reaches parity with plain pydantic on ordinary fields, and is faster on every other operation as well.

Two declaration front-ends share one implementation, so both are supported:

  • implicit - annotated field plus a range keyword (what the code generator emits today, unchanged),
  • explicit - Link / LinkList descriptors for hand-written models.

Verified by script, the descriptor binding is a strict superset of the current implementation: it satisfies every requirement the shipped binding satisfies, plus three it does not (attribute projection on link lists, validated json_schema_extra, no pydantic monkeypatch).

Requirement matrix

Produced by examples/check_binding_features.py, which exercises each requirement rather than asserting it. Each variant runs in its own subprocess.

requirementshipped v1shipped v2auto (implicit)auto (explicit)Ref[T]
syntax_unchangedokokokFAILFAIL
build_by_iriokokokokok
build_by_objectokokokokok
lazyokokokokok
real_object (isinstance holds)okokokokFAIL
polymorphic (subclass via type IRI)okokokokFAIL
batched (N links, one call)okokokokFAIL
cachedokokokokok
mutationokokokokFAIL
link_validatedokokokokok
list_lookup links["ex:t2"]okokokokFAIL
list_filter links[T.label == "two"]okokokokFAIL
list_projection links.labelFAILFAILokokFAIL
serialize_iriokokokokok
query_dsl Cls[Cls.f == v]okokokokFAIL
typed_extras (validated)FAILFAILokokFAIL
no_monkeypatchFAILFAILokokok

auto (implicit) and auto (explicit) are not competing options: they are the two
declaration front-ends of the same variant C, sharing one descriptor implementation and one
registry, and they can be mixed in a single class. syntax_unchanged is FAIL for the explicit
form by definition - it is deliberately a different declaration style, offered for
hand-written models - and FAIL for Ref[T] because the wrapper appears in the annotation.
Every other row is identical between the two front-ends, as expected.

Requirement legend
requirementmeaning
syntax_unchangedstandard annotations, no wrapper type in the declaration
build_by_iriconstruct a link from an IRI string
build_by_objectconstruct a link from a model instance
lazyno backend call before first access
real_objectaccess returns the real target, isinstance holds
polymorphicresolves to the actual subclass via its type IRI
batchedan N-item list resolves in ONE backend call
cacheda second access does not re-resolve
mutationassignment replaces the link and invalidates the cache
link_validatedthe linked object is validated by its own model on construction
list_lookuplinks["ex:t2"]
list_filterlinks[T.label == "two"]
list_projectionlinks.label collects the attribute across items
serialize_iriserialisation emits IRIs for links
query_dslCls.field == v and Cls[cond]
typed_extrasvalidated json_schema_extra (OoldExtra)
no_monkeypatchimporting the module does not patch pydantic.fields.FieldInfo (checked in a subprocess)

Performance matrix

100,000 iterations per operation, best of 5, each variant in its own subprocess (importing oold.model monkeypatches pydantic.fields.FieldInfo process-wide and would otherwise contaminate the baselines). Times in ms; (x) is relative to plain pydantic v2 plain-read. Link reads are warm (already resolved).

variantplain readplain writelink readlink writequery build
plain pydantic v13.7 (0.7x)46.9 (9.3x)nanana
plain pydantic v25.1 (1.0x)24.6 (4.9x)nanana
gated __getattribute__18.3 (3.6x)69.3 (13.7x)nanana
shipped v156.4 (11.2x)972.5 (192.5x)434.7 (86.0x)1597.4 (316.1x)283.1 (56.0x)
shipped v291.1 (18.0x)561.8 (111.2x)825.5 (163.4x)1162.5 (230.1x)484.0 (95.8x)
auto (implicit)5.1 (1.0x)50.1 (10.2x)6.3 (1.3x)270.8 (55.2x)194.6 (39.7x)
auto (explicit)5.1 (1.0x)47.9 (9.8x)6.4 (1.3x)277.5 (56.6x)194.7 (39.7x)
explicit Ref[T]4.9 (1.0x)24.4 (5.0x)5.8 (1.2x)27.6 (5.6x)na

Versus shipped v2 the descriptor binding is 19x faster on plain reads, 11x on plain writes, 131x on link reads, 4.2x on link writes and 2.5x on query construction.

On writes: pydantic itself defines __setattr__, so writes are Python-level in every variant (plain v2 writes already cost 4.9x a read). The descriptor variant adds one frame plus a dict lookup, landing at 9.5x - about 2x plain pydantic, but 12x cheaper than today. Classes with no link fields need no __setattr__ override at all.

Ref[T] is fastest on link access only because it returns the wrapper without resolving; it is not semantically comparable (isinstance fails).

Two further details from a separate run isolating the interception cost on plain reads:

gating strategyvs plain v2
best case (closure frozenset, no attribute lookup on the fast path)3.13x
realistic (per-class set, needs a type(self) lookup)4.53x

Note also that plain pydantic v1 attribute access is faster than v2 (0.7x), which is why each
binding is compared against its own pydantic baseline rather than a single one.

Environment: Python 3.11.6, pydantic 2.12.0, Windows.

Root cause

oold/model/__init__.py intercepts attribute access unconditionally:

classLinkedBaseModel(BaseModel, GenericLinkedBaseModel, metaclass=LinkedBaseModelMetaClass):
def__getattribute__(self, name): # runs for EVERY attribute
...
ifhasattr(self, "__iris__"):
ifnameinself.__iris__andlen(self.__iris__[name]) >0:
... # sync backend I/O inside the getterresult=BaseModel.__getattribute__(self, name)

Plus a process-wide pydantic.fields.FieldInfo = OOFieldInfo monkeypatch at import; a metaclass that also overrides __getattribute__ (for the query DSL) and therefore needs a _constructing guard to avoid corrupting pydantic's metaclass bookkeeping; and a parallel __iris__ side-dict duplicating field state.

Could the interception simply be gated on range annotations? Partly. Early-exiting for non-link fields removes most of the work (18x down to ~3.6x), but not the call: defining __getattribute__ at all forces the interpreter to invoke a Python-level function on every access instead of using the C-level slot, which alone costs ~3.6x. The gate shrinks the body, not the call. The descriptor is the same gate implemented in C.

Variants

A. Current implementation

fromtypingimportList, OptionalfrompydanticimportFieldfromoold.modelimportLinkedBaseModelclassPerson(LinkedBaseModel):
id: strname: Optional[str] =Noneknows: Optional[List["Person"]] =Field(
None, json_schema_extra={"range": "Person"}
)
p=Person(id="ex:p1", knows=["ex:p2", "ex:p3"]) # by IRI, or by objectp.knows[0].name# real object, lazily resolvedisinstance(p.knows[0], Person) # Truep.knows["ex:p2"] # IRI lookupPerson[Person.name=="John"] # query DSL

Pros: no special syntax (plain annotations, List[Person] for to-many - exactly what datamodel-code-generator emits); real objects on access; batched and lazy resolution; query DSL; IRI lookup and filtering on link lists.

Cons: 11x-18x on every attribute read and up to 316x on link writes; global FieldInfo monkeypatch; json_schema_extra is a raw untyped dict, so {"rnge": ...} fails silently; __iris__ duplicates field state; resolution is hidden in a sync getter, so it cannot be awaited.

B. Gate the interception on range annotations

Smallest possible change, keeps syntax A exactly.

classLinkedBaseModel(BaseModel, ...):
__link_names__=frozenset() # computed per class from range annotationsdef__getattribute__(self, name):
ifnameintype(self).__link_names__:
... # existing resolution logic, unchangedreturnobject.__getattribute__(self, name)

Pros: no syntax change, small diff, 18x down to ~3.6x on plain reads.

Cons: cannot reach parity; leaves the monkeypatch, __iris__ and the untyped extras untouched.

C. Descriptor binding (recommended)

After pydantic finishes building the class, scan model_fields for a range annotation and install a data descriptor per link field. A data descriptor takes precedence over the instance __dict__, so link reads go to the descriptor while every other field keeps native pydantic access.

Both declaration front-ends are supported and can be mixed in one class:

fromoold.experimental.auto_descriptor_bindingimport (
AutoLinkedModel, Link, LinkList, OoldField,
)
classPerson(AutoLinkedModel):
id: strname: Optional[str] =None# IMPLICIT: unchanged syntax; equivalent to# Field(None, json_schema_extra=OoldExtra(range="Person"))knows: Optional[List["Person"]] =OoldField(default=None, range="Person")
# EXPLICIT: for hand-written models. No doubled type argument needed -# the subscript carries the static type, __orig_class__ the runtime target.employer=Link(Organization)
friends=LinkList["Person"]()

Usage and query patterns are unchanged from A:

p=Person(id="ex:p1", name="Alice", knows=["ex:p2", "ex:p3"], employer="ex:acme")
p.link_iris("knows") # ['ex:p2', 'ex:p3'] - no backend callp.knows[0].name# 'Bob' -> ONE batched call resolves the listisinstance(p.knows[0], Person) # Truep.knows["ex:p3"] # IRI lookupp.knows[Person.name=="Bob"] # filteringp.knows.name# attribute projection -> ['Bob', 'Carol']p.model_dump(exclude_none=True) # links collapse back to IRIsPerson.name=="John"# Condition(field='name', operator=eq, ...)Person[Person.name=="John"] # query by conditionPerson["ex:p1"] # query by IRIEmployee.salary>100# inherited fieldsPerson.knows=="ex:p2"# link fields, straight off the descriptor

Implementation sketch:

classAutoLinkedModel(BaseModel, metaclass=LinkedQueryMeta):
_links: Dict[str, Any] =PrivateAttr(default_factory=dict)
_link_cache: Dict[str, Any] =PrivateAttr(default_factory=dict)
__link_fields__: ClassVar[dict] = {}
@classmethoddef__pydantic_init_subclass__(cls, **kwargs):
super().__pydantic_init_subclass__(**kwargs)
links=dict(getattr(cls, "__link_fields__", {}))
# explicit form: descriptors declared in the class bodyforklassinreversed(cls.__mro__):
forkey, valueinvars(klass).items():
ifisinstance(value, _AutoLink):
links[key] =value# implicit form: annotated fields carrying a range keywordforname, fieldincls.model_fields.items():
extra=field.json_schema_extraifisinstance(extra, dict) and (extra.get("x-oold-range") orextra.get("range")):
target, many=_extract_target(field.annotation) # Optional[List[X]] -> (X, True)descr=_AutoLink(name, target, many)
setattr(cls, name, descr) # data descriptor shadows the fieldlinks[name] =descrcls.__link_fields__=linksdef__init__(self, **data):
lf=type(self).__link_fields__# route link kwargs before validationlink_data= {k: data.pop(k) forkinlist(data) ifkinlf}
super().__init__(**data)
fork, vinlink_data.items():
lf[k].__set__(self, v)
def__setattr__(self, name, value):
# targeted: pydantic writes model fields straight into __dict__, which# would bypass a data descriptor's __set__ and leave the cache staledescr=type(self).__link_fields__.get(name)
ifdescrisnotNone:
descr.__set__(self, value)
else:
super().__setattr__(name, value)

The query DSL moves from __getattribute__ (every access) to __getattr__ (a fallback, only when lookup fails). Pydantic v2 removes field names from the class namespace, so Person.name fails naturally and lands there at no cost to anything else:

classLinkedQueryMeta(ModelMetaclass):
def__getattr__(cls, name):
# CRITICAL: never call getattr(cls, ...) here. cls.model_fields is a# property that itself calls getattr -> infinite recursion.ifname.startswith("_"):
raiseAttributeError(name)
forklassincls.__mro__:
fields=klass.__dict__.get("__pydantic_fields__")
iffieldsandnameinfields:
returnFieldProxy(name)
raiseAttributeError(name)
def__getitem__(cls, item):
returncls.oold_query(item)

For link fields no metaclass is involved: the descriptor's __get__(None, owner) returns the descriptor on class access, so comparison operators live directly on it.

Pros: no syntax change (implicit form); parity on plain reads and faster on every other operation; real objects, polymorphic dispatch, batching, caching, rich list operations, query DSL; validated extras; no monkeypatch; the _constructing guard disappears.

Cons: the descriptor shadows the pydantic field, so the parent's field validation is bypassed and link kwargs are routed in __init__ (the current implementation already special-cases them similarly). Link fields no longer live in __dict__. Metaclass __getitem__ still shadows generic subscripting (Model[int]) - unchanged from today.

On validation: the linked object is still validated at construction of the linked class, which is where its constraints live - a dict-valued link is constructed through the target model, so {"label": "no id"} raises for a required id. What is lost is only the parent field's own annotation check.

D. Explicit Ref[T] (opt-in handle)

classPerson(LinkedModel):
knows: Optional[List[Ref["Person"]]] =Nonep.knows[0].iri# 'ex:p2' without resolvingp.knows[0].resolve().name# explicitawaitp.knows[0].aresolve() # async

Pros: resolution is visible, batchable and awaitable - none of which A can express.

Cons: syntax and semantic change: p.knows[0] is a Ref, not a Person, so isinstance fails and list operations do not apply. Suitable as an opt-in handle where explicit or async resolution is wanted, not as the default.

E. Rejected: Annotated wrapper that reads as the target type

knows: Optional[List[Linked["Person"]]] =None# Linked[X] == Annotated[X, ...]

Type checkers report Person, but the runtime value is a Ref, so isinstance(p.knows[0], Person) is False. A static type not backed by the runtime value; do not use.

Typed json_schema_extra

The raw dict can be replaced by a validated class, but it must subclass dict: pydantic merges extras via isinstance(json_schema_extra, dict), so a plain BaseModel is accepted at declaration and then silently dropped from the schema.

classOoldExtraModel(BaseModel):
"""Constraints live here - real pydantic validation."""model_config=ConfigDict(populate_by_name=True, extra="allow")
range: str=Field(alias="x-oold-range", min_length=1)
required_iri: Optional[bool] =Field(None, alias="x-oold-required-iri")
classOoldExtra(Dict[str, Any]):
def__init__(self, *, range: str, required_iri: Optional[bool] =None, **vendor: Any):
data= {"x-oold-range": range}
ifrequired_iriisnotNone:
data["x-oold-required-iri"] =required_iridata.update(vendor) # x-jedison-*, x-osl-*, ...model=OoldExtraModel.model_validate(data) # validate via dict, not kwargs,object.__setattr__(self, "_model", model) # so aliases stay out of the signaturesuper().__init__(model.model_dump(by_alias=True, exclude_none=True))
@propertydefrange(self) ->str: returnself._model.range@propertydefrequired_iri(self) ->Optional[bool]: returnself._model.required_iri
OoldExtra(range="") # ValidationError: String should have at least 1 characterOoldExtra(range=123) # ValidationError: Input should be a valid stringPerson.model_json_schema() # ... 'x-oold-range': 'Person' -> preservedtype(Person.model_fields["knows"].json_schema_extra) # OoldExtraextra.range# typed read (str), instead of extra["x-oold-range"]

Type checking (pyright), confirmed: e.range is str, e.required_iri is bool | None, OoldExtra(range=123) and OoldExtra() are errors.

Caveats: validation happens in __init__ rather than by pydantic validating the field itself; pass the payload to model_validate as a dict rather than as aliased kwargs, otherwise type checkers reject range= as "No parameter named"; extras must stay JSON-serialisable for schema export.

Static typing

Confirmed on pyright and mypy for the explicit form; the implicit form is plain annotations and so types natively.

p.knows -> List[Person] (LinkList["Person"]() - subscript only)
p.knows[0].name -> str
p.employer -> Organization | None (Link(Organization) - argument only)
p.knows[0].nope -> error: Cannot access attribute "nope" for class "Person"

LinkList["Person"]() needs no second argument: the subscript carries the static type, and the runtime target is recovered from __orig_class__.

CPython and Rust optimisation potential

Already applied - warm link reads at native speed. The descriptor is deliberately a
non-data descriptor (it defines __get__ but not __set__) and stores the resolved value in
the instance __dict__. Because an instance dict entry shadows a non-data descriptor, every
subsequent read is a plain C-level dict lookup that never re-enters Python - the
functools.cached_property pattern. Writes remain intercepted by __setattr__, which pops the
cached entry to invalidate it.

The effect is large: caching in a pydantic PrivateAttr instead costs a Python-level
__getattr__ call per read, which is what made link reads slow.

link read (warm)timevs plain field
data descriptor + PrivateAttr cache336.0ms31.8x
non-data descriptor + __dict__ cache10.5ms1.00x
plain pydantic field (baseline)10.6ms1.00x

That is a 32x improvement on the hot path, and it takes link reads from 33.6x to 1.3x in the
full matrix above.

Remaining CPython headroom (not yet applied):

  • Query construction, ~6.6x available.Condition is a pydantic BaseModel, so every
    Cls.field == value pays full model validation: 200.1ms vs 30.4ms for an equivalent
    __slots__ class (200k iterations). This would take query build from 39.7x to roughly 7x. It
    touches the public oold.backend.interface API, so it is a deliberate change rather than a
    free win.
  • Link writes, currently 55.2x. Dominated by Ref construction and PrivateAttr access on
    the write path; __slots__ on Ref and avoiding the private-attr lookup should recover much
    of it.
  • Plain writes, 10.2x vs 4.9x for plain pydantic. Entirely the extra __setattr__ frame.
    Classes with no link fields need no override at all, so the base class should install it
    conditionally.

Rust potential. After the fix above, the binding hot path is already C-level (an instance
dict lookup), so there is essentially nothing left for Rust to win there, and pydantic's
validation core is Rust (pydantic-core) already. The real Rust opportunities are elsewhere in
the stack:

  • JSON-LD processing.pyld is pure Python and dominates RDF export: to_jsonld() costs
    120.7 us/op versus 19.7 us/op for to_json(), i.e. 6x, essentially all of it context
    expansion. A Rust-backed JSON-LD processor would attack the single most expensive operation in
    the library.
  • RDF and SPARQL.rdflib is likewise pure Python; pyoxigraph (Rust, oxigraph) is a
    drop-in-ish alternative for graph storage and SPARQL in the RDF backends.
  • Schema processing / code generation (bundling, $ref resolution over large schema graphs)
    is another candidate, though it is build-time rather than runtime.

Priority: the JSON-LD/RDF layer is where Rust would pay off, not the object binding.

Design rationale

The shipped design makes every attribute transparently resolve. Python has no cheap
whole-object proxy, so that choice forces __getattribute__ plus a metaclass. Per-field
descriptors give the same transparency for just the link fields, at native cost for everything
else - which is why they reach parity while gating cannot.

Cross-language, every ecosystem that handles this well either makes resolution explicit
(Rust/TreeLDR IdRef<T>, Java OGM sessions, Datomic pull) or has a language-level proxy
that makes transparency cheap (JavaScript Proxy). Python has neither at whole-object level,
but the descriptor protocol provides exactly the per-field equivalent, and Ref[T] covers the
explicit camp for async and batched control. Supporting both front-ends therefore matches the
two durable designs found elsewhere rather than picking one.

Remaining work

  • pydantic v1: the prototype is v2-only (__pydantic_init_subclass__). model/v1/__init__.py is a full parallel implementation and the package generator emits both v1 and v2, so a v1 path or a decision to drop v1 is required.
  • Public-API equivalence with the shipped LinkedBaseModel (to_json / to_jsonld / from_json / from_jsonld / cast / BaseController / Model["iri"]) must be demonstrated before adoption so osw-python is unaffected.
  • The prototype has no dedicated unit-test suite yet; it is currently covered by the two matrix scripts.

Proposal

Adopt the descriptor binding with both front-ends: the implicit, annotation-based form as the default (unchanged syntax, so generated packages are untouched), and the explicit Link / LinkList form for hand-written models. Keep Ref[T] as an opt-in handle for explicit or async resolution.

Reproduce

python examples/check_binding_features.py # requirement matrix
python examples/bench_binding_variants.py # performance matrix

Correction: an earlier revision of this issue stated that the current implementation issues N backend calls for an N-item list. That was wrong - the shipped binding already batches list resolution into one call. Batching is parity, not a gain.

Metadata

Metadata

Labels

M2Core Features Complete

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions