Schema Annotations in Bauplan 0.3.x

An overview of the new SDK: how to write typed pipelines and what Bauplan validates for you.
Colin Marc
Oct 28, 2026

This week, we released Bauplan 0.3.0, which offers a new syntax for defining and validating pipelines. This week we’re deprecating the old SDK, so we thought it’d be a good time to step back and offer an overview of the new features.

Our goal with the new SDK is to offer the same sort of tradeoff a compiler or linter does: invest more time up front, and the system will catch bugs early for you. As we wrote previously, we think that this tradeoff is even more valuable if you’re coding primarily with agents. Either way, you’ll find lots to like about the new annotations. Even if the new syntax can take a bit of getting used to!

How to write pipelines with the new SDK

At the heart of the new syntax is the TableSchema type:

class RawTrips(TableSchema):  # (1)
    """Columns read from taxi_fhvhv."""

    PULocationID: Int64 | None
    request_datetime: TimestampMicroUTC | None
    on_scene_datetime: TimestampMicroUTC | None

class WaitTimes(TableSchema):  # (2)
    """How long riders waited for their taxi."""

    PULocationID: Annotated[Int64, TableField(lineage=RawTrips["PULocationID"])]  # (3)
    wait_seconds: Annotated[
        Int64,
        TableField(doc="Seconds between the ride request and the driver's arrival."),
    ]

@bauplan.model()
@bauplan.python("3.12", pip={"polars": "2.0.0"})
def wait_times(
    trips: Annotated[pa.Table, Model("taxi_fhvhv", projection_schema=RawTrips)],  # (4)
) -> Annotated[pa.Table, WaitTimes]:  # (5)
    import polars as pl

    trips = pl.from_arrow(trips).drop_nulls()
    return compute_wait_seconds(trips).to_arrow()  # elided for clarity

Here we’ve defined a model in a pipeline, an input schema RawTrips, and an output schema WaitTimes. The types are attached to the model using the Annotated syntax:

  • RawTrips [1] is used as the projection_schema [4] for reading from taxi_fhvhv. Note that any valid subset of columns with compatible types (any projection) can be used here. The system automatically pushes the projection down, and transparently casts input types to the types in RawTrips if that can be done in a lossless way.
  • WaitTimes [2] is defined as the output of the function [5].
  • We use lineage [3] to track that PULocationID is the same field as in the raw table.

Why do we require this?

Each Bauplan model function represents the transformation between two data types, for example, a transformation from raw data in the lake to a cleaned-up representation.

If you take the transformations as the edges in a DAG, then these schemas are tagging the nodes. Because we don’t look inside your transformations and can’t reason about the Python code inside, this extra metadata gives us the ability to offer new error checks and planning-time optimizations that weren’t available before.

Using schemas also benefits the code itself; both the types and the docstrings serve as documentation for anyone working on the code. And this documentation outlives the code; it’s saved as part of the metadata for the table and versioned along with it, in each commit.

Anyone with access to the table can then fetch it (and previous versions!) via the API; as standardized Iceberg metadata; and of course via the CLI, Python SDK, and MCP:

$ bauplan table get wait_times
Table Documentation:
Dispatched trips and how long the rider waited for the driver.

COLUMN        TYPE  NULLABLE  DOC
PULocationID  long  false     -
wait_seconds  long  false     Seconds between the ride requ...

Note: some documentation was truncated. Use `-O json` to see it in full.

What Bauplan will validate for you

At planning time

If any of the schemas don’t match the actual input, we’ll reject that immediately, before anything runs.

For example, if you try to read from taxi_fhvhv with the following schema instead:

class RawTrips(TableSchema):
    """Columns read from taxi_fhvhv."""

    PULocationID: Int64  # Missing `| None` to indicate the column is nullable
    request_datetime: TimestampMicroUTC | None
    on_scene_datetime: TimestampMicroUTC | None

You will get the following error:

- "PULocationID" has incompatible projection:
  - constraint mismatch: cannot cast from optional to required

This may seem like a small thing, but if you’ve ever spent hours hunting down the source of nulls in a complicated pipeline, then you know the value of this type of static check.

At runtime

We can’t verify ahead of time that the code inside wait_times produces a table without nulls. But because we set WaitTimes as the output schema, the system will validate the output efficiently as it runs. If you remove the call to drop_nulls(), you’ll get the following error at runtime:

- "wait_seconds" has incompatible projection:
  - constraint mismatch: cannot cast from optional to required

This serves two purposes:

  • It prevents bugs in code from corrupting the data. This serves as a test that your code does what you say it does, efficiently and without having to run separate SQL queries or expensive data checks.
  • It lets downstream code rely on these constraints. If you say a column doesn’t have nulls in it, the system enforces that, so other pipelines using this table don’t have to be written defensively.

Upcoming work

Not everything we wanted to build made it in time for the cutoff, but we have a bunch of relevant features in the pipeline.

More types

Currently, most primitive Arrow types are supported, but we’ll be adding more soon. In particular:

  • Decimal128, with configurable scale/precision
  • Arbitrarily nested struct, list, and map types

In the meantime, you can use bauplan.Any for types that aren’t currently expressible, and we’ll skip validation for those.

More constraints

Today, the only constraint we can validate at runtime is nullability. We’re planning to extend that to arbitrary row-level checks, for example that an integer value is nonzero.

Better ergonomics

Although schema annotations are required in this release, we’re working to loosen that constraint, so that it’s easier to do quick, ad-hoc workflows or add types gradually to complicated pipelines.

We also recognize that the Annotated[...] syntax is quite verbose. We are aiming for correctness with this release, but we’re looking for ways to make the syntax more beautiful and easier for humans to read. To that end, we’re keeping a close eye on PEP 835.

Freely mixing SQL and Python models

Because SQL is a typed language, we can feed the type annotations from upstream Python models into the SQL planner. This will allow something we don’t currently: using SQL models in the middle of a DAG, downstream of Python models (without jinja templating!).

Better diagnostics, faster

In parallel with the new SDK, we’ve been working on a rewrite of our planner from the ground up, with the extra benefits of these type annotations built in from the get-go.

It’s still a work in progress; stay tuned for news in the coming months, but we can share a few details:

  • It’s much faster than the current planner. Excluding reads from the catalog, we can plan a DAG with hundreds of nodes in less than a millisecond. With all the fetching we need to do, we’re hopeful we can get to a worst-case time of around 50 ms for the vast majority of pipelines, so that you can iterate faster.
  • We’ve put a lot of effort into making errors more legible, and adding pointers to the source. For example, here’s the error above presented a different way:
Error: invalid `projection_schema`
    ╭─[ models.py:42:50 ]
    │
 42 │         bauplan.Model('trips', projection_schema=MismatchedSchema),
    │                                                  ────────┬───────
    │                                                          ╰───────── here
    │
    ├─[ models.py:42:50 ]
    │
  7 │ class PickupsSchema(bauplan.TableSchema):
    │       ──────┬──────
    │             ╰──────── the output of `trips` is defined here
    │
 18 │     PULocationID: bauplan.Int64
    │                   ──────┬──────
    │                         ╰──────── this must be `Int64 | None`,
    │                                   because `PULocationID` may contain nulls

Further resources

We have detailed documentation available for the new SDK on our documentation page. If you're migrating from the old syntax, we've also published some skills that make the process quick and painless.

If you'd like help, or if you have any questions or feedback, we'd be delighted to hear from you!

Share on

More From Our Blog

Love Python and Go development, serverless runtimes, data lakes and Apache Iceberg, and superb DevEx? We do too! Subscribe to our newsletter.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.