emiliano-go/crxml

High-performance Crystal Reports XML parser built on the rypipe columnar ingestion engine.

Python

1

186 commits

updated Sep 17, 2026

See the code
big-data
crystal-reports
csv
data-engineering
dataframe
data-pipeline
data-processing
etl
etl-framework
high-performance
pandas
parser
pipeline
pyo3
python
rust
streaming
streaming-parser
xml
xml-parser

See what people are saying (2)

SourceMessageScoreDate

rypipe - Format- and source-agnostic ingestion framework (r/rust)

Hi! My name is Emiliano, I'm a data engineer by trade and developer by love. Almost a year ago a client came to me with a problem: parse \~5TB of Crystal Reports XML in under an hour. So I built the parser, and a few months later, open sourced it: [crxml](https://github.com/emiliano-go/crxml), a…

0

Sep 16, 2026

rypipe - Format- and source-agnostic ingestion framework (r/opensource)

Hi! My name is Emiliano, I'm a data engineer by trade and developer by love. Almost a year ago a client came to me with a problem: parse \~5TB of Crystal Reports XML in under an hour. So I built the parser, and a few months later, open sourced it: [crxml](https://github.com/emiliano-go/crxml), a…

5

Sep 16, 2026

README

crxml

Stream Crystal Reports XML at memory bandwidth.

crxml

High-performance Crystal Reports XML → Arrow/DataFrame engine for Python.

Parse, filter, rename, cast, and project Crystal Reports XML directly into
columnar data, with Rust execution, parallel parsing, bounded-memory
processing, and automatic query fusion.

Python License Tests PyPI Docs


Quick start

from crxml import CrystalXMLSource

source = CrystalXMLSource("report.xml", row_tag="Details")

# Row iteration: yields dicts lazily
for row in source:
    print(row["invoice"], row["amount"])

# DataFrame (auto-routes to parallel engine)
df = source.to_dataframe()
print(df.head())

That is it. df is a pandas DataFrame with zero-copy ArrowDtype strings, built in under a second for a 100 MB file.

For maximum throughput, declare the schema upfront:

schema = ["Level","Section","Field22","Field23","Field38","Field39",
          "Field61","Field73","FieldG","Text20"]
src = CrystalXMLSource("report.xml", row_tag="Details", schema=schema)
batches = src.iter_record_batches(memory="64MB", threads=16)

With pipeline stages fused into the Rust parse loop:

from crxml.stages import RenameFields, DropFields

pipeline = source | RenameFields({"f1": "invoice"}) | DropFields(["temp_id"])
df = pipeline.to_dataframe()

Performance tip: When your column set is known, pass schema=[...] to skip column discovery and enable the fast path. On production data this is the single largest performance lever (crxml goes from 4.2 GB/s to 7.6 GB/s on a 533 MB report), and the row_satisfied projection skip reaches 11 GB/s on benchmarks.


Why crxml

This library was originally inspired by carlosplanchon/xmlstreamer.

Crystal Reports XML exports are deeply nested: <Group> wraps <GroupHeader> wraps <Section> wraps <Details> wraps <Field>/<Text>/<FormattedValue>/ <Value>/<TextValue>. Standard XML libraries (ElementTree, SAX, lxml) spend most of their CPU time descending into children you do not need.

crxml skips the nesting:

  • The stream engine walks the XML once with a hand-rolled memchr scanner (src/crxml_core/src/xml/scanner.rs, scan_one_row scanner.rs:119 via RowSink src/crxml_core/src/lib.rs:603) and yields flat dicts: 508 MB/s 100 MB.
  • The parallel engine memory-maps the file, splits it at row boundaries (splitter.rs:57 find_split_points), and parses each chunk on its own thread into Arrow buffers directly (no dicts): up to 4.2 GB/s on high-cardinality production reports (533 MB real par128 4231) and 4.2 GB/s on uniform exports (1 GB par128 4158) via rypipe (rypipe-core Vec<ColumnBuilder>+field_index engine.rs:16, row_dirty engine.rs:26).
  • Pipeline stages that rename, cast, drop, or filter fields execute in the Rust parse loop, before any Python object is created.

Comparison: stream vs parallel vs parallel streaming (bounded, schema)

Taskstream (single)parallel (full RAM)parallel streaming (bounded)
Row iterationYields dicts lazilyArrow table first, then dicts (slower)Yields RecordBatches incrementally, stable schema
DataFrame / Table outputCollects dicts, convertsDirect Arrow buffers, zero-copySame, incremental + bounded
533 MB real export (Table)953 MB/s (single) / 723 MB/s (1 MB)4231 MB/s par128 (4.16 MB)3828 auto / 7630 explicit schema=[...] (2 MB)
1 GB (Table)940 MB/s4158 MB/s par1283782 auto / ~4900 explicit
Peak RssAnon (533 MB)24 MB (1 MB)137 MB88 MB (auto or explicit)
Pipeline fusionNo (dict path)Yes (Rust BuildPlan)Yes (same plan, streamed)
ParquetWriterN/AN/Awrite_batch succeeds (batches share schema schema.rs:14)

Auto discovery (16x2 MiB windows for >128 MB) adds ~15% (19 ms on 533 MB) so auto is -10% vs par128 (3828 vs 4231) but still bounded and incremental. Explicit schema=[...] (FrozenSchema::from_plan) avoids Discovery and is +80% vs par128 (7630 vs 4231). Fastest bounded mode needs explicit schema; auto is safe and bounded but slightly slower. Use iter_record_batches(memory="64MB", threads=16, schema=[...]) for the fast path.

Full benchmark details: like-for-like Table vs Vec, chunk-per-cell, fixed-chunk isolation, and schema cost.


Install

pip install crxml

The columnar and parallel engines are included by default. For performance profiling counters: pip install -e . --config-settings=--features=profile.


Features

CategoryWhat crxml handles
Stream engineRow-by-row XML parsing, yields dict[str, str], GIL-released batching
Columnar engineSingle-threaded Arrow table output, zero-copy string columns
Parallel engineMulti-threaded (rayon), file split at row boundaries, off-GIL parse
Bounded modememory="500MB" splits into chunks; RSS independent of file size
Pipeline fusionRenameFields, DropFields, CastTypes, FilterRows compile into Rust BuildPlan
mmapMemory-maps input files (default, zero-copy)
prefaultMADV_WILLNEED vs MADV_SEQUENTIAL for RSS/speed trade-off
Arrow sinksto_arrow(), to_pandas() (ArrowDtype), to_polars(), to_parquet()
Auto-dict encodingauto_dict=True encodes low-cardinality string columns
Field typingfield_types={"amount": "float64"} coerces at parse time
Filter pushdownfilter={"field": "Status", "op": "==", "value": "Active"} in Rust
CorrectnessAll engines validated byte-identical against stream oracle (29 test cases + 465k-row real cross-check)

Engine guide: parallel streaming (explicit schema) is opt-in

Engine / APIWhen to useThroughput 533 MB / 1 GBRssAnon
stream (for row in source)Row-by-row dict iteration723 MB/s 1 MB budget (24 MB anon)24 MB
columnar (single)Single-threaded Arrow Table953 / 940 MB/s134 MB
parallel (par128 full RAM, 4 MB)Fastest full-RAM Table4231 / 4158 MB/s137 MB
iter_record_batches(..., threads=16, schema=[...]) (explicit schema)Fastest bounded, stable schema, yields RecordBatches7630 / — MB/s88 MB
iter_record_batches(memory="64MB", threads=16) autoBounded + incremental, stable schema3828 / 3782 MB/s (-14% vs par, +15% Discovery)88 MB
bounded (memory="64MB" single)Single-thread bounded645 / 546 MB/s133 MB

Pass engine= explicitly, or let auto select per call. auto stays "parallel if it fits" (blocked: auto discovery adds 15% and would make auto slower until cheaper). Streaming is opt-in via iter_record_batches(..., threads=16), keeping 4 MB for par (src/crxml/source.py:164), 2 MB via budget/(threads*2) for streaming. Provide schema= for the fast path.

# Recommended bounded paths
from crxml import CrystalXMLSource
import pyarrow as pa, pyarrow.parquet as pq

src = CrystalXMLSource("report.xml", row_tag="Details")
# explicit schema: fastest, no Discovery, writer succeeds
schema = ["Level","Section","Field22","Field23","Field38","Field39","Field61","Field73","FieldG","Text20"]
src = CrystalXMLSource("report.xml", row_tag="Details", schema=schema)
batches = src.iter_record_batches(memory="64MB", threads=16)
# auto: stable but pays 15% Discovery (16×2 MiB windows for >128 MB)
batches = src.iter_record_batches(memory="64MB", threads=16)

# ParquetWriter (now works; batches share schema)
it = src.iter_record_batches(memory="64MB", threads=16)
first = next(it)
w = pq.ParquetWriter("out.parquet", first.schema)
w.write_batch(first)
for b in it: w.write_batch(b)
w.close()

Framework support

FrameworkIntegration
FastAPI / Starlette / LitestarParse in route handler, return DataFrame or Arrow table directly
Django / FlaskCall source.to_dataframe() in view; pass to template or response
Pandas / Polarssource.to_dataframe() / source.to_polars() for zero-copy analysis
Airflow / PrefectParse in task, write to parquet with source.to_parquet()
CLI / ETL scriptsUse to_csv() sink or iterate rows for line-by-line processing

Limitations

  • UTF-8 input only. UTF-16 exports (which Crystal Reports can produce) fail validation; convert first.
  • No compressed input. .gz/.zst files must be decompressed before parsing.
  • Crystal Reports grammar, not general XML. The flat-row model fits CR exports; arbitrary XML documents are out of scope.
  • Linux-tuned performance. madvise hints and thread-count ratios were measured on Linux; other platforms work but are untested territory.
  • No async API. Row iteration is synchronous.

Documentation

Full docs at crxml.emiliano-go.com covering:

  • All CrystalXMLSource parameters
  • Pipeline stages and fusion rules
  • Sink reference
  • Batch iteration and parallel distribution
  • Performance with phase breakdowns
  • Architecture and correctness

License

MIT

Contributors

emiliano-go

186 commits

emiliano-go/crxml

High-performance Crystal Reports XML parser built on the rypipe columnar ingestion engine.

Python

1

186 commits

updated Sep 17, 2026

See the code
big-data
crystal-reports
csv
data-engineering
dataframe
data-pipeline
data-processing
etl
etl-framework
high-performance
pandas
parser
pipeline
pyo3
python
rust
streaming
streaming-parser
xml
xml-parser

See what people are saying (2)

SourceMessageScoreDate

rypipe - Format- and source-agnostic ingestion framework (r/rust)

Hi! My name is Emiliano, I'm a data engineer by trade and developer by love. Almost a year ago a client came to me with a problem: parse \~5TB of Crystal Reports XML in under an hour. So I built the parser, and a few months later, open sourced it: [crxml](https://github.com/emiliano-go/crxml), a…

0

Sep 16, 2026

rypipe - Format- and source-agnostic ingestion framework (r/opensource)

Hi! My name is Emiliano, I'm a data engineer by trade and developer by love. Almost a year ago a client came to me with a problem: parse \~5TB of Crystal Reports XML in under an hour. So I built the parser, and a few months later, open sourced it: [crxml](https://github.com/emiliano-go/crxml), a…

5

Sep 16, 2026

README

crxml

Stream Crystal Reports XML at memory bandwidth.

crxml

High-performance Crystal Reports XML → Arrow/DataFrame engine for Python.

Parse, filter, rename, cast, and project Crystal Reports XML directly into
columnar data, with Rust execution, parallel parsing, bounded-memory
processing, and automatic query fusion.

Python License Tests PyPI Docs


Quick start

from crxml import CrystalXMLSource

source = CrystalXMLSource("report.xml", row_tag="Details")

# Row iteration: yields dicts lazily
for row in source:
    print(row["invoice"], row["amount"])

# DataFrame (auto-routes to parallel engine)
df = source.to_dataframe()
print(df.head())

That is it. df is a pandas DataFrame with zero-copy ArrowDtype strings, built in under a second for a 100 MB file.

For maximum throughput, declare the schema upfront:

schema = ["Level","Section","Field22","Field23","Field38","Field39",
          "Field61","Field73","FieldG","Text20"]
src = CrystalXMLSource("report.xml", row_tag="Details", schema=schema)
batches = src.iter_record_batches(memory="64MB", threads=16)

With pipeline stages fused into the Rust parse loop:

from crxml.stages import RenameFields, DropFields

pipeline = source | RenameFields({"f1": "invoice"}) | DropFields(["temp_id"])
df = pipeline.to_dataframe()

Performance tip: When your column set is known, pass schema=[...] to skip column discovery and enable the fast path. On production data this is the single largest performance lever (crxml goes from 4.2 GB/s to 7.6 GB/s on a 533 MB report), and the row_satisfied projection skip reaches 11 GB/s on benchmarks.


Why crxml

This library was originally inspired by carlosplanchon/xmlstreamer.

Crystal Reports XML exports are deeply nested: <Group> wraps <GroupHeader> wraps <Section> wraps <Details> wraps <Field>/<Text>/<FormattedValue>/ <Value>/<TextValue>. Standard XML libraries (ElementTree, SAX, lxml) spend most of their CPU time descending into children you do not need.

crxml skips the nesting:

  • The stream engine walks the XML once with a hand-rolled memchr scanner (src/crxml_core/src/xml/scanner.rs, scan_one_row scanner.rs:119 via RowSink src/crxml_core/src/lib.rs:603) and yields flat dicts: 508 MB/s 100 MB.
  • The parallel engine memory-maps the file, splits it at row boundaries (splitter.rs:57 find_split_points), and parses each chunk on its own thread into Arrow buffers directly (no dicts): up to 4.2 GB/s on high-cardinality production reports (533 MB real par128 4231) and 4.2 GB/s on uniform exports (1 GB par128 4158) via rypipe (rypipe-core Vec<ColumnBuilder>+field_index engine.rs:16, row_dirty engine.rs:26).
  • Pipeline stages that rename, cast, drop, or filter fields execute in the Rust parse loop, before any Python object is created.

Comparison: stream vs parallel vs parallel streaming (bounded, schema)

Taskstream (single)parallel (full RAM)parallel streaming (bounded)
Row iterationYields dicts lazilyArrow table first, then dicts (slower)Yields RecordBatches incrementally, stable schema
DataFrame / Table outputCollects dicts, convertsDirect Arrow buffers, zero-copySame, incremental + bounded
533 MB real export (Table)953 MB/s (single) / 723 MB/s (1 MB)4231 MB/s par128 (4.16 MB)3828 auto / 7630 explicit schema=[...] (2 MB)
1 GB (Table)940 MB/s4158 MB/s par1283782 auto / ~4900 explicit
Peak RssAnon (533 MB)24 MB (1 MB)137 MB88 MB (auto or explicit)
Pipeline fusionNo (dict path)Yes (Rust BuildPlan)Yes (same plan, streamed)
ParquetWriterN/AN/Awrite_batch succeeds (batches share schema schema.rs:14)

Auto discovery (16x2 MiB windows for >128 MB) adds ~15% (19 ms on 533 MB) so auto is -10% vs par128 (3828 vs 4231) but still bounded and incremental. Explicit schema=[...] (FrozenSchema::from_plan) avoids Discovery and is +80% vs par128 (7630 vs 4231). Fastest bounded mode needs explicit schema; auto is safe and bounded but slightly slower. Use iter_record_batches(memory="64MB", threads=16, schema=[...]) for the fast path.

Full benchmark details: like-for-like Table vs Vec, chunk-per-cell, fixed-chunk isolation, and schema cost.


Install

pip install crxml

The columnar and parallel engines are included by default. For performance profiling counters: pip install -e . --config-settings=--features=profile.


Features

CategoryWhat crxml handles
Stream engineRow-by-row XML parsing, yields dict[str, str], GIL-released batching
Columnar engineSingle-threaded Arrow table output, zero-copy string columns
Parallel engineMulti-threaded (rayon), file split at row boundaries, off-GIL parse
Bounded modememory="500MB" splits into chunks; RSS independent of file size
Pipeline fusionRenameFields, DropFields, CastTypes, FilterRows compile into Rust BuildPlan
mmapMemory-maps input files (default, zero-copy)
prefaultMADV_WILLNEED vs MADV_SEQUENTIAL for RSS/speed trade-off
Arrow sinksto_arrow(), to_pandas() (ArrowDtype), to_polars(), to_parquet()
Auto-dict encodingauto_dict=True encodes low-cardinality string columns
Field typingfield_types={"amount": "float64"} coerces at parse time
Filter pushdownfilter={"field": "Status", "op": "==", "value": "Active"} in Rust
CorrectnessAll engines validated byte-identical against stream oracle (29 test cases + 465k-row real cross-check)

Engine guide: parallel streaming (explicit schema) is opt-in

Engine / APIWhen to useThroughput 533 MB / 1 GBRssAnon
stream (for row in source)Row-by-row dict iteration723 MB/s 1 MB budget (24 MB anon)24 MB
columnar (single)Single-threaded Arrow Table953 / 940 MB/s134 MB
parallel (par128 full RAM, 4 MB)Fastest full-RAM Table4231 / 4158 MB/s137 MB
iter_record_batches(..., threads=16, schema=[...]) (explicit schema)Fastest bounded, stable schema, yields RecordBatches7630 / — MB/s88 MB
iter_record_batches(memory="64MB", threads=16) autoBounded + incremental, stable schema3828 / 3782 MB/s (-14% vs par, +15% Discovery)88 MB
bounded (memory="64MB" single)Single-thread bounded645 / 546 MB/s133 MB

Pass engine= explicitly, or let auto select per call. auto stays "parallel if it fits" (blocked: auto discovery adds 15% and would make auto slower until cheaper). Streaming is opt-in via iter_record_batches(..., threads=16), keeping 4 MB for par (src/crxml/source.py:164), 2 MB via budget/(threads*2) for streaming. Provide schema= for the fast path.

# Recommended bounded paths
from crxml import CrystalXMLSource
import pyarrow as pa, pyarrow.parquet as pq

src = CrystalXMLSource("report.xml", row_tag="Details")
# explicit schema: fastest, no Discovery, writer succeeds
schema = ["Level","Section","Field22","Field23","Field38","Field39","Field61","Field73","FieldG","Text20"]
src = CrystalXMLSource("report.xml", row_tag="Details", schema=schema)
batches = src.iter_record_batches(memory="64MB", threads=16)
# auto: stable but pays 15% Discovery (16×2 MiB windows for >128 MB)
batches = src.iter_record_batches(memory="64MB", threads=16)

# ParquetWriter (now works; batches share schema)
it = src.iter_record_batches(memory="64MB", threads=16)
first = next(it)
w = pq.ParquetWriter("out.parquet", first.schema)
w.write_batch(first)
for b in it: w.write_batch(b)
w.close()

Framework support

FrameworkIntegration
FastAPI / Starlette / LitestarParse in route handler, return DataFrame or Arrow table directly
Django / FlaskCall source.to_dataframe() in view; pass to template or response
Pandas / Polarssource.to_dataframe() / source.to_polars() for zero-copy analysis
Airflow / PrefectParse in task, write to parquet with source.to_parquet()
CLI / ETL scriptsUse to_csv() sink or iterate rows for line-by-line processing

Limitations

  • UTF-8 input only. UTF-16 exports (which Crystal Reports can produce) fail validation; convert first.
  • No compressed input. .gz/.zst files must be decompressed before parsing.
  • Crystal Reports grammar, not general XML. The flat-row model fits CR exports; arbitrary XML documents are out of scope.
  • Linux-tuned performance. madvise hints and thread-count ratios were measured on Linux; other platforms work but are untested territory.
  • No async API. Row iteration is synchronous.

Documentation

Full docs at crxml.emiliano-go.com covering:

  • All CrystalXMLSource parameters
  • Pipeline stages and fusion rules
  • Sink reference
  • Batch iteration and parallel distribution
  • Performance with phase breakdowns
  • Architecture and correctness

License

MIT

Contributors

emiliano-go

186 commits

Languages

Python

57.4%

Rust

42.6%