Skip to main content

dst-parquet

Plans and builds a long-term Parquet layout for a raw DST register delivery:

<delivery>/<provider>/<register>/year=<YYYY>/<source file>.parquet

for example 2025/dst/bef/year=2015/bef201512.parquet. A register whose files carry no year keeps everything except the year= level.

The delivery it was written against is 3,032 GB in 1,469 files. Converting that is a day's work nobody wants to do twice, and the decisions that would make it wrong are knowable in advance from the filenames and the headers. So there are two halves over one set of rules. plan says where every file would go and what it could not decide alone. convert does the work, and refuses any register the plan left blocked.

It is a single binary. It was first written in Python, and this is that program ported to Rust: the same commands, flags, rules file and layout, with every plan file the same byte for byte. The conversion reads each source once, puts it into its register directory's schema, writes it and measures it in the same pass, and holds about a gigabyte however large the source is.

Install

pip install dst-parquet-layout

The wheel holds the binary and nothing else, so it needs no other package. The name is the one the Python implementation was published under, so a line that installed that installs this, [convert] included.

dst-parquet-system-heap is the same program with the platform's own memory allocator in place of mimalloc. It exists to time the two against each other on the machine that runs the conversion.

Use

Review the rules against every naming shape in a delivery, from an exported register map, with no server access:

dst-parquet plan --map .../2026_register_map --out-dir plan/

Plan every file, from a per-file list or from a walk of the delivery. A walk reads each file's header for its row count:

dst-parquet plan --files files.csv --out-dir plan/
dst-parquet plan --root E:/rawdata/<delivery> --out-dir plan/

Date a delivery directory whose name does not say when it arrived:

dst-parquet dates --root E:/rawdata/<delivery> --out-dir dates/
dst-parquet dates --dates dates/DELIVERY_DATES.csv

Convert, once the plan is clean:

dst-parquet convert --root E:/rawdata/<delivery> --out E:/workdata/<project>/parquet \
    --plan plan/ --jobs 4

On a terminal, convert keeps a panel at the bottom of the window: the share of bytes done, the rate over the last minute, the time left, and a line per file being read. Each register directory leaves a line above it when it finishes. With its output sent to a file it prints those lines as they happen and the panel's figures every five minutes. --quiet prints only the summary.

Name one copy of each snapshot across deliveries, and compare the others:

dst-parquet resolve --out E:/workdata/<project>/parquet --out-dir resolution/

plan writes six files. TREE.txt is the whole layout as a directory tree, one line per register directory, which is the form to hand somebody deciding whether this is the structure they want. SUMMARY.txt is the status of every register directory and every open decision. REGISTER_PLAN.csv is one row per destination directory, PLACEMENT.csv one row per source file, and REVIEW.csv the decisions that need a yes and the ones that block. PROVENANCE.json records the version, the time, and digests of the rules and the file list.

Both commands exit non-zero when something is wrong: plan when a register is blocked, so it can gate a conversion in a script, and convert when it skipped a register or found something wrong in what it wrote.

What a conversion is allowed to do

convert takes --plan, and refuses to run if the rules or the file list have changed since that plan was written. A plan is read by people and acted on by a program, sometimes weeks apart, and nothing about editing rules.toml in between leaves a mark on the plan. --accept-rules-change overrides it, out loud.

Every source is read once, and what is checked is checked as the rows go past:

  • The rows written are compared with the count in the source's header, less any rows the header marks deleted. A header that declares exactly 4,294,967,295 rows has a saturated counter, which says nothing about the file: it is read to its last page and reported, not compared.
  • Every string cell is checked for U+FFFD, the replacement character a lossy decode leaves behind, which no later repair can undo. The reader decodes every windows-1252 byte, so in this delivery it can only appear in a file that is not what it declares. A file holding one stays written and fails the run. --verify-utf8 is still accepted, and no longer needed.
  • A stock series' key, PNR for bef, is checked for a person held twice.
  • For a cumulative series, the file kept for a year is asked how many of its rows fall before each dropped file's cut-off, against the count in the dropped file's header.
  • Every file gets a fingerprint of its content, independent of row order and taken column by column, which resolve compares across deliveries.

What was measured goes into the register directory's _layout.json, and into each file's own Parquet footer, so a file whose sidecar is lost still says how many rows it holds. A file that is already there is not read again, so a run that resumes it reports again whatever the run that wrote it found wrong, marked as an earlier run's finding, and fails as that run did.

A run is resumable: a file that is already there is left alone, and the partial files an interrupted run left behind in the directories being converted are swept first. --jobs converts several register directories at once, largest first. --threads caps what the whole run uses, decoding and encoding together. One line per directory goes to stderr as it finishes, with a running share of the bytes and an estimate of the time left; --quiet turns it off.

A register directory is written once and then left alone. Each source's size and the creation stamp in its SAS header are recorded beside the data, and a conversion whose sources no longer match refuses and names the files that differ. --new-version bef then writes the corrected data to the next free bef_v2 beside the directory already there, which is left exactly as it is.

--io-backend chooses how sources are read: auto maps a local file into memory and reads a share in sequence, and mmap or buffered forces one.

How it is written

Files are written with zstd at the level the rules set. Floats are split by byte stream and dates and times delta-encoded, and a column nearly every value of which is distinct in its first rows does without a dictionary, which is what lets those encodings apply. On a synthetic bef-shaped file that made the output 28 MB where the Python implementation's polars writer made 72 MB.

Column types are the reader's own, carried through. A SAS TIME column is a nanosecond duration, because a SAS time can be negative or past 24 hours and a clock time cannot. Where the rules promote a code column from Float64 to String, a whole number is written without its decimal point: 1301.0 is "1301".

What is still open

OPEN_ITEMS.md, beside this file, is the list: the questions only a person can answer, the decisions the rules make that want a yes, and what is left undone in the code on purpose.

The rules

rules.toml holds every judgement, one entry each: which directory is which delivery and provider, which files are the same table, which registers are not partitioned by year, how a type conflict is promoted, which collisions are resolved and how, and what several files in one year of a register are to each other. The binary carries the file it was built with, and --rules PATH reads another. Changing a decision is a change to that file, not to the program, and tests/ pins the current answers, so an edit says out loud which files it moved.

What the rules have to survive

Each of these is observed in an August 2026 walk of one delivery.

The register is not the alphabetic prefix of the filename. soma_l2_t19_proc19 and soma_l3_t19_diag19 share a prefix and are different tables. Read as one register they give a schema in which 197 of 197 columns go missing somewhere.

115 files carry no year, for three different reasons: code lists with no time dimension, event tables pooled across the whole period, and tables the provider split into numbered shards. Each needs a different answer, and a register directory that is partitioned for some of its files and not others cannot be scanned at all.

A pooled table carries no year in its filename and thirty of them in its rows. [undated.partition_on] names the column for 27 of them, and they are cut into the same year= shape as every dated register. Where a register offers both dato_x and datotidspunkt_x the date is chosen, because the datetime variants are absent from one of the three files. A row whose date is missing keeps a null year in the standard __HIVE_DEFAULT_PARTITION__ directory: it is never dropped and never given a year it does not have, and the count is reported.

dnt_1159.sas7bdat parses as the year 1159 under any rule that takes four digits and stops. soma_l2_t19_drgkont19 carries its year as 19. dimcancergruppering_icd10 carries no year at all. bef199312 carries a month that must not be lost.

350 register-years appear in more than one delivery and 139 of those disagree on row count, usually by a handful of rows. Deliveries stay apart and nothing is deduplicated across them. Two files that would land in one partition with nothing to tell them apart block until a rule says which wins.

A month in a filename is a period in one register and a cut-off in another, and the filename does not say which. bef200803 is the population at the end of March 2008 and all four of that year's files are wanted, where lmdb202406 is every prescription of 2024 up to the end of June and is already inside lmdb202412. So a register that arrives more than once a year declares its grain in [[series]], the plan checks that against the file sizes before acting on it, and a year holding more than one file whose register declares nothing refuses to convert.

A year in a filename is a reading of a filename, not a measurement. A file more than eight times the median file of its own register is reported with the ratio, because udd_grundskole_fravaer_kmd2024 is 39.7 million rows where that register's years run 3 to 5 million.

624 of 2,491 register-columns are absent from at least one file of their own register, and 721 columns move position between years. Schemas are therefore built by name, never by order, names are uppercased, and each register directory carries a _layout.json naming the files each column was absent from.

Every file declares windows-1252 and Parquet declares UTF-8. Conversion checks the declaration per file and refuses a register that declares anything else.

Development

cargo nextest run -p dst-parquet
cargo clippy -p dst-parquet --all-targets -- -D warnings
just dst-parquet-wheel      # the Windows and macOS wheels, sanitised and scanned
just dst-parquet-publish    # uploads them, after asking

The conversion tests write real .sas7bdat files with sas7bdat-writer and read them through the published reader, so they test the same decode a delivery goes through. Six more are committed under tests/fixtures/, written by

cargo run -p sas7bdat-writer --example pooled_fixture -- tools/dst-parquet/tests/fixtures

from code/. The writer stamps a fixed creation time, so a re-run is an empty diff.

Metadata

Release files for dst-parquet-layout 0.1.6

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distributions (wheels)

Table of built distributions (wheels) for dst-parquet-layout 0.1.6
File Interpreter ABI Platform
dst_parquet_layout-0.1.6-py3-none-win_amd64.whl Python 3 none Windows x86-64 Details
dst_parquet_layout-0.1.6-py3-none-macosx_11_0_arm64.whl Python 3 none macOS 11.0+ ARM64 Details

Total release size: 13.2 MB

Release files / dst_parquet_layout-0.1.6-py3-none-win_amd64.whl

Download URL dst_parquet_layout-0.1.6-py3-none-win_amd64.whl
Size 7.0 MB
Tags Python 3 Windows x86-64
SHA-256 checksum
How to use checksums
7e47123a720585aa4235b0aae99e8416782c6149ff0548edc507f52d50a63228
BLAKE2b-256 checksum
How to use checksums
52f2237194ac0e84e822b97303e98d1247bf963c2cce01d70b1aef8b5d0716a9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.8

Release files / dst_parquet_layout-0.1.6-py3-none-macosx_11_0_arm64.whl

Download URL dst_parquet_layout-0.1.6-py3-none-macosx_11_0_arm64.whl
Size 6.2 MB
Tags Python 3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
bdca7d23151e5514888f143193515de0a89992ba759b736cf0254e479a8ef2cf
BLAKE2b-256 checksum
How to use checksums
f3d5203e51f1fa5d25f92bef6af4d4d21c4a3bf757395e65372667b03cddf375
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.8

Security for dst-parquet-layout 0.1.6

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page