This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new d9a289e35542 [SPARK-57253][SQL] Add `jaro_winkler_similarity` built-in
function
d9a289e35542 is described below
commit d9a289e355427b4e4c510fd4bd1170abac90572d
Author: Kousuke Saruta <[email protected]>
AuthorDate: Mon Jun 8 20:53:44 2026 -0700
[SPARK-57253][SQL] Add `jaro_winkler_similarity` built-in function
### What changes were proposed in this pull request?
This PR adds a new built-in string function `jaro_winkler_similarity(str1,
str2)` that computes the Jaro-Winkler similarit
y between two strings. The result is a double between 0.0 (no similarity)
and 1.0 (identical strings).
The Jaro-Winkler metric is an extension of the Jaro similarity that gives a
bonus for common prefixes (up to 4 characters)
, making it especially suited for short strings such as names.
Changes:
* `ExpressionImplUtils.jaroWinklerSimilarity()` — core algorithm
implementation
* `JaroWinkler` expression as `RuntimeReplaceable` + `StaticInvoke`
* Registration as `jaro_winkler_similarity` in `FunctionRegistry`
* Scala DataFrame API in `functions.scala`
* PySpark API in `pyspark.sql.functions` + Spark Connect
* Unit tests covering basic cases, symmetry, multi-byte strings, and null
handling
### Why are the changes needed?
Jaro-Winkler is one of the most commonly used string similarity metrics for
record linkage, deduplication, and fuzzy matching. It is available as a
built-in function in DuckDB, SQL Server 2025, and various other engines, but
Spark users currently need to implement it as a UDF.
A built-in function provides:
- Codegen support (no UDF overhead in whole-stage codegen pipelines)
- Consistency with the existing `levenshtein` function
- Better discoverability for SQL users
### Does this PR introduce _any_ user-facing change?
Yes. A new SQL function `jaro_winkler_similarity` is added.
### How was this patch tested?
Added new tests.
### Was this patch authored or co-authored using generative AI tooling?
Kiro CLI / Claude
Closes #56310 from sarutak/jaro-winkler.
Authored-by: Kousuke Saruta <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
(cherry picked from commit b67073f19ecd5a89193f96926cd832bb86a50382)
Signed-off-by: Wenchen Fan <[email protected]>
---
.../source/reference/pyspark.sql/functions.rst | 1 +
python/pyspark/sql/connect/functions/builtin.py | 7 +++
python/pyspark/sql/functions/__init__.py | 1 +
python/pyspark/sql/functions/builtin.py | 34 ++++++++++++
python/pyspark/sql/tests/test_functions.py | 8 +++
.../scala/org/apache/spark/sql/functions.scala | 9 ++++
.../catalyst/expressions/ExpressionImplUtils.java | 61 ++++++++++++++++++++++
.../sql/catalyst/analysis/FunctionRegistry.scala | 1 +
.../catalyst/expressions/stringExpressions.scala | 36 +++++++++++++
.../expressions/StringExpressionsSuite.scala | 38 ++++++++++++++
.../sql-functions/sql-expression-schema.md | 1 +
.../apache/spark/sql/StringFunctionsSuite.scala | 13 +++++
12 files changed, 210 insertions(+)
diff --git a/python/docs/source/reference/pyspark.sql/functions.rst
b/python/docs/source/reference/pyspark.sql/functions.rst
index 4d91d15a87d0..4b9084547b9a 100644
--- a/python/docs/source/reference/pyspark.sql/functions.rst
+++ b/python/docs/source/reference/pyspark.sql/functions.rst
@@ -180,6 +180,7 @@ String Functions
initcap
instr
is_valid_utf8
+ jaro_winkler_similarity
lcase
left
length
diff --git a/python/pyspark/sql/connect/functions/builtin.py
b/python/pyspark/sql/connect/functions/builtin.py
index 7c7edef02f0d..11ec77c7d30c 100644
--- a/python/pyspark/sql/connect/functions/builtin.py
+++ b/python/pyspark/sql/connect/functions/builtin.py
@@ -2704,6 +2704,13 @@ def levenshtein(
levenshtein.__doc__ = pysparkfuncs.levenshtein.__doc__
+def jaro_winkler_similarity(left: "ColumnOrName", right: "ColumnOrName") ->
Column:
+ return _invoke_function_over_columns("jaro_winkler_similarity", left,
right)
+
+
+jaro_winkler_similarity.__doc__ = pysparkfuncs.jaro_winkler_similarity.__doc__
+
+
def locate(substr: str, str: "ColumnOrName", pos: int = 1) -> Column:
return _invoke_function("locate", lit(substr), _to_col(str), lit(pos))
diff --git a/python/pyspark/sql/functions/__init__.py
b/python/pyspark/sql/functions/__init__.py
index 9a9b18a29278..3801ba0e51e5 100644
--- a/python/pyspark/sql/functions/__init__.py
+++ b/python/pyspark/sql/functions/__init__.py
@@ -141,6 +141,7 @@ __all__ = [ # noqa: F405
"initcap",
"instr",
"is_valid_utf8",
+ "jaro_winkler_similarity",
"lcase",
"left",
"length",
diff --git a/python/pyspark/sql/functions/builtin.py
b/python/pyspark/sql/functions/builtin.py
index d0dfb2278565..4c11bbca4152 100644
--- a/python/pyspark/sql/functions/builtin.py
+++ b/python/pyspark/sql/functions/builtin.py
@@ -15577,6 +15577,40 @@ def levenshtein(
)
+@_try_remote_functions
+def jaro_winkler_similarity(left: "ColumnOrName", right: "ColumnOrName") ->
Column:
+ """Computes the Jaro-Winkler similarity between the two given strings.
+
+ The result is a double between 0.0 (no similarity) and 1.0 (identical
strings).
+
+ .. versionadded:: 4.3.0
+
+ Parameters
+ ----------
+ left : :class:`~pyspark.sql.Column` or column name
+ first column value.
+ right : :class:`~pyspark.sql.Column` or column name
+ second column value.
+
+ Returns
+ -------
+ :class:`~pyspark.sql.Column`
+ Jaro-Winkler similarity as a double value.
+
+ Examples
+ --------
+ >>> from pyspark.sql import functions as sf
+ >>> df = spark.createDataFrame([('MARTHA', 'MARHTA')], ['l', 'r'])
+ >>> df.select(sf.jaro_winkler_similarity('l', 'r')).show()
+ +-----------------------------+
+ |jaro_winkler_similarity(l, r)|
+ +-----------------------------+
+ | 0.9611111111111111|
+ +-----------------------------+
+ """
+ return _invoke_function_over_columns("jaro_winkler_similarity", left,
right)
+
+
@_try_remote_functions
def locate(substr: str, str: "ColumnOrName", pos: int = 1) -> Column:
"""
diff --git a/python/pyspark/sql/tests/test_functions.py
b/python/pyspark/sql/tests/test_functions.py
index f0484cb98c83..33579f7066e5 100644
--- a/python/pyspark/sql/tests/test_functions.py
+++ b/python/pyspark/sql/tests/test_functions.py
@@ -1014,6 +1014,14 @@ class FunctionsTestsMixin:
self.assertAlmostEqual(sum_result[0], 4.0, places=4)
self.assertAlmostEqual(sum_result[1], 6.0, places=4)
+ def test_jaro_winkler_similarity_function(self):
+ df = self.spark.createDataFrame([("MARTHA", "MARHTA")], ["l", "r"])
+ result = df.select(F.jaro_winkler_similarity(df.l, df.r)).first()[0]
+ self.assertAlmostEqual(result, 0.9611111111111111, places=10)
+ # Null handling
+ null_result = df.select(F.jaro_winkler_similarity(df.l,
F.lit(None))).first()[0]
+ self.assertIsNone(null_result)
+
def test_between_function(self):
df = self.spark.createDataFrame(
[Row(a=1, b=2, c=3), Row(a=2, b=1, c=3), Row(a=4, b=1, c=4)]
diff --git a/sql/api/src/main/scala/org/apache/spark/sql/functions.scala
b/sql/api/src/main/scala/org/apache/spark/sql/functions.scala
index 115e2c572753..ab21a784796d 100644
--- a/sql/api/src/main/scala/org/apache/spark/sql/functions.scala
+++ b/sql/api/src/main/scala/org/apache/spark/sql/functions.scala
@@ -5220,6 +5220,15 @@ object functions {
*/
def levenshtein(l: Column, r: Column): Column = Column.fn("levenshtein", l,
r)
+ /**
+ * Computes the Jaro-Winkler similarity between the two given string
columns. The result is a
+ * double between 0.0 (no similarity) and 1.0 (identical).
+ * @group string_funcs
+ * @since 4.3.0
+ */
+ def jaro_winkler_similarity(l: Column, r: Column): Column =
+ Column.fn("jaro_winkler_similarity", l, r)
+
/**
* Locate the position of the first occurrence of substr.
*
diff --git
a/sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/ExpressionImplUtils.java
b/sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/ExpressionImplUtils.java
index d44922261674..e744ae3e2fc2 100644
---
a/sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/ExpressionImplUtils.java
+++
b/sql/catalyst/src/main/java/org/apache/spark/sql/catalyst/expressions/ExpressionImplUtils.java
@@ -467,4 +467,65 @@ public class ExpressionImplUtils {
}
numberFormat.applyLocalizedPattern(pattern.toString());
}
+
+ /**
+ * Returns the Jaro-Winkler similarity between two strings.
+ * The result is a double between 0 and 1, where 1 means identical.
+ *
+ * Note: This implementation uses String.charAt() which operates on UTF-16
code units.
+ * Strings containing supplementary characters (surrogate pairs) may produce
+ * inaccurate results.
+ */
+ public static double jaroWinklerSimilarity(UTF8String left, UTF8String
right) {
+ String s1 = left.toString();
+ String s2 = right.toString();
+
+ int len1 = s1.length();
+ int len2 = s2.length();
+
+ if (len1 == 0 && len2 == 0) return 1.0;
+ if (len1 == 0 || len2 == 0) return 0.0;
+
+ int matchWindow = Math.max(0, Math.max(len1, len2) / 2 - 1);
+
+ boolean[] s1Matched = new boolean[len1];
+ boolean[] s2Matched = new boolean[len2];
+
+ int matches = 0;
+ int transpositions = 0;
+
+ for (int i = 0; i < len1; i++) {
+ int start = Math.max(0, i - matchWindow);
+ int end = Math.min(i + matchWindow + 1, len2);
+ for (int j = start; j < end; j++) {
+ if (s2Matched[j] || s1.charAt(i) != s2.charAt(j)) continue;
+ s1Matched[i] = true;
+ s2Matched[j] = true;
+ matches++;
+ break;
+ }
+ }
+
+ if (matches == 0) return 0.0;
+
+ int k = 0;
+ for (int i = 0; i < len1; i++) {
+ if (!s1Matched[i]) continue;
+ while (!s2Matched[k]) k++;
+ if (s1.charAt(i) != s2.charAt(k)) transpositions++;
+ k++;
+ }
+
+ double jaro = ((double) matches / len1
+ + (double) matches / len2
+ + (double) (matches - transpositions / 2.0) / matches) / 3.0;
+
+ int prefix = 0;
+ for (int i = 0; i < Math.min(4, Math.min(len1, len2)); i++) {
+ if (s1.charAt(i) == s2.charAt(i)) prefix++;
+ else break;
+ }
+
+ return jaro + prefix * 0.1 * (1.0 - jaro);
+ }
}
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionRegistry.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionRegistry.scala
index ac1fbb9f9bef..1f216ad6a033 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionRegistry.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionRegistry.scala
@@ -630,6 +630,7 @@ object FunctionRegistry {
expression[Length]("length"),
expression[Length]("len", setAlias = true, Some("3.4.0")),
expression[Levenshtein]("levenshtein"),
+ expression[JaroWinkler]("jaro_winkler_similarity"),
expression[Luhncheck]("luhn_check"),
expression[Like]("like"),
expression[ILike]("ilike"),
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala
index 45ec0e6a8b30..ec04f9f713fc 100755
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/stringExpressions.scala
@@ -2721,6 +2721,42 @@ case class Levenshtein(
}
}
+// scalastyle:off line.size.limit
+@ExpressionDescription(
+ usage = "_FUNC_(str1, str2) - Returns the Jaro-Winkler similarity between
the two given strings. The result is a double between 0 and 1, where 1 means
identical.",
+ examples = """
+ Examples:
+ > SELECT _FUNC_('MARTHA', 'MARHTA');
+ 0.9611111111111111
+ > SELECT _FUNC_('kitten', 'sitting');
+ 0.746031746031746
+ > SELECT _FUNC_('ABC', 'XYZ');
+ 0.0
+ """,
+ since = "4.3.0",
+ group = "string_funcs")
+// scalastyle:on line.size.limit
+case class JaroWinkler(left: Expression, right: Expression)
+ extends RuntimeReplaceable with ImplicitCastInputTypes with
BinaryLike[Expression] {
+
+ override lazy val replacement: Expression = StaticInvoke(
+ classOf[ExpressionImplUtils],
+ DoubleType,
+ "jaroWinklerSimilarity",
+ Seq(left, right),
+ inputTypes)
+
+ override def inputTypes: Seq[AbstractDataType] = Seq(
+ StringTypeWithCollation(supportsTrimCollation = true),
+ StringTypeWithCollation(supportsTrimCollation = true))
+
+ override def prettyName: String = "jaro_winkler_similarity"
+
+ override protected def withNewChildrenInternal(
+ newLeft: Expression, newRight: Expression): Expression =
+ copy(left = newLeft, right = newRight)
+}
+
/**
* A function that return Soundex code of the given string expression.
*/
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/StringExpressionsSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/StringExpressionsSuite.scala
index aac4fafb7802..19df4e5f39e9 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/StringExpressionsSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/StringExpressionsSuite.scala
@@ -662,6 +662,44 @@ class StringExpressionsSuite extends SparkFunSuite with
ExpressionEvalHelper {
GenerateUnsafeProjection.generate(Levenshtein(Literal("\"quotea"),
Literal("\"quoteb")) :: Nil)
}
+ test("Jaro-Winkler similarity") {
+ // Identical strings
+ checkEvaluation(JaroWinkler(Literal("ABC"), Literal("ABC")), 1.0)
+ // Completely different
+ checkEvaluation(JaroWinkler(Literal("ABC"), Literal("XYZ")), 0.0)
+ // Empty strings
+ checkEvaluation(JaroWinkler(Literal(""), Literal("")), 1.0)
+ checkEvaluation(JaroWinkler(Literal("ABC"), Literal("")), 0.0)
+ checkEvaluation(JaroWinkler(Literal(""), Literal("ABC")), 0.0)
+ // Classic example: MARTHA vs MARHTA (well-known reference value)
+ checkEvaluation(JaroWinkler(Literal("MARTHA"), Literal("MARHTA")),
0.9611111111111111)
+ // Another well-known example
+ checkEvaluation(JaroWinkler(Literal("DWAYNE"), Literal("DUANE")),
0.8400000000000001)
+ // Symmetry: jaro_winkler(a, b) == jaro_winkler(b, a)
+ checkEvaluation(JaroWinkler(Literal("MARHTA"), Literal("MARTHA")),
0.9611111111111111)
+ checkEvaluation(JaroWinkler(Literal("sitting"), Literal("kitten")),
+ JaroWinkler(Literal("kitten"), Literal("sitting")).eval(null))
+ // Strings with very different lengths
+ assert(JaroWinkler(Literal("a"), Literal("abcdefgh")).eval(null)
+ .asInstanceOf[Double] > 0.0)
+ checkEvaluation(JaroWinkler(Literal("abc"), Literal("abcdefgh")),
+ JaroWinkler(Literal("abcdefgh"), Literal("abc")).eval(null))
+ // Single character strings
+ checkEvaluation(JaroWinkler(Literal("a"), Literal("a")), 1.0)
+ checkEvaluation(JaroWinkler(Literal("a"), Literal("b")), 0.0)
+ // Multi-byte (Japanese) strings
+ // scalastyle:off nonascii
+ checkEvaluation(JaroWinkler(Literal("東京都"), Literal("東京都")), 1.0)
+ checkEvaluation(JaroWinkler(Literal("東京都"), Literal("東京府")),
+ JaroWinkler(Literal("東京府"), Literal("東京都")).eval(null))
+ assert(JaroWinkler(Literal("東京都"), Literal("東京府")).eval(null)
+ .asInstanceOf[Double] > 0.8)
+ // scalastyle:on nonascii
+ // Null handling
+ checkEvaluation(JaroWinkler(Literal.create(null, StringType),
Literal("ABC")), null)
+ checkEvaluation(JaroWinkler(Literal("ABC"), Literal.create(null,
StringType)), null)
+ }
+
test("soundex unit test") {
checkEvaluation(SoundEx(Literal("ZIN")), "Z500")
checkEvaluation(SoundEx(Literal("SU")), "S000")
diff --git a/sql/core/src/test/resources/sql-functions/sql-expression-schema.md
b/sql/core/src/test/resources/sql-functions/sql-expression-schema.md
index 9ac6e49b0343..be8559d16cb7 100644
--- a/sql/core/src/test/resources/sql-functions/sql-expression-schema.md
+++ b/sql/core/src/test/resources/sql-functions/sql-expression-schema.md
@@ -181,6 +181,7 @@
| org.apache.spark.sql.catalyst.expressions.IsNotNull | isnotnull | SELECT
isnotnull(1) | struct<(1 IS NOT NULL):boolean> |
| org.apache.spark.sql.catalyst.expressions.IsNull | isnull | SELECT isnull(1)
| struct<(1 IS NULL):boolean> |
| org.apache.spark.sql.catalyst.expressions.IsValidUTF8 | is_valid_utf8 |
SELECT is_valid_utf8('Spark') | struct<is_valid_utf8(Spark):boolean> |
+| org.apache.spark.sql.catalyst.expressions.JaroWinkler |
jaro_winkler_similarity | SELECT jaro_winkler_similarity('MARTHA', 'MARHTA') |
struct<jaro_winkler_similarity(MARTHA, MARHTA):double> |
| org.apache.spark.sql.catalyst.expressions.JsonObjectKeys | json_object_keys
| SELECT json_object_keys('{}') | struct<json_object_keys({}):array<string>> |
| org.apache.spark.sql.catalyst.expressions.JsonToStructs | from_json | SELECT
from_json('{"a":1, "b":0.8}', 'a INT, b DOUBLE') | struct<from_json({"a":1,
"b":0.8}):struct<a:int,b:double>> |
| org.apache.spark.sql.catalyst.expressions.JsonTuple | json_tuple | SELECT
json_tuple('{"a":1, "b":2}', 'a', 'b') | struct<c0:string,c1:string> |
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/StringFunctionsSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/StringFunctionsSuite.scala
index b507cb62f9d1..772773cb07c8 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/StringFunctionsSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/StringFunctionsSuite.scala
@@ -145,6 +145,19 @@ class StringFunctionsSuite extends SharedSparkSession {
checkAnswer(df.selectExpr("levenshtein(l, null, 0)"), Seq(Row(null),
Row(null)))
}
+ test("string jaro_winkler_similarity") {
+ val df = Seq(("MARTHA", "MARHTA"), ("ABC", "XYZ")).toDF("l", "r")
+ checkAnswer(
+ df.select(jaro_winkler_similarity($"l", $"r")),
+ Seq(Row(0.9611111111111111), Row(0.0)))
+ checkAnswer(
+ df.selectExpr("jaro_winkler_similarity(l, r)"),
+ Seq(Row(0.9611111111111111), Row(0.0)))
+ checkAnswer(
+ df.select(jaro_winkler_similarity($"l", lit(null))),
+ Seq(Row(null), Row(null)))
+ }
+
test("string rlike / regexp / regexp_like") {
val df = Seq(
("1a 2b 14m", "\\d+b"),
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]