Skip to content

Adding a dataframe backend

A backend connects one or more dataframe classes and one native schema class to Data Joinery. The DataFrameBackend[Frame, NativeSchema] protocol makes those types part of the interface: create_dataframe and coerce_dataframe return Frame, while compile_schema returns NativeSchema. Register one backend instance with register_backend.

Import the protocol and registration/lookup functions from data_joinery.backends. The protocol is defined in backends/protocol.py; registry state, registration validation, lookup, and lazy built-in discovery live in backends/registry.py. Importing these modules does not load Polars or PySpark. Lookup discovers installed built-in backends when no registered backend matches

What to implement

Member What it does
name Gives the backend a unique name.
dataframe_types Lists the frame classes accepted and returned by this backend.
schema_type Identifies the native schema class returned by compile_schema.
compile_schema(schema) Maps each ModelSchema field and annotation to a native schema. Handle nested and backend-specific field types here.
create_dataframe(rows, schema, frame_type, **kwargs) Checks the model rows, builds the requested frame class with the compiled schema, and handles backend-specific options. Reject unknown options.
coerce_dataframe(frame, schema, mode) Checks or reshapes a frame according to the requested mode. Return the same frame class or raise SchemaCoercionError for schema mismatches.

Use ModelSchema[T].serialize_rows(rows) to validate model instances before creating a frame. It returns list[dict[str, Any]]: dataclass rows become dictionaries through dataclasses.asdict(), and Pydantic rows through model_dump(mode="python"). Nested models are converted to dictionaries as well.

The four coercion modes have distinct promises:

Mode Required behavior
strict Check field names and types without removing or casting fields. Extra and missing fields fail.
project_top_level Remove extra top-level fields. Check nested fields and types without changing them.
project Remove extra fields, including nested fields. Check types without casting.
project_cast Project fields and cast types where the native library allows it. Report casts that cannot be made.

Every mode rejects missing fields. See Coercing Schemas for the user-facing behavior. Use SchemaCoercionError and SchemaDifference to describe missing, additional, and mismatched fields.

A small example

This toy frame holds only column names, so its native schema is a tuple of names. It shows the registration and method shapes; a real backend must also handle values, field types, and nested fields.

from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any

from data_joinery import Schema
from data_joinery.backends import DataFrameBackend, register_backend
from data_joinery.schemas.coercion_error import (
    CoercionMode,
    SchemaCoercionError,
    SchemaDifference,
)
from data_joinery.schemas.model_schema import ModelSchema


@dataclass(frozen=True)
class ExampleDataFrame:
    columns: tuple[str, ...]


class ExampleBackend(DataFrameBackend[ExampleDataFrame, tuple[str, ...]]):
    name = "example"
    dataframe_types = (ExampleDataFrame,)
    schema_type = tuple

    def compile_schema(self, schema: ModelSchema[Any]) -> tuple[str, ...]:
        return tuple(field.name for field in schema.fields)

    def coerce_dataframe(
        self,
        dataframe: ExampleDataFrame,
        schema: ModelSchema[Any],
        mode: CoercionMode,
    ) -> ExampleDataFrame:
        expected = self.compile_schema(schema)
        missing = tuple(
            SchemaDifference("missing", name, None, name)
            for name in expected
            if name not in dataframe.columns
        )
        additional = tuple(
            SchemaDifference("additional", name, name, None)
            for name in dataframe.columns
            if name not in expected
        )
        violations = missing + (additional if mode == "strict" else ())
        if violations:
            raise SchemaCoercionError(mode, violations)
        if mode == "strict":
            return dataframe
        return ExampleDataFrame(expected)

    def create_dataframe(
        self,
        rows: Sequence[object],
        schema: ModelSchema[Any],
        frame_type: type[ExampleDataFrame],
        **kwargs: object,
    ) -> ExampleDataFrame:
        if kwargs:
            raise TypeError(f"Unexpected options: {', '.join(kwargs)}")
        schema.serialize_rows(rows)
        return frame_type(self.compile_schema(schema))


backend: DataFrameBackend[ExampleDataFrame, tuple[str, ...]] = ExampleBackend()
register_backend(backend)


@dataclass
class Order:
    order_id: int


print(Schema(Order).native_schema(tuple))
print(Schema(Order).create_dataframe([Order(1)], ExampleDataFrame))

Outputs:

('order_id',)
ExampleDataFrame(columns=('order_id',))

dataframe_types is a tuple of classes, while schema_type takes one class. Subclasses are recognized when finding a backend; when creating a frame or native schema, the returned object must also match the class requested by the caller. Registration rejects duplicate names and overlapping frame or schema classes across backends, so each type has one clear owner.

Connecting it to the rest of the library

Register the backend before using its frame or schema class. Schema.native_schema() finds it by schema_type; Schema.create_dataframe() and frame coercion find it by the classes in dataframe_types. Transform annotations use the same lookup.

Backends written against the earlier protocol need to replace dataframe_type with dataframe_types = (FrameClass,) and accept frame_type in create_dataframe().

Put the backend's native type mapping, row conversion, schema comparison, projection, and casting rules in its implementation. The built-in Polars and PySpark backends are complete examples in src/data_joinery/backends/polars/ and src/data_joinery/backends/spark/.