Repository: duckdb/ducklake (2026-07-23T17:42:37Z)

Issue:
ADD COLUMN + RENAME TABLE in one transaction corrupts catalog metadata ("Column with name ... already exists!" on every read)
### What happens?

`ALTER TABLE ... ADD COLUMN` followed by `ALTER TABLE ... RENAME TO` in the same transaction commits successfully but writes corrupt catalog metadata. Every subsequent read of the table — from any connection, including a fresh `ATTACH` — fails with:

```
Catalog Error: Column with name id already exists!
```

After the commit, the metadata catalog contains two live rows in `ducklake_table` for the same `table_id` (the pre-rename row's `end_snapshot` is never set alongside the new row), and the added column is never written to `ducklake_column` at all. Catalog load reads the table twice and hits the duplicate-column error.

No data is involved — the table can be empty. Either ALTER alone is fine; only the combination corrupts. ADD COLUMN + RENAME **COLUMN** (as opposed to renaming the table) is also fine without inlining, so this is distinct from #740.

This looks like another instance of the two-ALTERs-in-one-transaction family: same symptom as #683, which #714 fixed for the ALTER TYPE + SET DEFAULT pair; the repro below still fails on current `main`, which contains #714. Also adjacent: #973 (SET DEFAULT + RENAME, closed) and #740 (ADD + RENAME COLUMN with inlined-data flush, open — different failure: its catalog stays intact and the inlined-data read path breaks instead).

Found via PR #1306's CI: DuckDB's `test/sql/transactions/test_ddl_after_dml.test` under `test/configs/attach_ducklake.json` previously self-skipped (an earlier statement hit the whitelisted non-literal default error); that PR makes the statement succeed, so the test now runs far enough to hit this bug at its `restart`.

### To Reproduce

```sql
ATTACH 'ducklake:x.db' AS dl; USE dl;
CREATE TABLE t2 (id INT);
BEGIN;
ALTER TABLE t2 ADD COLUMN c0 INT DEFAULT 7;
ALTER TABLE t2 RENAME TO t3;
COMMIT;
SELECT * FROM t3;    -- Catalog Error: Column with name id already exists!
```

The COMMIT succeeds; the SELECT fails, and so does any later `ATTACH` + read from a new process. The broken metadata is visible directly:

```sql
ATTACH 'x.db' AS meta;
SELECT table_id, begin_snapshot, end_snapshot, table_name FROM meta.ducklake_table;
-- table_id 1 | begin 1 | end NULL | t2     <- never closed
-- table_id 1 | begin 2 | end NULL | t3
SELECT column_id, column_name FROM meta.ducklake_column;
-- only "id" — the added column c0 was never written
```

### OS:

Debian GNU/Linux 12 (bookworm), kernel 6.1.0-44-amd64, x86_64

### DuckDB Version:

duckdb `main` (source build) @ `0cd9e82950` (`v1.5.3-7258-g0cd9e82950`)

### DuckLake Version:

ducklake `main` @ `34db89b27efe23a2037df83bf809720cad64d915`

### DuckDB Client:

CLI

### Hardware:

Not performance-related.

### Full Name:

Gavin Brand

### Affiliation:

N/A

### What is the latest build you tested with? If possible, we recommend testing with the latest nightly build.

I have tested with a source build

### Did you include all relevant data sets for reproducing the issue?

Not applicable - the reproduction does not require a data set

### Did you include all code required to reproduce the issue?

- [X] Yes, I have

### Did you include all relevant configuration (e.g., CPU architecture, Python version, Linux distribution) to reproduce the issue?

- [X] Yes, I have



Discussion:

Patch:
diff --git a/src/storage/ducklake_transaction.cpp b/src/storage/ducklake_transaction.cpp
index 6d49a20702..a4b6ea4d39 100644
--- a/src/storage/ducklake_transaction.cpp
+++ b/src/storage/ducklake_transaction.cpp
@@ -1992,20 +1992,24 @@ void DuckLakeTransaction::AlterEntry(CatalogEntry &entry, unique_ptr<CatalogEntr
 static void HandleRenameOldEntry(DuckLakeCatalogSet &entries, const string &old_name, const string &new_name,
                                  TableIndex id, bool entry_is_transaction_local, set<TableIndex> &renamed_set,
                                  const set<TableIndex> &dropped_set) {
-	if (IsTransactionLocal(id)) {
-		// entry was created in this same transaction
+	if (!IsTransactionLocal(id) && !entry_is_transaction_local) {
+		// first rename of a committed entry that was untouched earlier in this transaction
+		// Invariant: an id cannot be both renamed and dropped in the same transaction.
+		D_ASSERT(dropped_set.find(id) == dropped_set.end());
+		renamed_set.insert(id);
+		return;
+	}
+	// changes made earlier in this transaction must still commit under the new name, but when the
+	// name does not change they are already chained under it and the drop would take them with it
+	if (!StringUtil::CIEquals(old_name, new_name)) {
 		auto dropped = entries.DropEntry(old_name);
 		auto new_entry_ptr = entries.GetEntry(new_name);
 		if (new_entry_ptr && dropped) {
 			new_entry_ptr->SetChild(std::move(dropped));
 		}
-	} else if (entry_is_transaction_local) {
-		// entry existed before this transaction and has already been renamed earlier in this txn
-		entries.DropEntry(old_name);
-	} else {
-		// first rename of a committed entry
-		// Invariant: an id cannot be both renamed and dropped in the same transaction.
-		D_ASSERT(dropped_set.find(id) == dropped_set.end());
+	}
+	if (!IsTransactionLocal(id)) {
+		// committed entry that was altered earlier in this transaction - the old row still needs closing
 		renamed_set.insert(id);
 	}
 }
diff --git a/src/storage/ducklake_transaction_state.cpp b/src/storage/ducklake_transaction_state.cpp
index 45288f2205..4ee4c8ed4f 100644
--- a/src/storage/ducklake_transaction_state.cpp
+++ b/src/storage/ducklake_transaction_state.cpp
@@ -1344,10 +1344,11 @@ void DuckLakeTransactionState::GetNewTableInfo(DuckLakeCommitState &commit_state
 		case LocalChangeType::CREATED:
 		case LocalChangeType::RENAMED: {
 			auto old_table_id = table.GetTableId();
-			if (local_change.type == LocalChangeType::RENAMED && IsTransactionLocal(old_table_id)) {
+			if (local_change.type == LocalChangeType::RENAMED) {
 				auto existing = commit_state.committed_tables.find(old_table_id);
 				if (existing != commit_state.committed_tables.end()) {
-					// If we are rename a table in the same transaction it was created, we need to patch it
+					// we already emitted a row for this table earlier in the chain (it was created or
+					// renamed in this same transaction) - patch the name instead of emitting a second row
 					RenameEmittedEntry(result.new_tables, existing->second, table.name.GetIdentifierName());
 					break;
 				}
@@ -1470,10 +1471,11 @@ void DuckLakeTransactionState::GetNewViewInfo(DuckLakeCommitState &commit_state,
 		case LocalChangeType::CREATED:
 		case LocalChangeType::RENAMED: {
 			auto old_view_id = view.GetViewId();
-			if (view.GetLocalChange().type == LocalChangeType::RENAMED && IsTransactionLocal(old_view_id)) {
+			if (view.GetLocalChange().type == LocalChangeType::RENAMED) {
 				auto existing = commit_state.committed_tables.find(old_view_id);
 				if (existing != commit_state.committed_tables.end()) {
-					// renaming a view in the same transaction it was created - patch the name on the existing row
+					// we already emitted a row for this view earlier in the chain (it was created or
+					// renamed in this same transaction) - patch the name instead of emitting a second row
 					RenameEmittedEntry(result.new_views, existing->second, view.name.GetIdentifierName());
 					break;
 				}


Test patch:
diff --git a/test/sql/issues/issue_1332.test b/test/sql/issues/issue_1332.test
new file mode 100644
index 0000000000..fe1d28dee5
--- /dev/null
+++ b/test/sql/issues/issue_1332.test
@@ -0,0 +1,526 @@
+# name: test/sql/issues/issue_1332.test
+# description: regression test for issue #1332 - altering a committed table then renaming it in the same transaction
+# group: [issues]
+
+require ducklake
+
+require parquet
+
+test-env DUCKLAKE_CONNECTION {TEST_DIR}/issue_1332.db
+
+test-env DATA_PATH {TEST_DIR}
+
+statement ok
+ATTACH 'ducklake:{DUCKLAKE_CONNECTION}' AS dl (DATA_PATH '{DATA_PATH}', DATA_INLINING_ROW_LIMIT 0)
+
+statement ok
+USE dl
+
+# ADD COLUMN + RENAME TABLE
+statement ok
+CREATE TABLE t_add (id INT)
+
+statement ok
+BEGIN
+
+statement ok
+ALTER TABLE t_add ADD COLUMN c0 INT DEFAULT 7
+
+statement ok
+ALTER TABLE t_add RENAME TO t_add2
+
+statement ok
+COMMIT
+
+query II
+SELECT * FROM t_add2
+----
+
+# SET DEFAULT + RENAME TABLE
+statement ok
+CREATE TABLE t_def (id INT)
+
+statement ok
+BEGIN
+
+statement ok
+ALTER TABLE t_def ALTER COLUMN id SET DEFAULT 5
+
+statement ok
+ALTER TABLE t_def RENAME TO t_def2
+
+statement ok
+COMMIT
+
+statement ok
+INSERT INTO t_def2 DEFAULT VALUES
+
+query I
+SELECT * FROM t_def2
+----
+5
+
+# RENAME COLUMN + RENAME TABLE
+statement ok
+CREATE TABLE t_col (id INT)
+
+statement ok
+BEGIN
+
+statement ok
+ALTER TABLE t_col RENAME COLUMN id TO id2
+
+statement ok
+ALTER TABLE t_col RENAME TO t_col2
+
+statement ok
+COMMIT
+
+query I
+SELECT id2 FROM t_col2
+----
+
+# ALTER TYPE + RENAME TABLE
+statement ok
+CREATE TABLE t_type (id INT)
+
+statement ok
+BEGIN
+
+statement ok
+ALTER TABLE t_type ALTER COLUMN id TYPE BIGINT
+
+statement ok
+ALTER TABLE t_type RENAME TO t_type2
+
+statement ok
+COMMIT
+
+query I
+SELECT id FROM t_type2
+----
+
+# RENAME TABLE twice (must still work - no duplicate rows)
+statement ok
+CREATE TABLE t_rr (id INT)
+
+statement ok
+BEGIN
+
+statement ok
+ALTER TABLE t_rr RENAME TO t_rr_mid
+
+statement ok
+ALTER TABLE t_rr_mid RENAME TO t_rr2
+
+statement ok
+COMMIT
+
+query I
+SELECT id FROM t_rr2
+----
+
+# COMMENT ON TABLE + RENAME TABLE to the same name (DuckDB accepts this as a no-op)
+statement ok
+CREATE TABLE t_same (id INT)
+
+statement ok
+INSERT INTO t_same VALUES (42)
+
+statement ok
+BEGIN
+
+statement ok
+COMMENT ON TABLE t_same IS 'hello'
+
+statement ok
+ALTER TABLE t_same RENAME TO t_same
+
+statement ok
+COMMIT
+
+query I
+SELECT id FROM t_same
+----
+42
+
+query I
+SELECT comment FROM duckdb_tables() WHERE table_name = 't_same'
+----
+hello
+
+# ADD COLUMN + RENAME TABLE to the same name - the column and its default must survive
+statement ok
+CREATE TABLE t_same_add (id INT)
+
+statement ok
+INSERT INTO t_same_add VALUES (100)
+
+statement ok
+BEGIN
+
+statement ok
+ALTER TABLE t_same_add ADD COLUMN c0 INT DEFAULT 7
+
+statement ok
+ALTER TABLE t_same_add RENAME TO t_same_add
+
+statement ok
+COMMIT
+
+statement ok
+INSERT INTO t_same_add (id) VALUES (200)
+
+query II
+SELECT * FROM t_same_add ORDER BY id
+----
+100	7
+200	7
+
+# RENAME TABLE to the same name in a different case - the new casing must stick
+statement ok
+CREATE TABLE t_case (id INT)
+
+statement ok
+INSERT INTO t_case VALUES (1)
+
+statement ok
+BEGIN
+
+statement ok
+COMMENT ON TABLE t_case IS 'hello'
+
+statement ok
+ALTER TABLE t_case RENAME TO T_CASE
+
+statement ok
+COMMIT
+
+query I
+SELECT id FROM t_case
+----
+1
+
+query II
+SELECT table_name, comment FROM duckdb_tables() WHERE table_name ILIKE 't_case'
+----
+T_CASE	hello
+
+# CREATE + RENAME TABLE to the same name in one txn (transaction-local id path)
+statement ok
+BEGIN
+
+statement ok
+CREATE TABLE t_same_local (id INT)
+
+statement ok
+INSERT INTO t_same_local VALUES (5)
+
+statement ok
+ALTER TABLE t_same_local RENAME TO t_same_local
+
+statement ok
+COMMIT
+
+query I
+SELECT id FROM t_same_local
+----
+5
+
+# RENAME TABLE first, THEN alter a column in the same txn (reverse of the cases above)
+statement ok
+CREATE TABLE t_rev (id INT)
+
+statement ok
+BEGIN
+
+statement ok
+ALTER TABLE t_rev RENAME TO t_rev2
+
+statement ok
+ALTER TABLE t_rev2 ADD COLUMN c0 INT DEFAULT 7
+
+statement ok
+COMMIT
+
+query II
+SELECT * FROM t_rev2
+----
+
+# ALTER + RENAME + ALTER + RENAME - a chain that holds more than one rename entry
+statement ok
+CREATE TABLE t_chain (id INT)
+
+statement ok
+BEGIN
+
+statement ok
+ALTER TABLE t_chain ADD COLUMN x INT DEFAULT 1
+
+statement ok
+ALTER TABLE t_chain RENAME TO t_chain2
+
+statement ok
+ALTER TABLE t_chain2 ADD COLUMN y INT DEFAULT 2
+
+statement ok
+ALTER TABLE t_chain2 RENAME TO t_chain3
+
+statement ok
+COMMIT
+
+query III
+SELECT * FROM t_chain3
+----
+
+# CREATE + ADD COLUMN + RENAME all in one txn (transaction-local id must not emit a stale row)
+statement ok
+BEGIN
+
+statement ok
+CREATE TABLE t_local (id INT)
+
+statement ok
+ALTER TABLE t_local ADD COLUMN c0 INT DEFAULT 3
+
+statement ok
+ALTER TABLE t_local RENAME TO t_local2
+
+statement ok
+COMMIT
+
+query II
+SELECT * FROM t_local2
+----
+
+# ADD COLUMN + RENAME, then insert and read in a later txn - the default and data must round-trip
+statement ok
+CREATE TABLE t_data (id INT)
+
+statement ok
+INSERT INTO t_data VALUES (100)
+
+statement ok
+BEGIN
+
+statement ok
+ALTER TABLE t_data ADD COLUMN c0 INT DEFAULT 7
+
+statement ok
+ALTER TABLE t_data RENAME TO t_data2
+
+statement ok
+COMMIT
+
+statement ok
+INSERT INTO t_data2 (id) VALUES (200)
+
+query II
+SELECT * FROM t_data2 ORDER BY id
+----
+100	7
+200	7
+
+# ROLLBACK of ADD COLUMN + RENAME leaves the original table untouched
+statement ok
+CREATE TABLE t_rb (id INT)
+
+statement ok
+INSERT INTO t_rb VALUES (42)
+
+statement ok
+BEGIN
+
+statement ok
+ALTER TABLE t_rb ADD COLUMN c0 INT DEFAULT 7
+
+statement ok
+ALTER TABLE t_rb RENAME TO t_rb2
+
+statement ok
+ROLLBACK
+
+query I
+SELECT id FROM t_rb
+----
+42
+
+statement error
+SELECT * FROM t_rb2
+----
+
+# COMMENT ON VIEW + RENAME VIEW
+statement ok
+CREATE TABLE vbase (id INT)
+
+statement ok
+CREATE VIEW v1 AS SELECT * FROM vbase
+
+statement ok
+BEGIN
+
+statement ok
+COMMENT ON VIEW v1 IS 'hello'
+
+statement ok
+ALTER VIEW v1 RENAME TO v2
+
+statement ok
+COMMIT
+
+query I
+SELECT comment FROM duckdb_views() WHERE view_name = 'v2'
+----
+hello
+
+# COMMENT ON VIEW + RENAME VIEW to the same name
+statement ok
+CREATE VIEW v_same AS SELECT * FROM vbase
+
+statement ok
+BEGIN
+
+statement ok
+COMMENT ON VIEW v_same IS 'hello'
+
+statement ok
+ALTER VIEW v_same RENAME TO v_same
+
+statement ok
+COMMIT
+
+query I
+SELECT comment FROM duckdb_views() WHERE view_name = 'v_same'
+----
+hello
+
+# same fix with data inlining left at its default (row limit 10) rather than disabled - covers the
+# inlined-data flush path adjacent to #740, including a reattach where the corruption would surface
+statement ok
+ATTACH 'ducklake:{TEST_DIR}/issue_1332_inline.db' AS dli (DATA_PATH '{DATA_PATH}')
+
+statement ok
+USE dli
+
+statement ok
+CREATE TABLE t_in (id INT)
+
+statement ok
+INSERT INTO t_in VALUES (1), (2)
+
+statement ok
+BEGIN
+
+statement ok
+ALTER TABLE t_in ADD COLUMN c0 INT DEFAULT 7
+
+statement ok
+ALTER TABLE t_in RENAME TO t_in2
+
+statement ok
+COMMIT
+
+query II
+SELECT * FROM t_in2 ORDER BY id
+----
+1	7
+2	7
+
+statement ok
+USE memory
+
+statement ok
+DETACH dli
+
+statement ok
+ATTACH 'ducklake:{TEST_DIR}/issue_1332_inline.db' AS dli (DATA_PATH '{DATA_PATH}')
+
+query II
+SELECT * FROM dli.t_in2 ORDER BY id
+----
+1	7
+2	7
+
+statement ok
+DETACH dli
+
+# force a fresh catalog load to prove the metadata is intact
+statement ok
+USE memory
+
+statement ok
+DETACH dl
+
+statement ok
+ATTACH 'ducklake:{DUCKLAKE_CONNECTION}' AS dl (DATA_PATH '{DATA_PATH}')
+
+query II
+SELECT * FROM dl.t_add2
+----
+
+query III
+SELECT * FROM dl.t_chain3
+----
+
+query II
+SELECT * FROM dl.t_local2
+----
+
+query II
+SELECT * FROM dl.t_data2 ORDER BY id
+----
+100	7
+200	7
+
+query I
+SELECT dl.t_col2.id2 FROM dl.t_col2
+----
+
+query I
+SELECT comment FROM duckdb_views() WHERE view_name = 'v2'
+----
+hello
+
+query I
+SELECT id FROM dl.t_same
+----
+42
+
+query II
+SELECT * FROM dl.t_same_add ORDER BY id
+----
+100	7
+200	7
+
+query I
+SELECT id FROM dl.t_case
+----
+1
+
+query I
+SELECT id FROM dl.t_same_local
+----
+5
+
+query II
+SELECT table_name, comment FROM duckdb_tables() WHERE table_name IN ('t_same', 'T_CASE') ORDER BY table_name
+----
+T_CASE	hello
+t_same	hello
+
+query I
+SELECT comment FROM duckdb_views() WHERE view_name = 'v_same'
+----
+hello
+
+# every renamed table has exactly one live row in ducklake_table (the pre-rename rows were closed)
+statement ok
+DETACH dl
+
+statement ok
+ATTACH '{DUCKLAKE_CONNECTION}' AS meta
+
+query I
+SELECT count(*) FROM meta.ducklake_table WHERE end_snapshot IS NULL AND table_name IN ('t_add', 't_add2', 't_def', 't_def2', 't_col', 't_col2', 't_type', 't_type2', 't_rr', 't_rr_mid', 't_rr2', 't_rev', 't_rev2', 't_chain', 't_chain2', 't_chain3', 't_local', 't_local2', 't_data', 't_data2', 't_rb', 't_rb2', 't_same', 't_same_add', 't_case', 'T_CASE', 't_same_local')
+----
+14