dlt version
1.29.0 source checkout from current devel at 91403f1a39058230361820de414b718a22d7a423.
Describe the problem
When the filesystem destination uses layout="{table_name}", replacing one table can silently delete an unrelated table whose name starts with the same prefix.
For example, after loading tables named event and events, replacing only event deletes the physical data file for events. The load completes successfully and rewrites event, so there is no failure or warning that unrelated data was removed.
The current write path automatically appends .<full_extension> when {ext} is omitted, producing files such as:
event.jsonl.gz
events.jsonl.gz
However, table-prefix extraction returns the bare prefix event. Replace truncation selects files with filepath.startswith(prefix), so both files are selected and deleted.
I reproduced this twice from a clean detached checkout of current upstream devel; both runs exited successfully with identical data loss.
Expected behavior
Replacing event should update only event.jsonl.gz; events.jsonl.gz should remain present and unchanged.
If an end-of-layout {table_name} cannot be handled safely, the destination should reject the layout during validation instead of accepting it and performing an overbroad delete.
Steps to reproduce
import tempfile
from pathlib import Path
import dlt
root = Path(tempfile.mkdtemp(prefix="dlt-fs-prefix-repro-"))
bucket = root / "bucket"
pipeline = dlt.pipeline(
pipeline_name="prefix_repro",
pipelines_dir=str(root / "pipelines"),
destination=dlt.destinations.filesystem(
bucket_url=str(bucket),
layout="{table_name}",
),
dataset_name="ds",
)
pipeline.run(
dlt.resource([{"id": 1}], name="event"),
loader_file_format="jsonl",
)
pipeline.run(
dlt.resource([{"id": 2}], name="events"),
loader_file_format="jsonl",
)
dataset = bucket / "ds"
def table_files():
return sorted(path.name for path in dataset.glob("event*"))
print("before:", table_files())
pipeline.run(
dlt.resource(
[{"id": 3}],
name="event",
write_disposition="replace",
),
loader_file_format="jsonl",
)
print("after:", table_files())
Actual output on current devel:
before: ['event.jsonl.gz', 'events.jsonl.gz']
after: ['event.jsonl.gz']
A control run changing only the layout to "{table_name}.{ext}" preserves both files:
before: ['event.jsonl.gz', 'events.jsonl.gz']
after: ['event.jsonl.gz', 'events.jsonl.gz']
Operating system
macOS
Runtime environment
Local
Python version
3.13
dlt data source
Inline Python resources containing dictionaries.
dlt destination
Filesystem & buckets
Other deployment details
The reproduction uses a local temporary filesystem and requires no credentials or external services. The affected prefix/deletion logic is shared by filesystem backends.
Additional information
The root cause appears to span these paths:
create_path appends a dot and the full extension when {ext} is omitted.
get_table_prefix_layout intends to retain the separator after {table_name}, specifically to avoid selecting similarly named tables, but returns bare {table_name} when the placeholder terminates the layout.
_delete_table_files deletes the files returned by prefix listing.
list_files_with_prefixes uses raw filepath.startswith(prefix).
The filesystem layout documentation says omitted extensions are automatically appended with a dot and that replace-compatible layouts need a separator after {table_name}.
A focused fix could make get_table_prefix_layout("{table_name}") derive "{table_name}.", matching the path produced by create_path. A stricter alternative is to reject the layout before loading.
Suggested regression coverage:
- Unit assertion for the exact end-of-layout case in
tests/destinations/test_path_utils.py.
- Filesystem replace test with sibling tables
event and events.
Focused validation on the clean upstream checkout:
tests/destinations/test_path_utils.py: 70 passed
local-only tests/load/filesystem/test_filesystem_client.py: 53 passed, 1 skipped
dlt version
1.29.0 source checkout from current
develat91403f1a39058230361820de414b718a22d7a423.Describe the problem
When the filesystem destination uses
layout="{table_name}", replacing one table can silently delete an unrelated table whose name starts with the same prefix.For example, after loading tables named
eventandevents, replacing onlyeventdeletes the physical data file forevents. The load completes successfully and rewritesevent, so there is no failure or warning that unrelated data was removed.The current write path automatically appends
.<full_extension>when{ext}is omitted, producing files such as:However, table-prefix extraction returns the bare prefix
event. Replace truncation selects files withfilepath.startswith(prefix), so both files are selected and deleted.I reproduced this twice from a clean detached checkout of current upstream
devel; both runs exited successfully with identical data loss.Expected behavior
Replacing
eventshould update onlyevent.jsonl.gz;events.jsonl.gzshould remain present and unchanged.If an end-of-layout
{table_name}cannot be handled safely, the destination should reject the layout during validation instead of accepting it and performing an overbroad delete.Steps to reproduce
Actual output on current
devel:A control run changing only the layout to
"{table_name}.{ext}"preserves both files:Operating system
macOS
Runtime environment
Local
Python version
3.13
dlt data source
Inline Python resources containing dictionaries.
dlt destination
Filesystem & buckets
Other deployment details
The reproduction uses a local temporary filesystem and requires no credentials or external services. The affected prefix/deletion logic is shared by filesystem backends.
Additional information
The root cause appears to span these paths:
create_pathappends a dot and the full extension when{ext}is omitted.get_table_prefix_layoutintends to retain the separator after{table_name}, specifically to avoid selecting similarly named tables, but returns bare{table_name}when the placeholder terminates the layout._delete_table_filesdeletes the files returned by prefix listing.list_files_with_prefixesuses rawfilepath.startswith(prefix).The filesystem layout documentation says omitted extensions are automatically appended with a dot and that replace-compatible layouts need a separator after
{table_name}.A focused fix could make
get_table_prefix_layout("{table_name}")derive"{table_name}.", matching the path produced bycreate_path. A stricter alternative is to reject the layout before loading.Suggested regression coverage:
tests/destinations/test_path_utils.py.eventandevents.Focused validation on the clean upstream checkout: