dlt version
1.30.0
Describe the problem
read_csv_duckdb reads each file off DuckDB's single global default connection and streams the result across yields. As soon as the extractor has two of these generators alive, they clobber each other's result set and every file but one is silently truncated to its first chunk.
The relation is built with the module-level function (sources/filesystem/readers.py:132 in 1.30.0):
file_data = duckdb.from_csv_auto(f, **duckdb_kwargs)
yield from helper(file_data, chunk_size)
and both helpers stream off that relation between yields (sources/filesystem/helpers.py):
def fetch_json(file_data, chunk_size):
while True:
batch = file_data.fetchmany(chunk_size)
if not batch:
break
yield add_columns(file_data.columns, batch)
def fetch_arrow(file_data, chunk_size):
yield from file_data.fetch_arrow_reader(batch_size=chunk_size)
A DuckDB connection holds one implicit result slot. Creating and fetching a second relation on the same connection invalidates the first relation's pending result, and its next fetchmany returns [].
That happens whenever the glob matches more than files_per_page files (default 100): filesystem yields file items in pages, the transformer gets one _read_csv_duckdb generator per page, and PipeIterator round-robins between them. This is not a threading problem — it reproduces single-threaded, and dlt is single-threaded here.
The failure is entirely silent. fetch_json's loop exits on if not batch: break, and an invalidated result is indistinguishable from end-of-file, so the reader "successfully" emits only the first chunk of each interleaved file. No warning, no exception, load package marked complete, load_info clean. The truncation point lands on a DuckDB vector-size boundary (2048), which is the only visible fingerprint: with chunk_size=10_000 we got exactly 10,240 rows per file.
Both fetch paths are affected (use_pyarrow=False and use_pyarrow=True).
Impact. In our production pipeline a 584-file / 11,166,412-row S3 CSV feed loaded 4,286,655 rows — 62% silently missing — across months of daily runs. We only found it because someone downloaded one CSV by hand and noticed rows absent from the warehouse.
Root cause in isolation (no dlt)
import duckdb, tempfile
from pathlib import Path
p = Path(tempfile.mkdtemp()) / "f.csv"
p.write_text("a\n" + "".join(f"{i}\n" for i in range(10_000)))
con = duckdb.connect()
rel_a = con.from_csv_auto(str(p))
rel_b = con.from_csv_auto(str(p))
def drain(rel): # what fetch_json does
total = 0
while True:
batch = rel.fetchmany(2048)
if not batch:
break
total += len(batch)
return total
rel_a.fetchmany(2048) # start streaming A
rel_b.fetchmany(2048) # starting B invalidates A's pending result
print("A total:", 2048 + drain(rel_a), " B total:", 2048 + drain(rel_b))
A total: 2048 B total: 10000 (expected 10000 each)
Expected behavior
Every file is read in full, regardless of how many files the glob matches or how the extractor interleaves pages. Row counts must not depend on files_per_page.
Failing that, an invalidated result set should raise rather than read as end-of-file — silently returning [] turns a recoverable error into undetectable data loss.
The fix is to stop sharing one connection across concurrently-open readers: con.cursor() off a single module-scoped connection gives each file its own result slot while keeping the shared buffer pool, catalog and settings (duckdb.connect() per file also works but builds a fresh in-memory database each time). Verified both restore full row counts. I'd like to open the PR for this.
Steps to reproduce
Self-contained, local filesystem, ~30s:
import shutil, tempfile
from pathlib import Path
import dlt
from dlt.sources.filesystem import filesystem, read_csv_duckdb
def run(n_files, n_rows, chunk, files_per_page, use_pyarrow):
tmp = Path(tempfile.mkdtemp())
for i in range(n_files):
rows = "\n".join(f"{i},{j}" for j in range(n_rows))
(tmp / f"part_{i:04d}.csv").write_text(f"file_id,row_id\n{rows}\n")
p = dlt.pipeline(pipeline_name="repro", destination="duckdb", dataset_name="r", dev_mode=True)
p.run(
(
filesystem(bucket_url=tmp.as_uri(), file_glob="*.csv", files_per_page=files_per_page)
| read_csv_duckdb(chunk_size=chunk, use_pyarrow=use_pyarrow)
).with_name("data")
)
with p.sql_client() as c:
loaded = c.execute_sql("select count(*) from data")[0][0]
expected = n_files * n_rows
print(
f"files={n_files:<4} files_per_page={files_per_page:<5} use_pyarrow={use_pyarrow!s:<5}"
f" expected={expected:>9,} loaded={loaded:>9,} lost={100 * (expected - loaded) / expected:5.1f}%"
)
shutil.rmtree(p.working_dir, ignore_errors=True)
shutil.rmtree(tmp, ignore_errors=True)
run(120, 3000, 1000, 1000, False) # control: single page, no interleaving
run(120, 3000, 1000, 100, False) # default paging
run(600, 3000, 1000, 100, False)
run(600, 3000, 1000, 100, True)
Output (identical on 1.20.0 and 1.30.0):
files=120 files_per_page=1000 use_pyarrow=False expected= 360,000 loaded= 360,000 lost= 0.0%
files=120 files_per_page=100 use_pyarrow=False expected= 360,000 loaded= 340,960 lost= 5.3%
files=600 files_per_page=100 use_pyarrow=False expected=1,800,000 loaded=1,229,752 lost= 31.7%
files=600 files_per_page=100 use_pyarrow=True expected=1,800,000 loaded= 602,000 lost= 66.6%
The first row is the control: raise files_per_page above the file count so only one generator is ever alive, and the loss disappears. Loss scales with the number of pages, i.e. with how much the extractor interleaves.
Operating system
macOS
Runtime environment
Local
Python version
3.13
dlt data source
filesystem(bucket_url=..., file_glob="fans/**/*.csv") | read_csv_duckdb(chunk_size=10_000, header=True)
Local files in the reproducer; S3 (s3fs) in production. 584 matching CSVs, ~1.1 GB.
dlt destination
DuckDB
Other deployment details
duckdb 1.4.3, pyarrow 18.1.0
- Reproducer run in a plain venv via
uv; no threading, no parallelize()
readers.py is the only module-level duckdb.* call site in dlt
Additional information
- Other readers are unaffected.
read_csv uses pandas.read_csv per file (its own parser state), and read_jsonl / read_parquet materialize before yielding. Only read_csv_duckdb holds a live cursor across yields.
- Any glob over 100 files is exposed, which makes this most likely to bite exactly the large-backfill case where per-file row counts are least likely to be checked by hand.
- Suggested defensive change alongside the fix: have the CSV readers assert that a file's emitted row count is non-decreasing to completion, or at minimum log at WARNING when a relation returns an empty
batch on its first fetch. The current code cannot distinguish "file finished" from "someone stole my result set".
dlt version
1.30.0
Describe the problem
read_csv_duckdbreads each file off DuckDB's single global default connection and streams the result acrossyields. As soon as the extractor has two of these generators alive, they clobber each other's result set and every file but one is silently truncated to its first chunk.The relation is built with the module-level function (
sources/filesystem/readers.py:132in 1.30.0):and both helpers stream off that relation between yields (
sources/filesystem/helpers.py):A DuckDB connection holds one implicit result slot. Creating and fetching a second relation on the same connection invalidates the first relation's pending result, and its next
fetchmanyreturns[].That happens whenever the glob matches more than
files_per_pagefiles (default100):filesystemyields file items in pages, the transformer gets one_read_csv_duckdbgenerator per page, andPipeIteratorround-robins between them. This is not a threading problem — it reproduces single-threaded, anddltis single-threaded here.The failure is entirely silent.
fetch_json's loop exits onif not batch: break, and an invalidated result is indistinguishable from end-of-file, so the reader "successfully" emits only the first chunk of each interleaved file. No warning, no exception, load package marked complete,load_infoclean. The truncation point lands on a DuckDB vector-size boundary (2048), which is the only visible fingerprint: withchunk_size=10_000we got exactly 10,240 rows per file.Both fetch paths are affected (
use_pyarrow=Falseanduse_pyarrow=True).Impact. In our production pipeline a 584-file / 11,166,412-row S3 CSV feed loaded 4,286,655 rows — 62% silently missing — across months of daily runs. We only found it because someone downloaded one CSV by hand and noticed rows absent from the warehouse.
Root cause in isolation (no dlt)
Expected behavior
Every file is read in full, regardless of how many files the glob matches or how the extractor interleaves pages. Row counts must not depend on
files_per_page.Failing that, an invalidated result set should raise rather than read as end-of-file — silently returning
[]turns a recoverable error into undetectable data loss.The fix is to stop sharing one connection across concurrently-open readers:
con.cursor()off a single module-scoped connection gives each file its own result slot while keeping the shared buffer pool, catalog and settings (duckdb.connect()per file also works but builds a fresh in-memory database each time). Verified both restore full row counts. I'd like to open the PR for this.Steps to reproduce
Self-contained, local filesystem, ~30s:
Output (identical on 1.20.0 and 1.30.0):
The first row is the control: raise
files_per_pageabove the file count so only one generator is ever alive, and the loss disappears. Loss scales with the number of pages, i.e. with how much the extractor interleaves.Operating system
macOS
Runtime environment
Local
Python version
3.13
dlt data source
Local files in the reproducer; S3 (
s3fs) in production. 584 matching CSVs, ~1.1 GB.dlt destination
DuckDB
Other deployment details
duckdb1.4.3,pyarrow18.1.0uv; no threading, noparallelize()readers.pyis the only module-levelduckdb.*call site indltAdditional information
read_csvusespandas.read_csvper file (its own parser state), andread_jsonl/read_parquetmaterialize before yielding. Onlyread_csv_duckdbholds a live cursor across yields.batch on its first fetch. The current code cannot distinguish "file finished" from "someone stole my result set".