About
csv:// is the last format-named scheme in either registry. excel:// was folded into the
filesystem readers in 67233322; everything else in sources and destinations names a
transport or a database. You called this one
in GH-106 already:
csv:// is certainly not a valid protocol scheme and should not be used as such [...] let's
exclusively use file:// for addressing filesystem-based resources going forward?
Agreed. This is what it costs, because csv:// is not a thin alias today: it has its own read
and write implementations, the two produce different results from the same file, and the write
one loses rows.
Verified at 02a60bcb (v0.12.0).
The legacy destination drops rows, silently
omniload.target.csv.CsvDestination.post_load() reads exactly one staged file:
def find_first_file(path):
for entry in os.listdir(path):
full_path = os.path.join(path, entry)
if os.path.isfile(full_path):
return full_path
return None
dlt stages {dataset}/{table}/{load_id}.{ext} and rotates load files once file_max_items or
file_max_bytes is configured, so a load that produces more than one data file is written out
partially, and the run reports success.
dlt_filesystem.target.local.LocalFilesystemDestination iterates sorted(os.listdir(table_dir))
and does not have this.
printf 'id,name\n1,alice\n2,bob\n3,carol\n' > three.csv
export DATA_WRITER__FILE_MAX_ITEMS=1
omniload ingest --source-uri "file://$PWD/three.csv" --source-table t \
--dest-uri "csv://$PWD/out_csv.csv" --dest-table public.t
omniload ingest --source-uri "file://$PWD/three.csv" --source-table t \
--dest-uri "file://$PWD/out_file.csv" --dest-table public.t
out_csv.csv out_file.csv
id,name id,name
2,bob 3,carol
1,alice
2,bob
One row of three, exit code 0. Separately, and less serious: file:// emits rows in staged-file
order rather than source order, visible above.
The read paths disagree too
omniload.source.csv.api.LocalCsvSource parses with csv.DictReader; file:// resolves to
read_csv, which is Polars (GH-165).
printf 'id,name,note\n1,alice,\n,,\n2,bob,hello\n' > one.csv
omniload ingest --source-uri "csv://$PWD/one.csv" --source-table t \
--dest-uri duckdb:///a.duckdb --dest-table public.t
omniload ingest --source-uri "file://$PWD/one.csv" --source-table t \
--dest-uri duckdb:///b.duckdb --dest-table public.t
csv:// id VARCHAR, name VARCHAR, note VARCHAR 2 rows
{'id': '1', 'name': 'alice', 'note': None}
{'id': '2', 'name': 'bob', 'note': 'hello'}
file:// id BIGINT, name VARCHAR, note VARCHAR 3 rows
{'id': 1, 'name': 'alice', 'note': None}
{'id': None, 'name': None, 'note': None}
{'id': 2, 'name': 'bob', 'note': 'hello'}
DictReader yields strings, so without explicit --columns hints or a constraining existing
schema a csv:// load lands as text; the Polars path infers. LocalCsvSource.dlt_source also
skips rows whose values are all empty, which is the ,, line, and remove_empty_columns drops
empty-valued keys per row, leaving dlt to reconstruct the column from whichever rows carry it.
LocalCsvSource passes uri.split("://")[1] straight to open(), so csv:// does no glob
expansion, no gzip decompression and no #format hint parsing, exposes no CSV dialect or parser
options, and the split form the filesystem family supports
(--source-uri csv:// --source-table one.csv) fails with
[Errno 2] No such file or directory: ''.
Incrementality is the one thing that only exists here
LocalCsvSource.handles_incrementality() is False, so run_ingest keeps --incremental-key
and the adapter filters rows against incremental.start_value. The filesystem family returns
True, and run_ingest preserves the CLI request as requested_incremental_key, which
dlt_filesystem.source.fsspec.local rejects:
ValueError: Local file source takes care of incrementality on its own, you should not provide incremental_key
So between the two local-file schemes, only csv:// attempts row-level cursor filtering.
--filesystem-incremental selects whole files by modification time, which is a different
mechanism.
It half works, though. --incremental-key alone loads, because the stored cursor is also a
string. Adding explicit bounds crashes, because the CLI parses those into datetime:
printf 'id,updated_at\n1,2024-01-01\n2,2025-06-01\n' > dates.csv
omniload ingest --source-uri "csv://$PWD/dates.csv" --source-table t \
--dest-uri duckdb:///c.duckdb --dest-table public.t \
--incremental-key updated_at --interval-start 2025-01-01
TypeError: '<' not supported between instances of 'str' and 'datetime.datetime'
So whatever is kept here has to be fixed, not ported as-is.
Write side, the rest of it
Both destinations are the same shape (stage into a temp directory, reassemble one file in
post_load()), and CsvDestination.dlt_dest already rewrites csv:// to file:// before
handing the URI to dlt. LocalFilesystemDestination's docstring records the relationship. They
differ in how they deal with dlt omitting null keys per row: CsvDestination rewrites the file
with an expanded header when a later row introduces a new column,
LocalFilesystemDestination buffers and takes the column union for CSV and Parquet (JSONL keeps
rows sparse). GH-168 carries that trade-off.
Docs
docs/supported-sources/file.md has a "Relationship to csv://" section telling readers
csv:// is unchanged and to use it "only when you specifically want the standalone CSV reader".
I wrote that in GH-135 as transitional wording; it and docs/supported-sources/csv.md are what
change with whatever is decided here.
Questions
@amotl, three calls before this is mechanical:
- Column types change for existing
csv:// users: text becomes inferred. Straight swap in one
release, or a release of deprecation warning first?
- Row-level
--incremental-key exists on this scheme and nowhere else in the family. Drop it
with csv://, or fix the string/datetime comparison and move it into the readers, which puts
a row-level concept into a family that is file-level throughout?
- A literal registry alias would also make
csv://data.jsonl and csv://*.csv work, since the
filesystem source routes on extension. Is csv:// meant to widen like that on its way out, or
should it stay CSV-only and warn?
About
csv://is the last format-named scheme in either registry.excel://was folded into thefilesystem readers in
67233322; everything else insourcesanddestinationsnames atransport or a database. You called this one
in GH-106 already:
Agreed. This is what it costs, because
csv://is not a thin alias today: it has its own readand write implementations, the two produce different results from the same file, and the write
one loses rows.
Verified at
02a60bcb(v0.12.0).The legacy destination drops rows, silently
omniload.target.csv.CsvDestination.post_load()reads exactly one staged file:dlt stages
{dataset}/{table}/{load_id}.{ext}and rotates load files oncefile_max_itemsorfile_max_bytesis configured, so a load that produces more than one data file is written outpartially, and the run reports success.
dlt_filesystem.target.local.LocalFilesystemDestinationiteratessorted(os.listdir(table_dir))and does not have this.
One row of three, exit code 0. Separately, and less serious:
file://emits rows in staged-fileorder rather than source order, visible above.
The read paths disagree too
omniload.source.csv.api.LocalCsvSourceparses withcsv.DictReader;file://resolves toread_csv, which is Polars (GH-165).DictReaderyields strings, so without explicit--columnshints or a constraining existingschema a
csv://load lands as text; the Polars path infers.LocalCsvSource.dlt_sourcealsoskips rows whose values are all empty, which is the
,,line, andremove_empty_columnsdropsempty-valued keys per row, leaving dlt to reconstruct the column from whichever rows carry it.
LocalCsvSourcepassesuri.split("://")[1]straight toopen(), socsv://does no globexpansion, no gzip decompression and no
#formathint parsing, exposes no CSV dialect or parseroptions, and the split form the filesystem family supports
(
--source-uri csv:// --source-table one.csv) fails with[Errno 2] No such file or directory: ''.Incrementality is the one thing that only exists here
LocalCsvSource.handles_incrementality()isFalse, sorun_ingestkeeps--incremental-keyand the adapter filters rows against
incremental.start_value. The filesystem family returnsTrue, andrun_ingestpreserves the CLI request asrequested_incremental_key, whichdlt_filesystem.source.fsspec.localrejects:So between the two local-file schemes, only
csv://attempts row-level cursor filtering.--filesystem-incrementalselects whole files by modification time, which is a differentmechanism.
It half works, though.
--incremental-keyalone loads, because the stored cursor is also astring. Adding explicit bounds crashes, because the CLI parses those into
datetime:So whatever is kept here has to be fixed, not ported as-is.
Write side, the rest of it
Both destinations are the same shape (stage into a temp directory, reassemble one file in
post_load()), andCsvDestination.dlt_destalready rewritescsv://tofile://beforehanding the URI to dlt.
LocalFilesystemDestination's docstring records the relationship. Theydiffer in how they deal with dlt omitting null keys per row:
CsvDestinationrewrites the filewith an expanded header when a later row introduces a new column,
LocalFilesystemDestinationbuffers and takes the column union for CSV and Parquet (JSONL keepsrows sparse). GH-168 carries that trade-off.
Docs
docs/supported-sources/file.mdhas a "Relationship tocsv://" section telling readerscsv://is unchanged and to use it "only when you specifically want the standalone CSV reader".I wrote that in GH-135 as transitional wording; it and
docs/supported-sources/csv.mdare whatchange with whatever is decided here.
Questions
@amotl, three calls before this is mechanical:
csv://users: text becomes inferred. Straight swap in onerelease, or a release of deprecation warning first?
--incremental-keyexists on this scheme and nowhere else in the family. Drop itwith
csv://, or fix the string/datetime comparison and move it into the readers, which putsa row-level concept into a family that is file-level throughout?
csv://data.jsonlandcsv://*.csvwork, since thefilesystem source routes on extension. Is
csv://meant to widen like that on its way out, orshould it stay CSV-only and warn?