Changelog¶
Release notes for earlier versions are on GitHub Releases.
Unreleased¶
write_parquetwrites Arrow data with genson's Parquet writer. Any object with__arrow_c_stream__(a pyarrow table or record batch reader, a Polars frame, genson's Arrow stream) is written as genson writes its own output, each row group's leaf columns encoded in parallel with the GIL released, its buffers not copied. Wikidata's claims statements (1.56 million deeply nested rows) write in 2.3 s on 4 threads against pyarrow's 8.5 s, 5% smaller, with the same schema and data as pyarrow writes. In Rust,genson_core::parquet::write_arrow_batches.
- Tall flat Parquet output is smaller. Data pages end at 1 MiB, as Polars' do, rather than every 20,000 rows (arrow-rs's default). zstd compresses each page on its own, so larger pages compress better: a 12.5-million-row table of a string and an Int64 column is 7.7% smaller (67.6 MB to 62.4 MB, under Polars' 62.6 MB), and a 94-column table 3.0% smaller. Tables of fewer than 20,000 rows, and nested columns whose pages already reached 1 MiB first, are written byte for byte as before.
- Parquet output dictionary-encodes only columns whose values repeat. A leaf column whose first values are mostly distinct (more than three quarters, as Polars decides) is written without a dictionary, which cost a hash per value and grew the file until arrow-rs gave the dictionary up: a table of 94 near-unique columns writes 2.5 times faster and 25% smaller. No file is larger; Wikidata claims are 0.5% smaller.
- Narrow tables write faster. genson's Parquet writer encodes several row groups at once, each leaf column of each a task of its own, where it encoded one row group at a time: a table of a few columns and many rows uses every thread rather than one (a 25-million-row string column: 2.4 s to 0.8 s on 4 threads). The file is unchanged, byte for byte.
- Typed Parquet output can be written in a stored schema.
normalise_from_parquettakesschema(apl.Schemaorpl.Struct), checked against the inferred schema before anything is written (schema_check="compatible","strict"or"none"), andunnest=Trueto write the struct's fields as top-level columns. Fields the file lacks are written as nulls and the file is laid out as Polars writes the same frame, so chunks inferred separately share one schema without reading the output back to conform it.check_schema_compatibilitygives the same verdict for two schemas. On a 10,000-entity chunk of the Wikidata dump, writing the claims this way took 3.0 s where genson's output read back, conformed and written again by Polars took 30 s.
normalise_json(decode=False)is faster. It parsed every row into aValue, normalised it and serialised it; rows now go through the typed decoder into Arrow and are written back as JSON, with the same text. Rows the typed decoder declines, and inputs too few rows for the schema's width to pay for its columns, take the old path.no_unifyworks through theDataFrame.gensonmethods.infer_json_schema,infer_polars_schemaandnormalise_jsonondf.gensonacceptedno_unifybut never passed it on, so a map candidate holding a key it names was still unified into a map. The expression functions (polars_genson.infer_json_schemaand the rest) were not affected.DataFrame.genson.normalise_jsondecodes rows straight into Polars dtypes. Withdecode=True(the default) it inferred the schema twice, once to normalise and once for the dtypes, then wrote every row out as JSON text forstr.json_decodeto parse again. It now infers once and normalises the rows straight into Arrow, as typednormalise_from_parquetoutput does: 2.3–8.8× faster on 1,000 label maps, 20,000 event logs, 200,000 small records and a chunk of Wikidata claims, with less than half the peak memory on most, and the same frames. Rows the direct decoder cannot take, anddecode=<schema>, still go by way of JSON text.
- A key that is a list in some places and a record or scalar in others keeps its
values. Such a field inferred as a union of the list and the other types, and both
avro_to_polars_schemaand normalisation take a union's first branch: normalisation wraps a record or scalar into the list, but the list's items did not hold its fields or type, so its values were dropped ({"d": 9}beside[{"d": null}]gaved: null, as in #238), or, with the record first, every list was. The union is now one list whose items unify the list's items with the other branches (records fieldwise, scalars promoted to__integer-style fields as among a list's records), so every value is kept, whichever comes first, besidenullor not. Branches the items cannot unify with (different scalars, a branch of several types), and every union withwrap_scalars=False, stay as they were. - A field that is a scalar in some rows and an empty list in the rest decodes. Its
Avro schema typed the list's items
null(the list was empty in every row), but normalisation wraps the scalar into the list, sonormalise_json's output (["x"]underList(Null)) failed to decode, whateverempty_as_null. Such a list's items now take the scalars' types (List(String), holding["x"]). Schemas change only there: a list that is ever non-empty is as before. - A value where the schema types
nullnormalises tonull, so the rows decode. A record field or a list's items typednull(as the Avro conversion types those only ever null or empty) kept any value given there as it was, though the column isNulland cannot hold it:normalise_json's output failed to decode. Such a value reaches there when a union's other branch is wrapped into its first. It is nownull, as a value a field's type cannot hold is anywhere else, and the typed decoder takes these rows rather than leaving them to the slower path. Output changes only there: withdecode=False, the JSON held the value; decoded, it failed. - A field forced to a map keeps its values' type when it is a scalar in some rows.
force_field_types={"labels": "map"}on a field that held{"en": "x"}in some rows and"s"in others (or only ever"s") gave a map ofnullvalues, as the union, the type list (["object", "string"]) or the scalar type left it no properties to read its values from; normalisation kept the strings regardless only because anull-typed value passed through. Such a map now takes its records' values with the scalar, which normalisation wraps into the map as a value (labels__string):map<string>here,map<int>for integers, strings where they differ. On Wikidata claims fixtures, the forcedlabelsmaps hold strings where they heldnull. - A map only ever empty, or holding nulls alone, unifies with one holding values. Its
values are typed
null, and unifying it with a map of typed values failed: the records holding them were not unified, so a field of such records was not inferred as a map (a map with{}in one row beside{"en": "x"}in another). Values typednullnow join typed ones asnulldoes: the map holds the type, nullable (nullandstring), so the nulls a map may hold stay possible; two maps ofnullvalues staynull. In a record's field, two maps whose values differ only in being nullable now unify too. Schemas change only where such maps failed to unify. - Normalisation no longer panics on a field whose type is
nullalone. The Avro conversion can give a record the fields of a differently shaped one that shares its name (#238, avrotize-rs#28), leaving a field typed["null"]where the data holds values. Normalising such a value panicked; it is nownull, as a value a field's type cannot hold is anywhere else. The record's lost fields are a separate bug, in the conversion.
1.0.3¶
- A field some map values lack is no longer required. Map unification made a
record field required (and its Avro field non-nullable) whenever every record it
unified listed the field, though a record that was itself unified lists every field
seen so far, required or not. A record field missing from some values could then be
required, depending on the order of the values, and normalised output held a record of
nulls where the field was missing; it now holds
null. Schemas change only by fields dropped fromrequiredand made nullable. -
Inference, normalisation and Parquet I/O do far less work (#256). Inference reads each row once as interned states and rewrites the merged schema as a hash-consed DAG, never writing it out. Normalisation compiles the schema into a plan once (
NormalisePlanin genson-core) and writes rows straight into Arrow, and thenormalise_jsonexpression normalises its rows in parallel. Values are moved rather than copied. Inferred schemas and normalised output are unchanged, except by the two fixes in this release. On genson-core's scorecard corpora (scripts/scorecard.py: instructions on one thread, wall time on 4 threads), from 1.0.2 with the row-hashing change below to 1.0.3:corpus instructions wall time claims (2,000 Wikidata-like rows, wikidata-pq's options) −91% 1,762 → 233 ms events (20,000 event logs) −77% 90 → 17 ms narrow (100,000 small records) −81% 186 → 23 ms labels (1,000 label maps) −54% 171 → 83 ms entities (150 wide entities) −59% 201 → 81 ms the claims, normalised −78% 1,109 → 423 ms A field forced to
"map"takes the slower per-row path: 20% fewer instructions there, with no measurable change in time. These are genson-core's workloads; the Python package runs the same code. - Parquet output is zstd-compressed. genson wrote every Parquet file uncompressed; typed output, string output, lookup tables and pruned values are now written with zstd (level 1). On 200 rows of Wikidata claims the typed output is 5 MB where it was 16 MB, in the same time, and the normalised JSON 27 MB where it was 212 MB. -normalise_from_parquetandinfer_from_parquetdecode only their column. Reading one column decoded every column of the file, and each row was then copied into aString; the column is now decoded alone and its rows borrowed from the Arrow arrays. On a chunk of wikidata-pq's shape (3,000 rows, 332 MB of claims beside the labels, descriptions, aliases and sitelinks), its three map steps take 0.7–0.8 s where they took 3.5–4.6 s. -extract_invariantsreads each row's text once. Each row was parsed into a tree, the fields removed and the row written out again; rows are now scanned as text, with the removed fields cut out, and only rows the scan cannot read exactly as before (invalid JSON, a line break, a repeated or escaped key in an object with fields removed) take the old path. The lookup table, the inferred schema and the normalised output are unchanged. On 3,000 rows of wikidata-pq's claims (332 MB, 98% of it the label maps removed), extraction takes 0.2–0.3 s where it took 0.7–0.8 s. With two values for one key, the error now names the first in row order. In genson-core'sextract_invariants, the slim rows keep their own spacing and escapes instead of serde_json's compact form. - Parquet string columns are decoded in parallel. Where the file has an offset index (Polars and arrow-rs write one) or several row groups, the column is split into row ranges of about equal size, each decoded by its own reader. Decoding the claims of a chunk written as wikidata-pq writes them (Polars, zstd level 9) takes 0.05–0.06 s on 4 cores where it took 0.3–0.4 s. - An integer beyond the 64-bit range no longer panics. Inference read it with simd-json, which failed on it after serde_json had accepted the input; it is now anumber, as serde_json reads it when normalising. - Less time spent deduplicating rows. Given 10 or more strings, inference hashes each string's schema to skip duplicates; a scalar field is now hashed without building its schema, for 6–17% less CPU time on Wikidata claims and on narrow rows.SchemaBuilder::schema_hashgives the same values as before.
1.0.2¶
SchemaBuilder::add_schemaskeeps what the builder already held (genson-core Rust API). Given schemas with more than 3 properties between them, it replaced the builder's schema for each of those properties instead of merging into it. Inference, genson-cli and the Python package do not call it.
1.0.1¶
- A key missing from some objects is no longer required. When a field's objects
were merged, those holding no key in common were not counted, so a key could be
marked required (and its Avro field non-nullable) although some rows lack it, and
which keys were required could depend on the order of the rows or vary from run to
run. Schemas change only by keys dropped from
required. wrap_rootwraps each document as written. Duplicate keys and number formatting now reach inference unchanged, as withoutwrap_root.- Faster inference. About twice as fast on Wikidata claims with
unify_maps, and on large rows with default options. See How inference works.
1.0.0¶
A JSON object genson infers as a map is now a Polars map, pl.Map, wherever genson
gives Polars data or dtypes, and a Parquet MAP in typed output. See
Map types.
- Polars ≥ 2 is required.
import polars_gensonraisesImportErroron Polars 1.x. - Maps are
pl.Mapby default.map_encodingdefaults to"mapping"throughout the Python package, as in genson-core and genson-cli:infer_polars_schema,avro_to_polars_schemaandnormalise_json(decode=True)givepl.Map(pl.String, V), JSON string output writes each map as an object ({"en": "Hello"}), andnormalise_from_parquet(typed=True)writes Parquet maps. Passmap_encoding="kv"for lists of{key, value}structs, as before. normalise_json(decode=schema)decodes maps in the schema's shape: apl.Mapfrom objects, a list of{key, value}structs from key/value lists, unlessmap_encodingis given.schema_to_dictrenders apl.Mapas{"map": {"key": ..., "value": ...}}.- genson-core's
avro_to_arrow_typeandavro_record_fieldstakenative_map, which writes an Avro map as an ArrowMapin place of a list of{key, value}structs.
0.9.7¶
infer_polars_schemaandavro_to_polars_schematakemap_encoding. Each map gets the dtype its encoding decodes to, so a schema can be asked for in the shape the data was normalised in."kv", the default, gives the list of{key, value}structs as before. polars-jsonschema-bridge now names a mapMap[String,V]in place ofList[Struct[key:String,value:V]], which theinfer_polars_schemaexpression shows.
0.9.3¶
normalise_from_parquet(prune=...)removes malformed records. Every record holding one of the named fields is removed during normalisation and written toprune_output_path, and the fields are left out of the output schema. See Remove malformed records. genson-cli has--pruneand--prune-output.
0.9.1¶
normalise_json(decode=schema)accepts apl.Schema, such as one frominfer_polars_schema, as its docstring says. It previously raisedTypeError.- Fields forced to
"map"keep their values' type.force_field_types={"x": "map"}used to make every value a string; now a map of integers stays a map of integers, and values fall back to strings only when they have no common type. See Maps and records.
0.9.0¶
This release fixes several ways genson silently lost or mistyped data, makes inferred
field order deterministic, and adds extract_invariants for JSON that embeds repeated
data.
Breaking changes¶
- Polars ≥ 1.43.2 is required. genson writes Parquet with
parquet60, which marks float columns with the IEEE 754 total column order, and Polars before 1.43.2 can't read typed output that has a float column. - Python ≥ 3.10 is required, because Polars 1.43.2 requires it.
- The
polars-lts-cpuextra is replaced byrtcompat, following Polars:pip install polars-genson[rtcompat]. infer_polars_schemadefaults toavro=True, so it reports the dtypes thatnormalise_jsonand typed Parquet output produce. Passavro=Falsefor the old route.
Output changes¶
- A field that is sometimes a scalar and sometimes an object keeps its scalars under a
promoted
field__string-style key by default. See Mixed types. - Integers in a float field become floats (
1→1.0) instead of null. - Arrays that are empty in every row get the dtype
List(Null)instead ofList(Boolean). - Field order follows the order keys appear in the JSON, for objects of any size. Objects of more than 32 keys previously gave a different order on each run. See Field order.
- JSON Schema to Polars conversion maps objects with
additionalPropertiestoList(Struct{key, value}), and nullable types to their non-null type.
New¶
extract_invariantsonnormalise_from_parquet: moves fields that are invariant per key into a lookup table before inference. See Factor out repeated embedded data.
Dependencies¶
- polars / polars-arrow 0.55.2, pyo3 0.29, pyo3-polars 0.28, arrow / parquet 60.