This is an automated email from the ASF dual-hosted git repository.

HyukjinKwon pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new ea911d076840 [SPARK-27052][SQL][PYTHON] Support Python UDFs inside 
higher-order function lambdas
ea911d076840 is described below

commit ea911d076840c8577381da422e5801df44c467fe
Author: Hyukjin Kwon <[email protected]>
AuthorDate: Mon Aug 10 20:10:39 2026 +0900

    [SPARK-27052][SQL][PYTHON] Support Python UDFs inside higher-order function 
lambdas
    
    ### What changes were proposed in this pull request?
    
    This PR lets a scalar Python UDF be used inside the lambda of a 
higher-order function, so that
    
    ```python
    df.select(F.transform("values", lambda x: plus_one(x)))
    ```
    
    works with a plain `pyspark.sql.functions.udf` instead of failing with
    `[UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF]`.
    
    **Why it fails today.** A `PythonUDF` runs in a separate operator that 
`ExtractPythonUDFs` pulls
    out of the enclosing one. A lambda's `NamedLambdaVariable`s only exist 
while the function iterates,
    so that operator cannot see them: the UDF can neither stay in the lambda 
nor be lifted out. So
    `CheckAnalysis` rejected it.
    
    **How it works.** A new optimizer rule `ExtractPythonUDFFromLambda` applies 
the UDF once to the
    *whole array*, outside every lambda, and has the lambda read the result 
positionally:
    
    ```
    -- before (rejected)
    transform(values, x -> plus_one(x))
    
    -- after (the PythonUDF is outside every lambda)
    transform(arrays_zip(values AS c0, plus_one_over_array(values) AS u0), s -> 
s.u0)
    ```
    
    `plus_one_over_array` is the same function re-typed `array<T> => array<R>` 
and run with a new eval
    type `SQL_ARROW_ELEMENTWISE_UDF`. Since the JVM cannot rewrap a pickled 
function, the
    array-at-a-time behaviour lives in the Python worker: it flattens each list 
column once, calls the
    function over all elements of the batch, and re-nests by the input's 
offsets. One row in, one row
    out; one Python round trip per batch. Every lifted argument is a 
single-level `array<T>` aligned
    with the iterated array (an outer column or constant is repeated into one), 
so the worker flattens
    all arguments the same way. The rule runs before `ExtractPythonUDFs` and is 
non-excludable.
    
    **What works.** All ten single-lambda functions: `transform`, `filter`, 
`exists`, `forall`,
    `zip_with`, `array_sort`, and the four map functions (desugared to 
`map_keys`/`map_values` arrays,
    rebuilt with `map_from_arrays`). Around the UDF, ordinary JVM work all 
composes: arithmetic,
    `when`, casts, the element index, multiple UDFs, and nested calls `f(g(x))`.
    
    ```python
    df.select(F.transform("vals", lambda x: F.when(is_pos(x), x * 
2).otherwise(0)))
    df.select(F.filter("vals", lambda x: is_even(x)))
    df.select(F.transform_values("m", lambda k, v: to_upper(v)))   # map family
    ```
    
    `array_sort` has two forms: a UDF used as a per-element sort key is 
precomputed per element; a
    pairwise comparator `(a, b) -> udf(a, b)`, which has no per-element key, is 
precomputed over the
    cross product of pairs (O(n^2) calls, so the key form is preferred where 
possible).
    
    To drive the rewrite generically, two marker traits are added to the 
Catalyst higher-order
    functions: `ResultTypeFromArgument` (result type is the input's - `filter`, 
`map_filter`,
    `array_sort`) and `ResultTypeFromFunction` (result type follows the lambda 
- `transform`,
    `transform_keys`/`transform_values`, `zip_with`, `map_zip_with`, 
`aggregate`, `exists`/`forall`).
    The first also hoists the shared `dataType = argument.dataType`. The rule 
reads these instead of
    special-casing each class, so it stays generic and a new function of a 
familiar shape needs no
    change.
    
    **What does not work** (still rejected with the same error):
    
    ```python
    # nested lambda: the inner array is the outer lambda's variable, not a real 
column
    df.select(F.transform("arr", lambda i: F.transform(i, lambda x: f(x))))     
# rejected
    # aggregate/reduce: the fold is sequential, so the UDF sees earlier steps, 
not array elements
    df.select(F.aggregate("vals", F.lit(0), lambda acc, x: acc + plus_one(x)))  
# rejected
    # pandas/arrow UDF: TBD after this PR
    ```
    
    A UDF in a nested function's *argument* is fine, since it does not read the 
inner lambda's variable:
    
    ```python
    df.select(F.transform("arr", lambda x: F.transform(udf(x), lambda y: y)))   
# works
    ```
    
    `CheckAnalysis` and the rule share one predicate, so analysis accepts 
exactly what the rule
    rewrites. Turn the rewrite off with
    `spark.sql.execution.pythonUDF.inHigherOrderFunction.enabled=false`.
    
    This builds on design notes from the
    [elementwise-udf](https://github.com/HyukjinKwon/elementwise-udf) prototype 
(Apache-2.0).
    
    ### Why are the changes needed?
    
    `transform(col, lambda x: my_udf(x))` is a natural thing to write and a 
long-standing gap
    (SPARK-27052). The alternative, `explode` + regroup, is far slower and OOMs 
on long arrays.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. A query that previously failed analysis now runs:
    
    ```python
    >>> plus_one = udf(lambda x: x + 1, "int")
    >>> df.select(F.transform("values", lambda x: plus_one(x))).show()
    # before: AnalysisException 
[UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF]
    # after:  [2, 3, 4]
    ```
    
    The shapes that cannot be rewritten keep failing with the same error 
condition. No existing
    successful query changes behaviour.
    
    ### How was this patch tested?
    
    - New end-to-end suite 
`pyspark.sql.tests.test_udf_in_higher_order_function` (34 tests): all ten
      functions, composition/index/multiple/nested calls, null and empty 
inputs, ragged `zip_with`,
      long arrays, integration with joins/caching/`groupBy`, and the rejected 
cases above. Results are
      asserted against the equivalent native expression wherever one exists.
    - New plan-shape suite `ExtractPythonUDFFromLambdaSuite` (15 tests): no 
`PythonUDF` remains in a
      lambda, duplicate calls evaluated once, the rule is inert without a UDF 
and not excludable, and
      nested/aggregate UDFs are rejected.
    - `PythonUDFSuite`, the `execution.python` suites and 
`DataFrameFunctionsSuite` pass.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
    
    Closes #57804 from HyukjinKwon/SPARK-27052-python-udf-in-hof.
    
    Authored-by: Hyukjin Kwon <[email protected]>
    Signed-off-by: Hyukjin Kwon <[email protected]>
---
 .../src/main/resources/error/error-conditions.json |   3 +-
 .../org/apache/spark/api/python/PythonRunner.scala |   4 +
 dev/sparktestsupport/modules.py                    |   2 +
 .../docs/source/tutorial/sql/arrow_python_udf.rst  |  11 +
 python/pyspark/sql/_typing.pyi                     |   1 +
 .../test_parity_udf_in_higher_order_function.py    |  37 ++
 .../sql/tests/test_udf_in_higher_order_function.py | 688 +++++++++++++++++++++
 python/pyspark/util.py                             |   2 +
 python/pyspark/worker.py                           | 133 ++++
 .../sql/catalyst/analysis/CheckAnalysis.scala      |  14 +-
 .../spark/sql/catalyst/expressions/PythonUDF.scala | 104 ++++
 .../expressions/higherOrderFunctions.scala         |  52 +-
 .../org/apache/spark/sql/internal/SQLConf.scala    |  20 +
 .../spark/sql/execution/SparkOptimizer.scala       |   4 +
 .../sql/execution/python/ArrowEvalPythonExec.scala |   1 +
 .../sql/execution/python/ArrowPythonRunner.scala   |   5 +-
 .../python/ExtractPythonUDFFromLambda.scala        | 619 ++++++++++++++++++
 .../sql/execution/python/ExtractPythonUDFs.scala   |  10 +-
 .../python/BatchEvalPythonExecSuite.scala          |   9 +-
 .../python/ExtractPythonUDFFromLambdaSuite.scala   | 379 ++++++++++++
 .../sql/execution/python/PythonUDFSuite.scala      |  20 +-
 21 files changed, 2092 insertions(+), 26 deletions(-)

diff --git a/common/utils/src/main/resources/error/error-conditions.json 
b/common/utils/src/main/resources/error/error-conditions.json
index 046302512298..7ba37615c364 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -8727,7 +8727,8 @@
       },
       "LAMBDA_FUNCTION_WITH_PYTHON_UDF" : {
         "message" : [
-          "Lambda function with Python UDF <funcName> in a higher order 
function."
+          "Cannot evaluate the Python UDF <funcName> inside the lambda of a 
higher-order function.",
+          "This placement is not supported. Rewrite the query so the UDF is 
applied outside the lambda."
         ]
       },
       "LAMBDA_FUNCTION_WITH_SQL_UDF" : {
diff --git a/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala 
b/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
index 20424e4f37df..19a482850311 100644
--- a/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
+++ b/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
@@ -51,6 +51,9 @@ private[spark] object PythonEvalType {
 
   val SQL_BATCHED_UDF = 100
   val SQL_ARROW_BATCHED_UDF = 101
+  // A scalar Python UDF applied element-wise over the elements of an array 
column, used to
+  // support Python UDFs inside higher-order function lambdas. See 
ExtractPythonUDFFromLambda.
+  val SQL_ARROW_ELEMENTWISE_UDF = 102
 
   val SQL_SCALAR_PANDAS_UDF = 200
   val SQL_GROUPED_MAP_PANDAS_UDF = 201
@@ -86,6 +89,7 @@ private[spark] object PythonEvalType {
     case NON_UDF => "NON_UDF"
     case SQL_BATCHED_UDF => "SQL_BATCHED_UDF"
     case SQL_ARROW_BATCHED_UDF => "SQL_ARROW_BATCHED_UDF"
+    case SQL_ARROW_ELEMENTWISE_UDF => "SQL_ARROW_ELEMENTWISE_UDF"
     case SQL_SCALAR_PANDAS_UDF => "SQL_SCALAR_PANDAS_UDF"
     case SQL_GROUPED_MAP_PANDAS_UDF => "SQL_GROUPED_MAP_PANDAS_UDF"
     case SQL_GROUPED_AGG_PANDAS_UDF => "SQL_GROUPED_AGG_PANDAS_UDF"
diff --git a/dev/sparktestsupport/modules.py b/dev/sparktestsupport/modules.py
index b0c4d6e85444..2faeea33b6f6 100644
--- a/dev/sparktestsupport/modules.py
+++ b/dev/sparktestsupport/modules.py
@@ -642,6 +642,7 @@ pyspark_sql = Module(
         "pyspark.sql.tests.test_geometrytype",
         "pyspark.sql.tests.test_udf",
         "pyspark.sql.tests.test_udf_combinations",
+        "pyspark.sql.tests.test_udf_in_higher_order_function",
         "pyspark.sql.tests.test_udf_profiler",
         "pyspark.sql.tests.test_udf_transpile_hypothesis",
         "pyspark.sql.tests.test_udf_transpile_parity",
@@ -1226,6 +1227,7 @@ pyspark_connect = Module(
         "pyspark.sql.tests.connect.test_parity_column",
         "pyspark.sql.tests.connect.test_parity_readwriter",
         "pyspark.sql.tests.connect.test_parity_udf",
+        "pyspark.sql.tests.connect.test_parity_udf_in_higher_order_function",
         "pyspark.sql.tests.connect.test_parity_udf_combinations",
         "pyspark.sql.tests.connect.test_parity_udf_profiler",
         "pyspark.sql.tests.connect.test_parity_unified_udf",
diff --git a/python/docs/source/tutorial/sql/arrow_python_udf.rst 
b/python/docs/source/tutorial/sql/arrow_python_udf.rst
index d1430844a705..0a0509f05003 100644
--- a/python/docs/source/tutorial/sql/arrow_python_udf.rst
+++ b/python/docs/source/tutorial/sql/arrow_python_udf.rst
@@ -479,6 +479,17 @@ SQL boolean expressions do not short-circuit: in ``WHERE 
cond AND udf(x)``, the
 called on all rows regardless of ``cond``. If the function can fail on certain 
input values
 (e.g., division by zero), handle those cases inside the function itself.
 
+The same holds for a Python UDF used inside a higher-order function's lambda, 
such as
+``transform(values, lambda x: udf(x))`` or ``array_sort(values, lambda a, b: 
udf(a, b))``.
+The UDF is not evaluated element by element inside the lambda; it is applied 
once to the whole
+array outside the lambda, and the lambda reads the precomputed result. As a 
result the UDF runs
+over *every* element (for ``array_sort`` with a two-argument comparator, over 
every pair of
+elements), even ones a lambda would otherwise skip, such as the elements after 
``exists`` finds a
+match or the branch of a ``when`` that is not taken. If the function can fail 
on some input,
+handle those cases inside the function itself. This path is enabled by
+``spark.sql.execution.pythonUDF.inHigherOrderFunction.enabled`` (default 
``true``); set it to
+``false`` to reject such queries at analysis instead.
+
 The Arrow data type of the returned ``pyarrow.Array`` should match the 
declared ``returnType``.
 When there is a mismatch, Spark will attempt to convert the returned data to 
the expected type
 using Arrow's safe casting, which raises an error on overflow or precision 
loss.
diff --git a/python/pyspark/sql/_typing.pyi b/python/pyspark/sql/_typing.pyi
index 94e3ccf77093..95bf27662ff1 100644
--- a/python/pyspark/sql/_typing.pyi
+++ b/python/pyspark/sql/_typing.pyi
@@ -64,6 +64,7 @@ RowLike = TypeVar("RowLike", List[Any], Tuple[Any, ...], 
pyspark.sql.types.Row)
 
 SQLBatchedUDFType = Literal[100]
 SQLArrowBatchedUDFType = Literal[101]
+SQLArrowElementwiseUDFType = Literal[102]
 SQLTableUDFType = Literal[300]
 SQLArrowTableUDFType = Literal[301]
 SQLArrowUDTFType = Literal[302]
diff --git 
a/python/pyspark/sql/tests/connect/test_parity_udf_in_higher_order_function.py 
b/python/pyspark/sql/tests/connect/test_parity_udf_in_higher_order_function.py
new file mode 100644
index 000000000000..67228b5c60fb
--- /dev/null
+++ 
b/python/pyspark/sql/tests/connect/test_parity_udf_in_higher_order_function.py
@@ -0,0 +1,37 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import unittest
+
+from pyspark.sql.tests.test_udf_in_higher_order_function import (
+    UDFInHigherOrderFunctionTestsMixin,
+)
+from pyspark.testing.connectutils import ReusedConnectTestCase
+
+
+class UDFInHigherOrderFunctionParityTests(
+    UDFInHigherOrderFunctionTestsMixin, ReusedConnectTestCase
+):
+    @unittest.skip("Asserts on the JVM optimized plan via _jdf, unavailable in 
Spark Connect.")
+    def test_lambda_without_udf_is_unchanged(self):
+        super().test_lambda_without_udf_is_unchanged()
+
+
+if __name__ == "__main__":
+    from pyspark.testing import main
+
+    main()
diff --git a/python/pyspark/sql/tests/test_udf_in_higher_order_function.py 
b/python/pyspark/sql/tests/test_udf_in_higher_order_function.py
new file mode 100644
index 000000000000..c027c4779fea
--- /dev/null
+++ b/python/pyspark/sql/tests/test_udf_in_higher_order_function.py
@@ -0,0 +1,688 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import unittest
+
+from pyspark.errors import AnalysisException
+from pyspark.sql import functions as sf
+from pyspark.sql.functions import udf
+from pyspark.sql.types import ArrayType, DoubleType, IntegerType, StringType
+from pyspark.testing.sqlutils import ReusedSQLTestCase
+from pyspark.testing.utils import (
+    assertDataFrameEqual,
+    have_pandas,
+    have_pyarrow,
+    pandas_requirement_message,
+    pyarrow_requirement_message,
+)
+
+
[email protected](
+    not have_pandas or not have_pyarrow, pandas_requirement_message or 
pyarrow_requirement_message
+)
+class UDFInHigherOrderFunctionTestsMixin:
+    """Tests for scalar Python UDFs used inside higher-order function lambdas 
(SPARK-27052).
+
+    ``ExtractPythonUDFFromLambda`` rewrites such a plan so the UDF is applied 
to the whole array
+    outside the lambda. Each test asserts the *result*, comparing against the 
equivalent native
+    expression wherever one exists, so that a rewrite that runs but computes 
the wrong thing
+    fails rather than passing quietly.
+    """
+
+    def test_transform(self):
+        df = self.spark.createDataFrame([([1, 2, 3],), ([],), ([10],)], 
"values array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: 
plus_one(x)).alias("r")),
+            df.select(sf.transform("values", lambda x: x + 1).alias("r")),
+        )
+
+    def test_transform_null_array_and_null_elements(self):
+        # A null array must stay null, and a null *element* must reach the UDF 
as None.
+        df = self.spark.createDataFrame([([1, None, 3],), (None,), ([],)], 
"values array<int>")
+        # Null-aware so the UDF itself can observe the null element.
+        f = udf(lambda x: -1 if x is None else x * 2, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: f(x)).alias("r")),
+            [([2, -1, 6],), (None,), ([],)],
+        )
+
+    def test_transform_udf_returning_null(self):
+        df = self.spark.createDataFrame([([1, 2, 3],)], "values array<int>")
+        f = udf(lambda x: None if x == 2 else x, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: f(x)).alias("r")),
+            [([1, None, 3],)],
+        )
+
+    def test_transform_with_index(self):
+        df = self.spark.createDataFrame([([10, 20, 30],), ([],)], "values 
array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+
+        # The index parameter must still work once the element is read from 
the carrier struct.
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x, i: plus_one(x) + 
i).alias("r")),
+            df.select(sf.transform("values", lambda x, i: (x + 1) + 
i).alias("r")),
+        )
+
+    def test_composition_around_udf_result(self):
+        df = self.spark.createDataFrame([([1, 2, 3],)], "values array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+
+        # Arithmetic, `when` and casts around the UDF result are ordinary JVM 
work.
+        assertDataFrameEqual(
+            df.select(
+                sf.transform("values", lambda x: plus_one(x) * 2).alias("mul"),
+                sf.transform(
+                    "values", lambda x: sf.when(plus_one(x) > 2, 
sf.lit(1)).otherwise(sf.lit(0))
+                ).alias("cond"),
+                sf.transform("values", lambda x: 
plus_one(x).cast("string")).alias("cast"),
+            ),
+            df.select(
+                sf.transform("values", lambda x: (x + 1) * 2).alias("mul"),
+                sf.transform(
+                    "values", lambda x: sf.when((x + 1) > 2, 
sf.lit(1)).otherwise(sf.lit(0))
+                ).alias("cond"),
+                sf.transform("values", lambda x: (x + 
1).cast("string")).alias("cast"),
+            ),
+        )
+
+    def test_udf_argument_is_expression_over_element(self):
+        # `udf(x * 2)`: the argument is itself an expression over the element.
+        df = self.spark.createDataFrame([([1, 2, 3],)], "values array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: plus_one(x * 
2)).alias("r")),
+            df.select(sf.transform("values", lambda x: x * 2 + 1).alias("r")),
+        )
+
+    def test_multiple_udfs_in_one_lambda(self):
+        df = self.spark.createDataFrame([([1, 2, 3],)], "values array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+        times_ten = udf(lambda x: x * 10, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: plus_one(x) + 
times_ten(x)).alias("r")),
+            df.select(sf.transform("values", lambda x: (x + 1) + (x * 
10)).alias("r")),
+        )
+
+    def test_nested_udfs(self):
+        # `f(g(x))`: both are lifted, and compose as array UDFs outside the 
lambda.
+        df = self.spark.createDataFrame([([1, 2, 3],)], "values array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+        times_ten = udf(lambda x: x * 10, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: 
times_ten(plus_one(x))).alias("r")),
+            df.select(sf.transform("values", lambda x: (x + 1) * 
10).alias("r")),
+        )
+
+    def test_nested_udf_inside_composite_argument(self):
+        # SPARK-27052: `f(g(x) + 1)` / `f(-g(x))`. The inner call is buried 
inside a composite
+        # argument, not a direct child. The inner result must be substituted 
before lifting `f`,
+        # or a raw `g` over the lambda variable would be left inside a lambda 
and mis-extracted
+        # (SPARK-48706). The result must match evaluating the composition 
element-wise.
+        df = self.spark.createDataFrame([([1, 2, 3],)], "values array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+        times_ten = udf(lambda x: x * 10, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: times_ten(plus_one(x) + 
1)).alias("r")),
+            df.select(sf.transform("values", lambda x: ((x + 1) + 1) * 
10).alias("r")),
+        )
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: 
times_ten(-plus_one(x))).alias("r")),
+            df.select(sf.transform("values", lambda x: (-(x + 1)) * 
10).alias("r")),
+        )
+
+    def test_udf_with_outer_column_argument(self):
+        # A non-element argument must be broadcast to every element of its row.
+        df = self.spark.createDataFrame([([1, 2], 100), ([3], 200)], "values 
array<int>, base int")
+        add = udf(lambda x, b: x + b, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: add(x, 
sf.col("base"))).alias("r")),
+            [([101, 102],), ([203],)],
+        )
+
+    def test_udf_with_constant_argument_only(self):
+        # SPARK-27052: `transform(arr, x -> udf(lit(10)))` does not read the 
element, but the UDF
+        # must still take the lambda's call domain: once per element, and zero 
times for an empty
+        # or null array (where the lambda never runs), rather than once per 
row.
+        df = self.spark.createDataFrame([([1, 2, 3],), ([],), (None,)], 
"values array<int>")
+        const = udf(lambda v: v * 2, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: 
const(sf.lit(10))).alias("r")),
+            [([20, 20, 20],), ([],), (None,)],
+        )
+
+    def test_filter(self):
+        # `filter`'s result is built from the input elements, not the lambda's 
value.
+        df = self.spark.createDataFrame([([1, 2, 3, 4],), ([],), (None,)], 
"values array<int>")
+        is_even = udf(lambda x: x % 2 == 0, "boolean")
+
+        assertDataFrameEqual(
+            df.select(sf.filter("values", lambda x: is_even(x)).alias("r")),
+            df.select(sf.filter("values", lambda x: (x % 2) == 0).alias("r")),
+        )
+
+    def test_exists_and_forall(self):
+        df = self.spark.createDataFrame([([1, 2, 3],), ([2, 4],), ([],)], 
"values array<int>")
+        is_even = udf(lambda x: x % 2 == 0, "boolean")
+
+        assertDataFrameEqual(
+            df.select(
+                sf.exists("values", lambda x: is_even(x)).alias("e"),
+                sf.forall("values", lambda x: is_even(x)).alias("f"),
+            ),
+            df.select(
+                sf.exists("values", lambda x: (x % 2) == 0).alias("e"),
+                sf.forall("values", lambda x: (x % 2) == 0).alias("f"),
+            ),
+        )
+
+    def test_zip_with(self):
+        # Two arrays at once. `arrays_zip` pads the shorter side with nulls, 
which is what
+        # `zip_with` does itself, so differing lengths must agree with the 
native version.
+        df = self.spark.createDataFrame(
+            [([1, 2], [10, 20]), ([1, 2, 3], [10]), ([], []), (None, [1]), 
([1], None)],
+            "l array<int>, r array<int>",
+        )
+        add = udf(lambda a, b: (0 if a is None else a) + (0 if b is None else 
b), IntegerType())
+
+        # Compare against the equivalent native expression with the same null 
handling.
+        assertDataFrameEqual(
+            df.select(sf.zip_with("l", "r", lambda a, b: add(a, 
b)).alias("r")),
+            df.select(
+                sf.zip_with(
+                    "l",
+                    "r",
+                    lambda a, b: sf.coalesce(a, sf.lit(0)) + sf.coalesce(b, 
sf.lit(0)),
+                ).alias("r")
+            ),
+        )
+
+    def test_zip_with_udf_on_one_side_only(self):
+        df = self.spark.createDataFrame([([1, 2], [10, 20])], "l array<int>, r 
array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.zip_with("l", "r", lambda a, b: plus_one(a) + 
b).alias("r")),
+            [([12, 23],)],
+        )
+
+    def test_array_sort_with_udf_key(self):
+        # When the UDF applies per element, it is precomputed as a sort key 
that the JVM comparator
+        # compares (a UDF taking both elements is instead precomputed over the 
pairs; see the
+        # pairwise test). This must actually reorder, not be a no-op.
+        df = self.spark.createDataFrame([([3, 1, 2],), ([],), (None,)], 
"values array<int>")
+        negate = udf(lambda x: -x, IntegerType())
+
+        # Sorting by -x gives descending order.
+        assertDataFrameEqual(
+            df.select(
+                sf.array_sort(
+                    "values",
+                    lambda a, b: sf.when(negate(a) < negate(b), sf.lit(-1))
+                    .when(negate(a) > negate(b), sf.lit(1))
+                    .otherwise(sf.lit(0)),
+                ).alias("r")
+            ),
+            [([3, 2, 1],), ([],), (None,)],
+        )
+
+    def test_array_sort_pairwise_comparator(self):
+        # One UDF call receiving both elements has no per-element key, so the 
UDF is precomputed
+        # over every ordered pair and the comparator indexes that matrix. 
Assert an actual
+        # reordering, not merely that the query runs.
+        df = self.spark.createDataFrame(
+            [([3, 1, 2],), ([],), (None,), ([5],), ([2, 2, 1],)], "values 
array<int>"
+        )
+        cmp_udf = udf(lambda a, b: (a > b) - (a < b), IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.array_sort("values", lambda a, b: cmp_udf(a, 
b)).alias("r")),
+            [([1, 2, 3],), ([],), (None,), ([5],), ([1, 2, 2],)],
+        )
+
+    def test_array_sort_pairwise_comparator_descending(self):
+        # Reversing the comparator must reverse the order, which a no-op 
rewrite would not do.
+        df = self.spark.createDataFrame([([3, 1, 2],)], "values array<int>")
+        cmp_desc = udf(lambda a, b: (b > a) - (b < a), IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.array_sort("values", lambda a, b: cmp_desc(a, 
b)).alias("r")),
+            [([3, 2, 1],)],
+        )
+
+    def test_transform_keys_and_values(self):
+        df = self.spark.createDataFrame(
+            [
+                ({"a": 1, "b": 2},),
+            ],
+            "m map<string,int>",
+        )
+        upper = udf(lambda s: s.upper(), StringType())
+        plus_one = udf(lambda v: v + 1, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.transform_keys("m", lambda k, v: 
upper(k)).alias("r")),
+            [({"A": 1, "B": 2},)],
+        )
+        assertDataFrameEqual(
+            df.select(sf.transform_values("m", lambda k, v: 
plus_one(v)).alias("r")),
+            [({"a": 2, "b": 3},)],
+        )
+        # The lambda may read both the key and the value.
+        assertDataFrameEqual(
+            df.select(sf.transform_values("m", lambda k, v: plus_one(v) + 
sf.length(k)).alias("r")),
+            [({"a": 3, "b": 4},)],
+        )
+
+    def test_map_filter(self):
+        df = self.spark.createDataFrame([({"a": 1, "b": 2, "c": 3},)], "m 
map<string,int>")
+        is_odd = udf(lambda v: v % 2 == 1, "boolean")
+
+        assertDataFrameEqual(
+            df.select(sf.map_filter("m", lambda k, v: is_odd(v)).alias("r")),
+            [({"a": 1, "c": 3},)],
+        )
+
+    def test_map_zip_with(self):
+        # The visited key set is the union of both maps' keys; a key missing 
from one side gives
+        # null on that side, matching map_zip_with's own semantics.
+        df = self.spark.createDataFrame(
+            [
+                (
+                    {"a": 1, "b": 2},
+                    {"b": 20, "c": 30},
+                )
+            ],
+            "l map<string,int>, r map<string,int>",
+        )
+        combine = udf(
+            lambda a, b: (0 if a is None else a) * 100 + (0 if b is None else 
b), IntegerType()
+        )
+
+        assertDataFrameEqual(
+            df.select(sf.map_zip_with("l", "r", lambda k, v1, v2: combine(v1, 
v2)).alias("r")),
+            [({"a": 100, "b": 220, "c": 30},)],
+        )
+
+    def test_transform_values_result_type_equals_key_type(self):
+        # SPARK-27052: transform_values on map<string,string> whose lambda 
also returns string.
+        # The rewrite must replace the values, not the keys (dispatch is by 
function, not type).
+        df = self.spark.createDataFrame([({"a": "x", "b": "y"},)], "m 
map<string,string>")
+        tag = udf(lambda v: v + "!", StringType())
+        assertDataFrameEqual(
+            df.select(sf.transform_values("m", lambda k, v: 
tag(v)).alias("r")),
+            [({"a": "x!", "b": "y!"},)],
+        )
+
+    def test_nondeterministic_udf_calls_are_distinct(self):
+        # SPARK-27052: two calls to a nondeterministic UDF in one lambda must 
stay distinct, not be
+        # collapsed into one shared value. `rand_add` returns x plus a 
per-call random draw, so
+        # f(x) + f(x) equals 2x only if the two calls were (wrongly) 
deduplicated.
+        import random
+
+        df = self.spark.createDataFrame([([10, 20, 30],)], "values array<int>")
+        rand_add = udf(
+            lambda x: x + random.randint(1, 1_000_000), IntegerType()
+        ).asNondeterministic()
+        row = df.select(
+            sf.transform("values", lambda x: rand_add(x) - 
rand_add(x)).alias("r")
+        ).collect()[0]
+        # If the two calls were deduplicated, every element would be exactly 0.
+        self.assertTrue(any(v != 0 for v in row["r"]), row["r"])
+
+    def test_fused_transforms_with_different_lengths(self):
+        # SPARK-27052: two transforms over arrays of different per-row lengths 
and null layouts get
+        # fused by ExtractPythonUDFs into one element-wise batch. Each UDF 
must be re-nested by its
+        # own array's shape, not a shared one.
+        df = self.spark.createDataFrame(
+            [([1, 2, 3], [10]), ([], None), (None, [7, 8])],
+            "a array<int>, b array<int>",
+        )
+        f = udf(lambda v: v + 1, IntegerType())
+        assertDataFrameEqual(
+            df.select(
+                sf.transform("a", lambda x: f(x)).alias("ra"),
+                sf.transform("b", lambda x: f(x)).alias("rb"),
+            ),
+            [([2, 3, 4], [11]), ([], None), (None, [8, 9])],
+        )
+
+    def test_element_and_return_types(self):
+        df = self.spark.createDataFrame([(["a", "bb"],)], "values 
array<string>")
+        upper_len = udf(lambda s: len(s), IntegerType())
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: 
upper_len(x)).alias("r")),
+            [([1, 2],)],
+        )
+
+        df2 = self.spark.createDataFrame([([1.5, 2.5],)], "values 
array<double>")
+        half = udf(lambda v: v / 2, DoubleType())
+        assertDataFrameEqual(
+            df2.select(sf.transform("values", lambda x: half(x)).alias("r")),
+            [([0.75, 1.25],)],
+        )
+
+        # A UDF returning a non-atomic type, so the re-nesting handles nested 
lists.
+        df3 = self.spark.createDataFrame([([1, 2],)], "values array<int>")
+        repeat = udf(lambda v: [v, v], ArrayType(IntegerType()))
+        assertDataFrameEqual(
+            df3.select(sf.transform("values", lambda x: repeat(x)).alias("r")),
+            [([[1, 1], [2, 2]],)],
+        )
+
+    def test_long_arrays_and_many_rows(self):
+        # Exercises batching: the wrapper evaluates all elements of a batch in 
one pass.
+        df = self.spark.range(0, 200).select(
+            sf.transform(
+                sf.sequence(sf.lit(1), sf.lit(20)), lambda x: (x + 
sf.col("id")).cast("int")
+            ).alias("values")
+        )
+        plus_one = udf(lambda x: x + 1, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: 
plus_one(x)).alias("r")),
+            df.select(sf.transform("values", lambda x: x + 1).alias("r")),
+        )
+
+    def test_all_null_rows(self):
+        df = self.spark.createDataFrame([(None,), (None,)], "values 
array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: 
plus_one(x)).alias("r")),
+            [(None,), (None,)],
+        )
+
+    def test_empty_dataframe(self):
+        df = self.spark.createDataFrame([], "values array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+        self.assertEqual(
+            df.select(sf.transform("values", lambda x: 
plus_one(x)).alias("r")).count(), 0
+        )
+
+    def test_udf_inside_nested_higher_order_function_fails(self):
+        # The inner array is the outer lambda's variable, not a real column, 
so a UDF in the inner
+        # lambda cannot be lifted onto an array outside the lambda. This must 
fail analysis. (A UDF
+        # over the outer element is fine; see 
test_udf_outside_inner_higher_order_function.)
+        df = self.spark.createDataFrame([([[1, 2], [3]],)], "values 
array<array<int>>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+        is_even = udf(lambda x: x % 2 == 0, "boolean")
+
+        nested = [
+            sf.transform("values", lambda inner: sf.transform(inner, lambda x: 
plus_one(x))),
+            # A `filter` inside a `transform`, with the UDF in the inner 
predicate.
+            sf.transform("values", lambda inner: sf.filter(inner, lambda x: 
is_even(x))),
+        ]
+        for expr in nested:
+            with self.assertRaises(AnalysisException) as ctx:
+                df.select(expr).collect()
+            self.assertIn("LAMBDA_FUNCTION_WITH_PYTHON_UDF", 
str(ctx.exception))
+
+    def test_udf_outside_inner_higher_order_function(self):
+        # The UDF applies to the outer array's element (itself an array), 
which *is* a real
+        # column, so this is rewritable even though a higher-order function is 
also present.
+        df = self.spark.createDataFrame([([[1, 2], [3]],)], "values 
array<array<int>>")
+        total = udf(lambda a: sum(a), IntegerType())
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda inner: 
total(inner)).alias("r")),
+            [([3, 3],)],
+        )
+
+    def test_rewritable_higher_order_function_inside_outer_lambda(self):
+        # A rewritable inner HOF over a *real* column, sitting inside an outer 
HOF's lambda:
+        # `transform(arr2, i -> array_max(transform(arr, x -> f(x))) + i)`. 
Analysis accepts it
+        # (the inner `transform` iterates the real column `arr`, not the outer 
lambda variable),
+        # the inner UDF is lifted out of the inner lambda, and the resulting 
element-wise UDF stays
+        # inside the outer lambda for `ExtractPythonUDFs` to extract per row. 
The result must match
+        # the native computation for a deterministic UDF.
+        df = self.spark.createDataFrame([([1, 2, 3], [10, 20])], "arr 
array<int>, arr2 array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+
+        assertDataFrameEqual(
+            df.select(
+                sf.transform(
+                    "arr2",
+                    lambda i: sf.array_max(sf.transform("arr", lambda x: 
plus_one(x))) + i,
+                ).alias("r")
+            ),
+            # array_max([2, 3, 4]) = 4; 4 + 10 = 14, 4 + 20 = 24
+            [([14, 24],)],
+        )
+
+    def test_sql_string_syntax(self):
+        # The rewrite is on the analyzed plan, so it must fire for the SQL 
string syntax too, not
+        # only the DataFrame API.
+        self.spark.udf.register("py_plus_one", udf(lambda x: x + 1, 
IntegerType()))
+        with self.temp_view("t"):
+            self.spark.createDataFrame([([1, 2, 3],)], "values 
array<int>").createOrReplaceTempView(
+                "t"
+            )
+            assertDataFrameEqual(
+                self.spark.sql("SELECT transform(values, x -> py_plus_one(x)) 
AS r FROM t"),
+                [([2, 3, 4],)],
+            )
+
+    def test_kwargs_call_still_fails(self):
+        # A UDF called with a keyword argument carries a 
NamedArgumentExpression, which the lift
+        # cannot preserve, so it must keep failing analysis rather than being 
rewritten.
+        df = self.spark.createDataFrame([([1, 2],)], "values array<int>")
+        add = udf(lambda x, y: x + y, IntegerType())
+
+        with self.assertRaises(AnalysisException) as ctx:
+            df.select(sf.transform("values", lambda x: add(x, 
y=sf.lit(1)))).collect()
+        self.assertIn("LAMBDA_FUNCTION_WITH_PYTHON_UDF", str(ctx.exception))
+
+    def test_zero_argument_udf_still_fails(self):
+        # A zero-argument UDF has no argument to carry the iterated array's 
shape, so the rewrite
+        # cannot express it; it must keep failing analysis rather than crash 
the worker at runtime.
+        df = self.spark.createDataFrame([([1, 2],)], "values array<int>")
+        const = udf(lambda: 7, IntegerType())
+
+        with self.assertRaises(AnalysisException) as ctx:
+            df.select(sf.transform("values", lambda x: const())).collect()
+        self.assertIn("LAMBDA_FUNCTION_WITH_PYTHON_UDF", str(ctx.exception))
+
+    def test_nondeterministic_iterated_argument_still_fails(self):
+        # The rewrite references the iterated argument several times; a 
nondeterministic one (e.g.
+        # shuffle) would be evaluated independently per reference and misalign 
the results, so it
+        # must fail analysis instead of being rewritten.
+        df = self.spark.createDataFrame([([1, 2, 3, 4],)], "values array<int>")
+        is_even = udf(lambda x: x % 2 == 0, "boolean")
+
+        with self.assertRaises(AnalysisException) as ctx:
+            df.select(sf.filter(sf.shuffle("values"), lambda x: 
is_even(x))).collect()
+        self.assertIn("LAMBDA_FUNCTION_WITH_PYTHON_UDF", str(ctx.exception))
+
+    def test_decimal_timestamp_and_struct_element_types(self):
+        # Arrow conversion edge cases beyond int/double/string: decimal, 
timestamp, and a struct
+        # return type. Each must round-trip through the element-wise wrapper 
correctly.
+        import datetime
+        from decimal import Decimal
+        from pyspark.sql.types import (
+            DecimalType,
+            StructField,
+            StructType,
+            TimestampType,
+        )
+
+        dec_df = self.spark.createDataFrame(
+            [([Decimal("1.50"), Decimal("2.25")],)], "values 
array<decimal(5,2)>"
+        )
+        add_half = udf(lambda v: v + Decimal("0.50"), DecimalType(5, 2))
+        assertDataFrameEqual(
+            dec_df.select(sf.transform("values", lambda x: 
add_half(x)).alias("r")),
+            [([Decimal("2.00"), Decimal("2.75")],)],
+        )
+
+        ts_df = self.spark.createDataFrame(
+            [([datetime.datetime(2020, 1, 1, 0, 0, 0)],)], "values 
array<timestamp>"
+        )
+        add_day = udf(lambda t: t + datetime.timedelta(days=1), 
TimestampType())
+        assertDataFrameEqual(
+            ts_df.select(sf.transform("values", lambda x: 
add_day(x)).alias("r")),
+            [([datetime.datetime(2020, 1, 2, 0, 0, 0)],)],
+        )
+
+        struct_type = StructType([StructField("a", IntegerType()), 
StructField("b", IntegerType())])
+        int_df = self.spark.createDataFrame([([1, 2],)], "values array<int>")
+        to_struct = udf(lambda v: (v, v * 10), struct_type)
+        assertDataFrameEqual(
+            int_df.select(sf.transform("values", lambda x: 
to_struct(x)).alias("r")),
+            [([(1, 10), (2, 20)],)],
+        )
+
+    def test_integration_with_joins_grouping_and_caching(self):
+        left = self.spark.createDataFrame(
+            [
+                (1, [1, 2]),
+                (
+                    2,
+                    [3],
+                ),
+            ],
+            "k int, values array<int>",
+        )
+        right = self.spark.createDataFrame([(1,), (2,)], "k int")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+
+        joined = left.join(right, "k").select(
+            "k", sf.transform("values", lambda x: plus_one(x)).alias("r")
+        )
+        assertDataFrameEqual(joined, [(1, [2, 3]), (2, [4])])
+
+        cached = left.select(sf.transform("values", lambda x: 
plus_one(x)).alias("r")).cache()
+        try:
+            assertDataFrameEqual(cached, [([2, 3],), ([4],)])
+        finally:
+            cached.unpersist()
+
+        grouped = left.groupBy().agg(
+            sf.sum(
+                sf.aggregate(
+                    sf.transform("values", lambda x: plus_one(x)), sf.lit(0), 
lambda a, x: a + x
+                )
+            ).alias("s")
+        )
+        # (1+1)+(2+1) + (3+1) = 9
+        assertDataFrameEqual(grouped, [(9,)])
+
+    def test_mixed_with_plain_python_udf(self):
+        df = self.spark.createDataFrame([([1, 2],)], "values array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+        size_udf = udf(lambda a: len(a), IntegerType())
+
+        assertDataFrameEqual(
+            df.select(
+                sf.transform("values", lambda x: plus_one(x)).alias("r"),
+                size_udf("values").alias("n"),
+            ),
+            [([2, 3], 2)],
+        )
+
+    def test_lambda_without_udf_is_unchanged(self):
+        # The rewrite must be inert for plans that contain no Python UDF in a 
lambda.
+        df = self.spark.createDataFrame([([1, 2, 3],)], "values array<int>")
+        native = df.select(sf.transform("values", lambda x: x + 1).alias("r"))
+        self.assertNotIn("pythonUDF", 
native._jdf.queryExecution().optimizedPlan().toString())
+        assertDataFrameEqual(native, [([2, 3, 4],)])
+
+    def test_disabled_by_conf(self):
+        df = self.spark.createDataFrame([([1, 2],)], "values array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+        with 
self.sql_conf({"spark.sql.execution.pythonUDF.inHigherOrderFunction.enabled": 
False}):
+            with self.assertRaises(AnalysisException) as ctx:
+                df.select(sf.transform("values", lambda x: 
plus_one(x))).collect()
+            self.assertIn("LAMBDA_FUNCTION_WITH_PYTHON_UDF", 
str(ctx.exception))
+
+    def test_udf_in_aggregate_still_fails(self):
+        # The fold is sequential: the values the UDF sees are outputs of 
earlier steps rather than
+        # elements of a collection (folding [1,2,3] with `acc*2 + x` calls it 
on 0, 1, 4), and
+        # computing the final accumulator alternates Python and JVM work once 
per element, which no
+        # single UDF call can do. Restating the fold as an iteration over the 
whole column would
+        # work but is an operator-level change, so a UDF anywhere in 
`aggregate` / `reduce` -
+        # `merge` or `finish` - must fail rather than mislead.
+        df = self.spark.createDataFrame([([1, 2],)], "values array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType())
+        double_it = udf(lambda acc: acc * 2, IntegerType())
+
+        aggregates = [
+            # A UDF in `merge`, on the element and on the accumulator.
+            sf.aggregate("values", sf.lit(0), lambda acc, x: acc + 
plus_one(x)),
+            sf.aggregate("values", sf.lit(0), lambda acc, x: plus_one(acc) + 
x),
+            # A UDF in `finish`.
+            sf.aggregate("values", sf.lit(0), lambda acc, x: acc + x, lambda 
acc: double_it(acc)),
+            # `reduce` is an alias of `aggregate`, so it is rejected the same 
way.
+            sf.reduce("values", sf.lit(0), lambda acc, x: acc + plus_one(x)),
+        ]
+        for agg in aggregates:
+            with self.assertRaises(AnalysisException) as ctx:
+                df.select(agg).collect()
+            self.assertIn("LAMBDA_FUNCTION_WITH_PYTHON_UDF", 
str(ctx.exception))
+
+    def test_pandas_udf_in_lambda_still_fails(self):
+        # A vectorized (pandas) UDF in a lambda is not supported.
+        import pandas as pd
+        from pyspark.sql.functions import pandas_udf
+
+        df = self.spark.createDataFrame([([1, 2],)], "values array<int>")
+
+        @pandas_udf(IntegerType())
+        def plus_one_pandas(s: pd.Series) -> pd.Series:
+            return s + 1
+
+        with self.assertRaises(AnalysisException) as ctx:
+            df.select(sf.transform("values", lambda x: 
plus_one_pandas(x))).collect()
+        self.assertIn("LAMBDA_FUNCTION_WITH_PYTHON_UDF", str(ctx.exception))
+
+    def test_non_arrow_udf_is_also_supported(self):
+        # A UDF created with useArrow=False is still rewritable; the generated 
array wrapper
+        # is Arrow-based regardless of how the user's UDF was declared.
+        df = self.spark.createDataFrame([([1, 2, 3],)], "values array<int>")
+        plus_one = udf(lambda x: x + 1, IntegerType(), useArrow=False)
+
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: 
plus_one(x)).alias("r")),
+            [([2, 3, 4],)],
+        )
+
+    def test_string_return_type(self):
+        df = self.spark.createDataFrame([([1, 2],), (None,)], "values 
array<int>")
+        to_str = udf(lambda x: f"v{x}", StringType())
+        assertDataFrameEqual(
+            df.select(sf.transform("values", lambda x: to_str(x)).alias("r")),
+            [(["v1", "v2"],), (None,)],
+        )
+
+
+class UDFInHigherOrderFunctionTests(UDFInHigherOrderFunctionTestsMixin, 
ReusedSQLTestCase):
+    pass
+
+
+if __name__ == "__main__":
+    from pyspark.testing import main
+
+    main()
diff --git a/python/pyspark/util.py b/python/pyspark/util.py
index 858dc1013e94..640728725ee3 100644
--- a/python/pyspark/util.py
+++ b/python/pyspark/util.py
@@ -90,6 +90,7 @@ if typing.TYPE_CHECKING:
     )
     from pyspark.sql._typing import (
         SQLArrowBatchedUDFType,
+        SQLArrowElementwiseUDFType,
         SQLArrowTableUDFType,
         SQLBatchedUDFType,
         SQLTableUDFType,
@@ -662,6 +663,7 @@ class PythonEvalType:
 
     SQL_BATCHED_UDF: "SQLBatchedUDFType" = 100
     SQL_ARROW_BATCHED_UDF: "SQLArrowBatchedUDFType" = 101
+    SQL_ARROW_ELEMENTWISE_UDF: "SQLArrowElementwiseUDFType" = 102
 
     SQL_SCALAR_PANDAS_UDF: "PandasScalarUDFType" = 200
     SQL_GROUPED_MAP_PANDAS_UDF: "PandasGroupedMapUDFType" = 201
diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index 9ac8abbfa485..c579c719c6e2 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -623,6 +623,7 @@ def read_single_udf(pickleSer, udf_info, eval_type, 
runner_conf, udf_index):
     # Scalar, aggregation and window UDFs: (func, args_offsets, 
kwargs_offsets, return_type).
     if eval_type in (
         PythonEvalType.SQL_ARROW_BATCHED_UDF,
+        PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF,
         PythonEvalType.SQL_GROUPED_AGG_ARROW_ITER_UDF,
         PythonEvalType.SQL_GROUPED_AGG_ARROW_UDF,
         PythonEvalType.SQL_GROUPED_AGG_PANDAS_ITER_UDF,
@@ -1821,6 +1822,7 @@ def read_udtf(pickleSer, udtf_info, eval_type, 
runner_conf, eval_conf):
 def read_udfs(pickleSer, udf_info_list, eval_type, runner_conf, eval_conf):
     if eval_type in (
         PythonEvalType.SQL_ARROW_BATCHED_UDF,
+        PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF,
         PythonEvalType.SQL_SCALAR_PANDAS_UDF,
         PythonEvalType.SQL_SCALAR_ARROW_UDF,
         PythonEvalType.SQL_COGROUPED_MAP_PANDAS_UDF,
@@ -2989,6 +2991,137 @@ def read_udfs(pickleSer, udf_info_list, eval_type, 
runner_conf, eval_conf):
         # profiling is not supported for UDF
         return func, None, ser, ser
 
+    if eval_type == PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF:
+        # This path exchanges data with the JVM over Arrow, so PyArrow is 
required. Fail with a
+        # clear message rather than a bare ImportError from `import pyarrow` 
below.
+        from pyspark.sql.pandas.utils import require_minimum_pyarrow_version
+
+        require_minimum_pyarrow_version()
+
+        import pyarrow as pa
+        import pyarrow.compute as pc
+
+        # Element-wise UDFs back higher-order lambdas like transform(arr, x -> 
udf(x)).
+        # ExtractPythonUDFFromLambda rewrites them so the UDF receives *all* 
array elements
+        # at once (as ``array<T>``) rather than per-element. Flatten once, 
evaluate once over
+        # the batch, then re-nest with input offsets. Example: array<int> -> 
udf -> array<int>.
+
+        # UDF preparation
+        udf_infos = []
+        for udf_func, udf_args_offsets, udf_kwargs_offsets, udf_return_type in 
udfs:
+            wrapped_func, args_kwargs_offsets = wrap_kwargs_support(
+                udf_func, udf_args_offsets, udf_kwargs_offsets
+            )
+            # UDF returns one value per element; return type was pickled, 
unchanged.
+            # This is per-element, so element type equals the declared return 
type.
+            element_return_type = udf_return_type
+            udf_infos.append(
+                (
+                    wrapped_func,
+                    args_kwargs_offsets,
+                    to_arrow_type(
+                        element_return_type,
+                        timezone="UTC",
+                        prefers_large_types=runner_conf.use_large_var_types,
+                    ),
+                    LocalDataToArrowConversion._create_converter(
+                        element_return_type,
+                        none_on_identity=True,
+                        
int_to_decimal_coercion_enabled=runner_conf.int_to_decimal_coercion_enabled,
+                    ),
+                )
+            )
+        col_names = [f"_{i}" for i in range(len(udfs))]
+
+        # Input: every argument arrives as ``array<T>`` aligned with the 
iterated array.
+        # Flatten once per column; convert elements with the array's element 
type.
+        input_fields = list(eval_conf.input_type)
+        arrow_to_py_converters = [
+            ArrowTableToRowsConversion._create_converter(
+                f.dataType.elementType,
+                none_on_identity=True,
+                binary_as_bytes=runner_conf.binary_as_bytes,
+            )
+            for f in input_fields
+        ]
+
+        @fail_on_stopiteration
+        def _evaluate_elementwise_udf(udf_func, rows):
+            if runner_conf.arrow_concurrency_level <= 0:
+                return [udf_func(*row) for row in rows]
+            from concurrent.futures import ThreadPoolExecutor
+
+            with 
ThreadPoolExecutor(max_workers=runner_conf.arrow_concurrency_level) as pool:
+                return list(pool.map(lambda row: udf_func(*row), rows))
+
+        def renest_spec(shape):
+            """Offsets, list class and null mask that re-nest a flat result 
list by ``shape``.
+
+            Null rows stay null and consume no offsets. ``ListArray`` uses 
int32 offsets and
+            ``LargeListArray`` int64, so the input's list width is preserved.
+            """
+            lengths = pc.list_value_length(shape).to_pylist()
+            offsets = []
+            running = 0
+            for n in lengths:
+                offsets.append(running)
+                if n is not None:
+                    running += n
+            offsets.append(running)
+            is_large = pa.types.is_large_list(shape.type)
+            list_cls = pa.LargeListArray if is_large else pa.ListArray
+            offsets_arr = pa.array(offsets, type=pa.int64() if is_large else 
pa.int32())
+            null_mask = pa.array([n is None for n in lengths], type=pa.bool_())
+            total_elements = running
+            return list_cls, offsets_arr, null_mask, total_elements
+
+        def func(split_index: int, data: Iterator[pa.RecordBatch]) -> 
Iterator[pa.RecordBatch]:
+            for input_batch in data:
+                # Flatten each list column once to its element list, 
converting to Python.
+                columns = []
+                for col, conv in zip(input_batch.itercolumns(), 
arrow_to_py_converters):
+                    values = 
ArrowTableToRowsConversion._to_pylist(col.flatten())
+                    if conv is not None:
+                        values = [conv(v) for v in values]
+                    columns.append(values)
+
+                # Each UDF is re-nested by *its own* first argument's shape. 
ExtractPythonUDFs can
+                # fuse UDFs over differently shaped arrays into one batch, so 
a single shared shape
+                # would misalign every UDF but the first. The rewrite always 
passes at least one
+                # array argument, so `offsets_meta` is non-empty.
+                output_arrays = []
+                for wrapped_func, offsets_meta, arrow_element_type, 
result_conv in udf_infos:
+                    list_cls, offsets_arr, null_mask, total_elements = 
renest_spec(
+                        input_batch.column(offsets_meta[0])
+                    )
+                    # Stream the argument tuples rather than materializing a 
batch-sized list.
+                    rows = zip(*[columns[o] for o in offsets_meta])
+                    results = _evaluate_elementwise_udf(wrapped_func, rows)
+                    verify_result_row_count(len(results), total_elements)
+
+                    # Convert results and re-nest to array<R> using that UDF's 
offsets.
+                    converted = (
+                        [result_conv(r) for r in results] if result_conv is 
not None else results
+                    )
+                    try:
+                        flat_arr = pa.array(converted, type=arrow_element_type)
+                    # Broader than the SQL_ARROW_BATCHED_UDF path above (which 
catches only
+                    # ArrowInvalid): the element-wise wrapper commonly returns 
list/struct-typed
+                    # elements, whose type mismatches surface as 
ArrowTypeError, so both are caught
+                    # before falling back to an explicit cast.
+                    except (pa.lib.ArrowInvalid, pa.lib.ArrowTypeError):
+                        flat_arr = pa.array(converted).cast(
+                            target_type=arrow_element_type, 
safe=runner_conf.safecheck
+                        )
+                    output_arrays.append(
+                        list_cls.from_arrays(offsets_arr, flat_arr, 
mask=null_mask)
+                    )
+
+                yield pa.RecordBatch.from_arrays(output_arrays, col_names)
+
+        # profiling is not supported for UDF
+        return func, None, ser, ser
+
     if eval_type == PythonEvalType.SQL_SCALAR_PANDAS_UDF:
         import pandas as pd
         import pyarrow as pa
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
index f4f9a0296a06..66e2e78aef27 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/CheckAnalysis.scala
@@ -501,10 +501,20 @@ trait CheckAnalysis extends LookupCatalog with 
QueryErrorsBase with PlanToString
                 hof.invalidFormat(checkRes)
             }
 
+          // A Python UDF cannot be evaluated inside a lambda, because it 
needs a separate
+          // physical operator that cannot see the lambda's variables. For 
some shapes
+          // `ExtractPythonUDFFromLambda` rewrites the plan in the optimizer 
so the UDF is
+          // applied to the whole array outside the lambda instead; those are 
allowed through
+          // here. Everything else must still fail, or nothing downstream can 
evaluate it.
           case hof: HigherOrderFunction
               if hof.resolved && hof.functions
-                .exists(_.exists(_.isInstanceOf[PythonUDF])) =>
-            val u = 
hof.functions.flatMap(_.find(_.isInstanceOf[PythonUDF])).head
+                .exists(_.exists(_.isInstanceOf[PythonUDF])) &&
+                !(conf.pythonUDFInHigherOrderFunctionEnabled &&
+                  PythonUDF.canRewritePythonUDFInLambda(hof)) =>
+            // Name the offending UDF: the first one of an unsupported eval 
type if any (e.g. a
+            // pandas UDF), otherwise the first Python UDF in the lambdas.
+            val udfs = hof.functions.flatMap(_.collect { case u: PythonUDF => 
u })
+            val u = 
udfs.find(!PythonUDF.isElementwiseRewritableUDF(_)).getOrElse(udfs.head)
             hof.failAnalysis(
               errorClass = 
"UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF",
               messageParameters = Map("funcName" -> toSQLExpr(u)))
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala
index 48da53ccf7c2..ec7e7832bb65 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala
@@ -36,6 +36,10 @@ object PythonUDF {
   private[this] val SCALAR_TYPES = Set(
     PythonEvalType.SQL_BATCHED_UDF,
     PythonEvalType.SQL_ARROW_BATCHED_UDF,
+    // Element-wise UDFs are row-shaped from the plan's point of view: one 
array column in, one
+    // array column out per row. They are extracted by `ExtractPythonUDFs` 
like any other scalar
+    // UDF; only the Python worker treats them element-wise.
+    PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF,
     PythonEvalType.SQL_SCALAR_PANDAS_UDF,
     PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF,
     PythonEvalType.SQL_SCALAR_ARROW_UDF,
@@ -46,6 +50,106 @@ object PythonUDF {
     e.isInstanceOf[PythonUDF] && 
SCALAR_TYPES.contains(e.asInstanceOf[PythonUDF].evalType)
   }
 
+  /**
+   * Whether `e` is a Python UDF that can be lifted out of a higher-order 
function's lambda by
+   * `ExtractPythonUDFFromLambda`, which applies it to the whole array outside 
the lambda.
+   *
+   * Only the row-at-a-time eval types qualify, since the rule lifts a UDF 
that takes one value per
+   * call. Otherwise-eligible shapes are excluded because the rewrite cannot 
preserve them:
+   *   - a zero-argument call, `f()`: the lift turns each argument into an 
aligned array, so with no
+   *     argument there is no array to carry the iterated shape, and the 
element-wise UDF would
+   *     reach the worker with no input column and crash there instead of 
failing analysis;
+   *   - a call with named arguments: its `NamedArgumentExpression` children 
would be buried inside
+   *     the generated `ArrayTransform`, losing the kwargs mapping the runner 
derives from the
+   *     direct children;
+   *   - a UDF whose argument or return type involves a UDT: the lift forces 
the Arrow element-wise
+   *     eval type, which has no UDT fallback (unlike `correctEvalType`'s 
Arrow -> pickle path), so
+   *     it would fail at runtime instead of at analysis.
+   * All keep the previous behavior (an analysis error) rather than being 
rewritten.
+   *
+   * This is shared with `CheckAnalysis` so that the shapes analysis accepts 
are exactly those the
+   * optimizer rule can rewrite.
+   */
+  def isElementwiseRewritableUDF(e: Expression): Boolean = e match {
+    case udf: PythonUDF =>
+      (udf.evalType == PythonEvalType.SQL_BATCHED_UDF ||
+        udf.evalType == PythonEvalType.SQL_ARROW_BATCHED_UDF) &&
+        udf.children.nonEmpty &&
+        !udf.children.exists(_.isInstanceOf[NamedArgumentExpression]) &&
+        !containsUDT(udf.dataType) &&
+        !udf.children.exists(c => containsUDT(c.dataType))
+    case _ => false
+  }
+
+  /**
+   * Whether every Python UDF in `hof`'s lambdas can be lifted out by 
`ExtractPythonUDFFromLambda`.
+   * Used by `CheckAnalysis` to decide whether to reject the plan. These 
shapes cannot be rewritten:
+   *   - a UDF in a *nested* lambda, `transform(arr, i -> transform(i, x -> 
f(x)))`: the inner array
+   *     `i` is not a real column. (A UDF in a nested *argument*, 
`transform(arr, x ->
+   *     transform(udf(x), y -> y))`, is fine - `udf(x)` lifts onto `arr`.)
+   *   - a UDF in `aggregate` / `reduce`: the fold is sequential, so it sees 
earlier steps' outputs;
+   *   - a vectorized (scalar pandas / arrow) UDF, which is not supported;
+   *   - a *nondeterministic iterated argument*, `filter(shuffle(arr), x -> 
f(x))`: the rewrite
+   *     references that argument several times (the carrier's `c0`, each 
lifted UDF's argument, the
+   *     `map_keys`/`map_values` desugar, the pairwise `array_sort` path), and 
nondeterministic
+   *     expressions are not subexpression-eliminated, so the copies would 
evaluate independently
+   *     and disagree - keeping the results misaligned. (This is distinct from 
a nondeterministic
+   *     UDF *call*, which `ExtractPythonUDFFromLambda.liftKey` keeps distinct 
but well-defined.)
+   */
+  def canRewritePythonUDFInLambda(hof: HigherOrderFunction): Boolean = {
+    // Only row-at-a-time eval types are supported; a vectorized UDF is not 
rewritable regardless
+    // of the enclosing function.
+    val allUDFsRewritable = hof.functions.forall { f =>
+      f.collect { case udf: PythonUDF => udf 
}.forall(isElementwiseRewritableUDF)
+    }
+    // Reading a free lambda variable means `hof` is itself nested in an 
enclosing lambda, so the
+    // array it iterates is not a real column (the nested case above).
+    val iteratesRealColumns = !hasFreeLambdaVariable(hof)
+    // The rewrite duplicates the iterated argument expression, so a 
nondeterministic one would be
+    // evaluated more than once with diverging results.
+    val deterministicArguments = hof.arguments.forall(_.deterministic)
+    allUDFsRewritable && iteratesRealColumns && deterministicArguments && 
isRewritableShape(hof)
+  }
+
+  /**
+   * The structural assumption the rewrite makes: one lambda with 
plain-variable parameters, over at
+   * least one array- or map-valued argument. `aggregate` / `reduce` fail this 
- they have two
+   * lambdas (`merge`, `finish`) - so a UDF in a fold is rejected. Checking 
the shape rather than
+   * listing classes means a new function of a familiar shape needs no change 
here.
+   *
+   * The function must also carry one of the result-type marker traits the 
rewrite dispatches on
+   * ([[ResultTypeFromArgument]] or [[ResultTypeFromFunction]]). Every 
built-in single-lambda HOF is
+   * marked today, but requiring it here keeps "analysis accepts exactly what 
the rule rewrites"
+   * structural: a future HOF missing both traits is rejected at analysis 
rather than slipping
+   * through and leaving the UDF inside the lambda at runtime.
+   */
+  private def isRewritableShape(hof: HigherOrderFunction): Boolean =
+    hof.functions.length == 1 &&
+      hof.functions.head.isInstanceOf[LambdaFunction] &&
+      hof.functions.head.asInstanceOf[LambdaFunction].arguments
+        .forall(_.isInstanceOf[NamedLambdaVariable]) &&
+      (hof.isInstanceOf[ResultTypeFromArgument] || 
hof.isInstanceOf[ResultTypeFromFunction]) &&
+      hof.arguments.exists { a =>
+        a.dataType.isInstanceOf[ArrayType] || a.dataType.isInstanceOf[MapType]
+      }
+
+  /**
+   * Whether `e` references a [[NamedLambdaVariable]] that it does not itself 
bind, i.e. one bound
+   * by an enclosing lambda. Such an expression cannot be evaluated outside 
that lambda.
+   *
+   * Shared with `ExtractPythonUDFFromLambda` so the rule can re-check the 
nested-lambda guard on
+   * its own, rather than relying only on `CheckAnalysis` having already 
rejected such plans.
+   */
+  def hasFreeLambdaVariable(e: Expression): Boolean = {
+    def check(expr: Expression, bound: Set[ExprId]): Boolean = expr match {
+      case LambdaFunction(function, arguments, _) =>
+        check(function, bound ++ arguments.map(_.exprId))
+      case v: NamedLambdaVariable => !bound.contains(v.exprId)
+      case other => other.children.exists(check(_, bound))
+    }
+    check(e, Set.empty)
+  }
+
   def isWindowPandasUDF(e: PythonFuncExpression): Boolean = {
     // This is currently only `PythonUDAF` (which means 
SQL_GROUPED_AGG_PANDAS_UDF or
     // SQL_GROUPED_AGG_ARROW_UDF), but we might
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/higherOrderFunctions.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/higherOrderFunctions.scala
index 3877de93a834..6c6b96f4f590 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/higherOrderFunctions.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/higherOrderFunctions.scala
@@ -393,6 +393,29 @@ trait MapBasedSimpleHigherOrderFunction extends 
SimpleHigherOrderFunction {
   override def argumentType: AbstractDataType = MapType
 }
 
+/**
+ * A higher-order function whose result type is its argument's type, because 
it returns a subset or
+ * reordering of the input rather than the lambda's values. Members: `filter`, 
`map_filter`,
+ * `array_sort` (e.g. `filter(array<int>, ...) => array<int>`). Provides the 
shared `dataType`.
+ *
+ * The counterpart is [[ResultTypeFromFunction]]; the split is a real property 
of the expression,
+ * not of any evaluation: `filter` keeps its input's type, `transform`'s type 
follows the lambda.
+ */
+trait ResultTypeFromArgument extends SimpleHigherOrderFunction {
+  override def dataType: DataType = argument.dataType
+}
+
+/**
+ * A higher-order function whose result type follows its lambda, not its 
argument. Members:
+ * `transform`, `transform_keys`, `transform_values`, `zip_with`, 
`map_zip_with`, `aggregate`, and
+ * the predicates `exists` / `forall` (whose boolean lambda gives a boolean 
result).
+ *
+ * Each member computes its own `dataType` (array of the element type, a 
re-keyed/re-valued map, the
+ * fold result, ...), so this is a marker with no shared implementation - the 
counterpart of
+ * [[ResultTypeFromArgument]].
+ */
+trait ResultTypeFromFunction extends HigherOrderFunction
+
 /**
  * Transform elements in an array using the transform function. This is 
similar to
  * a `map` in functional programming.
@@ -418,7 +441,7 @@ trait MapBasedSimpleHigherOrderFunction extends 
SimpleHigherOrderFunction {
 case class ArrayTransform(
     argument: Expression,
     function: Expression)
-  extends ArrayBasedSimpleHigherOrderFunction {
+  extends ArrayBasedSimpleHigherOrderFunction with ResultTypeFromFunction {
 
   override def dataType: ArrayType = ArrayType(function.dataType, 
function.nullable)
 
@@ -549,7 +572,7 @@ case class ArraySort(
     argument: Expression,
     function: Expression,
     allowNullComparisonResult: Boolean)
-  extends ArrayBasedSimpleHigherOrderFunction with CodegenFallback {
+  extends ArrayBasedSimpleHigherOrderFunction with CodegenFallback with 
ResultTypeFromArgument {
 
   def this(argument: Expression, function: Expression) = {
     this(
@@ -563,7 +586,6 @@ case class ArraySort(
   @transient lazy val elementType: DataType =
     argument.dataType.asInstanceOf[ArrayType].elementType
 
-  override def dataType: ArrayType = argument.dataType.asInstanceOf[ArrayType]
   override def checkInputDataTypes(): TypeCheckResult = {
     checkArgumentDataTypes() match {
       case TypeCheckResult.TypeCheckSuccess =>
@@ -680,7 +702,7 @@ object ArraySort {
 case class MapFilter(
     argument: Expression,
     function: Expression)
-  extends MapBasedSimpleHigherOrderFunction with CodegenFallback {
+  extends MapBasedSimpleHigherOrderFunction with CodegenFallback with 
ResultTypeFromArgument {
 
   @transient lazy val (keyVar, valueVar) = {
     val args = function.asInstanceOf[LambdaFunction].arguments
@@ -710,8 +732,6 @@ case class MapFilter(
     ArrayBasedMapData(retKeys.toArray, retValues.toArray)
   }
 
-  override def dataType: DataType = argument.dataType
-
   override def functionType: AbstractDataType = BooleanType
 
   override def nodeName: String = "map_filter"
@@ -750,9 +770,7 @@ case class MapFilter(
 case class ArrayFilter(
     argument: Expression,
     function: Expression)
-  extends ArrayBasedSimpleHigherOrderFunction {
-
-  override def dataType: DataType = argument.dataType
+  extends ArrayBasedSimpleHigherOrderFunction with ResultTypeFromArgument {
 
   override def functionType: AbstractDataType = BooleanType
 
@@ -897,7 +915,7 @@ case class ArrayExists(
     argument: Expression,
     function: Expression,
     followThreeValuedLogic: Boolean)
-  extends ArrayBasedSimpleHigherOrderFunction with Predicate {
+  extends ArrayBasedSimpleHigherOrderFunction with Predicate with 
ResultTypeFromFunction {
 
   def this(argument: Expression, function: Expression) = {
     this(
@@ -1035,7 +1053,7 @@ object ArrayExists {
 case class ArrayForAll(
     argument: Expression,
     function: Expression)
-  extends ArrayBasedSimpleHigherOrderFunction with Predicate {
+  extends ArrayBasedSimpleHigherOrderFunction with Predicate with 
ResultTypeFromFunction {
 
   override def nullable: Boolean =
       super.nullable || function.nullable
@@ -1165,7 +1183,7 @@ case class ArrayAggregate(
     zero: Expression,
     merge: Expression,
     finish: Expression)
-  extends HigherOrderFunction with QuaternaryLike[Expression] {
+  extends HigherOrderFunction with QuaternaryLike[Expression] with 
ResultTypeFromFunction {
 
   def this(argument: Expression, zero: Expression, merge: Expression) = {
     this(argument, zero, merge, LambdaFunction.identity)
@@ -1382,7 +1400,7 @@ case class ArrayAggregate(
 case class TransformKeys(
     argument: Expression,
     function: Expression)
-  extends MapBasedSimpleHigherOrderFunction with CodegenFallback {
+  extends MapBasedSimpleHigherOrderFunction with CodegenFallback with 
ResultTypeFromFunction {
 
   @transient lazy val MapType(keyType, valueType, valueContainsNull) = 
argument.dataType
 
@@ -1448,7 +1466,7 @@ case class TransformKeys(
 case class TransformValues(
     argument: Expression,
     function: Expression)
-  extends MapBasedSimpleHigherOrderFunction with CodegenFallback {
+  extends MapBasedSimpleHigherOrderFunction with CodegenFallback with 
ResultTypeFromFunction {
 
   @transient lazy val MapType(keyType, valueType, valueContainsNull) = 
argument.dataType
 
@@ -1514,7 +1532,8 @@ case class TransformValues(
   since = "3.0.0",
   group = "lambda_funcs")
 case class MapZipWith(left: Expression, right: Expression, function: 
Expression)
-  extends HigherOrderFunction with CodegenFallback with 
TernaryLike[Expression] {
+  extends HigherOrderFunction with CodegenFallback with TernaryLike[Expression]
+  with ResultTypeFromFunction {
 
   def functionForEval: Expression = functionsForEval.head
 
@@ -1752,7 +1771,8 @@ case class MapZipWith(left: Expression, right: 
Expression, function: Expression)
   group = "lambda_funcs")
 // scalastyle:on line.size.limit
 case class ZipWith(left: Expression, right: Expression, function: Expression)
-  extends HigherOrderFunction with CodegenFallback with 
TernaryLike[Expression] {
+  extends HigherOrderFunction with CodegenFallback with TernaryLike[Expression]
+  with ResultTypeFromFunction {
 
   def functionForEval: Expression = functionsForEval.head
 
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 5f80aaf9623a..8d4856a9c6ce 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -5387,6 +5387,23 @@ object SQLConf {
           " must be more than one.")
       .createOptional
 
+  val PYTHON_UDF_IN_HIGHER_ORDER_FUNCTION_ENABLED =
+    buildConf("spark.sql.execution.pythonUDF.inHigherOrderFunction.enabled")
+      .doc("When true, a scalar Python UDF may be used inside the lambda of a 
higher-order " +
+        "function such as `transform` or `filter`. The UDF is not evaluated 
inside the lambda; " +
+        "it is applied to the whole array outside the lambda and the lambda 
reads the " +
+        "precomputed result. This uses an Arrow-based execution path, so 
PyArrow is required " +
+        "even for a UDF created with useArrow=False. Because the UDF is 
precomputed over the " +
+        "whole array, it runs once per element regardless of any 
short-circuiting (`exists`, " +
+        "`when`) in the lambda. In particular, an `array_sort` comparator that 
calls the UDF on " +
+        "both elements, `(a, b) -> udf(a, b)`, precomputes it over every pair, 
costing O(n^2) " +
+        "calls and memory for an array of length n; avoid it for large arrays. 
When false, such " +
+        "queries fail with UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF 
as before.")
+      .version("4.4.0")
+      .withBindingPolicy(ConfigBindingPolicy.SESSION)
+      .booleanConf
+      .createWithDefault(true)
+
   val PYTHON_UDF_ARROW_FALLBACK_ON_UDT =
     buildConf("spark.sql.execution.pythonUDF.arrow.legacy.fallbackOnUDT")
       .internal()
@@ -9286,6 +9303,9 @@ class SQLConf extends Serializable with Logging with 
SqlApiConf {
 
   def pythonUDFArrowFallbackOnUDT: Boolean = 
getConf(PYTHON_UDF_ARROW_FALLBACK_ON_UDT)
 
+  def pythonUDFInHigherOrderFunctionEnabled: Boolean =
+    getConf(PYTHON_UDF_IN_HIGHER_ORDER_FUNCTION_ENABLED)
+
   def pysparkPlotMaxRows: Int = getConf(PYSPARK_PLOT_MAX_ROWS)
 
   def arrowSparkREnabled: Boolean = getConf(ARROW_SPARKR_EXECUTION_ENABLED)
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkOptimizer.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkOptimizer.scala
index 9dd7bef549fa..2e9520742b0a 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkOptimizer.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkOptimizer.scala
@@ -90,6 +90,8 @@ class SparkOptimizer(
       ExtractPythonUDFFromAggregate,
       // This must be executed after `ExtractPythonUDFFromAggregate` and 
before `ExtractPythonUDFs`.
       ExtractGroupingPythonUDFFromAggregate,
+      // `ExtractPythonUDFs` first lifts Python UDFs out of higher-order 
function lambdas
+      // (via `ExtractPythonUDFFromLambda`) and then extracts them as ordinary 
top-level UDFs.
       ExtractPythonUDFs,
       ExtractPythonUDTFs,
       // The eval-python node may be between Project/Filter and the scan node, 
which breaks
@@ -116,6 +118,8 @@ class SparkOptimizer(
       ExtractPythonUDFFromJoinCondition.ruleName,
       ExtractPythonUDFFromAggregate.ruleName,
       ExtractGroupingPythonUDFFromAggregate.ruleName,
+      // Non-excludable: a plan with a Python UDF in a higher-order function 
lambda only works
+      // because `ExtractPythonUDFs` lifts it out (via 
`ExtractPythonUDFFromLambda`).
       ExtractPythonUDFs.ruleName,
       GroupBasedRowLevelOperationScanPlanning.ruleName,
       V2ScanRelationPushDown.ruleName,
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowEvalPythonExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowEvalPythonExec.scala
index 4a09898a58c7..7c7ed9c50ab6 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowEvalPythonExec.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowEvalPythonExec.scala
@@ -162,6 +162,7 @@ case class ArrowEvalPythonExec(
   private def supportedPythonEvalTypes: Array[Int] =
     Array(
       PythonEvalType.SQL_ARROW_BATCHED_UDF,
+      PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF,
       PythonEvalType.SQL_SCALAR_ARROW_UDF,
       PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF,
       PythonEvalType.SQL_SCALAR_PANDAS_UDF,
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
index 75b8465e2607..4c1c472cb10d 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
@@ -138,7 +138,10 @@ class ArrowPythonWithNamedArgumentRunner(
   override protected def runnerConf: Map[String, String] = super.runnerConf ++ 
pythonRunnerConf
 
   override protected def evalConf: Map[String, String] = {
-    if (evalType == PythonEvalType.SQL_ARROW_BATCHED_UDF) {
+    // An element-wise UDF receives each argument as an `array<T>` column and 
flattens it, so like
+    // the Arrow batched UDF it needs the input schema to convert the incoming 
Arrow types.
+    if (evalType == PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF ||
+        evalType == PythonEvalType.SQL_ARROW_BATCHED_UDF) {
       super.evalConf ++ Map(
         "input_type" -> schema.json
       )
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambda.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambda.scala
new file mode 100644
index 000000000000..637ec259c651
--- /dev/null
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambda.scala
@@ -0,0 +1,619 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.python
+
+import org.apache.spark.api.python.PythonEvalType
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.catalyst.trees.TreePattern._
+import org.apache.spark.sql.types.{ArrayType, IntegerType, MapType}
+
+/**
+ * Rewrites scalar Python UDFs inside a higher-order function's lambda so they 
can be evaluated.
+ *
+ * A `PythonUDF` runs in a separate operator that [[ExtractPythonUDFs]] pulls 
out, but a lambda's
+ * [[NamedLambdaVariable]]s only exist while the function iterates, so the UDF 
can neither stay in
+ * the lambda nor be lifted out normally. Instead this rule applies the UDF 
once to the *whole
+ * array*, outside every lambda, and has the lambda read the result 
positionally:
+ *
+ * {{{
+ *   -- before (rejected)
+ *   transform(values, x -> plus_one(x))
+ *
+ *   -- after (the PythonUDF is outside every lambda)
+ *   transform(arrays_zip(values AS c0, plus_one_over_array(values) AS u0), s 
-> s.u0)
+ * }}}
+ *
+ * `plus_one_over_array` is the same function re-typed as `array<T> => 
array<R>` and run with
+ * [[PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF]]. The array-at-a-time behaviour 
lives in the Python
+ * worker: it flattens each list column once, calls the function over all 
elements of the batch, and
+ * re-nests by the input's offsets - one row in, one row out, one Python round 
trip per batch.
+ *
+ * Every lifted argument is a single-level `array<T>` aligned with the 
iterated array (an
+ * element-independent value is repeated into one with a native `transform`), 
so the worker flattens
+ * them uniformly with no per-argument metadata. With the result now an 
ordinary column, arithmetic,
+ * `when`, casts, the element index, multiple UDFs and nested calls `f(g(x))` 
all just work.
+ *
+ * Runs before [[ExtractPythonUDFs]]. Handles all ten single-lambda functions: 
`transform`,
+ * `filter`, `exists`, `forall`, `zip_with`, `array_sort`, and the four map 
functions (desugared to
+ * `map_keys`/`map_values` arrays and rebuilt with `map_from_arrays`). 
`array_sort` precomputes a
+ * per-element key, or, when one call takes both elements, the UDF over the 
cross product of pairs.
+ *
+ * `CheckAnalysis` still rejects what this rule does not handle:
+ *  - a UDF in a *nested* lambda, `transform(arr, i -> transform(i, x -> 
f(x)))`: the inner array
+ *    `i` is not a real column. (A UDF in a nested *argument*, `transform(arr, 
x ->
+ *    transform(udf(x), y -> y))`, is fine - `udf(x)` lifts onto `arr`.)
+ *  - a UDF in `aggregate` / `reduce`: the fold is sequential, so the UDF sees 
earlier steps'
+ *    outputs, not array elements.
+ *  - a vectorized (scalar pandas / arrow) UDF, which is not supported.
+ */
+object ExtractPythonUDFFromLambda extends Rule[LogicalPlan] {
+
+  def apply(plan: LogicalPlan): LogicalPlan = {
+    if (!conf.pythonUDFInHigherOrderFunctionEnabled) {
+      plan
+    } else {
+      // A single bottom-up pass lifts every liftable UDF: 
`transformExpressionsUpWithPruning`
+      // visits the innermost higher-order function first, and each rewrite 
lifts all of that
+      // lambda's UDFs at once. A UDF inside a *nested* function's lambda is 
not liftable at all
+      // (its argument is the outer lambda's variable, which is not a real 
column) and is rejected
+      // by `CheckAnalysis`, so no repeated fixed-point pass is needed.
+      plan.transformUpWithPruning(
+        _.containsAllPatterns(PYTHON_UDF, HIGH_ORDER_FUNCTION)) {
+        case p =>
+          p.transformExpressionsUpWithPruning(
+            _.containsAllPatterns(PYTHON_UDF, HIGH_ORDER_FUNCTION))(rewrite)
+      }
+    }
+  }
+
+  /**
+   * Whether one UDF call in an `array_sort` comparator takes both elements, 
e.g.
+   * `(a, b) -> udf(a, b)`. Such a call has no per-element key, so it is 
precomputed over the cross
+   * product of pairs rather than per element.
+   */
+  private def comparatorTakesBothElements(function: Expression): Boolean = 
function match {
+    case LambdaFunction(body, Seq(left: NamedLambdaVariable, right: 
NamedLambdaVariable), _) =>
+      body.exists {
+        case udf: PythonUDF if PythonUDF.isElementwiseRewritableUDF(udf) =>
+          def reads(id: ExprId) = udf.exists {
+            case v: NamedLambdaVariable => v.exprId == id
+            case _ => false
+          }
+          reads(left.exprId) && reads(right.exprId)
+        case _ => false
+      }
+    case _ => false
+  }
+
+  /**
+   * Rewrites one higher-order function whose lambda holds a rewritable Python 
UDF. The generic path
+   * reads arguments, lambdas and parameter roles off the 
[[HigherOrderFunction]] API and rebuilds
+   * with `withNewChildren` (children are `arguments` then `functions`), 
naming a concrete class
+   * only where a shape cannot be inferred otherwise (`ArraySort`'s 
comparator, and the result-type
+   * traits telling `ArrayFilter` from `ArrayTransform`). A pairwise 
`array_sort` comparator, whose
+   * single call takes both elements, needs its own path.
+   */
+  private val rewrite: PartialFunction[Expression, Expression] = {
+    case sort @ ArraySort(_, function, _)
+        if liftableHof(sort) && comparatorTakesBothElements(function) =>
+      rewritePairwiseComparator(sort)
+
+    // Every result-typed higher-order function is handled; anything else is 
left alone.
+    case hof: HigherOrderFunction
+        if liftableHof(hof) &&
+          (hof.isInstanceOf[ResultTypeFromArgument] || 
hof.isInstanceOf[ResultTypeFromFunction]) =>
+      rewriteMapping(hof)
+  }
+
+  /**
+   * Rewrites `array_sort(arr, (a, b) -> udf(a, b))`, where one call takes 
both elements so there is
+   * no per-element key. Precomputes the UDF over every ordered pair - an n x 
n matrix with
+   * `udf(arr[i], arr[j])` at (i, j) - and the comparator reads it by the two 
elements' positions,
+   * so no Python runs while sorting. Costs O(n^2) calls and memory vs. O(n) 
for a per-element key.
+   */
+  private def rewritePairwiseComparator(sort: ArraySort): Expression = {
+    val ArraySort(argument, function, allowNull) = sort
+    val LambdaFunction(body, Seq(leftVar: NamedLambdaVariable, rightVar: 
NamedLambdaVariable), _) =
+      function
+    val arrayType = argument.dataType.asInstanceOf[ArrayType]
+    val elementType = arrayType.elementType
+    val containsNull = arrayType.containsNull
+    val n = Size(argument)
+
+    // The two sides of the cross product. `array_repeat` avoids introducing a 
lambda that could
+    // capture the UDF; the one lambda here holds only the repeat, never the 
UDF.
+    val repeatVar = NamedLambdaVariable("a", elementType, containsNull)
+    val lefts = Flatten(
+      ArrayTransform(argument, LambdaFunction(ArrayRepeat(repeatVar, n), 
Seq(repeatVar))))
+    val rights = Flatten(ArrayRepeat(argument, n))
+
+    // The UDF over all n*n pairs: this is just the element-wise rewrite with 
the pair arrays as the
+    // iterated arguments, so `buildCarrier` lifts the UDF and a `transform` 
runs the rest of the
+    // comparator body (cast, `when`, arithmetic) once per pair in the JVM.
+    val pairLambda = LambdaFunction(body, Seq(leftVar, rightVar))
+    val pairCarrier = buildCarrier(Seq(lefts, rights), pairLambda, 
Seq(leftVar, rightVar), None)
+    val flatCells = ArrayTransform(
+      pairCarrier.carrier, LambdaFunction(pairCarrier.body, 
Seq(pairCarrier.boundVar)))
+
+    // Carry each element's position and the shared flat result array so the 
comparator can read
+    // its pair's precomputed cell, sort, then drop them again. `flatCells` 
must be built here, in
+    // the sort's *argument*, not inside the comparator: `ArraySort` 
re-evaluates the whole
+    // comparator body on every comparison, and `ExtractPythonUDFs` hoists 
only the `PythonUDF`
+    // node - the surrounding 
`arrays_zip`/`transform`/`flatten`/`array_repeat` that build the cells
+    // would otherwise be rebuilt O(n^2) per comparison (O(n^3 log n) 
overall). In interpreted
+    // evaluation `array_repeat` stores n references to the one computed 
`flatCells` array, so the
+    // carry is O(n^2); a later copy of the carrier into the Unsafe format 
would materialize each
+    // reference into O(n^3) bytes. Either way the whole pairwise path is 
already O(n^2) in Python
+    // calls, so it is only intended for small arrays (see the config doc).
+    // Also carry the row width `n` so the comparator does not re-evaluate 
`Size(argument)` on every
+    // comparison (negligible for a column, but `argument` may be a computed 
expression). Like the
+    // cells, it is repeated into the carrier once and read as a struct field.
+    val posElem = NamedLambdaVariable("x", elementType, containsNull)
+    val posIdx = NamedLambdaVariable("i", IntegerType, nullable = false)
+    val cellsField = "cells"
+    val sizeField = "n"
+    val indexed = ArraysZip(
+      Seq(
+        argument,
+        ArrayTransform(argument, LambdaFunction(posIdx, Seq(posElem, posIdx))),
+        ArrayRepeat(flatCells, n),
+        ArrayRepeat(n, n)),
+      Seq(
+        Literal(s"${carrierElementPrefix}0"),
+        Literal(carrierIndexField),
+        Literal(cellsField),
+        Literal(sizeField)))
+    val indexedElement = indexed.dataType.asInstanceOf[ArrayType].elementType
+
+    // Index the flat n*n results directly: cell (i, j) is at `i * n + j`. The 
cells and `n` live in
+    // struct fields carried by every element, so the comparator only does 
field reads plus an
+    // `element_at`, all O(1). `element_at` is 1-based.
+    val cmpLeft = NamedLambdaVariable("a", indexedElement, nullable = false)
+    val cmpRight = NamedLambdaVariable("b", indexedElement, nullable = false)
+    def idxOf(v: NamedLambdaVariable): Expression = GetStructField(v, 1, 
Some(carrierIndexField))
+    val cells = GetStructField(cmpLeft, 2, Some(cellsField))
+    val width = GetStructField(cmpLeft, 3, Some(sizeField))
+    val comparison = ElementAt(
+      cells,
+      Add(Add(Multiply(idxOf(cmpLeft), width), idxOf(cmpRight)), Literal(1)),
+      None,
+      failOnError = false)
+
+    unwrapCarrier(
+      ArraySort(indexed, LambdaFunction(comparison, Seq(cmpLeft, cmpRight)), 
allowNull), 0)
+  }
+
+  /**
+   * The generic rewrite for a mapping higher-order function.
+   *
+   * A map-valued argument is first desugared to its key and value arrays, so 
everything below works
+   * in terms of arrays; the result is rebuilt as a map afterwards. The 
lambda's parameters are then
+   * matched to those arrays, the UDFs are lifted onto them, and the node is 
rebuilt around a
+   * carrier that the single new lambda parameter reads.
+   */
+  private def rewriteMapping(hof: HigherOrderFunction): Expression = {
+    val lambda = hof.functions.head.asInstanceOf[LambdaFunction]
+    // The result is the input elements (so the carrier is unwrapped 
afterwards) rather than the
+    // lambda's value: `filter` / `array_sort` / `map_filter` keep the input's 
type.
+    val isFromElements = hof.isInstanceOf[ResultTypeFromArgument]
+
+    // Desugar maps into arrays. `map_zip_with` visits the union of both key 
sets and looks each map
+    // up per key, which yields null for a key missing from one side - exactly 
its own semantics.
+    val mapValued = hof.arguments.exists(_.dataType.isInstanceOf[MapType])
+    val (arrays, rebuildResult): (Seq[Expression], Expression => Expression) =
+      if (!mapValued) {
+        (hof.arguments, identity)
+      } else if (hof.arguments.length == 1) {
+        val map = hof.arguments.head
+        val keys = MapKeys(map)
+        val values = MapValues(map)
+        // Rebuild by the concrete function, not the result type: 
`transform_keys` replaces the
+        // keys, `transform_values` the values, `map_filter` keeps whichever 
pairs survive. Keying
+        // off the type would be wrong for e.g. `transform_values` on 
`map<string, string>`, whose
+        // lambda result type equals the key type.
+        val rebuild: Expression => Expression = hof match {
+          case _: TransformKeys => (newKeys: Expression) => 
MapFromArrays(newKeys, values)
+          case _: TransformValues => (newValues: Expression) => 
MapFromArrays(keys, newValues)
+          case _: MapFilter => (kept: Expression) =>
+            MapFromArrays(unwrapCarrier(kept, 0), unwrapCarrier(kept, 1))
+        }
+        (Seq(keys, values), rebuild)
+      } else {
+        val Seq(left, right) = hof.arguments
+        val keys = ArrayUnion(MapKeys(left), MapKeys(right))
+        val keyType = keys.dataType.asInstanceOf[ArrayType]
+        def valuesFor(map: Expression): Expression = {
+          val k = NamedLambdaVariable("k", keyType.elementType, 
keyType.containsNull)
+          ArrayTransform(keys, LambdaFunction(ElementAt(map, k, None, 
failOnError = false), Seq(k)))
+        }
+        (Seq(keys, valuesFor(left), valuesFor(right)),
+          (newValues: Expression) => MapFromArrays(keys, newValues))
+      }
+
+    // Match lambda parameters to the arrays they iterate: leading ones map to 
the arrays, a
+    // trailing extra one is the element index. `array_sort` is the one 
exception - its lambda is a
+    // comparator whose two parameters are two elements of the *same* array, 
indistinguishable from
+    // an indexed lambda by types alone (both `(T, Int)`), so it is 
special-cased by class here.
+    val params = lambda.arguments.map(_.asInstanceOf[NamedLambdaVariable])
+    val (elementVars, indexVar, alsoBind) =
+      if (hof.isInstanceOf[ArraySort]) {
+        (Seq(params.head), None, Seq(params.last))
+      } else {
+        (params.take(arrays.length), params.drop(arrays.length).headOption, 
Nil)
+      }
+
+    val built = buildCarrier(arrays, lambda, elementVars, indexVar, alsoBind)
+    val newLambda = LambdaFunction(built.body, built.boundVar +: 
built.extraBoundVars)
+
+    // Rebuild the node over the single carrier. A single-array function keeps 
its own class (via
+    // `withNewChildren`, children being arguments then functions); a 
desugared map or a multi-array
+    // one becomes a `transform`, or an `ArrayFilter` when the carrier must 
survive the filtering so
+    // both key and value sides can be projected out.
+    val keepsOwnNode = hof.arguments.length == 1 && !mapValued
+    val iterated =
+      if (keepsOwnNode) {
+        hof.withNewChildren(IndexedSeq(built.carrier, 
newLambda)).asInstanceOf[Expression]
+      } else if (isFromElements) {
+        ArrayFilter(built.carrier, newLambda)
+      } else {
+        ArrayTransform(built.carrier, newLambda)
+      }
+
+    // A from-elements result (e.g. `filter`) is the input elements, so 
project them back out of the
+    // carrier; for a map `rebuildResult` knows which of the key/value sides 
to keep.
+    if (!mapValued && isFromElements) rebuildResult(unwrapCarrier(iterated, 0))
+    else rebuildResult(iterated)
+  }
+
+  /**
+   * True if `hof`'s single lambda holds a UDF belonging to *this* lambda (not 
a nested function's
+   * lambda). A UDF in a nested lambda is rejected by `CheckAnalysis`, so it 
is never matched here.
+   */
+  private def liftableHof(hof: HigherOrderFunction): Boolean =
+    hof.functions.length == 1 && (hof.functions.head match {
+      case LambdaFunction(body, args, _) =>
+        hasDirectRewritableUDF(body) && 
args.forall(_.isInstanceOf[NamedLambdaVariable]) &&
+          // Defense in depth: `CheckAnalysis` already rejects a `hof` nested 
in an enclosing
+          // lambda (its argument is a free lambda variable, not a real 
column), but re-check here
+          // so the rule stays correct even for a plan that did not go through 
analysis - otherwise
+          // it could mis-rewrite `transform(arr, i -> transform(i, x -> 
f(x)))` and resurrect
+          // SPARK-48706 (a raw `PythonUDF` left inside a generated lambda).
+          !PythonUDF.hasFreeLambdaVariable(hof)
+      case _ => false
+    })
+
+  /**
+   * Whether `body` holds a rewritable UDF belonging to *this* lambda. A 
nested function's lambda is
+   * skipped (its UDF reads that lambda's variable), but its *arguments* are 
not: in
+   * `transform(arr, x -> transform(udf(x), y -> y))`, `udf(x)` is in the 
inner argument and lifts
+   * onto `arr`.
+   */
+  private def hasDirectRewritableUDF(body: Expression): Boolean = body match {
+    case e if PythonUDF.isElementwiseRewritableUDF(e) => true
+    case hof: HigherOrderFunction => 
hof.arguments.exists(hasDirectRewritableUDF)
+    case e => e.children.exists(hasDirectRewritableUDF)
+  }
+
+  /** The pieces produced by [[buildCarrier]]. */
+  private case class Carrier(
+      carrier: Expression,
+      body: Expression,
+      boundVar: NamedLambdaVariable,
+      extraBoundVars: Seq[NamedLambdaVariable])
+
+  /**
+   * Builds the carrier array and the rewritten lambda body.
+   *
+   * The carrier is `arrays_zip` of the original arrays, one array per lifted 
UDF, and - when the
+   * lambda declares an index parameter - an index array. The rewritten body 
reads each of those
+   * through a struct field of the lambda variable bound to the carrier.
+   *
+   * `alsoBind` names further lambda variables that should read the same 
carrier; it exists for
+   * `array_sort`'s comparator, whose two parameters are both elements of the 
same array.
+   */
+  private def buildCarrier(
+      arguments: Seq[Expression],
+      function: Expression,
+      elementVars: Seq[NamedLambdaVariable],
+      indexVar: Option[NamedLambdaVariable],
+      alsoBind: Seq[NamedLambdaVariable] = Nil): Carrier = {
+    val LambdaFunction(body, _, _) = function
+    val lambdaExprIds =
+      (elementVars ++ indexVar.toSeq ++ alsoBind).map(_.exprId).toSet
+
+    // Collect the UDF calls to lift. Innermost first, so that a nested call 
like `f(g(x))` has
+    // `g` lifted before `f`, letting `f`'s array UDF consume `g`'s array 
result.
+    val liftableUDFs = collectLiftableUDFs(body)
+
+    // With more than one argument the arrays may be ragged (`zip_with` / 
`map_zip_with` pad with
+    // nulls), so flattening them independently would misalign the elements. 
Projecting each out of
+    // one common `arrays_zip` pads them to the same per-row length, which the 
positional rewrite
+    // requires.
+    val alignedArguments =
+      if (arguments.length > 1) {
+        val names = arguments.indices.map(i => s"$carrierElementPrefix$i")
+        val zipped = ArraysZip(arguments, names.map(Literal(_)))
+        arguments.indices.map(i => unwrapCarrier(zipped, i))
+      } else {
+        arguments
+      }
+
+    // An index array, when the lambda asked for the element index.
+    val indexArray = indexVar.map { _ =>
+      val head = alignedArguments.head
+      val headType = head.dataType.asInstanceOf[ArrayType]
+      val v = NamedLambdaVariable("x", headType.elementType, 
headType.containsNull)
+      val i = NamedLambdaVariable("i", IntegerType, nullable = false)
+      ArrayTransform(head, LambdaFunction(i, Seq(v, i)))
+    }
+
+    // Maps each element/index variable to the array it stands for, so a UDF 
argument written in
+    // terms of the variables can be rewritten as an expression over whole 
arrays. For a
+    // comparator, `alsoBind`'s variables denote the same array as the element 
variable.
+    val arrayOfVar: Map[ExprId, Expression] =
+      elementVars.map(_.exprId).zip(alignedArguments).toMap ++
+        indexVar.map(_.exprId -> indexArray.get).toMap ++
+        alsoBind.map(_.exprId -> alignedArguments.head).toMap
+
+    // One lifted array UDF per distinct call. `arrayResults` maps each 
original call (by `liftKey`)
+    // to the array holding its per-element results, so a nested call 
`f(g(x))` and the carrier
+    // lookups can find it. Deterministic calls that lift to the *same* 
function over the *same*
+    // array arguments share one lifted UDF: a key-form comparator's `udf(a)` 
and `udf(b)`
+    // canonicalize differently (`a` != `b`) but both read the whole array, so 
without this the
+    // Python function would run 2n times instead of n. Nondeterministic calls 
stay distinct (their
+    // signature is the lifted node itself, carrying a distinct `resultId`), 
matching `liftKey`.
+    var arrayResults = Map.empty[Expression, Expression]
+    val distinctLifted = scala.collection.mutable.ArrayBuffer.empty[PythonUDF]
+    val ordinalBySignature = 
scala.collection.mutable.HashMap.empty[Expression, Int]
+    val udfFieldByKey = 
scala.collection.mutable.LinkedHashMap.empty[Expression, Int]
+    liftableUDFs.foreach { udf =>
+      // `overArray` turns each argument into an `array<T>` aligned with the 
iterated array, so the
+      // worker flattens every one exactly once (no per-argument shape to 
track).
+      val arrayArgs = udf.children.map { child =>
+        overArray(child, alignedArguments.head, arrayOfVar, lambdaExprIds, 
arrayResults)
+      }
+      val lifted = PythonUDF(
+        udf.name,
+        udf.func,
+        // The wrapper returns one element per input element, i.e. one array 
level on top of the
+        // user function's scalar return. Elements may be null (the UDF can 
return null), hence
+        // containsNull = true.
+        ArrayType(udf.dataType, containsNull = true),
+        arrayArgs,
+        PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF,
+        udf.udfDeterministic)
+      val signature: Expression = if (udf.udfDeterministic) 
lifted.canonicalized else lifted
+      val ordinal = ordinalBySignature.getOrElseUpdate(signature, {
+        val o = distinctLifted.length
+        distinctLifted += lifted
+        o
+      })
+      arrayResults += (liftKey(udf) -> distinctLifted(ordinal))
+      udfFieldByKey += (liftKey(udf) -> ordinal)
+    }
+    val liftedArrays = distinctLifted.toSeq
+
+    // The carrier: the original arrays first, then one field per lifted UDF, 
then the index.
+    val carrierFields = alignedArguments ++ liftedArrays ++ indexArray.toSeq
+    val carrierNames =
+      arguments.indices.map(i => s"$carrierElementPrefix$i") ++
+        liftedArrays.indices.map(i => s"$carrierUDFFieldPrefix$i") ++
+        indexArray.map(_ => carrierIndexField).toSeq
+    val carrier = ArraysZip(carrierFields, carrierNames.map(Literal(_)))
+
+    val structType = carrier.dataType.asInstanceOf[ArrayType].elementType
+    val boundVar = NamedLambdaVariable("s", structType, nullable = false)
+    val extraBoundVars = alsoBind.map(v =>
+      NamedLambdaVariable(v.name, structType, nullable = false))
+
+    // Which struct field each lambda variable reads. For a comparator, 
`alsoBind`'s variable reads
+    // the same ordinals but through its own bound variable.
+    val fieldOfVar: Map[ExprId, Int] =
+      elementVars.map(_.exprId).zipWithIndex.toMap ++
+        indexVar.map(_.exprId -> (carrierFields.length - 1)).toMap
+    val extraVarOf: Map[ExprId, NamedLambdaVariable] =
+      alsoBind.map(_.exprId).zip(extraBoundVars).toMap
+
+    // Rewrite the body. This must be top-down: a UDF call is matched by its 
canonicalized form,
+    // and rewriting its arguments first (a variable becoming a struct field 
read) would change
+    // that form so the call no longer matches and would be left inside the 
lambda. Replacing the
+    // call outright also stops the traversal descending into arguments that 
no longer exist.
+    def readerFor(v: NamedLambdaVariable, udfOrdinal: Option[Int]): Expression 
= {
+      val base = extraVarOf.getOrElse(v.exprId, boundVar)
+      udfOrdinal match {
+        case Some(u) =>
+          GetStructField(base, arguments.length + u, 
Some(s"$carrierUDFFieldPrefix$u"))
+        case None =>
+          val ordinal = fieldOfVar(v.exprId)
+          GetStructField(base, ordinal, Some(carrierNames(ordinal)))
+      }
+    }
+
+    val rewrittenBody = body.transformDown {
+      case udf: PythonUDF if udfFieldByKey.contains(liftKey(udf)) =>
+        val ordinal = udfFieldByKey(liftKey(udf))
+        // A UDF over a comparator's right-hand element must read that 
element's key, so the
+        // struct field is read through whichever bound variable the call's 
own arguments used.
+        val side = udf.collectFirst {
+          case v: NamedLambdaVariable if extraVarOf.contains(v.exprId) => v
+        }
+        side match {
+          case Some(v) => readerFor(v, Some(ordinal))
+          case None =>
+            GetStructField(boundVar, arguments.length + ordinal,
+              Some(s"$carrierUDFFieldPrefix$ordinal"))
+        }
+      case v: NamedLambdaVariable if fieldOfVar.contains(v.exprId) => 
readerFor(v, None)
+      case v: NamedLambdaVariable if extraVarOf.contains(v.exprId) =>
+        // A comparator's right-hand element itself, read through its own 
bound variable.
+        GetStructField(extraVarOf(v.exprId), 0, Some(carrierNames.head))
+    }
+
+    Carrier(carrier, rewrittenBody, boundVar, extraBoundVars)
+  }
+
+  /**
+   * Collects the Python UDF calls directly in `body` that must be lifted, 
innermost first.
+   *
+   * Every rewritable UDF directly in the lambda body is lifted, even one 
whose arguments do not
+   * read the lambda variable (`transform(arr, _ -> f(lit(10)))`). It cannot 
stay inside the lambda,
+   * and lifting it - `overArray` repeats a constant/outer-column argument 
into an aligned array -
+   * gives it the lambda's own call domain: once per element, and zero times 
for a null or empty
+   * array. Leaving it to [[ExtractPythonUDFs]] would instead call it once per 
row, including rows
+   * whose array is null or empty where the lambda never runs.
+   */
+  private def collectLiftableUDFs(body: Expression): Seq[PythonUDF] = {
+    val collected = Seq.newBuilder[PythonUDF]
+    def visit(e: Expression): Unit = {
+      // A nested higher-order function's lambda is not ours to rewrite, but 
its arguments are
+      // evaluated outside that lambda and so belong to this body. See 
`hasDirectRewritableUDF`.
+      val children = e match {
+        case hof: HigherOrderFunction => hof.arguments
+        case other => other.children
+      }
+      // Children first, so nested calls come out innermost-first.
+      children.foreach(visit)
+      e match {
+        case udf: PythonUDF if PythonUDF.isElementwiseRewritableUDF(udf) =>
+          collected += udf
+        case _ =>
+      }
+    }
+    visit(body)
+    // Deduplicate identical calls so the same UDF is evaluated once per 
array. Nondeterministic
+    // calls are kept distinct (see `liftKey`).
+    val seen = scala.collection.mutable.LinkedHashMap.empty[Expression, 
PythonUDF]
+    collected.result().foreach(udf => seen.getOrElseUpdate(liftKey(udf), udf))
+    seen.values.toSeq
+  }
+
+  private def readsLambdaVariable(e: Expression, lambdaExprIds: Set[ExprId]): 
Boolean =
+    e.exists {
+      case v: NamedLambdaVariable => lambdaExprIds.contains(v.exprId)
+      case _ => false
+    }
+
+  /**
+   * The key that decides whether two UDF calls are "the same call" for 
lifting. A deterministic
+   * call is deduplicated by canonical form, so an identical call is evaluated 
once. A
+   * nondeterministic call must stay distinct - `transform(arr, x -> f(x) + 
f(x))` calls `f` twice
+   * and each call may return a different value - so it is keyed by its own 
`resultId`-bearing node
+   * (`canonicalized` erases `resultId`, which would collapse the two calls 
into one).
+   */
+  private def liftKey(udf: PythonUDF): Expression =
+    if (udf.udfDeterministic) udf.canonicalized else udf
+
+  /**
+   * Turns a UDF argument expression, written in terms of single elements, 
into the equivalent
+   * `array<T>` aligned with the iterated array, so the worker can flatten 
every argument uniformly.
+   *
+   *  - a lambda variable becomes the array it stands for;
+   *  - an already-lifted UDF call (a nested call `f(g(x))`) becomes its array 
result;
+   *  - an expression independent of the lambda is repeated into an aligned 
array with a native
+   *    `transform`, rather than passed through as a scalar to broadcast;
+   *  - anything else is an expression over the elements, computed for every 
element by a native
+   *    `transform` that stays inside the JVM.
+   *
+   * An already-lifted UDF call nested inside a composite argument (`f(g(x) + 
1)`, `f(-g(x))`) is
+   * handled by the last case too: each such call is replaced by a synthetic 
variable standing for
+   * that call's aligned array result, which is then zipped in like any 
element array. Leaving the
+   * raw `g` inside the generated `transform` lambda would put a `PythonUDF` 
back inside a lambda -
+   * `ExtractPythonUDFs` would then extract a `g` whose child is a 
`NamedLambdaVariable` (the
+   * SPARK-48706 failure mode), so this substitution is for correctness, not 
just efficiency.
+   */
+  private def overArray(
+      child: Expression,
+      firstArgument: Expression,
+      arrayOfVar: Map[ExprId, Expression],
+      lambdaExprIds: Set[ExprId],
+      arrayResults: Map[Expression, Expression]): Expression = child match {
+    case v: NamedLambdaVariable if arrayOfVar.contains(v.exprId) => 
arrayOfVar(v.exprId)
+    case udf: PythonUDF if arrayResults.contains(liftKey(udf)) =>
+      arrayResults(liftKey(udf))
+    case e =>
+      // Replace each already-lifted nested UDF call with a synthetic variable 
standing for that
+      // call's aligned array result, then fold those arrays into the 
variable-to-array map so the
+      // logic below treats them exactly like element variables. This keeps a 
lifted UDF buried in
+      // a composite argument (`f(g(x) + 1)`) from being left as a raw UDF 
inside a lambda.
+      val nestedVars = 
scala.collection.mutable.LinkedHashMap.empty[Expression, NamedLambdaVariable]
+      val expr = e.transformUp {
+        case u: PythonUDF if arrayResults.contains(liftKey(u)) =>
+          nestedVars.getOrElseUpdate(liftKey(u), {
+            val arrType = 
arrayResults(liftKey(u)).dataType.asInstanceOf[ArrayType]
+            NamedLambdaVariable("g", arrType.elementType, arrType.containsNull)
+          })
+      }
+      val fullArrayOf = arrayOfVar ++
+        nestedVars.map { case (key, v) => v.exprId -> arrayResults(key) }
+
+      if (!readsLambdaVariable(expr, fullArrayOf.keySet)) {
+        // Independent of the element (an outer column or constant): repeat it 
into an array aligned
+        // with the iterated array, so every UDF argument is a single-level 
array the worker
+        // flattens the same way. `transform(arr, _ -> e)` keeps the value 
constant, matching shape.
+        val arrType = firstArgument.dataType.asInstanceOf[ArrayType]
+        val v = NamedLambdaVariable("x", arrType.elementType, 
arrType.containsNull)
+        ArrayTransform(firstArgument, LambdaFunction(expr, Seq(v)))
+      } else {
+        // An expression over the element(s) and/or nested results, e.g. 
`udf(x * 2)` or
+        // `f(g(x) + 1)`. Compute it for every element with a native 
`transform`, which stays inside
+        // the JVM. Multi-argument shapes need the values side by side, so zip 
them first.
+        val arrays = fullArrayOf.values.toSeq.distinct
+        if (arrays.length == 1) {
+          val arr = arrays.head
+          val elemType = arr.dataType.asInstanceOf[ArrayType]
+          val v = NamedLambdaVariable("x", elemType.elementType, 
elemType.containsNull)
+          val substituted = expr.transformUp {
+            case old: NamedLambdaVariable if fullArrayOf.contains(old.exprId) 
=> v
+          }
+          ArrayTransform(arr, LambdaFunction(substituted, Seq(v)))
+        } else {
+          // Zip every array the expression may read, then project the fields 
it needs.
+          val names = arrays.indices.map(i => s"$carrierElementPrefix$i")
+          val zipped = ArraysZip(arrays, names.map(Literal(_)))
+          val structType = zipped.dataType.asInstanceOf[ArrayType].elementType
+          val v = NamedLambdaVariable("z", structType, nullable = false)
+          val ordinalOf = fullArrayOf.map { case (id, arr) => id -> 
arrays.indexOf(arr) }
+          val substituted = expr.transformUp {
+            case old: NamedLambdaVariable if ordinalOf.contains(old.exprId) =>
+              GetStructField(v, ordinalOf(old.exprId), 
Some(names(ordinalOf(old.exprId))))
+          }
+          ArrayTransform(zipped, LambdaFunction(substituted, Seq(v)))
+        }
+      }
+  }
+
+  /**
+   * Projects field `ordinal` back out of a carrier array. Used where the 
result is built from the
+   * input elements rather than from the lambda's return value: `filter`, 
`array_sort` and the map
+   * family.
+   */
+  private def unwrapCarrier(carrierArray: Expression, ordinal: Int): 
Expression = {
+    val structType = carrierArray.dataType.asInstanceOf[ArrayType].elementType
+    val v = NamedLambdaVariable("s", structType, nullable = false)
+    ArrayTransform(
+      carrierArray,
+      LambdaFunction(
+        GetStructField(v, ordinal, Some(s"$carrierElementPrefix$ordinal")), 
Seq(v)))
+  }
+
+  private val carrierElementPrefix = "c"
+  private val carrierUDFFieldPrefix = "u"
+  private val carrierIndexField = "idx"
+}
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFs.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFs.scala
index af9b41e93a02..7a9ec702fea8 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFs.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFs.scala
@@ -264,7 +264,14 @@ object ExtractPythonUDFs extends Rule[LogicalPlan] with 
Logging {
     expressions.flatMap(collectEvaluableUDFs)
   }
 
-  def apply(plan: LogicalPlan): LogicalPlan = plan match {
+  def apply(plan: LogicalPlan): LogicalPlan = applyInternal(
+    // Lift Python UDFs out of higher-order function lambdas first, turning 
them into ordinary
+    // top-level UDFs this rule can then extract. Running it here rather than 
as a separate batch
+    // entry makes the ordering structural: `CheckAnalysis` accepts a 
lambda-UDF plan only because
+    // this rewrite will run, so the two must not be separable by rule 
reordering or exclusion.
+    ExtractPythonUDFFromLambda(plan))
+
+  private def applyInternal(plan: LogicalPlan): LogicalPlan = plan match {
     // SPARK-26293: A subquery will be rewritten into join later, and will go 
through this rule
     // eventually. Here we skip subquery, as Python UDF only needs to be 
extracted once.
     case s: Subquery if s.correlated => plan
@@ -342,6 +349,7 @@ object ExtractPythonUDFs extends Rule[LogicalPlan] with 
Logging {
             case PythonEvalType.SQL_SCALAR_PANDAS_UDF
                  | PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF
                  | PythonEvalType.SQL_ARROW_BATCHED_UDF
+                 | PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF
                  | PythonEvalType.SQL_SCALAR_ARROW_UDF
                  | PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF =>
               ArrowEvalPython(validUdfs, resultAttrs, child, evalType)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExecSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExecSuite.scala
index 42da9f060b85..d0228f9341c1 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExecSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/BatchEvalPythonExecSuite.scala
@@ -27,7 +27,7 @@ import org.apache.spark.sql.connector.catalog.CatalogManager
 import org.apache.spark.sql.execution.{FilterExec, InputAdapter, 
WholeStageCodegenExec}
 import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
 import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
-import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.test.{ExamplePointUDT, SharedSparkSession}
 import org.apache.spark.sql.types._
 import org.apache.spark.unsafe.types.UTF8String
 
@@ -367,3 +367,10 @@ class MyDummyScalarPandasUDF extends 
UserDefinedPythonFunction(
   dataType = BooleanType,
   pythonEvalType = PythonEvalType.SQL_SCALAR_PANDAS_UDF,
   udfDeterministic = true)
+
+class MyDummyUDTPythonUDF extends UserDefinedPythonFunction(
+  name = "dummyUDTUDF",
+  func = new DummyUDF,
+  dataType = new ExamplePointUDT,
+  pythonEvalType = PythonEvalType.SQL_BATCHED_UDF,
+  udfDeterministic = true)
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambdaSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambdaSuite.scala
new file mode 100644
index 000000000000..b764b5c9cf9d
--- /dev/null
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/ExtractPythonUDFFromLambdaSuite.scala
@@ -0,0 +1,379 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.python
+
+import org.apache.spark.api.python.PythonEvalType
+import org.apache.spark.sql.{AnalysisException, QueryTest}
+import org.apache.spark.sql.catalyst.expressions.{LambdaFunction, Literal, 
NamedArgumentExpression,
+  PythonUDF}
+import org.apache.spark.sql.catalyst.plans.logical.ArrowEvalPython
+import org.apache.spark.sql.functions.{array_sort, col, forall, lit, 
map_filter, map_zip_with,
+  transform, transform_keys, transform_values, when, zip_with}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.{ExamplePointUDT, SharedSparkSession}
+import org.apache.spark.sql.types.{ArrayType, IntegerType, MapType, StringType}
+
+/**
+ * Plan-shape tests for [[ExtractPythonUDFFromLambda]].
+ *
+ * These assert the structure the rewrite produces - that no `PythonUDF` is 
left inside a
+ * `LambdaFunction`, that the lifted UDF is an element-wise UDF over an array, 
and that plans
+ * without a UDF in a lambda are untouched. End-to-end result correctness is 
covered by
+ * `pyspark.sql.tests.test_udf_in_higher_order_function`, which needs a real 
Python worker.
+ */
+class ExtractPythonUDFFromLambdaSuite extends QueryTest with 
SharedSparkSession {
+  import testImplicits._
+
+  private val pythonUDF = new MyDummyPythonUDF
+  private val scalarPandasUDF = new MyDummyScalarPandasUDF
+  // Used where a UDF call must receive two arguments, e.g. a pairwise 
comparator.
+  private val pythonUDF2 = new MyDummyPythonUDF
+  private val nondeterministicUDF = new MyDummyNondeterministicPythonUDF
+  private val udtUDF = new MyDummyUDTPythonUDF
+
+  private def arrayDF = Seq(Seq(1, 2, 3)).toDF("values")
+
+  /** All `PythonUDF`s that remain inside a lambda in the optimized plan. */
+  private def udfsInsideLambda(df: org.apache.spark.sql.DataFrame): 
Seq[PythonUDF] = {
+    df.queryExecution.optimizedPlan.expressions.flatMap { e =>
+      e.collect { case l: LambdaFunction => l }.flatMap { l =>
+        l.collect { case u: PythonUDF => u }
+      }
+    }
+  }
+
+  private def liftedUDFs(df: org.apache.spark.sql.DataFrame): Seq[PythonUDF] = 
{
+    df.queryExecution.optimizedPlan.collect {
+      case a: ArrowEvalPython => a.udfs
+    }.flatten
+  }
+
+  test("transform: the UDF is lifted out of the lambda as an element-wise 
array UDF") {
+    val df = arrayDF.select(transform(col("values"), x => 
pythonUDF(x)).as("r"))
+
+    // The whole point of the rewrite: nothing Python-shaped is left inside a 
lambda.
+    assert(udfsInsideLambda(df).isEmpty)
+
+    val lifted = liftedUDFs(df)
+    assert(lifted.size == 1)
+    assert(lifted.head.evalType == PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF)
+    // The lifted UDF takes the array and returns an array of the original 
return type.
+    assert(lifted.head.dataType == ArrayType(pythonUDF.dataType, containsNull 
= true))
+    assert(lifted.head.children.size == 1)
+    assert(lifted.head.children.head.dataType.isInstanceOf[ArrayType])
+  }
+
+  test("filter/exists/forall: the UDF is lifted out of the lambda") {
+    val exprs = Seq(
+      org.apache.spark.sql.functions.filter(col("values"), x => pythonUDF(x)),
+      org.apache.spark.sql.functions.exists(col("values"), x => pythonUDF(x)),
+      org.apache.spark.sql.functions.forall(col("values"), x => pythonUDF(x)))
+
+    exprs.foreach { e =>
+      val df = arrayDF.select(e.as("r"))
+      assert(udfsInsideLambda(df).isEmpty, s"UDF still inside a lambda for $e")
+      assert(liftedUDFs(df).forall(_.evalType == 
PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF))
+    }
+  }
+
+  test("aggregate: a UDF anywhere in the fold is rejected") {
+    // The fold is sequential, so no array can precompute the values the UDF 
sees. A UDF in `merge`
+    // or in `finish` therefore has no rewrite and analysis must fail.
+    val cases = Seq(
+      "merge on element" ->
+        org.apache.spark.sql.functions.aggregate(
+          col("values"), lit(false), (acc, x) => acc || pythonUDF(x)),
+      "merge on accumulator" ->
+        org.apache.spark.sql.functions.aggregate(
+          col("values"), lit(false), (acc, x) => pythonUDF(acc) || 
x.cast("boolean")),
+      "finish" ->
+        org.apache.spark.sql.functions.aggregate(
+          col("values"), lit(0), (acc, x) => acc + x, acc => pythonUDF(acc)))
+    cases.foreach { case (name, expr) =>
+      val e = intercept[AnalysisException] {
+        arrayDF.select(expr.as("r")).collect()
+      }
+      assert(e.getCondition == 
"UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF",
+        s"expected aggregate with a UDF in $name to be rejected")
+    }
+  }
+
+  test("several UDFs and nested UDFs in one lambda are all lifted") {
+    val several = arrayDF.select(
+      transform(col("values"), x => pythonUDF(x) || pythonUDF(x + 
lit(1))).as("r"))
+    assert(udfsInsideLambda(several).isEmpty)
+    // Two distinct calls, so two lifted array UDFs.
+    assert(liftedUDFs(several).size == 2)
+
+    // The same deterministic call twice must be evaluated once.
+    val duplicated = arrayDF.select(
+      transform(col("values"), x => pythonUDF(x) || pythonUDF(x)).as("r"))
+    assert(udfsInsideLambda(duplicated).isEmpty)
+    assert(liftedUDFs(duplicated).size == 1)
+  }
+
+  test("a UDF nested inside a composite argument is lifted, not left inside a 
lambda") {
+    // SPARK-27052: `f(g(x) + 1)` / `f(-g(x))`. The inner call `g(x)` is 
already lifted; its
+    // occurrence buried inside the composite argument must be substituted 
too, or a raw `g` over a
+    // lambda variable would be left inside the generated transform for 
`ExtractPythonUDFs` to
+    // re-extract (the SPARK-48706 failure mode). Both nesting shapes must 
leave no UDF in a lambda.
+    val plusOne = arrayDF.select(
+      transform(col("values"), x => pythonUDF(pythonUDF(x).cast("int") + 
lit(1))).as("r"))
+    assert(udfsInsideLambda(plusOne).isEmpty)
+    // Two distinct calls (inner `g(x)`, outer `f(g(x) + 1)`), so two lifted 
array UDFs.
+    assert(liftedUDFs(plusOne).size == 2)
+
+    val negated = arrayDF.select(
+      transform(col("values"), x => 
pythonUDF(-pythonUDF(x).cast("int"))).as("r"))
+    assert(udfsInsideLambda(negated).isEmpty)
+    assert(liftedUDFs(negated).size == 2)
+  }
+
+  test("identical nondeterministic calls are lifted distinctly, not 
deduplicated") {
+    // Deduplicating `f(x)` with `f(x)` would collapse two independent draws 
into one; a
+    // nondeterministic UDF must keep each call, so both are lifted.
+    val df = arrayDF.select(
+      transform(col("values"), x => nondeterministicUDF(x) || 
nondeterministicUDF(x)).as("r"))
+    assert(udfsInsideLambda(df).isEmpty)
+    assert(liftedUDFs(df).size == 2)
+  }
+
+  test("transform_values whose result type equals the key type replaces 
values, not keys") {
+    // SPARK-27052: dispatch is by concrete function, not result type. For 
map<string, string>,
+    // transform_values' lambda also returns string, which must not be treated 
as new keys.
+    val maps = Seq(Map("a" -> "x")).toDF("m")
+    val df = maps.select(transform_values(col("m"), (k, v) => 
pythonUDF(v).cast("string")).as("r"))
+    assert(udfsInsideLambda(df).isEmpty)
+    // Keys are preserved: the map still has the original key type and no 
new-key projection.
+    val mapType = 
df.queryExecution.analyzed.schema.head.dataType.asInstanceOf[MapType]
+    assert(mapType.keyType == StringType)
+  }
+
+  test("a UDF inside a nested higher-order function's lambda is rejected") {
+    // The inner array is the outer lambda's variable, not a real column, so 
the UDF cannot be
+    // lifted onto an array outside the lambda. This must fail analysis rather 
than be rewritten.
+    val df = Seq(Seq(Seq(1, 2), Seq(3))).toDF("values")
+    val e = intercept[AnalysisException] {
+      df.select(transform(col("values"), inner =>
+        transform(inner, x => pythonUDF(x))).as("r")).collect()
+    }
+    assert(e.getCondition == 
"UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF")
+  }
+
+  test("a UDF on the outer element of a nested array is lifted") {
+    // Here the UDF applies to the outer array's element, which is a real 
column (the element just
+    // happens to be an array), so it is lifted onto that column like any 
other single-level array.
+    val df = Seq(Seq(Seq(1, 2), Seq(3))).toDF("values")
+      .select(transform(col("values"), inner => pythonUDF(inner)).as("r"))
+    assert(udfsInsideLambda(df).isEmpty)
+  }
+
+  test("a rewritable higher-order function inside another lambda leaves no UDF 
in any lambda") {
+    // `transform(arr2, i -> array_max(transform(arr, x -> f(x))) + i)`: the 
inner `transform`
+    // iterates the real column `arr` (not the outer lambda variable `i`), so 
it is rewritable and
+    // its UDF is lifted out of the inner lambda. The lifted element-wise UDF 
reads only `arr`, so
+    // `ExtractPythonUDFs` then hoists it out of the outer lambda too 
(evaluated once per row). The
+    // end state must leave no `PythonUDF` inside any lambda.
+    val df = Seq((Seq(1, 2, 3), Seq(10, 20))).toDF("arr", "arr2")
+      .select(transform(col("arr2"), i =>
+        org.apache.spark.sql.functions.array_max(
+          transform(col("arr"), x => pythonUDF(x).cast("int"))) + i).as("r"))
+    assert(udfsInsideLambda(df).isEmpty)
+    assert(liftedUDFs(df).size == 1)
+  }
+
+  test("a lambda with no Python UDF is left unchanged") {
+    val df = arrayDF.select(transform(col("values"), x => x + lit(1)).as("r"))
+    val analyzed = df.queryExecution.analyzed
+    val optimized = df.queryExecution.optimizedPlan
+    // The rule must be inert: no eval-python node is introduced.
+    assert(optimized.collect { case a: ArrowEvalPython => a }.isEmpty)
+    assert(!optimized.toString.contains("pythonUDF"))
+    assert(analyzed.expressions.flatMap(_.collect { case u: PythonUDF => u 
}).isEmpty)
+  }
+
+  test("every mapping higher-order function is rewritten") {
+    // One assertion per function, so that a shape regressing to "UDF left 
inside a lambda" is
+    // caught here rather than only by the end-to-end Python suite. 
`aggregate` / `reduce` are not
+    // here: they fold rather than map, so a UDF in them is rejected (see the 
aggregate test above).
+    val arrays = Seq((Seq(1, 2), Seq(3, 4))).toDF("l", "r")
+    val maps = Seq((Map("a" -> 1), Map("a" -> 2))).toDF("l", "r")
+
+    val arrayCases = Seq(
+      "transform" -> transform(col("l"), x => pythonUDF(x)),
+      "transform with index" -> transform(col("l"), (x, i) => pythonUDF(x) || 
i > 0),
+      "filter" -> org.apache.spark.sql.functions.filter(col("l"), x => 
pythonUDF(x)),
+      "exists" -> org.apache.spark.sql.functions.exists(col("l"), x => 
pythonUDF(x)),
+      "forall" -> forall(col("l"), x => pythonUDF(x)),
+      "zip_with" -> zip_with(col("l"), col("r"), (a, b) => pythonUDF(a) || 
pythonUDF(b)),
+      "array_sort" -> array_sort(col("l"),
+        (a, b) => when(pythonUDF(a) === pythonUDF(b), 
lit(0)).otherwise(lit(1))))
+    arrayCases.foreach { case (name, expr) =>
+      val df = arrays.select(expr.as("r"))
+      assert(udfsInsideLambda(df).isEmpty, s"UDF left inside a lambda for 
$name")
+    }
+
+    val mapCases = Seq(
+      "transform_keys" -> transform_keys(col("l"), (k, v) => 
pythonUDF(k).cast("string")),
+      "transform_values" -> transform_values(col("l"), (k, v) => pythonUDF(v)),
+      "map_filter" -> map_filter(col("l"), (k, v) => pythonUDF(v)),
+      "map_zip_with" -> map_zip_with(col("l"), col("r"), (k, a, b) => 
pythonUDF(a) || pythonUDF(b)))
+    mapCases.foreach { case (name, expr) =>
+      val df = maps.select(expr.as("r"))
+      assert(udfsInsideLambda(df).isEmpty, s"UDF left inside a lambda for 
$name")
+    }
+  }
+
+  test("a pairwise array_sort comparator is lifted over the cross product") {
+    // One UDF call receiving both elements has no per-element key, so the UDF 
is precomputed over
+    // every ordered pair instead. The call must end up outside every lambda 
like any other.
+    val df = arrayDF.select(
+      array_sort(col("values"), (a, b) => pythonUDF2(a, 
b).cast("int")).as("r"))
+    assert(udfsInsideLambda(df).isEmpty)
+    val lifted = liftedUDFs(df)
+    assert(lifted.size == 1)
+    // The UDF takes both pair sides, each a flat array of all n*n pairs.
+    assert(lifted.head.children.size == 2)
+  }
+
+  test("a key-form array_sort comparator lifts one UDF per element, not per 
comparator side") {
+    // `(a, b) -> udf(a) < udf(b)`: `udf(a)` and `udf(b)` canonicalize 
differently (distinct
+    // variable exprIds) but both lift to the same function over the whole 
array, so they must be
+    // deduplicated into one lifted UDF - otherwise the Python function runs 
2n times instead of n.
+    val df = arrayDF.select(
+      array_sort(col("values"),
+        (a, b) => when(pythonUDF(a) === pythonUDF(b), 
lit(0)).otherwise(lit(1))).as("r"))
+    assert(udfsInsideLambda(df).isEmpty)
+    assert(liftedUDFs(df).size == 1)
+  }
+
+  test("a pandas UDF inside a lambda still fails analysis") {
+    val e = intercept[AnalysisException] {
+      arrayDF.select(transform(col("values"), x => 
scalarPandasUDF(x))).collect()
+    }
+    assert(e.getCondition == 
"UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF")
+  }
+
+  test("a UDF with a UDT type inside a lambda still fails analysis") {
+    // Lifting forces the Arrow element-wise eval type, which has no UDT 
fallback, so a UDF whose
+    // argument or return type involves a UDT is not rewritable and keeps the 
previous analysis
+    // error rather than failing at runtime.
+    val e = intercept[AnalysisException] {
+      arrayDF.select(transform(col("values"), x => udtUDF(x))).collect()
+    }
+    assert(e.getCondition == 
"UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF")
+  }
+
+  test("the rewritable predicate excludes zero-argument, named-argument and 
UDT UDFs") {
+    // These shapes cannot be preserved by the lift (a zero-arg call has no 
array to carry the
+    // iterated shape and would crash the worker; a `NamedArgumentExpression` 
child would be buried
+    // inside the generated transform, losing kwargs; a UDT would hit the 
Arrow path with no
+    // fallback), so the shared predicate must reject them, keeping the 
previous analysis error.
+    val plain = PythonUDF("f", null, IntegerType, Seq(Literal(1)),
+      PythonEvalType.SQL_BATCHED_UDF, udfDeterministic = true)
+    assert(PythonUDF.isElementwiseRewritableUDF(plain))
+
+    val zeroArg = plain.copy(children = Seq.empty)
+    assert(!PythonUDF.isElementwiseRewritableUDF(zeroArg))
+
+    val named = plain.copy(children = Seq(NamedArgumentExpression("k", 
Literal(1))))
+    assert(!PythonUDF.isElementwiseRewritableUDF(named))
+
+    val udtReturn = plain.copy(dataType = new ExamplePointUDT)
+    assert(!PythonUDF.isElementwiseRewritableUDF(udtReturn))
+
+    val udtArg = plain.copy(children = Seq(Literal.create(null, new 
ExamplePointUDT)))
+    assert(!PythonUDF.isElementwiseRewritableUDF(udtArg))
+  }
+
+  test("a zero-argument UDF inside a lambda still fails analysis") {
+    // `transform(arr, x -> f())` has no argument to carry the iterated shape, 
so it is not
+    // rewritable and must keep failing analysis rather than crash the Python 
worker at runtime.
+    val e = intercept[AnalysisException] {
+      arrayDF.select(transform(col("values"), _ => pythonUDF())).collect()
+    }
+    assert(e.getCondition == 
"UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF")
+  }
+
+  test("a nondeterministic iterated argument is rejected") {
+    // The rewrite references the iterated argument several times and 
nondeterministic expressions
+    // are not subexpression-eliminated, so a nondeterministic argument like 
`shuffle(arr)` would be
+    // evaluated independently per reference and misalign the results. It must 
fail analysis.
+    val e = intercept[AnalysisException] {
+      arrayDF.select(
+        org.apache.spark.sql.functions.filter(
+          org.apache.spark.sql.functions.shuffle(col("values")),
+          x => pythonUDF(x))).collect()
+    }
+    assert(e.getCondition == 
"UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF")
+  }
+
+  test("the rewrite can be disabled by conf, restoring the previous error") {
+    withSQLConf(SQLConf.PYTHON_UDF_IN_HIGHER_ORDER_FUNCTION_ENABLED.key -> 
"false") {
+      val e = intercept[AnalysisException] {
+        arrayDF.select(transform(col("values"), x => pythonUDF(x))).collect()
+      }
+      assert(e.getCondition == 
"UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF")
+    }
+  }
+
+  test("the rewrite cannot be disabled via excludedRules") {
+    // The lambda rewrite is driven by `ExtractPythonUDFs` (not a standalone 
batch rule), and that
+    // rule is non-excludable, so a plan that only works because of the 
rewrite cannot be broken by
+    // excludedRules. Excluding either name must leave the UDF lifted out of 
the lambda.
+    withSQLConf(
+      SQLConf.OPTIMIZER_EXCLUDED_RULES.key ->
+        Seq(ExtractPythonUDFFromLambda.ruleName, 
ExtractPythonUDFs.ruleName).mkString(",")) {
+      val df = arrayDF.select(transform(col("values"), x => 
pythonUDF(x)).as("r"))
+      assert(udfsInsideLambda(df).isEmpty)
+    }
+  }
+
+  test("a UDF over only constants is still lifted per element") {
+    // SPARK-27052: `transform(arr, x -> udf(lit(10)))` does not read the 
element, but it is still
+    // lifted per element (`overArray` repeats the constant into an aligned 
array) so it keeps the
+    // lambda's call domain - once per element, zero times for a null/empty 
array - rather than
+    // being left to ExtractPythonUDFs, which would call it once per row.
+    val df = arrayDF.select(transform(col("values"), _ => 
pythonUDF(lit(10))).as("r"))
+    assert(udfsInsideLambda(df).isEmpty)
+  }
+
+  test("a UDF argument that is an expression over the element is lifted") {
+    val df = arrayDF.select(transform(col("values"), x => pythonUDF(x * 
lit(2))).as("r"))
+    assert(udfsInsideLambda(df).isEmpty)
+    val lifted = liftedUDFs(df)
+    assert(lifted.size == 1)
+    // The argument became an array-valued expression over the whole array.
+    assert(lifted.head.children.head.dataType.isInstanceOf[ArrayType])
+  }
+
+  test("an outer column argument is repeated into an aligned array for the 
lifted UDF") {
+    val df = Seq((Seq(1, 2), 10)).toDF("values", "base")
+      .select(transform(col("values"), x => pythonUDF(x, col("base"))).as("r"))
+    assert(udfsInsideLambda(df).isEmpty)
+    val lifted = liftedUDFs(df)
+    assert(lifted.size == 1)
+    // Both arguments are single-level arrays aligned with the iterated array: 
the element argument
+    // is the array itself, and the outer column is repeated into an aligned 
array so the worker
+    // flattens every argument uniformly.
+    lifted.head.children.foreach { c =>
+      assert(c.dataType.isInstanceOf[ArrayType])
+      assert(c.dataType.asInstanceOf[ArrayType].elementType == IntegerType)
+    }
+  }
+}
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonUDFSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonUDFSuite.scala
index dfff8ed9e975..88e1e7a9122b 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonUDFSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/python/PythonUDFSuite.scala
@@ -18,7 +18,7 @@
 package org.apache.spark.sql.execution.python
 
 import org.apache.spark.sql.{AnalysisException, IntegratedUDFTestUtils, Row}
-import org.apache.spark.sql.functions.{array, avg, col, count, transform}
+import org.apache.spark.sql.functions.{aggregate, array, avg, col, count, lit, 
transform}
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
 import org.apache.spark.sql.types.LongType
@@ -117,16 +117,28 @@ class PythonUDFSuite extends SharedSparkSession {
     
assert(df.agg(pandasTestUDF(df("id"))).schema.fieldNames.exists(_.startsWith(udfName)))
   }
 
-  test("SPARK-48706: Negative test case for Python UDF in higher order 
functions") {
+  test("SPARK-27052: Python UDF in a higher order function lambda") {
     assume(shouldTestPythonUDFs)
+    // SPARK-48706 originally rejected this. `ExtractPythonUDFFromLambda` now 
rewrites it so the
+    // UDF is applied to the whole array outside the lambda, so it runs and 
returns a result.
+    checkAnswer(
+      spark.range(1).select(transform(array("id"), x => pythonTestUDF(x))),
+      Row(Seq(0)))
+  }
+
+  test("SPARK-27052: Negative test case for Python UDF in higher order 
functions") {
+    assume(shouldTestPythonUDFs)
+    // A UDF reading `aggregate`'s accumulator is sequential, so there is no 
array to precompute
+    // over and the rewrite does not apply. This must still fail rather than 
give a wrong answer.
     checkError(
       exception = intercept[AnalysisException] {
-        spark.range(1).select(transform(array("id"), x => 
pythonTestUDF(x))).collect()
+        spark.range(1).select(
+          aggregate(array("id"), lit(0L), (acc, x) => pythonTestUDF(acc) + 
x)).collect()
       },
       condition = "UNSUPPORTED_FEATURE.LAMBDA_FUNCTION_WITH_PYTHON_UDF",
       parameters = Map("funcName" -> "\"pyUDF(namedlambdavariable())\""),
       context = ExpectedContext(
-        "transform", s".*${this.getClass.getSimpleName}.*"))
+        "aggregate", s".*${this.getClass.getSimpleName}.*"))
   }
 
   test("SPARK-48666: Python UDF execution against partitioned column") {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to