What happened?
The investigation and write-up below were done with Claude Code. I hit the bug on our production table, I've run the repro myself, and I can defend what's here.
Thank you for delta-rs — Delta Lake is the storage layer for essentially our whole system, so your work is genuinely load-bearing for us.
The symptom. vacuum(dry_run=False) deletes nothing, returns the superseded file as deleted, and commits VACUUM START / VACUUM END with numDeletedFiles counting it.
It shows up whenever the partition directory name contains a character in object_store's INVALID set. Every character escaped when that name is built becomes a %, and % is itself in the set, so any escaped partition value triggers it — a Timestamp unavoidably, since the rendered value always contains a space and colons. A few unescaped characters trigger it directly: "a~b" gives part=a~b, with no % anywhere, and still fails. The column type is not the trigger — in the repro below a String partition fails on "a b" and succeeds on "ab".
It works in 1.4.2 and fails from 1.5.0 onward — I ran the repro against each. The difference looks like #4227, which moved the lite path from a storage listing to building the delete list from tombstone paths. Still reproduces on main at fd7e9691, so it isn't covered by #4624.
These two things look like they combine:
1. The add and remove paths reach object-store keys by different routes. Both views decode first, so both start from the real on-disk directory name — LogicalFileView::path() and TombstoneView::path() (kernel/snapshot/iterators/tombstones.rs:29) each end in percent_decode_str. They then diverge. For an add, object_store_path() (kernel/snapshot/iterators.rs:140) calls Path::parse, which validates without re-encoding, so the key matches the file on disk. That is why reads work. For a remove, operations/vacuum.rs:329 (and :620) calls Path::from, which does encode, and object_store's INVALID set contains %. The key lands back on the log's escaped form, where no object exists.
2. A NotFound while deleting is counted as a deletion. operations/vacuum.rs:529 maps Err(Error::NotFound { .. }) to Ok(path), so the miss reaches the returned list and numDeletedFiles. This one predates 1.5.0 and was harmless until the first started producing misses.
To check that the encoding really is the cause rather than something else, I planted a decoy file at the double-encoded path: vacuum deleted the decoy and left the real superseded file.
On existing issues: this looks like the same inconsistent-path-encoding root cause as #4053 (closed by #4056), at a call site I don't think #4056 touched. It's easy to mistake for #3911, so to save you checking — the repro passes dry_run=False, and the "ab" control case does delete.
Also noticed while investigating: full=True behaviour
full=True still deletes the file, since it works from a storage listing. On a table with escaped partition names it reports each file twice, once per code path, and commits numDeletedFiles: 2 for a single file. Its dedupe and recent-tombstone guards (vacuum.rs:345, :365) also compare listed paths against a set built by the same Path::from at :620, so as far as I can tell the protection added by #4300 doesn't apply there and a file with a recent tombstone falls through to the physical-age branch. Happy to split this out into its own issue if you'd rather keep them separate.
Expected behavior
A remove action's path should resolve to the same object-store key an add action's path would.
Separately, a NotFound while deleting probably shouldn't count as a deleted file — reporting it as one means an operator believes space was reclaimed and time travel severed when neither happened.
On the shape of a fix, entirely your call: a change at the call site would do it, though TombstoneView has no object_store_path() of its own, so the two paths could drift apart again.
Operating System
Linux
Binding
Python
Bindings Version
1.6.2
Steps to reproduce
import tempfile
from datetime import datetime
from pathlib import Path
import pyarrow as pa
from deltalake import DeltaTable, write_deltalake
# Only the third case avoids percent-escaping. The type is irrelevant: the
# String "a b" fails exactly as the Timestamp does.
for value in (datetime(2026, 8, 13), "a b", "ab"):
table = Path(tempfile.mkdtemp()) / "t"
literal = value.isoformat() if isinstance(value, datetime) else value
for x in (1, 2): # the second write supersedes the first in the same partition
write_deltalake(
table,
pa.table({"part": [value], "x": [x]}),
mode="overwrite",
predicate=f"part = '{literal}'",
partition_by=["part"],
)
part_dir = next(p for p in table.iterdir() if p.name.startswith("part="))
before = len(list(part_dir.glob("*.parquet")))
reported = DeltaTable(table).vacuum(
retention_hours=0, dry_run=False, enforce_retention_duration=False
)
after = len(list(part_dir.glob("*.parquet")))
print(f"{part_dir.name}")
print(f" vacuum reported deleting {len(reported)}, actually deleted {before - after}")
print(f" reported path: {reported[0]}")
Relevant logs
part=2026-08-13%2000%3A00%3A00.000000
vacuum reported deleting 1, actually deleted 0
reported path: /tmp/tmp2pfefxfg/t/part=2026-08-13%252000%253A00%253A00.000000/part-00000-9c9bd228-….parquet
part=a%20b
vacuum reported deleting 1, actually deleted 0
reported path: /tmp/tmpdme8wrx9/t/part=a%2520b/part-00000-2ad7e9ae-….parquet
part=ab
vacuum reported deleting 1, actually deleted 1
reported path: part=ab/part-00000-559362dd-….parquet
The returned path is absolute in the two failing cases and relative in the working one — the absolute form is the NotFound error's path, which is the second point above showing through.
What happened?
The investigation and write-up below were done with Claude Code. I hit the bug on our production table, I've run the repro myself, and I can defend what's here.
Thank you for delta-rs — Delta Lake is the storage layer for essentially our whole system, so your work is genuinely load-bearing for us.
The symptom.
vacuum(dry_run=False)deletes nothing, returns the superseded file as deleted, and commitsVACUUM START/VACUUM ENDwithnumDeletedFilescounting it.It shows up whenever the partition directory name contains a character in object_store's
INVALIDset. Every character escaped when that name is built becomes a%, and%is itself in the set, so any escaped partition value triggers it — aTimestampunavoidably, since the rendered value always contains a space and colons. A few unescaped characters trigger it directly:"a~b"givespart=a~b, with no%anywhere, and still fails. The column type is not the trigger — in the repro below aStringpartition fails on"a b"and succeeds on"ab".It works in 1.4.2 and fails from 1.5.0 onward — I ran the repro against each. The difference looks like #4227, which moved the lite path from a storage listing to building the delete list from tombstone paths. Still reproduces on
mainatfd7e9691, so it isn't covered by #4624.These two things look like they combine:
1. The
addandremovepaths reach object-store keys by different routes. Both views decode first, so both start from the real on-disk directory name —LogicalFileView::path()andTombstoneView::path()(kernel/snapshot/iterators/tombstones.rs:29) each end inpercent_decode_str. They then diverge. For anadd,object_store_path()(kernel/snapshot/iterators.rs:140) callsPath::parse, which validates without re-encoding, so the key matches the file on disk. That is why reads work. For aremove,operations/vacuum.rs:329(and:620) callsPath::from, which does encode, and object_store'sINVALIDset contains%. The key lands back on the log's escaped form, where no object exists.2. A
NotFoundwhile deleting is counted as a deletion.operations/vacuum.rs:529mapsErr(Error::NotFound { .. })toOk(path), so the miss reaches the returned list andnumDeletedFiles. This one predates 1.5.0 and was harmless until the first started producing misses.To check that the encoding really is the cause rather than something else, I planted a decoy file at the double-encoded path: vacuum deleted the decoy and left the real superseded file.
On existing issues: this looks like the same inconsistent-path-encoding root cause as #4053 (closed by #4056), at a call site I don't think #4056 touched. It's easy to mistake for #3911, so to save you checking — the repro passes
dry_run=False, and the"ab"control case does delete.Also noticed while investigating:
full=Truebehaviourfull=Truestill deletes the file, since it works from a storage listing. On a table with escaped partition names it reports each file twice, once per code path, and commitsnumDeletedFiles: 2for a single file. Its dedupe and recent-tombstone guards (vacuum.rs:345,:365) also compare listed paths against a set built by the samePath::fromat:620, so as far as I can tell the protection added by #4300 doesn't apply there and a file with a recent tombstone falls through to the physical-age branch. Happy to split this out into its own issue if you'd rather keep them separate.Expected behavior
A
removeaction's path should resolve to the same object-store key anaddaction's path would.Separately, a
NotFoundwhile deleting probably shouldn't count as a deleted file — reporting it as one means an operator believes space was reclaimed and time travel severed when neither happened.On the shape of a fix, entirely your call: a change at the call site would do it, though
TombstoneViewhas noobject_store_path()of its own, so the two paths could drift apart again.Operating System
Linux
Binding
Python
Bindings Version
1.6.2
Steps to reproduce
Relevant logs
The returned path is absolute in the two failing cases and relative in the working one — the absolute form is the
NotFounderror's path, which is the second point above showing through.