Getting started¶
Declare a spec¶
A spec is a class. Subclass FrameSpec and assign a ColSpec per column, in
the order the columns should appear.
from datetime import date
import polars as pl
from polspec import ColSpec, FrameSpec
class Customers(FrameSpec):
customer_id = ColSpec(pl.Int64, bounds=(1, 100_000))
name = ColSpec(pl.String, string_length=(4, 20))
tier = ColSpec(pl.Enum(["free", "pro", "enterprise"]))
signed_up = ColSpec(pl.Date, bounds=(date(2020, 1, 1), None))
churned = ColSpec(pl.Boolean, nullable=True, null_probability=0.3)
Nothing runs at declaration time except validation of the declaration itself. A contradictory spec fails here, at the line that caused it, rather than thousands of rows later:
ColSpec(pl.Int8, bounds=(0, 1_000))
# ValueError: ColSpec.bounds max (1000) is outside the range Int8 can represent [-128, 127]
Generate data¶
seed makes the result reproducible across processes and machines. Omit it and
each call differs.
Validate data¶
validate() checks a DataFrame or LazyFrame against the same declaration and
returns it, so it drops into a pipeline:
Every breach is collected before anything is raised, so one call tells you everything that is wrong rather than only the first thing:
from polspec import ValidationError
broken_df = pl.DataFrame(
{
"customer_id": [100_050, 150_000, 200_000], # all past the upper bound
"name": ["Adam", None, "Alan"], # one null in a non-nullable column
"tier": ["trial", "trial", "pro"], # "trial" is not a tier
"signed_up": [date(2021, 5, 1)] * 3,
"churned": [None, True, False],
}
)
try:
Customers.validate(broken_df)
except ValidationError as err:
for problem in err.errors:
print(problem)
Column 'customer_id': found 3 value(s) out of bounds [1, 100000] (min found: 100050, max found: 200000). Out of bounds samples: [100050, 150000, 200000]
Column 'name': non-nullable column contains 1 null value(s)
Column 'tier': found 2 invalid value(s) not in allowed choices/categories ['free', 'pro', 'enterprise']. Invalid samples: ['trial']
One pass, not one per column
Every check across every column is compiled into a single Polars aggregation and evaluated in one scan. Validating a wide table costs about the same as validating a narrow one.
Handle data that nearly fits¶
Real input rarely arrives in exactly the declared shape. validate() takes
policies for the two structural mismatches:
Customers.validate(
df,
extra_cols="drop", # "raise" (default) | "drop" | "allow"
missing_cols="raise", # "raise" (default) | "add" | "allow"
strict_dtypes=False, # allow Int32 where Int64 was declared, String for an Enum
cast=True, # cast surviving columns to the declared dtype
)
By default a String column arriving where an Enum was declared is accepted —
that is how data comes back from CSV and JSON. strict_dtypes=True demands the
exact dtype. For a file someone else hands you, Checking a file you were
given walks through
the whole flow, dates included.
Infer a spec instead of writing one¶
Pointed at an existing DataFrame, polspec writes the spec for you:
existing_df = pl.DataFrame(
{
"customer_id": [1, 2, 3, 4],
"tier": ["free", "free", "pro", None],
}
)
Profiled = FrameSpec.from_dataframe(existing_df, weights=True)
print(Profiled.to_markdown())
It infers nullability and observed null rates, narrows low-cardinality strings
to Enum, and — with weights=True — records how often each category actually
occurred, so regenerated data keeps the observed mix rather than a uniform one.
Treat the result as a first draft to edit, not a finished contract: it describes the sample it saw, which may be narrower than the rule you actually mean.
Next¶
- Declaring columns — everything a
ColSpecaccepts - Constraints — rules, checks, uniqueness, foreign keys
- Generating data — coverage, batching, writing to files