Lakehouse – Phase 7 Deletes and Updates
Parquet files are immutable. Once written, you cannot remove row 42 without rewriting the entire file. But lakehouse tables need DELETE and UPDATE. The solution is positional delete files: small secondary files that record which rows in a data file have been logically removed. During scans, the reader loads delete positions and skips those rows. UPDATE decomposes into DELETE + INSERT within a single transaction.
Here is the roadmap for the phases to come:
- Phase 7: Deletes and updates
- Phase 8: Storage and access
Full Source Code
The code referenced in this post can be found in https://gitlab.com/kimserey.lam/lake-learn.
Copy-on-Write vs Merge-on-Read
There are two strategies for deletes in lakehouse formats:
Copy-on-write (COW): Rewrite the entire data file, omitting deleted rows. Simple to read, expensive to write. Delta Lake defaults to this approach.
Merge-on-read (MOR): Write a small delete file listing positions of deleted rows. Cheap to write, slightly more expensive to read (must check positions during scan). Iceberg and DuckLake use this approach.
DuckLake uses positional delete files — Parquet files with two columns (data_file_id, position) that identify deleted rows by their 0-based position within a specific data file. The core implementation lives in src/storage/ducklake_delete.cpp.
The Delete File Format
A positional delete file is a Parquet file with a specific schema:
1
2
3
4
5
6
7
8
9
10
11
12
Delete file: ducklake-{uuid}-delete.parquet
Schema:
data_file_id BIGINT -- which data file
position BIGINT -- 0-based row number
Example contents:
data_file_id | position
-------------|--------
3 | 42
3 | 107
3 | 2048
Delete files are tracked in ducklake_delete_file with the same snapshot visibility as everything else:
1
2
3
4
5
6
7
ducklake_delete_file
┌────────────────┬──────────┬────────────────┬──────────────┬───────────────────┬──────────────┐
│ delete_file_id │ table_id │ begin_snapshot │ data_file_id │ path │ delete_count │
├────────────────┼──────────┼────────────────┼──────────────┼───────────────────┼──────────────┤
│ 1 │ 1 │ 5 │ 3 │ del/001-del.pqt │ 3 │
│ 2 │ 1 │ 6 │ 5 │ del/002-del.pqt │ 12 │
└────────────────┴──────────┴────────────────┴──────────────┴───────────────────┴──────────────┘
How DELETE Works (lake/delete.go)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
DELETE FROM orders WHERE customer = 'bob';
Step 1: SCAN for matching rows (with file positions)
file: data/001.parquet positions: [42, 107]
file: data/003.parquet positions: [0, 15, 88]
Step 2: WRITE DELETE FILES
For data/001.parquet → ducklake-uuid1-delete.parquet [42, 107]
For data/003.parquet → ducklake-uuid2-delete.parquet [0, 15, 88]
Step 3: REGISTER IN METADATA
BEGIN transaction
INSERT delete files into ducklake_delete_file
INSERT new snapshot
COMMIT
Step 4: RETURN "Deleted 5 rows"
Data files are unchanged on disk. Only new delete files are added:
1
2
3
4
5
6
7
8
9
Before DELETE:
data/001.parquet: [row0, row1, ..., row42, ..., row107, ..., row999]
data/003.parquet: [row0, row1, ..., row15, ..., row88, ...]
After DELETE (data files unchanged, delete files added):
data/001.parquet: (unchanged)
deletes/uuid1-delete.parquet: positions [42, 107]
data/003.parquet: (unchanged)
deletes/uuid2-delete.parquet: positions [0, 15, 88]
The Go implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
func (l *Lake) Delete(ctx context.Context, schemaName, tableName string, predicates []Predicate) (int64, error) {
txn, err := l.snaps.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("lake: begin transaction: %w", err)
}
defer txn.Rollback(ctx)
readSnap := txn.ReadSnapshot()
matches, err := l.scanWithPositions(ctx, schemaName, tableName, predicates, readSnap)
if err != nil {
return 0, fmt.Errorf("lake: scan for delete: %w", err)
}
if len(matches) == 0 {
return 0, nil
}
_, table, _ := l.resolveTableInTx(ctx, txn, schemaName, tableName)
// Group positions by source data file
byFile := make(map[int64][]int64)
for _, m := range matches {
byFile[m.dataFileID] = append(byFile[m.dataFileID], m.position)
}
for dataFileID, positions := range byFile {
deleteFileName := fmt.Sprintf("ducklake-%s-delete.parquet", newUUID())
relativePath := storage.DeletePath(schemaName, tableName, deleteFileName)
localPath := l.localPath(relativePath)
os.MkdirAll(filepath.Dir(localPath), 0o755)
writeDeleteFile(localPath, dataFileID, positions)
l.uploadFile(ctx, localPath, relativePath)
txn.AddDeleteFile(catalog.DeleteFileMeta{
TableID: table.TableID,
DataFileID: dataFileID,
Path: relativePath,
DeleteCount: int64(len(positions)),
})
}
txn.RecordChange(fmt.Sprintf("delete %d rows from %s.%s", len(matches), schemaName, tableName))
_, err = txn.Commit(ctx)
return int64(len(matches)), err
}
Scanning with Positions
The delete operation needs to know both the row data (to evaluate the WHERE clause) and each row’s coordinates (file ID + position). scanWithPositions provides this:
1
2
3
4
5
type rowWithPosition struct {
row datafile.Row
dataFileID int64
position int64
}
It reads every row from every data file (respecting existing deletes), evaluates predicates, and records the position of matching rows.
Writing Delete Files
Delete files are standard Parquet files with a fixed two-column schema:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func writeDeleteFile(path string, dataFileID int64, positions []int64) error {
deleteCols := []catalog.ColumnDef{
{FieldID: 1, Name: "data_file_id", Type: catalog.TypeBigInt, Nullable: false},
{FieldID: 2, Name: "position", Type: catalog.TypeBigInt, Nullable: false},
}
var rows []datafile.Row
for _, pos := range positions {
rows = append(rows, datafile.Row{
"data_file_id": dataFileID,
"position": pos,
})
}
writer := datafile.NewWriter(deleteCols)
_, err := writer.WriteFile(path, rows)
return err
}
How UPDATE Works
UPDATE is decomposed into DELETE + INSERT within a single transaction:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
UPDATE orders SET amount = 200.0 WHERE id = 5;
Step 1: SCAN for matching rows
Find: file data/001.parquet, position 42 → {id=5, name="alice", amount=100.0}
Step 2: DELETE the old row
Write delete file for position 42
Step 3: INSERT the updated row
Apply update: {id=5, name="alice", amount=200.0}
Write new data file
Step 4: REGISTER in single transaction
BEGIN
Register delete file (for old row)
Register new data file (with updated row)
Create snapshot
COMMIT
Both the delete file and the new data file share the same snapshot, so readers see the update atomically:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
func (l *Lake) Update(ctx context.Context, schemaName, tableName string, predicates []Predicate, updates map[string]any) (int64, error) {
txn, err := l.snaps.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("lake: begin transaction: %w", err)
}
defer txn.Rollback(ctx)
readSnap := txn.ReadSnapshot()
matches, _ := l.scanWithPositions(ctx, schemaName, tableName, predicates, readSnap)
if len(matches) == 0 {
return 0, nil
}
_, table, _ := l.resolveTableInTx(ctx, txn, schemaName, tableName)
// DELETE step: write delete files for old positions
byFile := make(map[int64][]int64)
for _, m := range matches {
byFile[m.dataFileID] = append(byFile[m.dataFileID], m.position)
}
for dataFileID, positions := range byFile {
// ... write and register delete file (same as Delete)
txn.AddDeleteFile(catalog.DeleteFileMeta{...})
}
// INSERT step: apply updates and write new data file
var updatedRows []datafile.Row
for _, m := range matches {
newRow := make(datafile.Row, len(m.row))
for k, v := range m.row {
newRow[k] = v
}
for k, v := range updates {
newRow[k] = v
}
updatedRows = append(updatedRows, newRow)
}
writer := datafile.NewWriter(table.Columns)
result, _ := writer.WriteFile(localPath, updatedRows)
txn.AddDataFile(catalog.DataFileMeta{...}, stats)
txn.RecordChange(fmt.Sprintf("update %d rows in %s.%s", len(matches), schemaName, tableName))
_, err = txn.Commit(ctx)
return int64(len(matches)), err
}
Delete Filtering During Scans
The scan pipeline (Phase 4) already handles delete filtering. For each data file, it queries ducklake_delete_file for associated delete files, loads the deleted positions into a map, and skips those positions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Scan pipeline with deletes:
1. Get active data files at snapshot N
→ [file1.parquet, file2.parquet, file3.parquet]
2. For each file, query delete files:
file1: no delete files
file2: del/001.parquet (positions: [5, 12, 99])
file3: del/002.parquet (positions: [0])
3. Read and filter:
file1: read all rows
file2: read all rows, skip positions 5, 12, 99
file3: read all rows, skip position 0
Deleted positions are stored in a map[int64]struct{} for O(1) lookup. For files with many deletes, a bitset (1 bit per possible position) would be more memory-efficient: 10,000 rows = ~1.2 KB.
Why Not Just Rewrite the File?
Merge-on-read adds complexity to the read path but avoids the much larger cost of rewriting data files. For a table with thousands of files totaling terabytes, deleting one row via copy-on-write means rewriting a multi-gigabyte file. The delete file is typically a few kilobytes.
The tradeoff is read amplification. But in practice:
- Most rows are not deleted, so the overhead is small.
- Loading positions into a map and checking membership is fast.
- Compaction can periodically rewrite files to absorb accumulated deletes.