Skip to content

Schema for Adapter Authors

When your format has a known set of columns, declaring them upfront with schema_order and field_types is the single largest performance gain available to adapter authors. In the crxml reference adapter, explicit schema lifts throughput from 4.2 GB/s to 7.6 GB/s on production data (+80%).

Why schema matters

Without schema declaration, the engine must discover column names (one full I/O pass), reconcile column order across parallel chunks (merge-time sorting), and store intermediate strings before casting (double memory, double CPU).

With schema declaration, column names are known before parsing, every chunk produces identical column order, and Arrow arrays are built directly from parsed values with no intermediate strings.

Mode 533 MB throughput RSS Notes
Auto discovery 4,497 MB/s 88 MB Discovery adds ~5.3 ms
Explicit schema 4,980 MB/s 88 MB No discovery, fast export
Explicit schema + projection 7,630 MB/s 87 MB row_satisfied byte-jump

The two schema knobs

schema_order: column names and output order

schema_order tells the engine which columns exist and in what order they appear in the output. When set, the engine skips column discovery entirely.

use rypipe_core::ExecutionPlan;
// Declare the exact columns and their output order
let plan = ExecutionPlan::new()
    .schema_order(["id", "timestamp", "amount", "status"]);
# Python: pass schema as a list of column names { #python-pass-schema-as-a-list-of-column-names }
source = MyAdapter("data.log", schema=["id", "timestamp", "amount", "status"])

field_types: typed arrays during parse

field_types tells the engine which Arrow storage type to use for each column. Without it, all values are stored as strings and cast later.

use rypipe_core::{ExecutionPlan, FieldType};
// Declare types so values are parsed directly into Arrow arrays
let plan = ExecutionPlan::new()
    .schema_order(["id", "amount", "timestamp"])
    .type_as("id", FieldType::Int64)
    .type_as("amount", FieldType::Float64)
    .type_as("timestamp", FieldType::Timestamp(arrow::datatypes::TimeUnit::Microsecond));
# Python: map column names to type strings for direct parsing { #python-map-column-names-to-type-strings-for-direct-parsing }
source = MyAdapter(
    "data.log",
    schema=["id", "amount", "timestamp"],
    field_types={"id": "int64", "amount": "float64", "timestamp": "timestamp[us]"},
)

Supported types

Type string Rust variant Arrow type
string FieldType::String Utf8Array
int64 FieldType::Int64 Int64Array
float64 FieldType::Float64 Float64Array
bool / boolean FieldType::Boolean BooleanArray
dictionary FieldType::Dictionary DictionaryArray<Int32>
date32 FieldType::Date32 Date32Array
timestamp FieldType::Timestamp(Microsecond) Timestamp<Microsecond>
timestamp[s] FieldType::Timestamp(Second) Timestamp<Second>
timestamp[ms] FieldType::Timestamp(Millisecond) Timestamp<Millisecond>
timestamp[us] FieldType::Timestamp(Microsecond) Timestamp<Microsecond>
timestamp[ns] FieldType::Timestamp(Nanosecond) Timestamp<Nanosecond>

How the parser uses schema

Checking wants() before scanning

ColumnarSink::wants returns true if the engine needs a particular field. Check it before doing expensive extraction:

fn parse_chunk(&self, bytes: &[u8], sink: &mut dyn ColumnarSink) -> Result<()> {
    for row in self.split_rows(bytes) {
        sink.begin_row();
        for field in self.fields(row) {
            // Skip dropped fields entirely (no scanning, no decoding)
            if sink.wants(field.name) {
                sink.put_field(field.name, Value::Str(Cow::Borrowed(field.value)));
            }
        }
        sink.end_row();
    }
    Ok(())
}

When schema_order is set and a field is not in it, wants returns false, meaning no byte scanning, no UTF-8 decoding, and no hash lookup. In crxml, this saves ~66% of parse time on drop_all workloads.

Using resolve + put_field_resolved

For fields that appear rarely or require expensive extraction, use the resolve + put_field_resolved pair for a single hash probe instead of two:

fn parse_chunk(&self, bytes: &[u8], sink: &mut dyn ColumnarSink) -> Result<()> {
    for row in self.split_rows(bytes) {
        sink.begin_row();
        for field in self.fields(row) {
            // Single hash probe: resolve returns the column index
            if let Some(idx) = sink.resolve(field.name) {
                sink.put_field_resolved(idx, Value::Str(Cow::Borrowed(field.value)));
            }
        }
        sink.end_row();
    }
    Ok(())
}

Emitting typed values

When field_types is set, emit the correct Value variant to skip string-to-number conversion:

// Instead of emitting a string (requires later conversion):
sink.put_field("amount", Value::Str(Cow::Borrowed("123.45")));
// Emit the typed value directly (no intermediate string allocation)
let value: f64 = field.value.parse().map_err(|e| Error::Plan(e.to_string()))?;
sink.put_field("amount", Value::Float64(value));

Combining schema with fusion

schema_order and field_types compose cleanly with pipeline stages:

result = (
    MyAdapter(
        "data.log",
        schema=["id", "amount", "status"],
        field_types={"id": "int64", "amount": "float64"},
    )
    | RenameFields({"id": "record_id"})
    | DropFields(["internal_debug"])
    | FilterRows(field="amount", op=">", value="100.0")
).to_arrow()

The execution order: schema defines columns (no discovery), DropFields removes "internal_debug", RenameFields maps "id" to "record_id", FilterRows adds a predicate. During parse, wants("internal_debug") returns false (skipped), put_field("id", ...) maps to "record_id", and put_field("amount", ...) builds Float64Array directly. The filter checks amount > 100.0 per row with native f64 comparison. Without field_types, the filter falls back to string comparison or is skipped.

Tip

Partial schemas work too: declare the columns you know and the engine automatically appends unknown columns in parallel streaming. This is useful when the format has optional fields that appear only in some rows.

Common patterns

// All columns needed, fully typed
let plan = ExecutionPlan::new()
    .schema_order(["timestamp", "user_id", "action", "value"])
    .type_as("timestamp", FieldType::Timestamp(TimeUnit::Microsecond))
    .type_as("user_id", FieldType::Int64)
    .type_as("value", FieldType::Float64);
// Some columns dropped, one renamed
let plan = ExecutionPlan::new()
    .schema_order(["id", "name", "amount", "status"])
    .type_as("id", FieldType::Int64)
    .type_as("amount", FieldType::Float64)
    .drop("internal_debug")
    .rename("uid", "id")
    .filter_eq("status", "active");
// Partial schema: declare known columns, unknowns are appended automatically
let plan = ExecutionPlan::new()
    .schema_order(["id", "name", "amount"])
    .type_as("id", FieldType::Int64)
    .type_as("amount", FieldType::Float64);

Implementing schema in your adapter

Step 1: Accept schema kwargs

In your Python adapter, accept schema and field_types kwargs and pass them to the Rust reader:

class MyAdapter(rypipe.Adapter):
    def read(self, path, *, schema=None, field_types=None, **kwargs):
        # Forward schema kwargs to the Rust core
        plan_kwargs = {}
        if schema:
            plan_kwargs["schema"] = schema
        if field_types:
            plan_kwargs["field_types"] = field_types
        return _my_rust_core.read_file(path, **plan_kwargs, **kwargs)

Step 2: Build the plan in Rust

In your Rust parser, build the ExecutionPlan from the kwargs:

pub fn read_file(path: &str, schema: Option<Vec<String>>, field_types: Option<HashMap<String, String>>) -> Result<PyArrowTable> {
    let mut plan = ExecutionPlan::new();
    // Apply schema_order if provided
    if let Some(names) = schema { plan.schema_order = names; }
    // Apply field_types if provided
    if let Some(types) = field_types {
        for (name, type_str) in &types {
            if let Some(ft) = FieldType::from_str(type_str) {
                plan.field_types.insert(name.clone(), ft);
            }
        }
    }
    let pipeline = Pipeline::new(MySplitter::new(), MyParser::new())
        .with_plan(plan);
    pipeline.read_path(path, false, false)
}

Step 3: Use wants() and emit typed values

Check sink.wants() before scanning, and emit typed values when possible:

fn parse_chunk(&self, bytes: &[u8], sink: &mut dyn ColumnarSink) -> Result<()> {
    for row in self.split_rows(bytes) {
        sink.begin_row();
        for field in self.fields(row) {
            // Skip fields the user doesn't want
            if sink.wants(field.name) {
                let value = self.parse_value(field);
                sink.put_field(field.name, value);
            }
        }
        sink.end_row();
    }
    Ok(())
}

fn parse_value(&self, field: &Field) -> Value {
    match self.plan.column_type(field.name) {
        // Parse directly into Int64 (no string intermediate)
        FieldType::Int64 => Value::Int64(field.value.parse().unwrap_or(0)),
        // Parse directly into Float64 (no string intermediate)
        FieldType::Float64 => Value::Float64(field.value.parse().unwrap_or(0.0)),
        // Parse boolean from common truthy values
        FieldType::Boolean => Value::Bool(matches!(field.value, "true" | "1" | "yes")),
        // Fall back to string for unknown types
        _ => Value::Str(Cow::Borrowed(field.value)),
    }
}

Performance characteristics

Memory: Without field_types, each string value is heap-allocated and a casting pass converts strings to typed arrays afterward (peak 2x). With field_types, values are parsed directly into typed builders (peak 1x). For a 533 MB file with 10 columns, this saves ~200-400 MB.

CPU: Without field_types, every value is stored as a string then post-parse casting runs str::parse::<i64>() for each value (two passes). With field_types, values are parsed once directly into the correct type (one pass). Typical savings: 10-20% of total parse time for numeric-heavy workloads.

Export speed: Without schema_order, each batch may have different column order, forcing sequential merge (~4,497 MB/s). With schema_order, identical column order enables parallel export (~4,980 MB/s, +11%). With schema_order + projection, the scanner skips unwanted fields via row_satisfied byte-jump (~7,630 MB/s, +80%).

Warning

Without field_types, string-to-number filters compare lexicographically ("9" > "10" is true). Always set field_types on columns used in numeric filters to get native comparison.

Troubleshooting

"unknown field not in frozen schema": a field appeared that was not in schema_order. Add it, omit schema_order for full-scan discovery, or rely on partial schema (unknown columns are automatically appended in parallel streaming).

Column order does not match schema_order: check that you are calling sort_columns or using the fast export path. The merge path preserves discovery order.

Filter not working on numeric columns: ensure field_types is set for the filtered columns. Without it, values are strings and comparison is lexicographic.

Memory higher than expected: ensure field_types is set for all numeric columns. Without it, intermediate strings double memory for those columns.