Convert CSV ↔ Parquet ↔ DuckDB ↔ zip-CSV ¶
When to use this ¶
You have data in format X (a regulator handed you a folder of CSVs, an upstream pipeline emits parquet, a teammate sent a DuckDB file) and a downstream tool wants format Y. Or you’re loading the same package over and over and want to switch to a format with faster cold-start.
Quick example ¶
Convert the bundled Leavenworth CSV directory into a parquet directory. The destination extension determines the output format; --json swaps the rich summary table for one JSON document:
Expected:
{
"source": "packages/netstead/netstead/fixtures/leavenworth/csv",
"dest": "leavenworth.parquet",
"format": "parquet",
"engine": "IbisEngine",
"table_count": 9,
"total_rows": 1239
}
Without --json the same six fields render as a rich table on stdout. ./leavenworth.parquet is a directory with one Parquet file per table (link.parquet, node.parquet, …) plus a datapackage.json with the schemas inlined.
Step-by-step ¶
1. Pick the destination format ¶
Each format has different trade-offs for cold-read latency, write speed, and predicate pushdown. Rule of thumb: parquet for interchange, duckdb for repeated local use, zipcsv for emailing, csv only when a downstream tool demands it.
| Format | Cold read | Write | Predicate pushdown | Portability |
|---|---|---|---|---|
csv (directory) |
Slowest — full scan, no schema. | Slow — text encoding. | None. | Universal. Any tool reads CSV. |
parquet (directory, one file per table) |
Fast — columnar, schema embedded. | Fast. | Yes — column + row-group prune. | Wide. Arrow, DuckDB, Polars, pandas. |
duckdb (single file) |
Fastest — pre-indexed, statistics cached. | Slowest — DDL + insert per table. | Yes — full SQL. | DuckDB-only readers. |
zipcsv (single .zip) |
Slow — text decode after unzip. | Medium. | None. | Universal + single-file. Carries an inlined datapackage.json. |
2. Run corral convert ¶
The CLI infers the format from the destination extension — .parquet, .csv, .duckdb, .zip — case-insensitively, and an extension-less destination defaults to a parquet directory:
Override the inference with --format when the extension is missing or ambiguous:
netstead convert is the same command with GMNS defaults (it knows the vendored spec, so you don’t need a datapackage.json next to the source).
The same dispatch is available from Python — Package.write accepts the same destination + optional format= kwarg as the CLI. Leavenworth ships as a CSV directory without a manifest, so pass its spec explicitly:
import tempfile, pathlib
from corral import Package
from netstead.fixtures import leavenworth
pkg = Package.from_source(leavenworth.csv_dir(), spec=leavenworth.spec_path())
with tempfile.TemporaryDirectory() as d:
pkg.write(pathlib.Path(d) / "leavenworth.duckdb")
3. (Optional) Keep the schema with the data ¶
Parquet and CSV directories and zips carry a datapackage.json with the schemas inlined; reloading a directory finds it without spec=. A zip’s descriptor is inside the archive and is not read automatically yet, so pass spec= for a zip. DuckDB output carries the data only: keep the source manifest (or the spec= you loaded with) and pass it again when you reload a .duckdb copy. This also matters for all-null columns: without a schema an entirely-empty CSV column comes back as a string column, whereas with spec= it is cast to the declared type (the case behind #163).
4. Verify the round-trip ¶
Load the destination back (passing the spec again) and validate. This catches any schema drift introduced by the conversion (e.g. a string column that came back as int because every value happened to parse):
import tempfile, pathlib
from corral import Package
from corral.reports import Severity
from netstead.fixtures import leavenworth
# Round-trip CSV → parquet → re-load → validate.
with tempfile.TemporaryDirectory() as d:
out = pathlib.Path(d) / "leavenworth.parquet"
Package.from_source(leavenworth.csv_dir(), spec=leavenworth.spec_path()).write(out)
pkg = Package.from_source(out, spec=leavenworth.spec_path())
report = pkg.validate()
assert not report.has_errors, [i.code for i in report.issues if i.severity is Severity.ERROR]
print(f"{len(pkg.tables)} tables, {pkg['link'].count()} links — round-trip clean")
Expected:
See validate-network for what’s in the report.
5. (Optional) Convert remote → local ¶
convert accepts the same URL surface as from_source, so cloud → local is a one-liner. The download happens once; from then on you load the local copy:
See Read from S3 for credential handling.
Common variations ¶
Default — CLI conversion with extension inference
Most conversions are one-line CLI calls. The destination extension picks the format.
Programmatic conversion in Python
Same dispatch as the CLI; useful inside scripts and notebooks.
```python
from corral import Package
Package.from_source(src, spec=spec).write(dest)
```
Re-pack only a subset of tables
Load just the tables you need with tables=; the writer only emits what was loaded.
```python
Package.from_source(src, spec=spec, tables=["link", "node"]).write(dest)
```
`Package.scope(tables=[...])` does the same on an already-loaded package. See the [netstead scope recipes](https://e-lo.github.io/netstead/netstead/cookbook/) for FK-aware variants that walk relationships.
Parquet directory (one file per table)
Any extension-less destination, or --format parquet, writes one GeoParquet file per table. Hive partitioning is not applied by default — it is opt-in per table via ParquetAdapter.write(expr, dest, engine, partition_by=[...]) from Python.
Compress the CSV output (zipcsv)
Same wire format as CSV but typically 5–10× smaller — useful for emailing or storing in artifact stores. The archive also carries a datapackage.json with the schemas inlined, so it reloads without a separate spec.
Validate before the write
validate() returns a report rather than the package, so check it and then write:
```python
pkg = Package.from_source(src, spec=spec)
report = pkg.validate()
if report.has_errors:
raise SystemExit(f"{len(report.issues)} issues — not writing")
pkg.write(dest)
```
Pitfalls ¶
- All-null columns lose their type without a schema. A column that is 100% null in the source CSV comes back as a string column unless the declared type is available — pass
spec=(or keep thedatapackage.jsonnext to the source) so the cast is applied. Background in #163. - Converting to CSV loses dtypes. Re-reading the CSV without a schema infers columns afresh. Round-trip safety relies on the schema being re-applied at load time — pass
spec=again (netstead does this automatically for GMNS-shaped directories). - DuckDB files lock per-process. Two Python processes can’t open the same
.duckdbfile in write mode at once. For shared use, write parquet instead. - Zip-CSV holds the whole archive in memory on read. Fine for Leavenworth-scale networks; not fine for regional ones. Convert zip-CSV to parquet up-front if you’ll re-read.
- An existing destination is never clobbered silently.
Package.writeraisesFileExistsErrorunless you passoverwrite=True;corral converthas no overwrite flag, so delete the old destination first.
See also ¶
- Read from S3 —
convertworks across cloud schemes too (corral convert s3://… ./local.parquet). - Architecture — Package → Engine → format dispatch.
- API reference —
Package.write,Package.from_source.