Repository: duckdb/duckdb (2026-07-06T09:32:31Z)

Issue:
Total string length records incorrectly
### What happens?

DuckDB supports `stats` function to get column stats from a table, but for string type, its total length doesn't seem to be recorded correctly.

### To Reproduce

```sql
memory D PRAGMA force_compression='dictionary';
memory D ATTACH '/tmp/issue_jul_6.db' as db;
memory D USE db;
db D CREATE TABLE t (s VARCHAR);
db D INSERT INTO t SELECT 'hello' FROM range(10000);
db D CHECKPOINT;
┌─────────┐
│ Success │
│ boolean │
└─────────┘
  0 rows 

-- Should be 5000 bytes, instead of 5 bytes.
db D select s.total_string_length from (select stats(s) s from t limit 1);
┌─────────────────────┐
│ total_string_length │
│       variant       │
├─────────────────────┤
│ 5                   │
└─────────────────────┘
```

### OS:

macos

### DuckDB Version:

a81547b43c56c671a7ad1a636f83886713efdd8a

### DuckDB Client:

SQL

### Hardware:

_No response_

### Full Name:

dentiny

### Affiliation:

N/A

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

- [x] Yes, I have

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

- [x] Yes, I have

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

Yes


Discussion:

Patch:
diff --git a/src/include/duckdb/storage/compression/dictionary/analyze.hpp b/src/include/duckdb/storage/compression/dictionary/analyze.hpp
index 62821e65b0c8..87b2ffd29e3c 100644
--- a/src/include/duckdb/storage/compression/dictionary/analyze.hpp
+++ b/src/include/duckdb/storage/compression/dictionary/analyze.hpp
@@ -16,7 +16,7 @@ struct DictionaryAnalyzeState : public AnalyzeState {
 public:
 	bool LookupString(string_t str);
 	void AddNewString(string_t str);
-	void AddLastLookup();
+	void AddLastLookup(string_t str);
 	void AddNull();
 	bool CalculateSpaceRequirements(bool new_string, idx_t string_size);
 	void Flush(bool final = false);
diff --git a/src/include/duckdb/storage/compression/dictionary/common.hpp b/src/include/duckdb/storage/compression/dictionary/common.hpp
index 1845e504efcb..acfdd3864870 100644
--- a/src/include/duckdb/storage/compression/dictionary/common.hpp
+++ b/src/include/duckdb/storage/compression/dictionary/common.hpp
@@ -65,7 +65,7 @@ struct DictionaryCompression {
 			} else if (new_string) {
 				state.AddNewString(entry.GetValue());
 			} else {
-				state.AddLastLookup();
+				state.AddLastLookup(entry.GetValue());
 			}
 
 			state.Verify();
diff --git a/src/include/duckdb/storage/compression/dictionary/compression.hpp b/src/include/duckdb/storage/compression/dictionary/compression.hpp
index 61592a8cfa1a..fd9266934edf 100644
--- a/src/include/duckdb/storage/compression/dictionary/compression.hpp
+++ b/src/include/duckdb/storage/compression/dictionary/compression.hpp
@@ -33,7 +33,7 @@ struct DictionaryCompressionCompressState : public StandardCompressionState {
 	bool LookupString(string_t str);
 	void AddNewString(string_t str);
 	void AddNull();
-	void AddLastLookup();
+	void AddLastLookup(string_t str);
 	bool CalculateSpaceRequirements(bool new_string, idx_t string_size);
 	void Flush(bool final = false);
 	idx_t Finalize();
diff --git a/src/include/duckdb/storage/statistics/stats_writer.hpp b/src/include/duckdb/storage/statistics/stats_writer.hpp
index 7325d3bdd32d..90de93b2a19c 100644
--- a/src/include/duckdb/storage/statistics/stats_writer.hpp
+++ b/src/include/duckdb/storage/statistics/stats_writer.hpp
@@ -176,6 +176,16 @@ struct StatsWriter<string_t> : public BaseStatsWriter {
 		}
 	}
 
+	//! Update stats for a value that was already passed to Update - only additive stats need to be re-counted
+	inline void UpdateRepeated(const string_t &value) {
+		if (is_geometry) {
+			Update(value);
+			return;
+		}
+		SetHasValid();
+		total_string_length += value.GetSize();
+	}
+
 	void Merge(BaseStatistics &other) const {
 		MergeBase(other);
 		if (is_geometry) {
diff --git a/src/storage/compression/dictionary/analyze.cpp b/src/storage/compression/dictionary/analyze.cpp
index 352940f3d0c4..d7ff65164fdb 100644
--- a/src/storage/compression/dictionary/analyze.cpp
+++ b/src/storage/compression/dictionary/analyze.cpp
@@ -19,7 +19,7 @@ void DictionaryAnalyzeState::AddNewString(string_t str) {
 	current_width = next_width;
 }
 
-void DictionaryAnalyzeState::AddLastLookup() {
+void DictionaryAnalyzeState::AddLastLookup(string_t str) {
 	current_tuple_count++;
 }
 
diff --git a/src/storage/compression/dictionary/compression.cpp b/src/storage/compression/dictionary/compression.cpp
index 75b1ea794909..3c100ed1699a 100644
--- a/src/storage/compression/dictionary/compression.cpp
+++ b/src/storage/compression/dictionary/compression.cpp
@@ -85,7 +85,9 @@ void DictionaryCompressionCompressState::AddNull() {
 	current_segment->count++;
 }
 
-void DictionaryCompressionCompressState::AddLastLookup() {
+void DictionaryCompressionCompressState::AddLastLookup(string_t str) {
+	// the string is already in the dictionary - only additive stats (total_string_length) need updating
+	stats_writer.UpdateRepeated(str);
 	selection_buffer.push_back(latest_lookup_result);
 	current_segment->count++;
 }


Test patch:
diff --git a/test/configs/force_storage_restart.json b/test/configs/force_storage_restart.json
index 9b56e7049dfa..629651f6205e 100644
--- a/test/configs/force_storage_restart.json
+++ b/test/configs/force_storage_restart.json
@@ -83,6 +83,7 @@
       "reason": "total_string_length requires exact per-row-group stats; force_restart causes persistent row groups with unloaded columns where only bound-expansion is possible, making the stat unknown.",
       "paths": [
         "test/sql/storage/total_string_length_repeated_insert.test",
+        "test/sql/storage/compression/dictionary/total_string_length_dictionary.test",
         "test/sql/storage/extended_string_stats.test"
       ]
     },
diff --git a/test/configs/stats_dependent_tests.json b/test/configs/stats_dependent_tests.json
index 851382c447ce..b9d0f9ac95fb 100644
--- a/test/configs/stats_dependent_tests.json
+++ b/test/configs/stats_dependent_tests.json
@@ -33,6 +33,7 @@
         "test/sql/storage/extended_string_stats.test",
         "test/sql/storage/min_string_length_too_large.test_slow",
         "test/sql/storage/total_string_length_repeated_insert.test",
+        "test/sql/storage/compression/dictionary/total_string_length_dictionary.test",
         "test/sql/types/geo/geometry_from_wkb_stats.test",
         "test/parquet/variant/variant_stats_propagation.test",
         "test/sql/types/geo/geometry_stats.test",
diff --git a/test/sql/storage/compression/dictionary/total_string_length_dictionary.test b/test/sql/storage/compression/dictionary/total_string_length_dictionary.test
new file mode 100644
index 000000000000..7d702b0f24b1
--- /dev/null
+++ b/test/sql/storage/compression/dictionary/total_string_length_dictionary.test
@@ -0,0 +1,32 @@
+# name: test/sql/storage/compression/dictionary/total_string_length_dictionary.test
+# description: Test that total_string_length counts repeated strings when checkpointing with dictionary compression
+# group: [dictionary]
+
+require no_latest_storage
+
+require vector_size 2048
+
+load {TEST_DIR}/total_string_length_dictionary.db readwrite v1.0.0
+
+statement ok
+PRAGMA force_compression='dictionary'
+
+statement ok
+CREATE TABLE t (s VARCHAR);
+
+statement ok
+INSERT INTO t SELECT 'hello' FROM range(10000);
+
+statement ok
+CHECKPOINT
+
+query I
+SELECT compression FROM pragma_storage_info('t') WHERE segment_type ILIKE 'VARCHAR' LIMIT 1
+----
+Dictionary
+
+# total_string_length must count every row, not just the unique dictionary entries
+query I
+select s.total_string_length from (select stats(s) s from t limit 1)
+----
+50000