schema-sanitizer
Version 0.2.1: this project is still being tuned and tested, especially for generating Parquet files used by BigQuery external tables.
schema-sanitizer converts messy CSV, JSON, JSON Lines, NDJSON, XML, and
Parquet data into stable analytical tables or sanitized files. The native C++23
core handles schema inference, scalar/container reconciliation, field
versioning, bounded streaming, and Arrow C Data materialization.
Index
- Install
- Public API
- Input Formats
- Input Mode
- Shared Parameters
- Paths And Input Selection
- Schema And Field Handling
- String Scalar Parsing
- Source-Specific Parsing
- Errors And Resources
- Configuration Examples
- Result
- ETL Generated Columns
- Schema Reconciliation
- Field Names
- Timestamp Precision
- Depth Limits
- Memory Safety And Tuning
- Filesystems
- Example 7
- Development
- License
Install
pip install 'schema-sanitizer[pyarrow]'
Optional analytical targets:
pip install 'schema-sanitizer[pandas]'
pip install 'schema-sanitizer[polars]'
pip install 'schema-sanitizer[duckdb]'
pip install 'schema-sanitizer[all]'
import schema_sanitizer as ss
Public API
All public operations are named to_*.
In-memory analytical functions:
| Function | Result.clean_data |
|---|---|
to_pyarrow(...) |
pyarrow.Table |
to_pandas(...) |
pandas.DataFrame |
to_polars(...) |
polars.DataFrame |
to_duckdb(...) |
DuckDB relation |
File-to-file functions:
| Function | Output |
|---|---|
to_csv(input_path, output_path, ...) |
CSV file |
to_jsonl(input_path, output_path, ...) |
JSON Lines file |
to_parquet(input_path, output_path, ...) |
Parquet file |
events = ss.to_pyarrow(
"raw/events.jsonl",
input_format="jsonl",
)
customers = ss.to_pandas(
"raw/customers.csv",
input_format="csv",
)
ss.to_parquet(
"raw/events.jsonl",
"silver/events.parquet",
input_format="jsonl",
)
All seven functions expose the same input and cleaning options. File-to-file
functions additionally take output_path.
Input Formats
input_format must always be selected explicitly. The signature default is
None, but calling any to_* function with None raises an error. Neither
None nor "auto" infers a format from the extension or file contents.
The selected format also validates the source extension. For .json files,
choose "json" for one document treated as one row or "json_array" for a
top-level array of row objects.
input_format |
Required extension | Content |
|---|---|---|
csv |
.csv |
Delimited rows |
json |
.json |
One JSON document treated as one source row |
json_array |
.json |
Top-level array containing JSON objects |
jsonl |
.jsonl |
One JSON object per line |
ndjson |
.ndjson |
One JSON object per line |
xml |
.xml |
XML document or streamed xml_row_tag elements |
parquet |
.parquet or .pq |
Parquet rows |
jsonl and ndjson use the same newline-delimited JSON parser. Their only
difference is the required extension.
Valid JSONL or NDJSON:
{"a": 1}
{"a": 2}
Valid json_array:
[
{"id": 1, "name": "Ana"},
{"id": 2, "name": "Luis"},
{"id": 3, "name": "Marta"}
]
Every top-level json_array element must be an object. The array is split
incrementally into rows instead of being materialized as one nested value.
Passing a mismatched extension fails before ingestion:
# Raises: jsonl requires .jsonl, not .ndjson
ss.to_pyarrow("events.ndjson", input_format="jsonl")
Input Mode
input_mode accepts:
| Value | Behavior |
|---|---|
single_file |
Default. Process exactly one source file. |
directory |
Process matching direct child files in deterministic filename order. |
Directory traversal is non-recursive. Files with other extensions and nested directories are ignored.
table = ss.to_pyarrow(
"raw/2026-01/",
input_format="jsonl",
input_mode="directory",
).clean_data
Directory behavior:
jsonlreads only direct.jsonlchildren.ndjsonreads only direct.ndjsonchildren.jsonreads direct.jsondocuments as rows.json_arrayflattens each direct.jsonarray into rows.csvremoves repeated matching headers and rejects header mismatches.xmlcombines direct.xmldocuments and requires a compatible root/row tag.parquetstreams direct.parquetand.pqchildren.
Directory mode requires an explicit input_format.
Shared Parameters
result = ss.to_pyarrow(
input_path,
input_format="jsonl",
input_mode="single_file",
schema_mode="additive",
column_order="alphabetically",
field_name_policy="lower_alpha",
timestamp_precision="TIMESTAMP_MICROS",
parse_integers=False,
parse_floats=False,
parse_float_decimal_separator=".",
parse_float_thousands_separator=",",
parse_iso_timestamps=False,
parse_iso_dates=False,
parse_iso_times=False,
true_tokens=(),
false_tokens=(),
custom_timestamp_patterns=(),
custom_date_patterns=(),
custom_time_patterns=(),
arrow_max_depth=32,
parquet_max_depth=15,
scalar_object_key="default_key",
csv_has_header=True,
csv_delimiter=",",
input_text_encoding="utf-8",
xml_row_tag=None,
on_error="emit_null_row",
batch_memory_limit_bytes=None,
read_chunk_bytes=1024 * 1024,
schema_registry=None,
)
Paths And Input Selection
| Parameter | Default | Accepted values / example | Use |
|---|---|---|---|
input_path |
Required | "events.jsonl", Path("events.csv"), "gs://bucket/events.jsonl" |
Source file or directory. Local paths and supported PyArrow filesystem URIs are accepted. |
output_path |
Required for file sinks | "events.parquet", "s3://bucket/events.jsonl" |
Destination used only by to_csv, to_jsonl, and to_parquet. |
input_format |
None (raises) |
"csv", "json", "json_array", "jsonl", "ndjson", "xml", "parquet" |
Required parser selection. The default None and "auto" are rejected. The selected format validates the source extension. |
input_mode |
"single_file" |
"single_file", "directory" |
Process one source file or all matching direct children of one directory. Directory traversal is non-recursive. |
Schema And Field Handling
| Parameter | Default | Accepted values / example | Use |
|---|---|---|---|
schema_mode |
"additive" |
"additive", "strict" |
additive preserves the registry contract and adds compatible fields or versions. strict rejects incompatible input and requires a registry-derived schema. |
column_order |
"alphabetically" |
"alphabetically", "schema_contract_first" |
Order fields recursively. schema_contract_first keeps registered fields first and appends new fields deterministically. |
field_name_policy |
"lower_alpha" |
"lower_alpha", "lower_snake", "preserve" |
Sanitize every field name. lower_alpha keeps lowercase a-z; lower_snake also keeps digits and _; preserve retains source spelling. |
scalar_object_key |
"default_key" |
"value", "raw_value" |
Child field used when reconciling a scalar with a struct, for example 5 becomes {"default_key": 5}. The name is processed by the selected field-name policy. |
arrow_max_depth |
32 |
8, 16, 32 |
Maximum expanded Arrow container depth. Structs and lists count; deeper values are flattened to string-compatible output. |
parquet_max_depth |
15 |
8, 12, 15 |
Maximum Parquet/BigQuery RECORD depth. List wrappers do not add a RECORD level. |
schema_registry |
None |
Python mapping, registry JSON string, or None |
Previous registry used as the source of truth for incremental conversion and historical reprocessing. None starts a new registry. |
String Scalar Parsing
These options apply to string values such as CSV cells, XML text, and quoted JSON values. Actual JSON numbers and booleans are already typed by JSON syntax and do not depend on these options.
| Parameter | Default | Accepted values / example | Use |
|---|---|---|---|
parse_integers |
False |
True, False |
Convert integer-looking strings such as "42" and "-7" to int64. |
parse_floats |
False |
True, False |
Convert float-looking strings such as "12.5" or "1,234.56" to float64. |
parse_float_decimal_separator |
"." |
".", "," |
Decimal separator used when parse_floats=True. Must be one ASCII punctuation character. |
parse_float_thousands_separator |
"," |
",", ".", "_" |
Optional grouping separator used when parse_floats=True. It must differ from the decimal separator and grouped sections must contain exactly three digits. |
true_tokens |
() |
("true", "yes", "y") |
Case-insensitive string tokens converted to Boolean True. An empty sequence disables custom string-to-Boolean parsing. |
false_tokens |
() |
("false", "no", "n") |
Case-insensitive string tokens converted to Boolean False. True and false token sets must not overlap. |
parse_iso_timestamps |
False |
True, False |
Parse built-in ISO timestamps such as "2026-01-02T03:04:05Z" or "2026-01-02 03:04:05+01:00". |
parse_iso_dates |
False |
True, False |
Parse built-in ISO dates in YYYY-MM-DD form. |
parse_iso_times |
False |
True, False |
Parse built-in ISO times in HH:MM:SS form. |
custom_timestamp_patterns |
() |
(r"(\d{4})/(\d{2})/(\d{2}) (\d{2}):(\d{2}):(\d{2})",) |
Additional timestamp patterns. Capture groups 1-6 represent year, month, day, hour, minute, and second; optional groups 7 and 8 represent fraction and timezone. |
custom_date_patterns |
() |
(r"(\d{4})#(\d{2})#(\d{2})",) |
Additional date patterns. Capture groups 1-3 represent year, month, and day. |
custom_time_patterns |
() |
(r"(\d{2})|(\d{2})|(\d{2})",) |
Additional time patterns. Capture groups 1-3 represent hour, minute, and second. |
timestamp_precision |
"TIMESTAMP_MICROS" |
"TIMESTAMP_MILLIS", "TIMESTAMP_MICROS", "TIMESTAMP_NANOS" |
Arrow and Parquet unit used after timestamp parsing. Microseconds are the BigQuery-compatible default. |
Source-Specific Parsing
| Parameter | Default | Accepted values / example | Use |
|---|---|---|---|
csv_has_header |
True |
True, False |
Treat the first CSV row as field names. In directory mode, repeated matching headers are removed. |
csv_delimiter |
"," |
",", ";", "\t", `" |
"` |
input_text_encoding |
"utf-8" |
"utf-8", "utf-16", "latin-1" |
Decode text inputs. Python codec names and aliases are accepted and normalized. It does not affect Parquet input. |
xml_row_tag |
None |
None, "row", "item" |
Stream each direct matching XML element as one row. None treats the complete XML document as one row. |
Errors And Resources
| Parameter | Default | Accepted values / example | Use |
|---|---|---|---|
on_error |
"emit_null_row" |
"stop", "skip_row", "emit_null_row" |
Stop immediately, drop an offending row, or retain it while writing null for fields that cannot be materialized. |
batch_memory_limit_bytes |
None |
64 * 1024 * 1024, 256 * 1024 * 1024, None |
Best-effort native inference/materialization budget per batch or document. Lower values reduce peak memory at a possible throughput cost. |
read_chunk_bytes |
1024 * 1024 |
256 * 1024, 4 * 1024 * 1024 |
Streaming source read-buffer size. Smaller chunks use less transient memory and perform more reads. |
ISO timestamp, date, and time parsing is opt-in. With all three parse_iso_*
flags left at False, ISO-looking source strings remain strings. The
custom_*_patterns options are independent: configured custom patterns are
still applied even when the corresponding built-in ISO parser is disabled.
Float separator options apply only when parse_floats=True and only to string
values, including CSV cells and XML text. Real JSON numbers always use JSON's
. decimal syntax. Grouping is strict: the default configuration accepts
"1,234.56", while European input can use:
result = ss.to_pyarrow(
"prices.csv",
input_format="csv",
parse_floats=True,
parse_float_decimal_separator=",",
parse_float_thousands_separator=".",
)
That configuration accepts "1.234,56" and "1234,56". Grouped sections
after the first must contain exactly three digits. In comma-delimited CSV,
values containing commas must be quoted.
Configuration Examples
European numeric and semicolon-delimited CSV:
prices = ss.to_pyarrow(
"prices.csv",
input_format="csv",
csv_delimiter=";",
parse_floats=True,
parse_float_decimal_separator=",",
parse_float_thousands_separator=".",
).clean_data
Custom Boolean and temporal strings:
events = ss.to_pandas(
"events.ndjson",
input_format="ndjson",
true_tokens=("yes", "active"),
false_tokens=("no", "inactive"),
parse_iso_timestamps=True,
custom_date_patterns=(r"(\d{4})-(\d{2})-(\d{2})",),
).clean_data
Strict incremental conversion using an existing registry:
result = ss.to_parquet(
"raw/events.jsonl",
"silver/events.parquet",
input_format="jsonl",
schema_mode="strict",
schema_registry=previous_result.schema_registry,
on_error="stop",
)
Memory-first processing of a large directory:
result = ss.to_parquet(
"raw/2026-01/",
"silver/2026-01.parquet",
input_format="jsonl",
input_mode="directory",
batch_memory_limit_bytes=64 * 1024 * 1024,
read_chunk_bytes=256 * 1024,
)
to_csv, to_jsonl, and to_parquet return Result.clean_data is None.
Analytical functions return their named in-memory object.
Result
Every public function returns schema_sanitizer.Result.
| Property | Description |
|---|---|
clean_data |
Analytical object, or None for file outputs |
stats |
Inference, materialization, batching, depth, and error counters |
schema_registry / schema_registry_json |
Updated registry state |
schema_drifts / schema_drifts_json |
Drift events generated by this run |
Analytical and file outputs use the same registry-backed native path, so they produce the same schema and metadata behavior.
ETL Generated Columns
Every analytical and file conversion adds these fixed top-level columns:
| Column | Behavior |
|---|---|
source_file |
Full local/cloud file path, or the input directory path in directory mode |
ingestion_timestamp |
Native UTC conversion timestamp |
schema_registry |
Canonical schema and field-version registry |
schema_drifts |
Drift events generated for this input |
These columns contain values only in the first output row. Remaining rows are null to avoid repeating large registry payloads.
The names are part of the ETL output contract and cannot be configured. They
are reserved at the top level: conversion fails before writing if the source
schema already contains any of them. A nested source key such as
payload.source_file is allowed because it does not conflict with the
generated top-level columns; normal field-name sanitization still applies to
that nested key. Reserved top-level source fields are not renamed or versioned,
since silently doing so would make downstream registry discovery ambiguous.
Schema Reconciliation
The embedded schema_registry is the source of truth for incremental
processing. Pass the latest registry to the next conversion:
result = ss.to_parquet(
"raw/2026-01-09/events.jsonl",
"silver/2026-01-09/events.parquet",
input_format="jsonl",
schema_registry=previous_registry,
)
next_registry = result.schema_registry
Before generating a field version, the native merge attempts compatible reconciliation:
- A singleton can be wrapped into an existing list.
- A scalar can be wrapped into an existing struct under
default_key. - Empty objects are treated as null.
- New compatible struct children are added as nullable fields.
Irreconcilable drift creates _vN at the lowest incompatible schema level.
sentiment_analysis: struct<...>
sentiment_analysis_v2: list<struct<
magnitude: double,
magnitude_v2: string
>>
The struct-to-list drift versions sentiment_analysis. A later
float-to-string drift versions only magnitude; it does not create
sentiment_analysis_v3.
Existing exact historical variants are preferred during past-date
reprocessing. Otherwise the newest compatible container is evolved
recursively. Repeating an already known shape does not increment
schema_generation.
Materialization routes one source value to one most-compatible sibling:
- Arrays prefer list variants.
- Numeric values prefer numeric scalar variants.
- Ordinary strings prefer string variants.
- Parse-enabled numeric and temporal strings can target typed variants.
Each drift event receives a native UTC detected_at timestamp. The same
timestamp is written to the output file's first-row ingestion_timestamp, so
the materialized file and its drift audit events share one conversion time.
Source partition identity remains available through source_file and any Hive
partition columns, including during historical reprocessing.
Field Names
field_name_policy="lower_alpha" keeps lowercase a-z only.
lower_snake keeps lowercase letters, digits, and underscores. preserve
keeps source names.
Collisions use deterministic suffixes derived from the original dirty key, so source field order does not change the dirty-key to clean-key mapping.
Timestamp Precision
Accepted values:
TIMESTAMP_MILLISTIMESTAMP_MICROS(default)TIMESTAMP_NANOS
Microseconds are the default because BigQuery external tables support Parquet
timestamp micros. BigQuery does not accept Parquet TIMESTAMP_NANOS.
Depth Limits
arrow_max_depth counts struct and list containers. parquet_max_depth counts
Parquet/BigQuery RECORD levels; list wrappers do not add a RECORD level.
Over-depth nested values are flattened to string-compatible output rather than allowing unbounded schema expansion.
Memory Safety And Tuning
The pipeline uses replayable streaming sources, bounded inference batches, and
streaming file writers. batch_memory_limit_bytes controls the approximate
per-batch budget.
Memory-first settings for large files:
ss.to_parquet(
"raw/large.jsonl",
"silver/large.parquet",
input_format="jsonl",
batch_memory_limit_bytes=64 * 1024 * 1024,
read_chunk_bytes=256 * 1024,
)
64 * 1024 * 1024 is 64 MiB.
Trade-offs:
- Lower
batch_memory_limit_bytesreduces peak memory and may reduce speed. - Lower
read_chunk_bytesreduces transient input buffers and increases read calls. - Parquet decoding enables threads only when the memory budget is large enough.
- Directory mode processes direct child files incrementally rather than loading the full directory at once.
- CSV directory normalization holds at most one configured-size source file in memory while validating and removing repeated headers.
Filesystems
Input and output paths may be local paths or PyArrow filesystem URIs such as:
file:///data/events.jsonl
s3://bucket/events/2026-01-09/events.jsonl
gs://bucket/events/2026-01-09/events.jsonl
abfs://container/events/2026-01-09/events.jsonl
Directory listing uses the same filesystem and is non-recursive.
Example 7
examples/example_07/07_gcs_jsonl_to_silver_parquet_range_prefix.py implements
a single-writer daily GCS JSON-array-to-Parquet pipeline with:
- explicit
input_format="json_array" .jsonsource extension validation- integer, float, ISO timestamp, ISO date, and ISO time string parsing enabled
- source existence discovery and missing-date skipping
- embedded registry retrieval through Arrow ADBC
- incremental and random past-date reprocessing
- one final BigQuery external-table create/replace operation
Development
pip install -e .[dev]
pytest
Native build:
cmake -S . -B build/dev -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build/dev
License
Apache License 2.0. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file schema_sanitizer-0.2.1.tar.gz.
File metadata
- Download URL: schema_sanitizer-0.2.1.tar.gz
- Upload date:
- Size: 334.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
efd05e2e595dac6fea926ac8645b74586191403087857abb63e79ec34d0a3e26
|
|
| MD5 |
56fb3069ec0611f735a423bcd42031d7
|
|
| BLAKE2b-256 |
3af05cb9e06737857f155dec39f1e32ea978d7e9e7dc33c2f3f4e9276d3772cc
|
File details
Details for the file schema_sanitizer-0.2.1-cp311-abi3-win_amd64.whl.
File metadata
- Download URL: schema_sanitizer-0.2.1-cp311-abi3-win_amd64.whl
- Upload date:
- Size: 691.1 kB
- Tags: CPython 3.11+, Windows x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
73ea699d8c4f74e206f1f4a17f8aa49ae06d6afbf6576feb09e35cd3d9fb2e5b
|
|
| MD5 |
d3505c44ecbb48a54b5960e42221d06a
|
|
| BLAKE2b-256 |
045f94b780815ce0711c1acebc25ac992fc0cda69621f4c133226a5b1762b0fb
|
File details
Details for the file schema_sanitizer-0.2.1-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl.
File metadata
- Download URL: schema_sanitizer-0.2.1-cp311-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- Upload date:
- Size: 646.2 kB
- Tags: CPython 3.11+, manylinux: glibc 2.27+ x86-64, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3f556cc1a14574ea0b72749d39bcd871c5c6f25b19a2b3962ca79062cc417926
|
|
| MD5 |
7eb0dbebd86a97c51643f4e20a80d6ce
|
|
| BLAKE2b-256 |
d8d2647374ef3bce460bea4e2664c29513ae032216ff61e6c987a52b68e8d1f8
|
File details
Details for the file schema_sanitizer-0.2.1-cp311-abi3-macosx_11_0_arm64.whl.
File metadata
- Download URL: schema_sanitizer-0.2.1-cp311-abi3-macosx_11_0_arm64.whl
- Upload date:
- Size: 512.4 kB
- Tags: CPython 3.11+, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f2fc690aef0b460e411d44bfe11f240dfb75de5d939c4204ec42bc77b10c8dde
|
|
| MD5 |
54a57860b4b762a77b19db9759833969
|
|
| BLAKE2b-256 |
4a2d82f0d1fbcf16650f338232a05f95fb92a64c3ef27b7db17b061528a80106
|
File details
Details for the file schema_sanitizer-0.2.1-cp311-abi3-macosx_10_9_x86_64.whl.
File metadata
- Download URL: schema_sanitizer-0.2.1-cp311-abi3-macosx_10_9_x86_64.whl
- Upload date:
- Size: 538.7 kB
- Tags: CPython 3.11+, macOS 10.9+ x86-64
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.2.0 CPython/3.11.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
64d8d96bd9334b0ca0c860c23c5f4d4be4d26315253c85c4607808219f24813c
|
|
| MD5 |
3b34dfeabb85ca0d6fa1629417e8104e
|
|
| BLAKE2b-256 |
b3693922bcd6906c57a6191accdb0fe7dffd39796aa4fd3b7946593db4cd8c39
|