Skip to main content

A statically type-safe DataFrame abstraction layer

Project description

Colnade logo

Colnade

CI Coverage PyPI Python 3.10+

A statically type-safe DataFrame abstraction layer for Python.

Colnade replaces string-based column references (pl.col("age")) with typed descriptors (Users.age), so column misspellings and type mismatches are caught by your type checker — before your code runs.

Works with ty, mypy, and pyright. No plugins, no code generation.

Installation

pip install colnade colnade-polars

Colnade requires Python 3.10+. Install the backend adapter for your engine:

Backend Install
Polars pip install colnade-polars
Pandas pip install colnade-pandas
Dask pip install colnade-dask

Quick Start

1. Define a schema

import colnade as cn

class Users(cn.Schema):
    id: cn.Column[cn.UInt64]
    name: cn.Column[cn.Utf8]
    age: cn.Column[cn.UInt64]
    score: cn.Column[cn.Float64]

2. Create or read typed data

from colnade_polars import from_rows, read_parquet

# From Python data — schema drives dtype coercion
df = from_rows(Users, [
    Users.Row(id=1, name="Alice", age=30, score=85.0),
    Users.Row(id=2, name="Bob", age=25, score=92.5),
])

# Or from files
df = read_parquet("users.parquet", Users)
# df is DataFrame[Users] — the type checker knows the schema

3. Transform with type safety

# Column references are attributes, not strings
result = (
    df.filter(Users.age > 25)
      .sort(Users.score.desc())
      .select(Users.name, Users.score)
)

4. Bind to an output schema

class UserSummary(cn.Schema):
    name: cn.Column[cn.Utf8]
    score: cn.Column[cn.Float64]

output = result.cast_schema(UserSummary)
# output is DataFrame[UserSummary]

Safety Model

Colnade catches errors at three levels:

  1. In your editor — misspelled columns, schema mismatches at function boundaries, and nullability violations are flagged by your type checker (ty, pyright, mypy) before code runs
  2. At data boundaries — runtime validation ensures files and external data match your schemas (columns, types, nullability) and that expressions reference columns from the correct schema
  3. On your data valuesField() constraints validate domain invariants like ranges, patterns, and uniqueness

Where static safety ends: Static checking covers column references and schema-preserving operations (filter, sort, with_columns). Schema-transforming operations (select, group_by) return DataFrame[Any] — you call cast_schema() to assert the output schema, and runtime validation (if enabled) verifies it. No type checker plugin is needed, but this means output schemas aren't inferred statically. See Type Checker Integration for the full list of what is and isn't checked.

Key Features

Type-safe column references

Column references are class attributes verified by the type checker at lint time:

Users.name   # Column[Utf8] — valid
Users.naem   # ty error: Class `Users` has no attribute `naem`

Schema-preserving operations

Operations that don't change the schema (filter, sort, limit, with_columns) preserve the type parameter:

def process(df: DataFrame[Users]) -> DataFrame[Users]:
    return df.filter(Users.age > 25).sort(Users.score.desc())

Typed expressions

Column descriptors build an expression tree with typed operators:

Users.age > 18          # Expr[Bool] — comparison
Users.score * 2         # Expr[Float64] — arithmetic
(Users.age > 18) & (Users.score > 80)  # Expr[Bool] — logical
Users.name.str_starts_with("A")        # Expr[Bool] — string method

Aggregations

class UserStats(cn.Schema):
    name: cn.Column[cn.Utf8]
    avg_score: cn.Column[cn.Float64]
    user_count: cn.Column[cn.UInt64]

result = df.group_by(Users.name).agg(
    Users.score.mean().alias(UserStats.avg_score),
    Users.id.count().alias(UserStats.user_count),
)

Conditional expressions

df.with_columns(
    cn.when(Users.age > 65).then(cn.lit("senior")).otherwise(cn.lit("standard")).alias(Users.tier)
)

Vertical concatenation

combined = cn.concat(df_jan, df_feb, df_mar)  # DataFrame[Sales]

Null handling

# Fill nulls, filter nulls, check nulls
df.with_columns(Users.score.fill_null(0.0).alias(Users.score))
df.filter(Users.score.is_not_null())
df.drop_nulls(Users.score)

Joins with typed output

joined = users.join(orders, on=Users.id == Orders.user_id)
# JoinedDataFrame[Users, Orders] — both schemas accessible

# mapped_from tells cast_schema which source column maps to each target column
class UserOrders(cn.Schema):
    user_name: cn.Column[cn.Utf8] = cn.mapped_from(Users.name)
    amount: cn.Column[cn.Float64]  # same name as Orders.amount — matched automatically

result = joined.cast_schema(UserOrders)

Schema-polymorphic utility functions

Write generic functions that work with any schema:

from colnade.schema import S

def first_n(df: cn.DataFrame[S], n: int) -> cn.DataFrame[S]:
    return df.head(n)

# Works with any schema — type preserved
users_subset: cn.DataFrame[Users] = first_n(users_df, 10)

Struct and List support

class Address(cn.Schema):
    city: cn.Column[cn.Utf8]
    zip_code: cn.Column[cn.Utf8]

class UserProfile(cn.Schema):
    name: cn.Column[cn.Utf8]
    address: cn.Column[cn.Struct[Address]]
    tags: cn.Column[cn.List[cn.Utf8]]
    tag_count: cn.Column[cn.UInt32]

# Access nested data
df.filter(UserProfile.address.field(Address.city) == "New York")
df.with_columns(UserProfile.tags.list.len().alias(UserProfile.tag_count))

Value-level constraints

import colnade as cn

class Users(cn.Schema):
    id: cn.Column[cn.UInt64] = cn.Field(unique=True)
    age: cn.Column[cn.UInt64] = cn.Field(ge=0, le=150)
    email: cn.Column[cn.Utf8] = cn.Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")
    status: cn.Column[cn.Utf8] = cn.Field(isin=["active", "inactive"])

    @cn.schema_check
    def adult(cls):
        return Users.age >= 18

# Validate with df.validate() or auto-validate at the FULL level
cn.set_validation(cn.ValidationLevel.FULL)

Lazy execution

from colnade_polars import scan_parquet

lazy = scan_parquet("users.parquet", Users)
# LazyFrame[Users] — builds a query plan

result = lazy.filter(Users.age > 25).sort(Users.score.desc()).collect()
# Executes the optimized query plan

Performance

Colnade translates an expression AST into engine-native calls, adding a fixed cost per operation that doesn't grow with dataset size. For Polars and Pandas, this overhead is below the measurement noise floor — benchmarked pipelines (filter + sort + select) show no measurable difference from 100 to 1M rows. Dask graph construction adds ~200–300 us per operation, negligible compared to .compute() time.

Pipeline overhead vs dataset size — Raw Polars and Colnade lines overlap completely

Validation (STRUCTURAL, FULL) adds measurable cost at data boundaries and is designed for development/CI, not production hot paths. See the full benchmark results for details.

Type Checker Error Showcase

Colnade catches real errors at lint time. Here are actual error messages from ty:

ty catching Colnade type errors — misspelled column, schema mismatch, nullability mismatch

Comparison with Existing Solutions

Feature Colnade Pandera StaticFrame Patito Narwhals
Column refs checked statically Named attrs No Positional types No No
Schema preserved through ops Through ops¹ At boundaries² Positional No No
Works with existing engines Polars, Pandas, Dask Pandas, Polars, others Own engine Polars only Many engines
No plugins or code gen Yes Optional mypy plugin Yes Yes Yes
Generic utility functions Yes No No No No
Struct/List typed access Yes No No No No
Lazy execution support Yes No No No Yes
Value-level constraints Field() Check CallGuard Pydantic validators No
Maturity / ecosystem New (v0.8) Mature, large community Mature Small Growing fast
Engine breadth 3 backends 4+ backends Own engine 1 backend 6+ backends
select/group_by output typing DataFrame[Any]³ Decorator-checked Positional types No No

¹ Schema-preserving ops (filter, sort, with_columns) retain DataFrame[S]. Schema-transforming ops (select, group_by) return DataFrame[Any] — use cast_schema() to bind. ² Pandera supports Polars (since v0.19), Pandas, and others. @check_types validates schemas at function boundaries, but column references within function bodies remain unchecked strings. ³ cast_schema() re-binds at runtime. A type checker plugin could theoretically infer output schemas, but Colnade intentionally avoids plugin coupling.

See Detailed Comparisons for a fuller discussion of tradeoffs.

Documentation

Full documentation is available at colnade.com, including:

Examples

Runnable examples are in the examples/ directory:

Limitations

Colnade provides static type safety for the most common DataFrame operations, but it is not a complete static type system for DataFrames. Know these limitations before adopting:

  • select() and group_by().agg() return DataFrame[Any] — these operations change the column set, so the output schema must be asserted via cast_schema(). A type checker plugin could infer output schemas, but Colnade intentionally avoids plugin coupling.
  • Joins require cast_schema()JoinedDataFrame is a transitional type. You must cast_schema() to a flat output schema before further operations like group_by.
  • Runtime validation is OFF by default — set cn.set_validation("structural") or COLNADE_VALIDATE=structural to enable. Validation adds overhead and is designed for development/CI.
  • List accessor returns Any.list operations produce ListOp[Any] due to a ty limitation with property self-types. The annotations are in place and will become precise in a future ty release.

See Type Checker Integration for the full list of what is and isn't checked statically.

Contact

Bug reports and feature requests: GitHub Issues. For anything else: jay@colnade.com.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

colnade-0.8.2.tar.gz (314.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

colnade-0.8.2-py3-none-any.whl (35.3 kB view details)

Uploaded Python 3

File details

Details for the file colnade-0.8.2.tar.gz.

File metadata

  • Download URL: colnade-0.8.2.tar.gz
  • Upload date:
  • Size: 314.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for colnade-0.8.2.tar.gz
Algorithm Hash digest
SHA256 f4d94288ef056ec62765d55ae8431af94d2b62cd02225aeb55b5db306b2b7446
MD5 97eb81f774e6aaee2b027c9f3a5bce59
BLAKE2b-256 8a6a0120de703fcea47f04698900d5bae434380520d6cfdc658813072c71c9a5

See more details on using hashes here.

Provenance

The following attestation bundles were made for colnade-0.8.2.tar.gz:

Publisher: publish.yml on jwde/colnade

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file colnade-0.8.2-py3-none-any.whl.

File metadata

  • Download URL: colnade-0.8.2-py3-none-any.whl
  • Upload date:
  • Size: 35.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for colnade-0.8.2-py3-none-any.whl
Algorithm Hash digest
SHA256 735f861a8797745d9eabe87aee2756622c803ed5b5f99b0cceeb16b79dbdbf92
MD5 42677200aa7c00d7cf81209b6c6a0a19
BLAKE2b-256 54d1197ddde7db3b83b8eb1ca43d82652d993c2e0d675d0435fc9d7d5f7c1665

See more details on using hashes here.

Provenance

The following attestation bundles were made for colnade-0.8.2-py3-none-any.whl:

Publisher: publish.yml on jwde/colnade

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page