Repository: duckdb/duckdb (2026-06-23T13:42:35Z)

Issue:
`regexp_replace` silently mishandles invalid replacement strings
`regexp_replace` does not validate the **replacement** string. When the replacement contains something RE2 cannot expand (out-of-range backreference, invalid escape (`\x`), or a trailing backslash), DuckDB silently produces wrong output instead of raising an error. Worse, the behavior is internally inconsistent: depending on *which* kind of malformed replacement it is, you either get the input back unchanged, or a partially-rewritten string.

### To Reproduce

```sql
-- (A) out-of-range backreference, global: input returned unchanged
SELECT regexp_replace('abcabc', '(b)', '\3Y', 'g');   -- => 'abcabc'

-- (B) invalid escape, global: PARTIAL output -- the 'X' is emitted, '\xY' is silently dropped
SELECT regexp_replace('abcabc', 'b', 'X\xY', 'g');    -- => 'aXcaXc'

-- (C) trailing backslash, global: same partial-output behavior
SELECT regexp_replace('abcabc', 'b', 'X\', 'g');      -- => 'aXcaXc'

-- (D) out-of-range backreference, non-global: input returned unchanged
SELECT regexp_replace('abc', '(b)', '\3Y');           -- => 'abc'
```

Cases (A)/(D) return the input unchanged; cases (B)/(C) return a half-rewritten
string. None of them raises an error, and (B)/(C) are arguably the worst outcome:
a silently corrupted result.

### Expected behavior

A malformed replacement string should be handled deterministically. IMO, reasonable options would be:

1. **Raise an error** (consistent with the #16380 fix for malformed patterns)
2. **Leniently expand** like PostgreSQL/MariaDB: an out-of-range backref expands to empty and an unknown escape is treated as a literal. Producing a consistent (if forgiving) result.

But the current mix of "input unchanged" for some malformed replacements and "partial output" is hard to understand.

### Potential fix

RE2 offers `RE2::CheckRewriteString()` to check if a replacement string is valid.
Afaict, either of the two options above could be easily implemented using this.

### Comparison with other engines

Same query `regexp_replace('abcabc', <pat>, <repl>, 'g')` (two matches of `b`):

| Engine | out-of-range backref | invalid escape `\x` | trailing `\` |
|---|---|---|---|
| **DuckDB v1.2.0** | `abcabc` (unchanged) | `aXcaXc` (**partial**) | `aXcaXc` (**partial**) |
| PostgreSQL 17 | `aXYcaXYc` (backref → empty) | `aX\xYc…` (`\x` → literal) | `aX\c…` (`\` → literal) |
| MariaDB 10.11 | `aXYcaXYc` (backref → empty) | `aXxYc…` (`\x` → `x`) | `aXcaXc` (`\` dropped) |
| Apache Spark | **error** `No group 9` | `aXxYc…` (literal) | **error** `character to be escaped is missing` |

DuckDB is the only engine I tested which produces *partial* output, and the only one whose result for a malformed replacement is inconsistent across the different malformed forms.

### Related

This is the replacement-string analogue of #16340 ("regexp_replace silently ignores errors in the regexp pattern"). That issue was fixed for the **pattern** in #16380 by adding an `re.ok()` check, but the replacement string is still passed straight to `RE2::GlobalReplace` / `RE2::Replace` with no validation. RE2 offers `RE2::CheckRewriteString()` for this but it is not called.


Discussion:
I'd like to work on this. Planning to implement Option 1 (raise an error), following the same pattern as #16380 which fixed malformed patterns. Would use `RE2::CheckRewriteString()` for validation. Let me know if you prefer a different approacch.
Hi, I've opened a PR #23422 to fix this using the error-raising approach you suggested.
Also added 6 test cases covering all malformed forms.



Patch:
diff --git a/src/function/scalar/string/regexp.cpp b/src/function/scalar/string/regexp.cpp
index b21e37425e2b..01c11a6cb9a2 100644
--- a/src/function/scalar/string/regexp.cpp
+++ b/src/function/scalar/string/regexp.cpp
@@ -198,11 +198,16 @@ static void RegexReplaceFunction(DataChunk &args, ExpressionState &state, Vector
 		auto &lstate = ExecuteFunctionState::GetFunctionState(state)->Cast<RegexLocalState>();
 		BinaryExecutor::Execute<string_t, string_t, string_t>(
 		    strings, replaces, result, [&](string_t input, string_t replace) {
+			    auto replace_piece = CreateStringPiece(replace);
+			    std::string rewrite_error;
+			    if (!lstate.constant_pattern.CheckRewriteString(replace_piece, &rewrite_error)) {
+				    throw InvalidInputException("Invalid replacement string for regexp_replace: %s", rewrite_error);
+			    }
 			    std::string sstring = input.GetString();
 			    if (info.global_replace) {
-				    RE2::GlobalReplace(&sstring, lstate.constant_pattern, CreateStringPiece(replace));
+				    RE2::GlobalReplace(&sstring, lstate.constant_pattern, replace_piece);
 			    } else {
-				    RE2::Replace(&sstring, lstate.constant_pattern, CreateStringPiece(replace));
+				    RE2::Replace(&sstring, lstate.constant_pattern, replace_piece);
 			    }
 			    return heap.AddString(sstring);
 		    });
@@ -213,11 +218,16 @@ static void RegexReplaceFunction(DataChunk &args, ExpressionState &state, Vector
 			    if (!re.ok()) {
 				    throw InvalidInputException(re.error());
 			    }
+			    auto replace_piece = CreateStringPiece(replace);
+			    std::string rewrite_error;
+			    if (!re.CheckRewriteString(replace_piece, &rewrite_error)) {
+				    throw InvalidInputException("Invalid replacement string for regexp_replace: %s", rewrite_error);
+			    }
 			    std::string sstring = input.GetString();
 			    if (info.global_replace) {
-				    RE2::GlobalReplace(&sstring, re, CreateStringPiece(replace));
+				    RE2::GlobalReplace(&sstring, re, replace_piece);
 			    } else {
-				    RE2::Replace(&sstring, re, CreateStringPiece(replace));
+				    RE2::Replace(&sstring, re, replace_piece);
 			    }
 			    return heap.AddString(sstring);
 		    });


Test patch:
diff --git a/test/sql/function/string/regex_replace.test b/test/sql/function/string/regex_replace.test
index b0a95685df6b..fe47ff7424a5 100644
--- a/test/sql/function/string/regex_replace.test
+++ b/test/sql/function/string/regex_replace.test
@@ -189,3 +189,54 @@ query T
 SELECT regexp_replace('héllo-café/rest', '^([^/]+)/.*$', '\1');
 ----
 héllo-café
+
+# Invalid replacement strings should raise an error (issue #23370).
+# Previously these silently produced wrong output instead of erroring.
+
+# (A) out-of-range backreference, global: was returning input unchanged
+statement error
+SELECT regexp_replace('abcabc', '(b)', '\3Y', 'g');
+----
+Invalid replacement string for regexp_replace
+
+# (B) invalid escape sequence in replacement, global: was returning partial output
+statement error
+SELECT regexp_replace('abcabc', 'b', E'X\\xY', 'g');
+----
+Invalid replacement string for regexp_replace
+
+# (C) trailing backslash in replacement, global: was returning partial output
+statement error
+SELECT regexp_replace('abcabc', 'b', E'X\\', 'g');
+----
+Invalid replacement string for regexp_replace
+
+# (D) out-of-range backreference, non-global: was returning input unchanged
+statement error
+SELECT regexp_replace('abc', '(b)', '\3Y');
+----
+Invalid replacement string for regexp_replace
+
+# Non-constant pattern path: out-of-range backreference should also raise an error
+statement ok
+CREATE TABLE t_repl_invalid(s VARCHAR, pat VARCHAR, repl VARCHAR);
+
+statement ok
+INSERT INTO t_repl_invalid VALUES ('abcabc', '(b)', '\3Y');
+
+statement error
+SELECT regexp_replace(s, pat, repl) FROM t_repl_invalid;
+----
+Invalid replacement string for regexp_replace
+
+# Non-constant pattern path: invalid escape should also raise an error
+statement ok
+DELETE FROM t_repl_invalid;
+
+statement ok
+INSERT INTO t_repl_invalid VALUES ('abcabc', 'b', E'X\\xY');
+
+statement error
+SELECT regexp_replace(s, pat, repl) FROM t_repl_invalid;
+----
+Invalid replacement string for regexp_replace