Search
Search documentation
Working with Tables in Rust
Updated Oct 28, 2026
Load, save, query, and validate tabular data in Rust. Tables are represented as Polars LazyFrame values for high-performance processing.
Available Functions
The table API provides utilities for working with tabular data:
load_table- Load a table from aResource(format-agnostic)save_table- Save a table to disk in a specified formatinfer_table- Load a table, inferring its missing dialect and schemavalidate_table- Validate table data against a Table Schemaquery_table- Run a SQL query against a loaded tableinspect_table- Validate an in-memory table and collect errorsinfer_table_schema- Infer a Table Schema from a resourceinfer_table_schema_from_table- Infer a Table Schema from an in-memory tablerender_table_schema_as- Render a Table Schema through the render pluginsnormalize_table/denormalize_table- Convert between typed and string representations
load_table, save_table, infer_table, validate_table, infer_table_schema and render_table_schema_as live in fairspec; the rest, and the per-format loaders, in fairspec::fairspec_table.
Loading a Table
Load a table from a Resource. The format is detected from the file extension or dialect, and dispatched to the appropriate plugin:
use fairspec::fairspec_table::LoadTableOptions;
use fairspec::load_table;
use serde_json::json;
let options = LoadTableOptions::default();
// Load a local CSV
let resource = serde_json::from_value(json!({"data": "data.csv"}))?;
let table = load_table(&resource, &options)?;
// Load a remote file
let resource = serde_json::from_value(json!({"data": "https://example.com/data.csv"}))?;
let table = load_table(&resource, &options)?;
// Load with an explicit dialect
let resource = serde_json::from_value(json!({
"data": "data.csv",
"fileDialect": {"format": "csv", "delimiter": ";"},
}))?;
let table = load_table(&resource, &options)?;load_table returns Option<Table>: None means no plugin recognised the resource's format. Table is an alias for polars::prelude::LazyFrame, so call .collect() to materialise it:
use fairspec::fairspec_table::LoadTableOptions;
use fairspec::load_table;
if let Some(table) = load_table(&resource, &LoadTableOptions::default())? {
let df = table.collect()?;
println!("{df}");
}Format-specific loaders are available when you want to bypass detection: load_csv_table, load_json_table, load_xlsx_table, load_arrow_table, load_parquet_table, load_sqlite_table, load_inline_table. See the per-format guides for their options.
Saving a Table
Save a table to disk. The path is required; the file_dialect controls the output format:
use fairspec::fairspec_table::SaveTableOptions;
use fairspec::save_table;
use serde_json::json;
save_table(table.clone(), &SaveTableOptions {
path: "output.csv".into(),
..Default::default()
})?;
// Custom dialect, overwriting the existing file
save_table(table, &SaveTableOptions {
path: "output.csv".into(),
file_dialect: Some(serde_json::from_value(json!({"format": "csv", "delimiter": "\t"}))?),
overwrite: Some(true),
..Default::default()
})?;SaveTableOptions:
path(required) โ output pathfile_dialectโ output dialect (auto-detected from path if omitted)table_schemaโ optional Table Schema (drives type-aware serialisation)overwriteโ replace an existing file instead of erroring
A save takes the table by value; clone() a LazyFrame to save it more than once โ it only copies the query plan.
Inferring a Table
infer_table loads a table, inferring any dialect and schema the resource leaves out:
use fairspec::fairspec_table::{InferTableSchemaOptions, LoadTableOptions};
use fairspec::{infer_resource, infer_table};
use serde_json::json;
let resource = serde_json::from_value(json!({"data": "data.csv"}))?;
let table = infer_table(&resource, &LoadTableOptions::default())?;
// The resource is borrowed, not filled in: infer_resource returns it with metadata
let resource = infer_resource(&resource, None, &InferTableSchemaOptions::default())?;
println!("{:?}", resource.file_dialect);
println!("{:?}", resource.table_schema);Python's infer_table fills the resource in place; in Rust, infer_resource returns the resource with its name, file_dialect, table_schema and integrity populated.
Validating a Table
Validate table data against a Table Schema:
use fairspec::fairspec_metadata::{PathOr, Resource};
use fairspec::{ValidateTableOptions, validate_table};
use serde_json::json;
// Validate with an inferred schema
let mut resource: Resource = serde_json::from_value(json!({"data": "data.csv"}))?;
let report = validate_table(&resource, &ValidateTableOptions::default())?;
// Validate against a provided schema
resource.table_schema = Some(PathOr::Value(schema));
let report = validate_table(&resource, &ValidateTableOptions::default())?;
if !report.valid {
for error in &report.errors {
let error = serde_json::to_value(error)?;
println!("row {}: {}", error["rowNumber"], error["type"]);
}
}The returned Report has valid: bool and errors: Vec<FairspecError>. FairspecError is an enum over the error families; serialise it with serde to read its wire fields: type (for example cell/type, row/primary-key, column/missing), rowNumber, columnName, cell. Error messages from metadata validation are jsonschema-rs wording, not Python's.
Custom Checks
checks adds rules a Table Schema cannot express. Each check has a name and a condition: a Polars SQL condition or a Polars expression. A row fails when its condition is false; null passes, as in a SQL CHECK constraint. Conditions see the values typed by the schema.
use fairspec::fairspec_table::polars::prelude::{col, lit};
use fairspec::fairspec_table::{TableCheck, TableCheckCondition};
use fairspec::{ValidateTableOptions, validate_table};
use serde_json::json;
let check = |name: &str, condition: TableCheckCondition| TableCheck {
name: name.into(),
condition,
column_name: None,
message: None,
};
let resource = serde_json::from_value(json!({"data": "orders.csv"}))?;
let report = validate_table(&resource, &ValidateTableOptions {
checks: Some(vec![
check("end-after-start", TableCheckCondition::Sql(r#""end" >= start"#.into())),
TableCheck {
column_name: Some("price".into()),
..check("positive-price", TableCheckCondition::Expr(col("price").gt(lit(0))))
},
check(
"repeat-customer",
TableCheckCondition::Sql("count(*) OVER (PARTITION BY customer) >= 2".into()),
),
TableCheck {
message: Some("The export is empty".into()),
..check("not-empty", TableCheckCondition::Sql("count(*) > 0".into()))
},
]),
..Default::default()
})?;- A condition that reduces to one value, such as
count(*) > 0, checks the whole table and reportstable/check - Any other condition reports
row/checkfor each failing row, orcell/checkwith the cell value when the check names acolumn_name - A window (
OVER (PARTITION BY โฆ)in SQL,.over(โฆ)in Polars) checks each row against its group messageis carried on every error the check reports- A check that cannot run (an unknown column, a syntax error, a condition that is not boolean) reports
check, and the other checks still run - Quote column names that are SQL keywords:
"end" >= start
SQL-only checks also deserialise from JSON as SqlTableCheck and convert with .into().
For an already-loaded table, use inspect_table to collect errors against an in-memory schema:
use fairspec::fairspec_table::inspect_table;
let errors = inspect_table(table, Some(&schema), None, Some(1000), Some(100), None)?;Its arguments are the table, the schema, the checks, sample_rows, max_errors and concurrency.
Querying Tables
Run SQL queries against a loaded table using the Polars SQL engine. The table is registered as self. An ORDER BY item that names no null order sorts nulls last, in ascending and descending order alike, as the Table Schema sets:
use fairspec::fairspec_table::query_table;
let filtered = query_table(table.clone(), "SELECT * FROM self WHERE age > 25")?;
let aggregated = query_table(
table,
"SELECT region, SUM(amount) AS total FROM self GROUP BY region",
)?;
println!("{}", filtered.collect()?);query_table returns a Table (lazy), so further Polars operations can be chained before calling .collect(). query_tables takes several (name, table) pairs, each exposed under its name.
Inferring a Table Schema
Two entry points depending on what you have:
use fairspec::fairspec_table::{InferTableSchemaOptions, infer_table_schema_from_table};
use fairspec::infer_table_schema;
use serde_json::json;
// From a Resource (file)
let resource = serde_json::from_value(json!({"data": "data.csv"}))?;
let schema = infer_table_schema(&resource, &InferTableSchemaOptions::default())?;
// From an already-loaded Table
let schema = infer_table_schema_from_table(table, &InferTableSchemaOptions {
sample_rows: Some(100),
confidence: Some(0.9),
..Default::default()
})?;InferTableSchemaOptions includes sample_rows, confidence, keep_strings, comma_decimal, month_first and concurrency, plus the table_schema_options (column_types, column_properties, missing_values, date/time format strings). Use these to bias inference when defaults pick the wrong type. It deserialises from the same camelCase keys as Python's options.
columnProperties gives part of the schema up front and infers the rest. A property with a type
replaces the inferred one as written; one without a type keeps the inferred type and adds
title, description or rdfType to it, so give the type to add constraints. It takes precedence over columnTypes, and
naming a column the table lacks is an error. A resource's own tableSchema still replaces inference
entirely.
use fairspec::infer_table_schema;
use serde_json::json;
let resource = serde_json::from_value(json!({"data": "data.csv"}))?;
let schema = infer_table_schema(&resource, &serde_json::from_value(json!({
"columnProperties": {
"id": {"type": "string"},
"price": {"title": "Price"},
},
}))?)?;The inferred schema detects:
- Column types โ string, integer, number, boolean, date, date-time, local-date-time, time, local-time, duration, etc.
- Required columns based on null presence
- Enum values for low-cardinality columns
- Numeric constraints (minimum, maximum)
Example:
use fairspec::fairspec_table::polars::prelude::{IntoLazy, df};
use fairspec::fairspec_table::{InferTableSchemaOptions, infer_table_schema_from_table};
let table = df!(
"id" => ["1", "2", "3"],
"price" => ["10.50", "25.00", "15.75"],
"date" => ["2023-01-15", "2023-02-20", "2023-03-25"],
"active" => ["true", "false", "true"],
)?
.lazy();
let schema = infer_table_schema_from_table(table, &InferTableSchemaOptions {
sample_rows: Some(100),
confidence: Some(0.9),
..Default::default()
})?;Rendering a Table Schema
render_table_schema_as asks the render plugins for the requested format:
use fairspec::fairspec_metadata::RenderTableSchemaOptions;
use fairspec::render_table_schema_as;
let markdown = render_table_schema_as(&schema, &RenderTableSchemaOptions {
format: "markdown".into(),
..Default::default()
})?;No Rust plugin renders yet, so the result is None for every format.
Inferring a File Dialect
Detect a file's dialect (CSV delimiter, JSON pointer, Excel sheet, etc.) without loading the table:
use fairspec::fairspec_dataset::InferFileDialectOptions;
use fairspec::infer_file_dialect;
use serde_json::json;
let resource = serde_json::from_value(json!({"data": "data.csv"}))?;
let dialect = infer_file_dialect(&resource, &InferFileDialectOptions::default())?;See Files for the broader file-operations API.
Normalizing Tables
normalize_table converts string-typed columns into their schema-declared types (integer, number, date, boolean, etc.):
use fairspec::fairspec_metadata::TableSchema;
use fairspec::fairspec_table::normalize_table;
use fairspec::fairspec_table::polars::prelude::{IntoLazy, df};
use serde_json::json;
let table = df!(
"id" => ["1", "2", "3"],
"price" => ["10.50", "25.00", "15.75"],
"active" => ["true", "false", "true"],
"date" => ["2023-01-15", "2023-02-20", "2023-03-25"],
)?
.lazy();
let schema: TableSchema = serde_json::from_value(json!({
"properties": {
"id": {"type": "integer"},
"price": {"type": "number"},
"active": {"type": "boolean"},
"date": {"type": "string", "format": "date"},
},
}))?;
let typed = normalize_table(table, &schema)?;denormalize_table does the reverse โ converts a typed table back into a representation suitable for saving as CSV or JSON:
use fairspec::fairspec_table::{DenormalizeColumnOptions, denormalize_table};
let raw = denormalize_table(table, &schema, &DenormalizeColumnOptions {
native_types: Some(vec!["string".into(), "number".into(), "boolean".into()]),
})?;Defining a Table Schema
Build schemas from a descriptor with column properties and constraints:
use fairspec::fairspec_metadata::TableSchema;
use serde_json::json;
let schema: TableSchema = serde_json::from_value(json!({
"properties": {
"id": {"type": "integer", "minimum": 1},
"name": {"type": "string", "minLength": 1, "maxLength": 100},
"email": {"type": "string", "pattern": r"^[^@]+@[^@]+\.[^@]+$"},
"age": {"type": "integer", "minimum": 0, "maximum": 150},
"status": {"type": "string", "enum": ["active", "inactive", "pending"]},
},
"required": ["id", "name", "email"],
"primaryKey": ["id"],
"uniqueKeys": [["email"]],
}))?;Serde does not enforce the standard's constraints; check a schema descriptor with validate_table_schema from fairspec::fairspec_metadata.
Supported Column Types
- Primitive โ
string,integer,number,boolean - Temporal โ
date,time,local-time,date-time,local-date-time,duration;date-timeandtimevalues carry a UTC offset and are read as UTC - Spatial โ
geojson,wkt,wkb - Complex โ
array,list,object - Specialised โ
email,url,categorical,base64,hex
The Table Type
Table is an alias for polars::prelude::LazyFrame, and fairspec::fairspec_table::polars re-exports the Polars version Fairspec is built with:
use fairspec::fairspec_table::Table;
use fairspec::fairspec_table::polars::prelude::{IntoLazy, df};
let table: Table = df!("id" => [1, 2, 3])?.lazy();This means the full Polars lazy API (filters, joins, group-by, window functions, โฆ) is available directly on any loaded table โ load_table, query_table, and normalize_table all return LazyFrame. Call .collect() when you want a materialised DataFrame.
Common Workflows
Explore Unknown Data
use fairspec::fairspec_dataset::InferFileDialectOptions;
use fairspec::fairspec_table::{LoadTableOptions, query_table};
use fairspec::{infer_file_dialect, infer_table};
use serde_json::json;
let resource = serde_json::from_value(json!({"data": "unknown-data.txt"}))?;
println!("{:?}", infer_file_dialect(&resource, &InferFileDialectOptions::default())?);
if let Some(table) = infer_table(&resource, &LoadTableOptions::default())? {
println!("{}", table.clone().limit(10).collect()?);
let preview = query_table(table, "SELECT * FROM self LIMIT 10")?;
println!("{}", preview.collect()?);
}Schema-Driven Validation
use fairspec::fairspec_metadata::Resource;
use fairspec::fairspec_table::InferTableSchemaOptions;
use fairspec::{ValidateTableOptions, infer_table_schema, validate_table};
use serde_json::json;
let sample = serde_json::from_value(json!({"data": "sample.csv"}))?;
let schema = infer_table_schema(&sample, &InferTableSchemaOptions::default())?;
let resource: Resource = serde_json::from_value(json!({
"data": "production.csv",
"tableSchema": schema,
}))?;
let report = validate_table(&resource, &ValidateTableOptions::default())?;
assert!(report.valid, "{:?}", report.errors);Format Conversion
use fairspec::fairspec_table::{LoadTableOptions, SaveTableOptions};
use fairspec::{load_table, save_table};
use serde_json::json;
let resource = serde_json::from_value(json!({"data": "data.csv"}))?;
if let Some(table) = load_table(&resource, &LoadTableOptions::default())? {
save_table(table, &SaveTableOptions {
path: "data.parquet".into(),
file_dialect: Some(serde_json::from_value(json!({"format": "parquet"}))?),
..Default::default()
})?;
}Examples
CSV Data Analysis
use fairspec::fairspec_table::{LoadTableOptions, query_table};
use fairspec::load_table;
use serde_json::json;
let resource = serde_json::from_value(json!({"data": "sales.csv"}))?;
let table = load_table(&resource, &LoadTableOptions::default())?.ok_or("unsupported format")?;
let top_customers = query_table(
table,
"
SELECT customer, SUM(amount) AS total
FROM self
GROUP BY customer
ORDER BY total DESC
LIMIT 10
",
)?;
println!("{}", top_customers.collect()?);Multi-Format Pipeline
use fairspec::fairspec_table::{LoadTableOptions, query_table};
use fairspec::{ValidateTableOptions, load_table, validate_table};
use serde_json::json;
let resource = serde_json::from_value(json!({
"data": "report.xlsx",
"fileDialect": {"format": "xlsx", "sheetName": "Q1 Sales"},
}))?;
let table = load_table(&resource, &LoadTableOptions::default())?.ok_or("unsupported format")?;
let totals = query_table(table, "SELECT region, SUM(revenue) FROM self GROUP BY region")?;
println!("{}", totals.collect()?);
let report = validate_table(&resource, &ValidateTableOptions::default())?;
assert!(report.valid);Remote Data Validation
use fairspec::fairspec_metadata::Resource;
use fairspec::fairspec_table::InferTableSchemaOptions;
use fairspec::{ValidateTableOptions, infer_table_schema, validate_table};
use serde_json::json;
let remote = serde_json::from_value(json!({"data": "https://api.example.com/export.csv"}))?;
let schema = infer_table_schema(&remote, &InferTableSchemaOptions::default())?;
let local: Resource = serde_json::from_value(json!({
"data": "local-data.csv",
"tableSchema": schema,
}))?;
let report = validate_table(&local, &ValidateTableOptions::default())?;
for error in &report.errors {
let error = serde_json::to_value(error)?;
println!("row {}: {}", error["rowNumber"], error["type"]);
}Created with โค and Livemark