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

Yicong-Huang pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 2bbb9e248c76 [SPARK-60532][PYTHON] Chain UDF functions in 
EvalTypeHandler and enforce single-UDF handlers in the base
2bbb9e248c76 is described below

commit 2bbb9e248c76f0c018671ab919d2338579a453a8
Author: Yicong Huang <[email protected]>
AuthorDate: Tue Oct 27 11:00:31 2026 +0000

    [SPARK-60532][PYTHON] Chain UDF functions in EvalTypeHandler and enforce 
single-UDF handlers in the base
    
    ### What changes were proposed in this pull request?
    
    Two handler API changes under the EvalTypeHandler umbrella (SPARK-59415):
    
    1. **Handlers take one UDF unless they declare otherwise.** 
`EvalTypeHandler` gets a `_multi_udf` class flag, `False` by default. Handlers 
that take any number of UDFs set it (scalar pandas/Arrow, batched, 
Arrow-batched, elementwise, grouped/window agg, incremental agg/window). For 
every other handler (grouped/cogrouped map, map iter, scalar iter, grouped agg 
iter, scalar iter elementwise, stateful, UDTF), `__init__` fails fast with a 
`PySparkRuntimeError` naming the eval type (as the [...]
    2. **Chaining nested UDF calls moves from the worker into the handler.** 
`worker.py` only deserializes (`unpickle_and_chain` and `chain` removed, 
`read_command` inlined in `invoke_udf`), and the `UDF` record carries the 
unchained `funcs` instead of `raw_func`. `EvalTypeHandler.__init__` chains them 
into `_raw_funcs`, then wraps for profiling and `fail_on_stopiteration` as 
before. A single function is kept as is, so incremental `Aggregator` objects, 
`getfullargspec` and the data-source [...]
    
    ### Why are the changes needed?
    
    Keeps the worker to reading the wire format and gives handlers one place 
for their UDF invariants instead of repeating them per eval type.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    New unit tests in `test_base_eval_type_handlers.py` (single-UDF guard, 
`_multi_udf` per handler, chaining order, single function not wrapped); the 
other handler tests are updated for the `funcs` field. Existing e2e tests pass, 
including nested-UDF chaining in `test_udf` / `test_pandas_udf*`, 
`test_udf_profiler`, `test_python_datasource`, `test_udtf`, and 
transform-with-state.
    
    ASV (`bench_eval_type`, 11 TimeBench classes) shows no regression; 
per-bench geomean of after/before is 0.96-1.01. Representative run, 
`GroupedMapArrowUDFTimeBench`, `identity_udf`:
    
    | scenario | before | after |
    |---|---|---|
    | few_groups_sm | 5.01±0.08ms | 5.45±0.3ms |
    | few_groups_lg | 40.4±2ms | 39.1±0.3ms |
    | many_groups_sm | 162±4ms | 157±4ms |
    | many_groups_lg | 104±2ms | 103±0.9ms |
    | wide_cols | 147±2ms | 145±0.7ms |
    | multi_key | 28.9±0.7ms | 28.4±0.1ms |
    
    `few_groups_sm` does not reproduce in an interleaved `timeit` run of 
`time_worker` (4.68ms before vs 4.65ms after, median); the per-batch path is 
unchanged.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5.5
    
    This pull request and its description were written by Isaac.
    
    Closes #59776 from Yicong-Huang/single-udf-chain-in-handler.
    
    Authored-by: Yicong Huang <[email protected]>
    Signed-off-by: Yicong-Huang <[email protected]>
    (cherry picked from commit 834d036612dd5853ff0db69730ccee80b38f704d)
    Signed-off-by: Yicong-Huang <[email protected]>
---
 python/pyspark/eval_handlers/_arrow.py             | 176 +++++++++++----------
 python/pyspark/eval_handlers/_base.py              |  48 +++++-
 python/pyspark/eval_handlers/_elementwise.py       |  45 +++---
 python/pyspark/eval_handlers/_pandas.py            | 154 ++++++++++--------
 python/pyspark/eval_handlers/_stateful.py          |  86 +++++-----
 python/pyspark/eval_handlers/_typing.py            |   6 +-
 python/pyspark/eval_handlers/_udtf.py              |  71 +++++----
 python/pyspark/eval_handlers/_vanilla.py           |  11 +-
 .../tests/test_arrow_eval_type_handlers.py         |  41 +++--
 .../tests/test_base_eval_type_handlers.py          | 150 ++++++++++++++----
 .../tests/test_elementwise_eval_type_handlers.py   |   8 +-
 .../tests/test_pandas_eval_type_handlers.py        |  24 +--
 .../tests/test_stateful_eval_type_handlers.py      |   8 +-
 .../tests/test_udtf_eval_type_handlers.py          |  14 +-
 .../tests/test_vanilla_eval_type_handlers.py       |   8 +-
 python/pyspark/worker.py                           |  42 ++---
 16 files changed, 516 insertions(+), 376 deletions(-)

diff --git a/python/pyspark/eval_handlers/_arrow.py 
b/python/pyspark/eval_handlers/_arrow.py
index dd75347a0bf6..fb635c6b1fca 100644
--- a/python/pyspark/eval_handlers/_arrow.py
+++ b/python/pyspark/eval_handlers/_arrow.py
@@ -79,18 +79,18 @@ class 
ArrowCoGroupedMapUDFHandler(CoGroupedEvalTypeHandler["pa.RecordBatch"]):
 
     eval_type = PythonEvalType.SQL_COGROUPED_MAP_ARROW_UDF
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One COGROUPED_MAP_ARROW UDF expected here."
-        udf = udfs[0]
+        super().__init__(udf_descs, runner_conf, eval_conf)
         # signature was lost when wrapping it
-        self._num_udf_args = len(inspect.getfullargspec(udf.raw_func).args)
-        parsed_offsets = extract_key_value_indexes(udf.args_offsets)
+        self._num_udf_args = len(inspect.getfullargspec(self._raw_func).args)
+        parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
         self._left_key_cols, self._left_val_cols = parsed_offsets[0]
         self._right_key_cols, self._right_val_cols = parsed_offsets[1]
         self._arrow_return_schema = to_arrow_schema(
-            udf.return_type, timezone="UTC", 
prefers_large_types=runner_conf.use_large_var_types
+            self._udf_desc.return_type,
+            timezone="UTC",
+            prefers_large_types=runner_conf.use_large_var_types,
         )
 
     def run(self, split_index: int, data: Iterator[CoGroupedBatch]) -> 
Iterator[pa.RecordBatch]:
@@ -109,11 +109,11 @@ class 
ArrowCoGroupedMapUDFHandler(CoGroupedEvalTypeHandler["pa.RecordBatch"]):
             right_values = table_from_batches(right_batches, 
self._right_val_cols)
 
             if self._num_udf_args == 2:
-                result = self._funcs[0](left_values, right_values)
+                result = self._func(left_values, right_values)
             else:
                 key_table = left_keys if left_keys.num_rows > 0 else right_keys
                 key = tuple(c[0] for c in key_table.columns)
-                result = self._funcs[0](key, left_values, right_values)
+                result = self._func(key, left_values, right_values)
 
             verify_return_type(result, pa.Table)
             # Verify types (and reorder by name when configured).
@@ -136,21 +136,21 @@ class 
ArrowGroupedMapIterUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
     eval_type = PythonEvalType.SQL_GROUPED_MAP_ARROW_ITER_UDF
     _iter_based = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One GROUPED_MAP_ARROW_ITER UDF expected here."
-        udf = udfs[0]
+        super().__init__(udf_descs, runner_conf, eval_conf)
         # signature was lost when wrapping it
-        self._num_udf_args = len(inspect.getfullargspec(udf.raw_func).args)
-        parsed_offsets = extract_key_value_indexes(udf.args_offsets)
+        self._num_udf_args = len(inspect.getfullargspec(self._raw_func).args)
+        parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
         assert len(parsed_offsets) == 1, (
             "Expected one pair of offsets for GROUPED_MAP_ARROW_ITER UDF."
         )
         self._key_offsets = parsed_offsets[0][0]
         self._value_offsets = parsed_offsets[0][1]
         self._arrow_return_schema = to_arrow_schema(
-            udf.return_type, timezone="UTC", 
prefers_large_types=runner_conf.use_large_var_types
+            self._udf_desc.return_type,
+            timezone="UTC",
+            prefers_large_types=runner_conf.use_large_var_types,
         )
 
     def run(self, split_index: int, data: Iterator[GroupedBatch]) -> 
Iterator[pa.RecordBatch]:
@@ -179,10 +179,10 @@ class 
ArrowGroupedMapIterUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
 
             # Call UDF with iterator of batches
             if self._num_udf_args == 1:
-                result = self._funcs[0](value_batches)
+                result = self._func(value_batches)
             else:
                 key = tuple(c[0] for c in keys.columns)
-                result = self._funcs[0](key, value_batches)
+                result = self._func(key, value_batches)
 
             # Verify (and reorder by name when configured) each output batch
             for batch in verify_return_type(result, Iterator[pa.RecordBatch]):
@@ -206,19 +206,19 @@ class 
ArrowGroupedMapUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
 
     eval_type = PythonEvalType.SQL_GROUPED_MAP_ARROW_UDF
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One GROUPED_MAP_ARROW UDF expected here."
-        udf = udfs[0]
+        super().__init__(udf_descs, runner_conf, eval_conf)
         # signature was lost when wrapping it
-        self._num_udf_args = len(inspect.getfullargspec(udf.raw_func).args)
-        parsed_offsets = extract_key_value_indexes(udf.args_offsets)
+        self._num_udf_args = len(inspect.getfullargspec(self._raw_func).args)
+        parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
         assert len(parsed_offsets) == 1, "Expected one pair of offsets for 
GROUPED_MAP_ARROW UDF."
         self._key_offsets = parsed_offsets[0][0]
         self._value_offsets = parsed_offsets[0][1]
         self._arrow_return_schema = to_arrow_schema(
-            udf.return_type, timezone="UTC", 
prefers_large_types=runner_conf.use_large_var_types
+            self._udf_desc.return_type,
+            timezone="UTC",
+            prefers_large_types=runner_conf.use_large_var_types,
         )
 
     def run(self, split_index: int, data: Iterator[GroupedBatch]) -> 
Iterator[pa.RecordBatch]:
@@ -248,10 +248,10 @@ class 
ArrowGroupedMapUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
             # Call UDF
             value_table = pa.Table.from_batches(value_batches)
             if self._num_udf_args == 1:
-                result = self._funcs[0](value_table)
+                result = self._func(value_table)
             else:
                 key = tuple(c[0] for c in keys.columns)
-                result = self._funcs[0](key, value_table)
+                result = self._func(key, value_table)
 
             verify_return_type(result, pa.Table)
             # Verify types (and reorder by name when configured).
@@ -272,13 +272,16 @@ class 
ArrowGroupedAggUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
     coerced to the declared schema."""
 
     eval_type = PythonEvalType.SQL_GROUPED_AGG_ARROW_UDF
+    _multi_udf = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        self._col_names = ["_%d" % i for i in range(len(udfs))]
+        super().__init__(udf_descs, runner_conf, eval_conf)
+        self._col_names = ["_%d" % i for i in range(len(udf_descs))]
         self._return_schema = to_arrow_schema(
-            StructType([StructField(n, udf.return_type) for n, udf in 
zip(self._col_names, udfs)]),
+            StructType(
+                [StructField(n, udf.return_type) for n, udf in 
zip(self._col_names, udf_descs)]
+            ),
             timezone="UTC",
             prefers_large_types=runner_conf.use_large_var_types,
         )
@@ -293,7 +296,7 @@ class 
ArrowGroupedAggUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
                     *[concatenated.column(o) for o in udf.args_offsets],
                     **{k: concatenated.column(v) for k, v in 
udf.kwargs_offsets.items()},
                 )
-                for udf, func in zip(self._udfs, self._funcs)
+                for udf, func in zip(self._udf_descs, self._funcs)
             ]
             result_arrays = [pa.array([r]) for r in results]
             batch = pa.RecordBatch.from_arrays(result_arrays, self._col_names)
@@ -306,13 +309,11 @@ class 
ArrowGroupedAggIterUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
 
     eval_type = PythonEvalType.SQL_GROUPED_AGG_ARROW_ITER_UDF
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One GROUPED_AGG_ARROW_ITER UDF expected here."
-        udf = udfs[0]
+        super().__init__(udf_descs, runner_conf, eval_conf)
         self._return_schema = to_arrow_schema(
-            StructType([StructField("_0", udf.return_type)]),
+            StructType([StructField("_0", self._udf_desc.return_type)]),
             timezone="UTC",
             prefers_large_types=runner_conf.use_large_var_types,
         )
@@ -320,7 +321,7 @@ class 
ArrowGroupedAggIterUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
     def run(self, split_index: int, data: Iterator[GroupedBatch]) -> 
Iterator[pa.RecordBatch]:
         import pyarrow as pa
 
-        args_offsets = self._udfs[0].args_offsets
+        args_offsets = self._udf_desc.args_offsets
 
         def extract_args(batch: pa.RecordBatch) -> Any:
             args = tuple(batch.column(o) for o in args_offsets)
@@ -328,7 +329,7 @@ class 
ArrowGroupedAggIterUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
 
         for group in data:
             batch_iter = map(extract_args, group)
-            result = self._funcs[0](batch_iter)
+            result = self._func(batch_iter)
             # Drain remaining batches to maintain stream position
             for _ in batch_iter:
                 pass
@@ -343,14 +344,17 @@ class 
ArrowWindowAggUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
     UDF, coerced to the declared schema."""
 
     eval_type = PythonEvalType.SQL_WINDOW_AGG_ARROW_UDF
+    _multi_udf = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
         self._window_bound_types = runner_conf.window_bound_types
-        self._col_names = ["_%d" % i for i in range(len(udfs))]
+        self._col_names = ["_%d" % i for i in range(len(udf_descs))]
         self._return_schema = to_arrow_schema(
-            StructType([StructField(n, udf.return_type) for n, udf in 
zip(self._col_names, udfs)]),
+            StructType(
+                [StructField(n, udf.return_type) for n, udf in 
zip(self._col_names, udf_descs)]
+            ),
             timezone="UTC",
             prefers_large_types=runner_conf.use_large_var_types,
         )
@@ -363,7 +367,7 @@ class 
ArrowWindowAggUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
             num_rows = concatenated.num_rows
 
             result_arrays = []
-            for udf_index, (udf, udf_func) in enumerate(zip(self._udfs, 
self._funcs)):
+            for udf_index, (udf, udf_func) in enumerate(zip(self._udf_descs, 
self._funcs)):
                 args_offsets, kwargs_offsets = udf.args_offsets, 
udf.kwargs_offsets
                 bound_type = self._window_bound_types[udf_index]
                 if bound_type == "unbounded":
@@ -412,10 +416,11 @@ class 
ArrowGroupedAggIncrementalPartialUDFHandler(BatchEvalTypeHandler["pa.Recor
     compares unequal to itself) are merged downstream."""
 
     eval_type = PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF
+    _multi_udf = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
         # The leading `num_grouping_keys` input columns are the grouping keys 
(see the operator);
         # `grouping_key_schema` carries their names/types so the emitted key 
columns round-trip.
         grouping_key_schema = eval_conf.grouping_key_schema
@@ -425,8 +430,8 @@ class 
ArrowGroupedAggIncrementalPartialUDFHandler(BatchEvalTypeHandler["pa.Recor
 
         # The pickled function is the Aggregator object itself, whose 
zero/reduce/merge/finish
         # methods are called directly, so take it unwrapped rather than as a 
plain callable.
-        self._aggs = [udf.raw_func for udf in udfs]
-        self._buffer_col_names = ["_%d" % i for i in range(len(udfs))]
+        self._aggs = self._raw_funcs
+        self._buffer_col_names = ["_%d" % i for i in range(len(udf_descs))]
         self._buffer_arrow_types = [
             to_arrow_type(
                 agg.bufferSchema,
@@ -443,7 +448,7 @@ class 
ArrowGroupedAggIncrementalPartialUDFHandler(BatchEvalTypeHandler["pa.Recor
         # preserves that order). This mirrors how a Python call `f(*args, 
**kwargs)` would order
         # them into one value tuple.
         self._input_offsets_by_udf = [
-            list(udf.args_offsets) + list(udf.kwargs_offsets.values()) for udf 
in udfs
+            list(udf.args_offsets) + list(udf.kwargs_offsets.values()) for udf 
in udf_descs
         ]
         # Input columns actually consumed per batch: the leading grouping keys 
plus every
         # aggregator input, deduplicated. A UDF input may reuse a grouping-key 
column (the operator
@@ -540,18 +545,21 @@ class 
ArrowGroupedAggIncrementalFinalUDFHandler(GroupedEvalTypeHandler["pa.Recor
     by the operator) still produces ``finish(zero)``."""
 
     eval_type = PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF
+    _multi_udf = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        self._col_names = ["_%d" % i for i in range(len(udfs))]
+        super().__init__(udf_descs, runner_conf, eval_conf)
+        self._col_names = ["_%d" % i for i in range(len(udf_descs))]
         self._return_schema = to_arrow_schema(
-            StructType([StructField(n, udf.return_type) for n, udf in 
zip(self._col_names, udfs)]),
+            StructType(
+                [StructField(n, udf.return_type) for n, udf in 
zip(self._col_names, udf_descs)]
+            ),
             timezone="UTC",
             prefers_large_types=runner_conf.use_large_var_types,
         )
         # The Aggregator object itself, unwrapped (see 
ArrowGroupedAggIncrementalPartialUDFHandler).
-        self._aggs = [udf.raw_func for udf in udfs]
+        self._aggs = self._raw_funcs
         # Buffer field names are invariant across groups and batches; compute 
once per aggregator.
         self._field_names_by_udf = [[f.name for f in agg.bufferSchema.fields] 
for agg in self._aggs]
 
@@ -561,7 +569,7 @@ class 
ArrowGroupedAggIncrementalFinalUDFHandler(GroupedEvalTypeHandler["pa.Recor
         for group in data:
             merged: list = [None] * len(self._aggs)
             for batch in group:
-                for i, (udf, agg) in enumerate(zip(self._udfs, self._aggs)):
+                for i, (udf, agg) in enumerate(zip(self._udf_descs, 
self._aggs)):
                     field_names = self._field_names_by_udf[i]
                     m = merged[i]
                     for row in batch.column(udf.args_offsets[0]).to_pylist():
@@ -592,19 +600,22 @@ class 
ArrowWindowAggIncrementalUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch
     intermediate buffer never leaves the worker; ``merge`` is not used here."""
 
     eval_type = PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF
+    _multi_udf = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
         self._window_bound_types = runner_conf.window_bound_types
-        self._col_names = ["_%d" % i for i in range(len(udfs))]
+        self._col_names = ["_%d" % i for i in range(len(udf_descs))]
         self._return_schema = to_arrow_schema(
-            StructType([StructField(n, udf.return_type) for n, udf in 
zip(self._col_names, udfs)]),
+            StructType(
+                [StructField(n, udf.return_type) for n, udf in 
zip(self._col_names, udf_descs)]
+            ),
             timezone="UTC",
             prefers_large_types=runner_conf.use_large_var_types,
         )
         # The Aggregator object itself, unwrapped (see 
ArrowGroupedAggIncrementalPartialUDFHandler).
-        self._aggs = [udf.raw_func for udf in udfs]
+        self._aggs = self._raw_funcs
 
     def run(self, split_index: int, data: Iterator[GroupedBatch]) -> 
Iterator[pa.RecordBatch]:
         import pyarrow as pa
@@ -621,7 +632,7 @@ class 
ArrowWindowAggIncrementalUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch
             num_rows = concatenated.num_rows
 
             result_arrays = []
-            for udf_index, (udf, agg) in enumerate(zip(self._udfs, 
self._aggs)):
+            for udf_index, (udf, agg) in enumerate(zip(self._udf_descs, 
self._aggs)):
                 args_offsets, kwargs_offsets = udf.args_offsets, 
udf.kwargs_offsets
                 bound_type = self._window_bound_types[udf_index]
                 result_type = self._return_schema.field(udf_index).type
@@ -679,10 +690,9 @@ class 
ArrowMapUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
     eval_type = PythonEvalType.SQL_MAP_ARROW_ITER_UDF
     _iter_based = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One MAP_ARROW_ITER UDF expected here."
+        super().__init__(udf_descs, runner_conf, eval_conf)
 
     def run(self, split_index: int, data: Iterator[pa.RecordBatch]) -> 
Iterator[pa.RecordBatch]:
         import pyarrow as pa
@@ -691,7 +701,7 @@ class 
ArrowMapUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
         input_batches = map(ArrowBatchTransformer.flatten_struct, data)
 
         # invoke the UDF
-        output_batches = self._funcs[0](input_batches)
+        output_batches = self._func(input_batches)
 
         # The declared signature is Iterator[...], so a strict iterator is 
required by
         # default. With the legacy flag, accept any object Python can iterate 
over -- via
@@ -720,19 +730,19 @@ class 
ArrowScalarIterUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
     eval_type = PythonEvalType.SQL_SCALAR_ARROW_ITER_UDF
     _iter_based = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One SCALAR_ARROW_ITER UDF expected here."
-        udf = udfs[0]
+        super().__init__(udf_descs, runner_conf, eval_conf)
         self._arrow_return_type = to_arrow_type(
-            udf.return_type, timezone="UTC", 
prefers_large_types=runner_conf.use_large_var_types
+            self._udf_desc.return_type,
+            timezone="UTC",
+            prefers_large_types=runner_conf.use_large_var_types,
         )
 
     def run(self, split_index: int, data: Iterator[pa.RecordBatch]) -> 
Iterator[pa.RecordBatch]:
         import pyarrow as pa
 
-        args_offsets = self._udfs[0].args_offsets
+        args_offsets = self._udf_desc.args_offsets
         num_input_rows = 0
 
         def extract_args(batch: pa.RecordBatch) -> Any:
@@ -745,7 +755,7 @@ class 
ArrowScalarIterUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
         args_iter = map(extract_args, data)
 
         # Call UDF and verify result type (iterator of pa.Array)
-        verified_iter = verify_return_type(self._funcs[0](args_iter), 
Iterator[pa.Array])
+        verified_iter = verify_return_type(self._func(args_iter), 
Iterator[pa.Array])
 
         # Process results: enforce schema and assemble into RecordBatch
         target_schema = pa.schema([pa.field("_0", self._arrow_return_type)])
@@ -773,13 +783,16 @@ class 
ArrowScalarUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
     the result to the declared schema, and check the row count."""
 
     eval_type = PythonEvalType.SQL_SCALAR_ARROW_UDF
+    _multi_udf = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        self._col_names = ["_%d" % i for i in range(len(udfs))]
+        super().__init__(udf_descs, runner_conf, eval_conf)
+        self._col_names = ["_%d" % i for i in range(len(udf_descs))]
         self._combined_arrow_schema = to_arrow_schema(
-            StructType([StructField(n, udf.return_type) for n, udf in 
zip(self._col_names, udfs)]),
+            StructType(
+                [StructField(n, udf.return_type) for n, udf in 
zip(self._col_names, udf_descs)]
+            ),
             timezone="UTC",
             prefers_large_types=runner_conf.use_large_var_types,
         )
@@ -794,7 +807,7 @@ class 
ArrowScalarUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
                         *[batch.column(o) for o in udf.args_offsets],
                         **{k: batch.column(v) for k, v in 
udf.kwargs_offsets.items()},
                     )
-                    for udf, func in zip(self._udfs, self._funcs)
+                    for udf, func in zip(self._udf_descs, self._funcs)
                 ],
                 self._col_names,
             )
@@ -813,16 +826,17 @@ class 
ArrowBatchedUDFArrowHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
     eval_type = PythonEvalType.SQL_ARROW_BATCHED_UDF
     variant_conf = "use_legacy_pandas_udf_conversion"
     variant_value = False
+    _multi_udf = True
     # run wraps the row-by-row evaluation in fail_on_stopiteration itself.
     _fail_on_stopiteration = False
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
 
         # --- UDF preparation ---
         self._udf_infos = []
-        for udf, func in zip(udfs, self._funcs):
+        for udf, func in zip(udf_descs, self._funcs):
             wrapped_func, args_kwargs_offsets = wrap_kwargs_support(
                 func, udf.args_offsets, udf.kwargs_offsets
             )
@@ -845,7 +859,7 @@ class 
ArrowBatchedUDFArrowHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
                 )
             )
         assert self._udf_infos, "At least one Arrow UDF expected here."
-        self._col_names = [f"_{i}" for i in range(len(udfs))]
+        self._col_names = [f"_{i}" for i in range(len(udf_descs))]
 
         # --- Input preparation ---
         self._arrow_to_py_converters = [
diff --git a/python/pyspark/eval_handlers/_base.py 
b/python/pyspark/eval_handlers/_base.py
index 327f5189c4cd..98e9c91b6830 100644
--- a/python/pyspark/eval_handlers/_base.py
+++ b/python/pyspark/eval_handlers/_base.py
@@ -21,10 +21,12 @@ This leaf module imports only ``_typing``, the serializers 
and ``pyspark.util``,
 package ``__init__`` and the concrete-handler submodules can both import it.
 """
 
+import functools
 from abc import ABCMeta, abstractmethod
 from collections.abc import Iterator
 from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Optional
 
+from pyspark.errors import PySparkRuntimeError
 from pyspark.eval_handlers._typing import (
     UDF,
     UDTF,
@@ -39,7 +41,7 @@ from pyspark.sql.pandas.serializers import (
     ArrowStreamGroupSerializer,
     ArrowStreamSerializer,
 )
-from pyspark.util import fail_on_stopiteration
+from pyspark.util import PythonEvalType, fail_on_stopiteration
 
 if TYPE_CHECKING:
     import pyarrow as pa  # noqa: F401  # only in the batch category's 
forward-ref subscript
@@ -153,24 +155,56 @@ class EvalTypeHandler(Generic[InputBatch, OutputBatch], 
metaclass=_EvalTypeHandl
     # Whether __init__ wraps the UDF in fail_on_stopiteration, so a 
StopIteration raised by
     # the UDF call fails with STOP_ITERATION_OCCURRED instead of PEP 479's 
generic RuntimeError.
     _fail_on_stopiteration: ClassVar[bool] = True
+    # Whether the handler takes any number of UDFs. Otherwise it takes exactly 
one UDF or UDTF:
+    # __init__ exposes a UDF as _udf_desc and its functions as _raw_func and 
_func, and
+    # UDTFHandlerBase exposes a UDTF as _udtf_desc.
+    _multi_udf: ClassVar[bool] = False
 
     def __init__(
-        self, udfs: "list[UDF] | list[UDTF]", runner_conf: "RunnerConf", 
eval_conf: "EvalConf"
+        self, udf_descs: "list[UDF] | list[UDTF]", runner_conf: "RunnerConf", 
eval_conf: "EvalConf"
     ) -> None:
-        self._udfs: list[Any] = udfs
+        if not self._multi_udf and len(udf_descs) != 1:
+            raise PySparkRuntimeError(
+                "One {} expected, but got {}.".format(self._expected_udf(), 
len(udf_descs))
+            )
+        self._udf_descs: list[Any] = udf_descs
         self._runner_conf = runner_conf
         self._eval_conf = eval_conf
-        # Each UDF's function wrapped for profiling and, unless 
_fail_on_stopiteration is False,
-        # in fail_on_stopiteration, at the indexes of _udfs; empty for a UDTF.
+        # Each UDF's functions chained into one before any wrapping, at the 
indexes of _udf_descs: a
+        # single function is the pickled object itself (an incremental 
Aggregator) with its
+        # signature. Empty for a UDTF.
+        self._raw_funcs: list[Any] = []
+        # Each raw function wrapped for profiling and, unless 
_fail_on_stopiteration is False,
+        # in fail_on_stopiteration, at the indexes of _udf_descs; empty for a 
UDTF.
         self._funcs: list[Callable[..., Any]] = []
-        for udf in udfs:
+        for udf in udf_descs:
             if isinstance(udf, UDF):
-                profiling_func = self._with_profiler(udf.raw_func, 
udf.result_id)
+                raw_func = functools.reduce(lambda f, g: lambda *a: g(f(*a)), 
udf.funcs)
+                self._raw_funcs.append(raw_func)
+                profiling_func = self._with_profiler(raw_func, udf.result_id)
                 self._funcs.append(
                     fail_on_stopiteration(profiling_func)
                     if self._fail_on_stopiteration
                     else profiling_func
                 )
+        if not self._multi_udf and isinstance(udf_descs[0], UDF):
+            self._udf_desc: UDF = udf_descs[0]
+            self._raw_func: Any = self._raw_funcs[0]
+            self._func: Callable[..., Any] = self._funcs[0]
+
+    def _expected_udf(self) -> str:
+        """What a single-UDF handler takes, as named when it is given another 
number of UDFs:
+        its eval type without the ``SQL_`` prefix and ``_UDF`` suffix, e.g. 
``MAP_ARROW_ITER
+        UDF``."""
+        name = next(
+            (
+                k
+                for k, v in vars(PythonEvalType).items()
+                if k.startswith("SQL_") and v == self.eval_type
+            ),
+            type(self).__name__,
+        )
+        return "{} UDF".format(name.removeprefix("SQL_").removesuffix("_UDF"))
 
     def _with_profiler(self, f: Callable[..., Any], result_id: int) -> 
Callable[..., Any]:
         """Return ``f`` profiled by the profiler its config selects, or ``f`` 
itself when none is
diff --git a/python/pyspark/eval_handlers/_elementwise.py 
b/python/pyspark/eval_handlers/_elementwise.py
index 7646b8254026..6c8e0e9a6e6f 100644
--- a/python/pyspark/eval_handlers/_elementwise.py
+++ b/python/pyspark/eval_handlers/_elementwise.py
@@ -179,12 +179,13 @@ class 
ArrowElementwiseUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
     per element tuple, convert the results to the declared Arrow type and 
re-nest them."""
 
     eval_type = PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF
+    _multi_udf = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         # 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`.
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
 
         # Element-wise UDFs back higher-order lambdas like transform(arr, x -> 
udf(x)).
         # ExtractPythonUDFFromLambda rewrites them so the UDF receives *all* 
array elements
@@ -197,7 +198,7 @@ class 
ArrowElementwiseUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
         # UDF preparation
         nesting = eval_conf.elementwise_nesting
         self._udf_infos = []
-        for udf_index, udf in enumerate(udfs):
+        for udf_index, udf in enumerate(udf_descs):
             udf_return_type = udf.return_type
             wrapped_func, args_kwargs_offsets = wrap_kwargs_support(
                 self._funcs[udf_index], udf.args_offsets, udf.kwargs_offsets
@@ -235,7 +236,7 @@ class 
ArrowElementwiseUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
                     ),
                 )
             )
-        self._col_names = [f"_{i}" for i in range(len(udfs))]
+        self._col_names = [f"_{i}" for i in range(len(udf_descs))]
 
     def run(self, split_index: int, data: Iterator[pa.RecordBatch]) -> 
Iterator[pa.RecordBatch]:
         import pyarrow as pa
@@ -306,9 +307,9 @@ class 
_VectorizedElementwiseUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
     whole flat columns. The conversions below are the Arrow flavor 
(``pa.Array`` in and out);
     ``_PandasElementwiseUDFHandler`` overrides them for pandas."""
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
 
     def _to_udf_inputs(self, flat_batch: pa.RecordBatch, input_schema: 
StructType) -> list[Any]:
         """Adapt one flattened input batch (one aligned leaf Array per 
argument) to the UDF's
@@ -386,12 +387,14 @@ class 
_ScalarElementwiseUDFHandler(_VectorizedElementwiseUDFHandler):
     ``array<R>`` using the input's offsets - one row in, one row out, one 
Python round trip per
     batch. Subclasses supply the flavor's conversions and result check."""
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
-        super().__init__(udfs, runner_conf, eval_conf)
+    _multi_udf = True
+
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
+        super().__init__(udf_descs, runner_conf, eval_conf)
 
         nesting = eval_conf.elementwise_nesting
         self._udf_infos = []
-        for udf_index, udf in enumerate(udfs):
+        for udf_index, udf in enumerate(udf_descs):
             udf_return_type = udf.return_type
             wrapped_func, args_kwargs_offsets = wrap_kwargs_support(
                 self._funcs[udf_index], udf.args_offsets, udf.kwargs_offsets
@@ -413,7 +416,7 @@ class 
_ScalarElementwiseUDFHandler(_VectorizedElementwiseUDFHandler):
                     udf_input_schema,
                 )
             )
-        self._col_names = [f"_{i}" for i in range(len(udfs))]
+        self._col_names = [f"_{i}" for i in range(len(udf_descs))]
 
     @abstractmethod
     def _verify_result(self, result: Any, return_type: DataType, num_elements: 
int) -> None:
@@ -512,12 +515,10 @@ class 
_ScalarIterElementwiseUDFHandler(_VectorizedElementwiseUDFHandler):
 
     _iter_based = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
-        super().__init__(udfs, runner_conf, eval_conf)
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
+        super().__init__(udf_descs, runner_conf, eval_conf)
 
-        assert len(udfs) == 1, "One SCALAR_*_ITER_ELEMENTWISE UDF expected 
here."
-        udf = udfs[0]
-        assert not udf.kwargs_offsets, "Iterator UDFs do not take keyword 
arguments."
+        assert not self._udf_desc.kwargs_offsets, "Iterator UDFs do not take 
keyword arguments."
 
         # Each argument arrives as ``array^depth<T>``; the vectorized function 
must see the leaf
         # element type ``T`` (arguments may differ, e.g. an outer column 
repeated into an aligned
@@ -525,9 +526,11 @@ class 
_ScalarIterElementwiseUDFHandler(_VectorizedElementwiseUDFHandler):
         nesting = eval_conf.elementwise_nesting
         self._depth = nesting[0] if nesting is not None else 1
         self._udf_input_schema = _elementwise_input_schema(
-            eval_conf.input_type, udf.args_offsets, self._depth
+            eval_conf.input_type, self._udf_desc.args_offsets, self._depth
+        )
+        self._udf_output_schema = _elementwise_output_schema(
+            self._udf_desc.return_type, runner_conf
         )
-        self._udf_output_schema = _elementwise_output_schema(udf.return_type, 
runner_conf)
 
     @abstractmethod
     def _verify_return_type(self, results: Any) -> Iterator[Any]:
@@ -549,7 +552,7 @@ class 
_ScalarIterElementwiseUDFHandler(_VectorizedElementwiseUDFHandler):
             # shapes re-nest this batch's rows. The user function sees the 
flat leaves as a
             # pandas Series / DataFrame (pandas) or a pa.Array (Arrow), each 
with its leaf type.
             flat_batch, list_lengths_by_level, large_list_by_level = 
_elementwise_flatten_inputs(
-                batch, self._udfs[0].args_offsets, self._depth
+                batch, self._udf_desc.args_offsets, self._depth
             )
             pending_shapes.append((list_lengths_by_level, large_list_by_level, 
flat_batch.num_rows))
             num_input_elements += flat_batch.num_rows
@@ -558,7 +561,7 @@ class 
_ScalarIterElementwiseUDFHandler(_VectorizedElementwiseUDFHandler):
 
         flat_args_iter = map(extract_flat, data)
 
-        verified_iter = 
self._verify_return_type(self._funcs[0](flat_args_iter))
+        verified_iter = self._verify_return_type(self._func(flat_args_iter))
 
         # Buffer the streamed flat element results and emit an ``array<R>`` 
row as soon as the
         # shape at the head of the FIFO is fully covered. A row whose length 
is 0 (an empty
@@ -601,7 +604,7 @@ class 
_ScalarIterElementwiseUDFHandler(_VectorizedElementwiseUDFHandler):
             nonlocal pending_chunks, pending_len, empty_schema, 
num_output_elements
             for result in verified_iter:
                 chunk = self._to_flat_batch(
-                    result, self._udfs[0].return_type, self._udf_output_schema
+                    result, self._udf_desc.return_type, self._udf_output_schema
                 )
                 num_output_elements += chunk.num_rows
                 # Fail fast if the UDF over-produces, before the buffer grows 
unbounded (the
@@ -658,7 +661,7 @@ class PandasScalarIterElementwiseUDFHandler(
 
         pandas_iter_type = (
             Iterator[pd.DataFrame]
-            if isinstance(self._udfs[0].return_type, StructType)
+            if isinstance(self._udf_desc.return_type, StructType)
             else Iterator[pd.Series]
         )
         return verify_return_type(results, pandas_iter_type)
diff --git a/python/pyspark/eval_handlers/_pandas.py 
b/python/pyspark/eval_handlers/_pandas.py
index a9215e95bb21..f5301e9e74b1 100644
--- a/python/pyspark/eval_handlers/_pandas.py
+++ b/python/pyspark/eval_handlers/_pandas.py
@@ -75,13 +75,16 @@ class 
PandasScalarUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
     ``pyspark.sql.conversion`` directly with the runner_conf-derived 
parameters."""
 
     eval_type = PythonEvalType.SQL_SCALAR_PANDAS_UDF
+    _multi_udf = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf") -> None:
+    def __init__(
+        self, udf_descs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf"
+    ) -> None:
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
         self._return_schema = StructType(
-            [StructField("_%d" % i, udf.return_type) for i, udf in 
enumerate(udfs)]
+            [StructField("_%d" % i, udf.return_type) for i, udf in 
enumerate(udf_descs)]
         )
 
     def run(self, split_index: int, data: "Iterator[pa.RecordBatch]") -> 
"Iterator[pa.RecordBatch]":
@@ -102,7 +105,7 @@ class 
PandasScalarUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
 
             # Process: evaluate each UDF column-wise on pandas Series.
             results = []
-            for udf, func in zip(self._udfs, self._funcs):
+            for udf, func in zip(self._udf_descs, self._funcs):
                 return_type = udf.return_type
                 result = func(
                     *[pandas_columns[o] for o in udf.args_offsets],
@@ -151,18 +154,18 @@ class 
PandasScalarIterUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
     eval_type = PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF
     _iter_based = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf") -> None:
+    def __init__(
+        self, udf_descs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf"
+    ) -> None:
         import pandas as pd
 
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One SCALAR_PANDAS_ITER UDF expected here."
-        udf = udfs[0]
-        self._return_schema = StructType([StructField("_0", udf.return_type)])
+        super().__init__(udf_descs, runner_conf, eval_conf)
+        self._return_schema = StructType([StructField("_0", 
self._udf_desc.return_type)])
         self._expected_iter_type = (
             Iterator[pd.DataFrame]
-            if isinstance(udf.return_type, StructType)
+            if isinstance(self._udf_desc.return_type, StructType)
             else Iterator[pd.Series]
         )
 
@@ -180,19 +183,19 @@ class 
PandasScalarIterUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
                 prefer_int_ext_dtype=self._runner_conf.prefer_int_ext_dtype,
                 df_for_struct=True,
             )
-            args = tuple(pandas_columns[o] for o in self._udfs[0].args_offsets)
+            args = tuple(pandas_columns[o] for o in 
self._udf_desc.args_offsets)
             num_input_rows += batch.num_rows
             return args[0] if len(args) == 1 else args
 
         # Extract args from input batches (streaming), then call the UDF and 
verify the
         # result type (iterator of pd.Series / pd.DataFrame).
         args_iter = map(extract_args, data)
-        verified_iter = verify_return_type(self._funcs[0](args_iter), 
self._expected_iter_type)
+        verified_iter = verify_return_type(self._func(args_iter), 
self._expected_iter_type)
 
         def convert(result: Any) -> "pa.RecordBatch":
             verify_pandas_result(
                 result,
-                self._udfs[0].return_type,
+                self._udf_desc.return_type,
                 assign_cols_by_name=True,
                 truncate_return_schema=True,
             )
@@ -224,17 +227,18 @@ class 
PandasMapUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
     eval_type = PythonEvalType.SQL_MAP_PANDAS_ITER_UDF
     _iter_based = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf") -> None:
+    def __init__(
+        self, udf_descs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf"
+    ) -> None:
         import pandas as pd
 
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One MAP_PANDAS_ITER UDF expected here."
-        self._return_schema = StructType([StructField("_0", 
udfs[0].return_type)])
+        super().__init__(udf_descs, runner_conf, eval_conf)
+        self._return_schema = StructType([StructField("_0", 
self._udf_desc.return_type)])
         self._expected_iter_type = (
             Iterator[pd.DataFrame]
-            if isinstance(udfs[0].return_type, StructType)
+            if isinstance(self._udf_desc.return_type, StructType)
             else Iterator[pd.Series]
         )
 
@@ -250,7 +254,7 @@ class 
PandasMapUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
                     df_for_struct=True,
                 )[0]
 
-        result = self._funcs[0](dataframe_iter())
+        result = self._func(dataframe_iter())
         # The declared signature is Iterator[...], so a strict iterator is 
required by
         # default. With the legacy flag, accept any object Python can iterate 
over -- via
         # iter(...), which honors both __iter__ and the sequence protocol 
(__getitem__) --
@@ -267,7 +271,10 @@ class 
PandasMapUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
 
         for df in verify_return_type(result, self._expected_iter_type):
             verify_pandas_result(
-                df, self._udfs[0].return_type, assign_cols_by_name=True, 
truncate_return_schema=True
+                df,
+                self._udf_desc.return_type,
+                assign_cols_by_name=True,
+                truncate_return_schema=True,
             )
             yield PandasToArrowConversion.from_pandas(
                 [df],
@@ -289,18 +296,18 @@ class 
PandasCoGroupedMapUDFHandler(CoGroupedEvalTypeHandler["pa.RecordBatch"]):
 
     eval_type = PythonEvalType.SQL_COGROUPED_MAP_PANDAS_UDF
 
-    def __init__(self, udfs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf") -> None:
+    def __init__(
+        self, udf_descs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf"
+    ) -> None:
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One COGROUPED_MAP_PANDAS UDF expected here."
-        udf = udfs[0]
+        super().__init__(udf_descs, runner_conf, eval_conf)
         # signature was lost when wrapping it
-        self._num_udf_args = len(inspect.getfullargspec(udf.raw_func).args)
-        parsed_offsets = extract_key_value_indexes(udf.args_offsets)
+        self._num_udf_args = len(inspect.getfullargspec(self._raw_func).args)
+        parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
         self._left_key_offsets, self._left_value_offsets = parsed_offsets[0]
         self._right_key_offsets, self._right_value_offsets = parsed_offsets[1]
-        self._return_schema = StructType([StructField("_0", udf.return_type)])
+        self._return_schema = StructType([StructField("_0", 
self._udf_desc.return_type)])
 
     def run(self, split_index: int, data: "Iterator[CoGroupedBatch]") -> 
"Iterator[pa.RecordBatch]":
         """Apply cogroupBy Pandas UDF.
@@ -329,7 +336,7 @@ class 
PandasCoGroupedMapUDFHandler(CoGroupedEvalTypeHandler["pa.RecordBatch"]):
             right_df = pd.concat([right_series[o] for o in 
self._right_value_offsets], axis=1)
 
             if self._num_udf_args == 2:
-                result = self._funcs[0](left_df, right_df)
+                result = self._func(left_df, right_df)
             else:
                 key_series = (
                     [left_series[o] for o in self._left_key_offsets]
@@ -337,14 +344,14 @@ class 
PandasCoGroupedMapUDFHandler(CoGroupedEvalTypeHandler["pa.RecordBatch"]):
                     else [right_series[o] for o in self._right_key_offsets]
                 )
                 key = tuple(s.iloc[0] for s in key_series)
-                result = self._funcs[0](key, left_df, right_df)
+                result = self._func(key, left_df, right_df)
 
             del left_batches, right_batches, left_table, right_table
             del left_series, right_series, left_df, right_df
 
             verify_pandas_result(
                 result,
-                self._udfs[0].return_type,
+                self._udf_desc.return_type,
                 self._runner_conf.assign_cols_by_name,
                 truncate_return_schema=False,
             )
@@ -371,20 +378,20 @@ class 
PandasGroupedMapIterUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
     eval_type = PythonEvalType.SQL_GROUPED_MAP_PANDAS_ITER_UDF
     _iter_based = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf") -> None:
+    def __init__(
+        self, udf_descs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf"
+    ) -> None:
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One GROUPED_MAP_PANDAS_ITER UDF expected here."
-        udf = udfs[0]
+        super().__init__(udf_descs, runner_conf, eval_conf)
         # signature was lost when wrapping it
-        self._num_udf_args = len(inspect.getfullargspec(udf.raw_func).args)
-        parsed_offsets = extract_key_value_indexes(udf.args_offsets)
+        self._num_udf_args = len(inspect.getfullargspec(self._raw_func).args)
+        parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
         assert len(parsed_offsets) == 1, (
             "Expected one pair of offsets for GROUPED_MAP_PANDAS_ITER UDF."
         )
         self._key_offsets, self._value_offsets = parsed_offsets[0]
-        self._return_schema = StructType([StructField("_0", udf.return_type)])
+        self._return_schema = StructType([StructField("_0", 
self._udf_desc.return_type)])
 
     def run(self, split_index: int, data: "Iterator[GroupedBatch]") -> 
"Iterator[pa.RecordBatch]":
         """Apply groupBy Pandas UDF (iterator variant)."""
@@ -412,15 +419,15 @@ class 
PandasGroupedMapIterUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
                     yield pd.concat([series[o] for o in value_offsets], axis=1)
 
             if self._num_udf_args == 1:
-                result = self._funcs[0](dataframe_iter())
+                result = self._func(dataframe_iter())
             else:
                 key = tuple(first_series[o].iloc[0] for o in self._key_offsets)
-                result = self._funcs[0](key, dataframe_iter())
+                result = self._func(key, dataframe_iter())
 
             for df in result:
                 verify_pandas_result(
                     df,
-                    self._udfs[0].return_type,
+                    self._udf_desc.return_type,
                     self._runner_conf.assign_cols_by_name,
                     truncate_return_schema=False,
                 )
@@ -451,18 +458,18 @@ class 
PandasGroupedMapUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
 
     eval_type = PythonEvalType.SQL_GROUPED_MAP_PANDAS_UDF
 
-    def __init__(self, udfs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf") -> None:
+    def __init__(
+        self, udf_descs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf"
+    ) -> None:
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One GROUPED_MAP_PANDAS UDF expected here."
-        udf = udfs[0]
+        super().__init__(udf_descs, runner_conf, eval_conf)
         # signature was lost when wrapping it
-        self._num_udf_args = len(inspect.getfullargspec(udf.raw_func).args)
-        parsed_offsets = extract_key_value_indexes(udf.args_offsets)
+        self._num_udf_args = len(inspect.getfullargspec(self._raw_func).args)
+        parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
         assert len(parsed_offsets) == 1, "Expected one pair of offsets for 
GROUPED_MAP_PANDAS UDF."
         self._key_offsets, self._value_offsets = parsed_offsets[0]
-        self._return_schema = StructType([StructField("_0", udf.return_type)])
+        self._return_schema = StructType([StructField("_0", 
self._udf_desc.return_type)])
 
     def run(self, split_index: int, data: "Iterator[GroupedBatch]") -> 
"Iterator[pa.RecordBatch]":
         # Bound each output RecordBatch toward the byte cap (no limit when 
unset).
@@ -498,16 +505,16 @@ class 
PandasGroupedMapUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
             value_df = pd.concat([all_series[o] for o in self._value_offsets], 
axis=1)
 
             if self._num_udf_args == 1:
-                result = self._funcs[0](value_df)
+                result = self._func(value_df)
             else:
                 key = tuple(all_series[o].iloc[0] for o in self._key_offsets)
-                result = self._funcs[0](key, value_df)
+                result = self._func(key, value_df)
 
             del all_batches, table, all_series, value_df
 
             verify_pandas_result(
                 result,
-                self._udfs[0].return_type,
+                self._udf_desc.return_type,
                 self._runner_conf.assign_cols_by_name,
                 truncate_return_schema=False,
             )
@@ -532,13 +539,16 @@ class 
PandasGroupedAggUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
     ArrowGroupedAggUDFHandler."""
 
     eval_type = PythonEvalType.SQL_GROUPED_AGG_PANDAS_UDF
+    _multi_udf = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf") -> None:
+    def __init__(
+        self, udf_descs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf"
+    ) -> None:
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
         self._return_schema = StructType(
-            [StructField("_%d" % i, udf.return_type) for i, udf in 
enumerate(udfs)]
+            [StructField("_%d" % i, udf.return_type) for i, udf in 
enumerate(udf_descs)]
         )
 
     def run(self, split_index: int, data: "Iterator[GroupedBatch]") -> 
"Iterator[pa.RecordBatch]":
@@ -560,7 +570,7 @@ class 
PandasGroupedAggUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
                     *[all_series[o] for o in udf.args_offsets],
                     **{k: all_series[v] for k, v in 
udf.kwargs_offsets.items()},
                 )
-                for udf, func in zip(self._udfs, self._funcs)
+                for udf, func in zip(self._udf_descs, self._funcs)
             ]
             yield PandasToArrowConversion.from_pandas(
                 [pd.Series([r]) for r in results],
@@ -582,18 +592,18 @@ class 
PandasGroupedAggIterUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
 
     eval_type = PythonEvalType.SQL_GROUPED_AGG_PANDAS_ITER_UDF
 
-    def __init__(self, udfs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf") -> None:
+    def __init__(
+        self, udf_descs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf"
+    ) -> None:
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One GROUPED_AGG_PANDAS_ITER UDF expected here."
-        udf = udfs[0]
-        self._return_schema = StructType([StructField("_0", udf.return_type)])
+        super().__init__(udf_descs, runner_conf, eval_conf)
+        self._return_schema = StructType([StructField("_0", 
self._udf_desc.return_type)])
 
     def run(self, split_index: int, data: "Iterator[GroupedBatch]") -> 
"Iterator[pa.RecordBatch]":
         import pandas as pd
 
-        args_offsets = self._udfs[0].args_offsets
+        args_offsets = self._udf_desc.args_offsets
 
         def extract_series(batch: "pa.RecordBatch") -> Any:
             # Convert one RecordBatch to a pandas Series per column, then 
select args:
@@ -609,7 +619,7 @@ class 
PandasGroupedAggIterUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
 
         for group in data:
             series_iter = map(extract_series, group)
-            result = self._funcs[0](series_iter)
+            result = self._func(series_iter)
             # Drain remaining batches to maintain stream position
             for _ in series_iter:
                 pass
@@ -632,14 +642,17 @@ class 
PandasWindowAggUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
     converted back to Arrow. The pandas counterpart of 
ArrowWindowAggUDFHandler."""
 
     eval_type = PythonEvalType.SQL_WINDOW_AGG_PANDAS_UDF
+    _multi_udf = True
 
-    def __init__(self, udfs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf") -> None:
+    def __init__(
+        self, udf_descs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf"
+    ) -> None:
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
         self._window_bound_types = runner_conf.window_bound_types
         self._return_schema = StructType(
-            [StructField("_%d" % i, udf.return_type) for i, udf in 
enumerate(udfs)]
+            [StructField("_%d" % i, udf.return_type) for i, udf in 
enumerate(udf_descs)]
         )
 
     def run(self, split_index: int, data: "Iterator[GroupedBatch]") -> 
"Iterator[pa.RecordBatch]":
@@ -659,7 +672,7 @@ class 
PandasWindowAggUDFHandler(GroupedEvalTypeHandler["pa.RecordBatch"]):
             num_rows = table.num_rows
 
             result_series = []
-            for udf_index, (udf, udf_func) in enumerate(zip(self._udfs, 
self._funcs)):
+            for udf_index, (udf, udf_func) in enumerate(zip(self._udf_descs, 
self._funcs)):
                 args_offsets, kwargs_offsets = udf.args_offsets, 
udf.kwargs_offsets
                 bound_type = self._window_bound_types[udf_index]
                 if bound_type == "unbounded":
@@ -725,17 +738,20 @@ class 
ArrowBatchedUDFLegacyPandasHandler(BatchEvalTypeHandler["pa.RecordBatch"])
     eval_type = PythonEvalType.SQL_ARROW_BATCHED_UDF
     variant_conf = "use_legacy_pandas_udf_conversion"
     variant_value = True
+    _multi_udf = True
     # run wraps the row-by-row evaluation in fail_on_stopiteration itself.
     _fail_on_stopiteration = False
 
-    def __init__(self, udfs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf") -> None:
+    def __init__(
+        self, udf_descs: list[UDF], runner_conf: "RunnerConf", eval_conf: 
"EvalConf"
+    ) -> None:
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
 
         # --- UDF preparation ---
         self._udf_infos = []
-        for udf, func in zip(udfs, self._funcs):
+        for udf, func in zip(udf_descs, self._funcs):
             udf_return_type = udf.return_type
             wrapped_func, args_kwargs_offsets = wrap_kwargs_support(
                 func, udf.args_offsets, udf.kwargs_offsets
@@ -758,7 +774,7 @@ class 
ArrowBatchedUDFLegacyPandasHandler(BatchEvalTypeHandler["pa.RecordBatch"])
                     coerce,
                 )
             )
-        col_names = [f"_{i}" for i in range(len(udfs))]
+        col_names = [f"_{i}" for i in range(len(udf_descs))]
         self._return_schema = StructType(
             [StructField(name, info[3]) for name, info in zip(col_names, 
self._udf_infos)]
         )
diff --git a/python/pyspark/eval_handlers/_stateful.py 
b/python/pyspark/eval_handlers/_stateful.py
index 87f38371cf6c..56503ba9eef0 100644
--- a/python/pyspark/eval_handlers/_stateful.py
+++ b/python/pyspark/eval_handlers/_stateful.py
@@ -98,16 +98,14 @@ class 
PandasGroupedMapWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]
         ]
     )
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-
-        assert len(udfs) == 1, "One GROUPED_MAP_PANDAS_UDF_WITH_STATE UDF 
expected here."
+        super().__init__(udf_descs, runner_conf, eval_conf)
 
         # See FlatMapGroupsInPandasWithStateExec for how arg_offsets are used 
to
         # distinguish between grouping attributes and data attributes.
-        parsed_offsets = extract_key_value_indexes(udfs[0].args_offsets)
+        parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
         self._key_offsets = parsed_offsets[0][0]
         self._value_offsets = parsed_offsets[0][1]
 
@@ -227,13 +225,13 @@ class 
PandasGroupedMapWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]
             # The number of columns of the result must match the return type,
             # but an empty result with no columns at all is acceptable.
             if not (
-                len(result.columns) == len(self._udfs[0].return_type)
+                len(result.columns) == len(self._udf_desc.return_type)
                 or (len(result.columns) == 0 and result.empty)
             ):
                 raise PySparkRuntimeError(
                     errorClass="RESULT_COLUMN_SCHEMA_MISMATCH",
                     messageParameters={
-                        "expected": str(len(self._udfs[0].return_type)),
+                        "expected": str(len(self._udf_desc.return_type)),
                         "actual": str(len(result.columns)),
                     },
                 )
@@ -261,7 +259,7 @@ class 
PandasGroupedMapWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]
             else:
                 values = (pd.concat(x, axis=1) for x in value_series_gen)
 
-            result_iter = self._funcs[0](key, values, state)
+            result_iter = self._func(key, values, state)
 
             if isinstance(result_iter, pd.DataFrame):
                 raise PySparkTypeError(
@@ -394,7 +392,7 @@ class 
PandasGroupedMapWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]
                         yield construct_record_batch(
                             pdfs,
                             pdf_data_cnt,
-                            self._udfs[0].return_type,
+                            self._udf_desc.return_type,
                             state_pdfs,
                             state_data_cnt,
                         )
@@ -411,7 +409,7 @@ class 
PandasGroupedMapWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]
         # Flush the trailing batch if it has any data or state left.
         if pdf_data_cnt > 0 or state_data_cnt > 0:
             yield construct_record_batch(
-                pdfs, pdf_data_cnt, self._udfs[0].return_type, state_pdfs, 
state_data_cnt
+                pdfs, pdf_data_cnt, self._udf_desc.return_type, state_pdfs, 
state_data_cnt
             )
 
 
@@ -423,23 +421,21 @@ class 
PandasTransformWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"])
 
     eval_type = PythonEvalType.SQL_TRANSFORM_WITH_STATE_PANDAS_UDF
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-
-        assert len(udfs) == 1, "One TRANSFORM_WITH_STATE_PANDAS UDF expected 
here."
+        super().__init__(udf_descs, runner_conf, eval_conf)
 
         # See TransformWithStateInPandasExec for how arg_offsets are used to
         # distinguish between grouping attributes and data attributes
-        parsed_offsets = extract_key_value_indexes(udfs[0].args_offsets)
+        parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
         assert len(parsed_offsets) == 1, (
             "Expected one pair of offsets for TRANSFORM_WITH_STATE_PANDAS UDF."
         )
 
         self._key_offsets = parsed_offsets[0][0]
         self._value_offsets = parsed_offsets[0][1]
-        self._output_schema = StructType([StructField("_0", 
udfs[0].return_type)])
+        self._output_schema = StructType([StructField("_0", 
self._udf_desc.return_type)])
 
         self._stateful_processor_api_client = StatefulProcessorApiClient(
             eval_conf.state_server_socket_port,  # type: ignore[arg-type]
@@ -517,7 +513,7 @@ class 
PandasTransformWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"])
 
         def convert_results(result_iter: Iterable[pd.DataFrame]) -> 
Iterator[pa.RecordBatch]:
             for result in result_iter:
-                if isinstance(self._udfs[0].return_type, StructType) and not 
isinstance(
+                if isinstance(self._udf_desc.return_type, StructType) and not 
isinstance(
                     result, pd.DataFrame
                 ):
                     raise PySparkValueError(
@@ -540,7 +536,7 @@ class 
PandasTransformWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"])
             # This must be a generator expression - do not materialize.
             values_gen = (df.iloc[:, self._value_offsets] for _, df in group)
             yield from convert_results(
-                self._funcs[0](
+                self._func(
                     self._stateful_processor_api_client,
                     TransformWithStateInPandasFuncMode.PROCESS_DATA,
                     key,
@@ -549,7 +545,7 @@ class 
PandasTransformWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"])
             )
 
         yield from convert_results(
-            self._funcs[0](
+            self._func(
                 self._stateful_processor_api_client,
                 TransformWithStateInPandasFuncMode.PROCESS_TIMER,
                 None,
@@ -558,7 +554,7 @@ class 
PandasTransformWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"])
         )
 
         yield from convert_results(
-            self._funcs[0](
+            self._func(
                 self._stateful_processor_api_client,
                 TransformWithStateInPandasFuncMode.COMPLETE,
                 None,
@@ -575,12 +571,10 @@ class 
PandasTransformWithStateInitStateUDFHandler(BatchEvalTypeHandler["pa.Recor
 
     eval_type = PythonEvalType.SQL_TRANSFORM_WITH_STATE_PANDAS_INIT_STATE_UDF
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pandas_version()
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-
-        assert len(udfs) == 1, "One TRANSFORM_WITH_STATE_PANDAS_INIT_STATE UDF 
expected here."
+        super().__init__(udf_descs, runner_conf, eval_conf)
 
         # See TransformWithStateInPandasExec for how arg_offsets are used to
         # distinguish between grouping attributes and data attributes.
@@ -589,10 +583,10 @@ class 
PandasTransformWithStateInitStateUDFHandler(BatchEvalTypeHandler["pa.Recor
         #     [groupingKeyOffsets, dedupDataOffsets],
         #     [initStateGroupingOffsets, dedupInitDataOffsets]
         # ]
-        parsed_offsets = extract_key_value_indexes(udfs[0].args_offsets)
+        parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
         self._key_offsets = parsed_offsets[0][0]
         self._init_key_offsets = parsed_offsets[1][0]
-        self._output_schema = StructType([StructField("_0", 
udfs[0].return_type)])
+        self._output_schema = StructType([StructField("_0", 
self._udf_desc.return_type)])
 
         self._stateful_processor_api_client = StatefulProcessorApiClient(
             eval_conf.state_server_socket_port,  # type: ignore[arg-type]
@@ -729,7 +723,7 @@ class 
PandasTransformWithStateInitStateUDFHandler(BatchEvalTypeHandler["pa.Recor
             # TODO(SPARK-49100): add verification that elements in result_iter 
are
             # indeed of type pd.DataFrame and conform to assigned cols
             for result in result_iter:
-                if isinstance(self._udfs[0].return_type, StructType) and not 
isinstance(
+                if isinstance(self._udf_desc.return_type, StructType) and not 
isinstance(
                     result, pd.DataFrame
                 ):
                     raise PySparkValueError(
@@ -756,7 +750,7 @@ class 
PandasTransformWithStateInitStateUDFHandler(BatchEvalTypeHandler["pa.Recor
             state_values = (data_df for _, data_df, _ in group_data if not 
data_df.empty)
             init_states = (init_df for _, _, init_df in group_init if not 
init_df.empty)
             yield from convert_results(
-                self._funcs[0](
+                self._func(
                     self._stateful_processor_api_client,
                     TransformWithStateInPandasFuncMode.PROCESS_DATA,
                     key,
@@ -766,7 +760,7 @@ class 
PandasTransformWithStateInitStateUDFHandler(BatchEvalTypeHandler["pa.Recor
             )
 
         yield from convert_results(
-            self._funcs[0](
+            self._func(
                 self._stateful_processor_api_client,
                 TransformWithStateInPandasFuncMode.PROCESS_TIMER,
                 None,
@@ -776,7 +770,7 @@ class 
PandasTransformWithStateInitStateUDFHandler(BatchEvalTypeHandler["pa.Recor
         )
 
         yield from convert_results(
-            self._funcs[0](
+            self._func(
                 self._stateful_processor_api_client,
                 TransformWithStateInPandasFuncMode.COMPLETE,
                 None,
@@ -794,15 +788,13 @@ class 
RowTransformWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
 
     eval_type = PythonEvalType.SQL_TRANSFORM_WITH_STATE_PYTHON_ROW_UDF
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-
-        assert len(udfs) == 1, "One TRANSFORM_WITH_STATE_PYTHON_ROW UDF 
expected here."
+        super().__init__(udf_descs, runner_conf, eval_conf)
 
         # See TransformWithStateInPySparkExec for how arg_offsets are used to
         # distinguish between grouping attributes and data attributes.
-        parsed_offsets = extract_key_value_indexes(udfs[0].args_offsets)
+        parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
         self._key_offsets = parsed_offsets[0][0]
 
         self._stateful_processor_api_client = StatefulProcessorApiClient(
@@ -850,7 +842,7 @@ class 
RowTransformWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
             # Convert spark type to arrow type
             # TODO: we need to make this configurable, currently using default 
values.
             arrow_type = to_arrow_type(
-                self._udfs[0].return_type,
+                self._udf_desc.return_type,
                 timezone="UTC",
                 prefers_large_types=False,
             )
@@ -865,7 +857,7 @@ class 
RowTransformWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
             # This must be a generator expression - do not materialize.
             values_gen = map(lambda x: x[1], group)
             yield from convert_results(
-                self._funcs[0](
+                self._func(
                     self._stateful_processor_api_client,
                     TransformWithStateInPandasFuncMode.PROCESS_DATA,
                     key,
@@ -874,7 +866,7 @@ class 
RowTransformWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
             )
 
         yield from convert_results(
-            self._funcs[0](
+            self._func(
                 self._stateful_processor_api_client,
                 TransformWithStateInPandasFuncMode.PROCESS_TIMER,
                 None,
@@ -883,7 +875,7 @@ class 
RowTransformWithStateUDFHandler(BatchEvalTypeHandler["pa.RecordBatch"]):
         )
 
         yield from convert_results(
-            self._funcs[0](
+            self._func(
                 self._stateful_processor_api_client,
                 TransformWithStateInPandasFuncMode.COMPLETE,
                 None,
@@ -900,11 +892,9 @@ class 
RowTransformWithStateInitStateUDFHandler(BatchEvalTypeHandler["pa.RecordBa
 
     eval_type = 
PythonEvalType.SQL_TRANSFORM_WITH_STATE_PYTHON_ROW_INIT_STATE_UDF
 
-    def __init__(self, udfs: list[UDF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         require_minimum_pyarrow_version()
-        super().__init__(udfs, runner_conf, eval_conf)
-
-        assert len(udfs) == 1, "One TRANSFORM_WITH_STATE_PYTHON_ROW_INIT_STATE 
UDF expected here."
+        super().__init__(udf_descs, runner_conf, eval_conf)
 
         # See TransformWithStateInPandasExec for how arg_offsets are used to
         # distinguish between grouping attributes and data attributes.
@@ -913,7 +903,7 @@ class 
RowTransformWithStateInitStateUDFHandler(BatchEvalTypeHandler["pa.RecordBa
         #     [groupingKeyOffsets, dedupDataOffsets],
         #     [initStateGroupingOffsets, dedupInitDataOffsets]
         # ]
-        parsed_offsets = extract_key_value_indexes(udfs[0].args_offsets)
+        parsed_offsets = extract_key_value_indexes(self._udf_desc.args_offsets)
         self._key_offsets = parsed_offsets[0][0]
         self._init_key_offsets = parsed_offsets[1][0]
 
@@ -1030,7 +1020,7 @@ class 
RowTransformWithStateInitStateUDFHandler(BatchEvalTypeHandler["pa.RecordBa
             # Convert spark type to arrow type
             # TODO: we need to make this configurable, currently using default 
values.
             arrow_type = to_arrow_type(
-                self._udfs[0].return_type,
+                self._udf_desc.return_type,
                 timezone="UTC",
                 prefers_large_types=False,
             )
@@ -1045,7 +1035,7 @@ class 
RowTransformWithStateInitStateUDFHandler(BatchEvalTypeHandler["pa.RecordBa
             # These must be generator expressions - do not materialize.
             for _, (values_gen, init_states_gen) in group:
                 yield from convert_results(
-                    self._funcs[0](
+                    self._func(
                         self._stateful_processor_api_client,
                         TransformWithStateInPandasFuncMode.PROCESS_DATA,
                         key,
@@ -1055,7 +1045,7 @@ class 
RowTransformWithStateInitStateUDFHandler(BatchEvalTypeHandler["pa.RecordBa
                 )
 
         yield from convert_results(
-            self._funcs[0](
+            self._func(
                 self._stateful_processor_api_client,
                 TransformWithStateInPandasFuncMode.PROCESS_TIMER,
                 None,
@@ -1065,7 +1055,7 @@ class 
RowTransformWithStateInitStateUDFHandler(BatchEvalTypeHandler["pa.RecordBa
         )
 
         yield from convert_results(
-            self._funcs[0](
+            self._func(
                 self._stateful_processor_api_client,
                 TransformWithStateInPandasFuncMode.COMPLETE,
                 None,
diff --git a/python/pyspark/eval_handlers/_typing.py 
b/python/pyspark/eval_handlers/_typing.py
index d01180bd5544..833ccd3ceb0a 100644
--- a/python/pyspark/eval_handlers/_typing.py
+++ b/python/pyspark/eval_handlers/_typing.py
@@ -45,10 +45,10 @@ OutputBatch = TypeVar("OutputBatch")
 class UDF:
     """One UDF as the worker reads it; each handler takes the fields it 
needs."""
 
-    # The chained function before any wrapping: the pickled object itself (an 
incremental
-    # Aggregator) with its signature. EvalTypeHandler.__init__ keeps it 
wrapped for profiling and,
+    # The functions applied in turn to the UDF's arguments, as unpickled. 
EvalTypeHandler.__init__
+    # chains them into one as the handler's _raw_funcs, and keeps that wrapped 
for profiling and,
     # for most eval types, in fail_on_stopiteration, as the handler's _funcs.
-    raw_func: Any
+    funcs: list[Any]
     args_offsets: list[int]
     kwargs_offsets: dict[str, int]
     # The DataType of the last function in the chain; a StructType for the 
eval types that
diff --git a/python/pyspark/eval_handlers/_udtf.py 
b/python/pyspark/eval_handlers/_udtf.py
index 2b1b67b348e2..e179f4b68a1a 100644
--- a/python/pyspark/eval_handlers/_udtf.py
+++ b/python/pyspark/eval_handlers/_udtf.py
@@ -599,20 +599,23 @@ def build_null_checker(return_type: StructType) -> 
Optional[Callable[[Any], None
 
 
 class UDTFHandlerBase(EvalTypeHandler[InputBatch, OutputBatch]):
-    """Base for every UDTF eval type handler; ``__init__`` creates the UDTF 
instance as
-    ``_udtf``."""
+    """Base for every UDTF eval type handler; ``__init__`` keeps the UDTF 
record as
+    ``_udtf_desc`` and creates the UDTF instance as ``_udtf``."""
 
     # Wraps the UDTF when its TABLE argument has PARTITION BY expressions.
     partition_wrapper: ClassVar[type[PartitionedUDTF]] = UDTFWithPartitions
 
-    def __init__(self, udfs: list[UDTF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def _expected_udf(self) -> str:
+        return "UDTF"
+
+    def __init__(self, udf_descs: list[UDTF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         """Create, as ``_udtf``, the instance of the UDTF's class (wrapped in
         ``partition_wrapper`` when its TABLE argument has PARTITION BY 
expressions, its
         constructor given the AnalyzeResult when it takes one), and check its 
'eval' signature
         against the call arguments."""
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One UDTF expected here."
-        udtf = udfs[0]
+        super().__init__(udf_descs, runner_conf, eval_conf)
+        self._udtf_desc: UDTF = udf_descs[0]
+        udtf = self._udtf_desc
         if not isinstance(udtf.udtf_cls, type):
             raise PySparkRuntimeError(
                 f"Invalid UDTF handler type. Expected a class (type 'type'), 
but "
@@ -685,11 +688,10 @@ class UDTFEvalTypeHandler(UDTFHandlerBase[InputBatch, 
OutputBatch]):
     ``cleanup``. Subclasses implement the two hooks and pick the serializer.
     """
 
-    def __init__(self, udfs: list[UDTF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One UDTF expected here."
+    def __init__(self, udf_descs: list[UDTF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
+        super().__init__(udf_descs, runner_conf, eval_conf)
         self._eval_method, self._args_kwargs_offsets = wrap_kwargs_support(
-            self._udtf.eval, udfs[0].args_offsets, udfs[0].kwargs_offsets
+            self._udtf.eval, self._udtf_desc.args_offsets, 
self._udtf_desc.kwargs_offsets
         )
         self._terminate_method = getattr(self._udtf, "terminate", None)
         self._cleanup_method = getattr(self._udtf, "cleanup", None)
@@ -725,19 +727,20 @@ class TableUDTFHandler(UDTFHandlerBase[Any, tuple]):
 
     eval_type = PythonEvalType.SQL_TABLE_UDF
 
-    def __init__(self, udfs: list[UDTF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
-        super().__init__(udfs, runner_conf, eval_conf)
-        assert len(udfs) == 1, "One UDTF expected here."
-        self._check_output_row_against_schema = 
build_null_checker(udfs[0].return_type)
+    def __init__(self, udf_descs: list[UDTF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
+        super().__init__(udf_descs, runner_conf, eval_conf)
+        self._check_output_row_against_schema = 
build_null_checker(self._udtf_desc.return_type)
 
         eval_func_kwargs_support, self._args_kwargs_offsets = 
wrap_kwargs_support(
-            self._udtf.eval, udfs[0].args_offsets, udfs[0].kwargs_offsets
+            self._udtf.eval, self._udtf_desc.args_offsets, 
self._udtf_desc.kwargs_offsets
         )
-        self._eval = self._wrap_udtf(eval_func_kwargs_support, 
udfs[0].return_type)
+        self._eval = self._wrap_udtf(eval_func_kwargs_support, 
self._udtf_desc.return_type)
 
         self._terminate: Optional[Callable[..., tuple]]
         if hasattr(self._udtf, "terminate"):
-            self._terminate = self._wrap_udtf(getattr(self._udtf, 
"terminate"), udfs[0].return_type)
+            self._terminate = self._wrap_udtf(
+                getattr(self._udtf, "terminate"), self._udtf_desc.return_type
+            )
         else:
             self._terminate = None
 
@@ -850,16 +853,16 @@ class ArrowUDTFHandler(
     eval_type = PythonEvalType.SQL_ARROW_UDTF
     partition_wrapper = ArrowUDTFWithPartition
 
-    def __init__(self, udfs: list[UDTF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDTF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         import pyarrow as pa
 
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
         arrow_return_type = to_arrow_type(
-            self._udfs[0].return_type,
+            self._udtf_desc.return_type,
             timezone="UTC",
             prefers_large_types=runner_conf.use_large_var_types,
         )
-        self._return_type_size = len(self._udfs[0].return_type)
+        self._return_type_size = len(self._udtf_desc.return_type)
         self._target_schema = pa.schema(list(arrow_return_type))
 
         self._table_arg_offsets = (
@@ -962,10 +965,10 @@ class ArrowTableUDTFHandler(
     ``spark.sql.legacy.execution.pythonUDTF.pandas.conversion.enabled``. 
Subclasses implement
     ``_to_columns`` and ``_convert_result``."""
 
-    def __init__(self, udfs: list[UDTF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
-        super().__init__(udfs, runner_conf, eval_conf)
-        self._return_type_size = len(self._udfs[0].return_type)
-        self._check_output_row_against_schema = 
build_null_checker(self._udfs[0].return_type)
+    def __init__(self, udf_descs: list[UDTF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
+        super().__init__(udf_descs, runner_conf, eval_conf)
+        self._return_type_size = len(self._udtf_desc.return_type)
+        self._check_output_row_against_schema = 
build_null_checker(self._udtf_desc.return_type)
 
     @abstractmethod
     def _to_columns(self, batch: pa.RecordBatch) -> list:
@@ -1014,13 +1017,13 @@ class 
ArrowTableUDTFLegacyPandasHandler(ArrowTableUDTFHandler):
     variant_conf = "use_legacy_pandas_udtf_conversion"
     variant_value = True
 
-    def __init__(self, udfs: list[UDTF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDTF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         import pandas  # noqa: F401
 
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
         # The output pandas DataFrame is converted as a single struct column 
named
         # "_0" against this schema.
-        self._output_schema = StructType([StructField("_0", 
self._udfs[0].return_type)])
+        self._output_schema = StructType([StructField("_0", 
self._udtf_desc.return_type)])
 
     def _verify_result(self, result: Any, method_name: str) -> Any:
         import pandas as pd
@@ -1053,7 +1056,7 @@ class 
ArrowTableUDTFLegacyPandasHandler(ArrowTableUDTFHandler):
         # Verify the type and the schema of the result.
         verify_pandas_result(
             result,
-            self._udfs[0].return_type,
+            self._udtf_desc.return_type,
             assign_cols_by_name=False,
             truncate_return_schema=False,
         )
@@ -1127,12 +1130,12 @@ class ArrowTableUDTFArrowHandler(ArrowTableUDTFHandler):
     variant_conf = "use_legacy_pandas_udtf_conversion"
     variant_value = False
 
-    def __init__(self, udfs: list[UDTF], runner_conf: RunnerConf, eval_conf: 
EvalConf) -> None:
+    def __init__(self, udf_descs: list[UDTF], runner_conf: RunnerConf, 
eval_conf: EvalConf) -> None:
         import pyarrow  # noqa: F401
 
-        super().__init__(udfs, runner_conf, eval_conf)
+        super().__init__(udf_descs, runner_conf, eval_conf)
         self._arrow_return_type = to_arrow_type(
-            self._udfs[0].return_type,
+            self._udtf_desc.return_type,
             timezone="UTC",
             prefers_large_types=runner_conf.use_large_var_types,
         )
@@ -1217,14 +1220,14 @@ class ArrowTableUDTFArrowHandler(ArrowTableUDTFHandler):
                 errorClass="UDTF_ARROW_DATA_CONVERSION_ERROR",
                 messageParameters={
                     "data": str(data),
-                    "schema": self._udfs[0].return_type.simpleString(),
+                    "schema": self._udtf_desc.return_type.simpleString(),
                     "arrow_schema": str(arrow_return_type),
                 },
             ) from original_exception
 
         try:
             table = LocalDataToArrowConversion.convert(
-                data, self._udfs[0].return_type, 
self._runner_conf.use_large_var_types
+                data, self._udtf_desc.return_type, 
self._runner_conf.use_large_var_types
             )
         except PySparkValueError as e:
             if e.getErrorClass() == "AXIS_LENGTH_MISMATCH":
diff --git a/python/pyspark/eval_handlers/_vanilla.py 
b/python/pyspark/eval_handlers/_vanilla.py
index b20da0f2c839..a76a0cae6e45 100644
--- a/python/pyspark/eval_handlers/_vanilla.py
+++ b/python/pyspark/eval_handlers/_vanilla.py
@@ -42,10 +42,15 @@ class BatchedUDFHandler(EvalTypeHandler[Any, Any]):
     row."""
 
     eval_type = PythonEvalType.SQL_BATCHED_UDF
+    _multi_udf = True
 
-    def __init__(self, udfs: "list[UDF]", runner_conf: "RunnerConf", 
eval_conf: "EvalConf") -> None:
-        super().__init__(udfs, runner_conf, eval_conf)
-        self._udf_infos = [self._prepare_udf(udf, func) for udf, func in 
zip(udfs, self._funcs)]
+    def __init__(
+        self, udf_descs: "list[UDF]", runner_conf: "RunnerConf", eval_conf: 
"EvalConf"
+    ) -> None:
+        super().__init__(udf_descs, runner_conf, eval_conf)
+        self._udf_infos = [
+            self._prepare_udf(udf, func) for udf, func in zip(udf_descs, 
self._funcs)
+        ]
 
     @staticmethod
     def _prepare_udf(udf: "UDF", func: Callable[..., Any]) -> tuple[list[int], 
Callable[..., Any]]:
diff --git 
a/python/pyspark/eval_handlers/tests/test_arrow_eval_type_handlers.py 
b/python/pyspark/eval_handlers/tests/test_arrow_eval_type_handlers.py
index 1c29e79b98d4..834fa7599874 100644
--- a/python/pyspark/eval_handlers/tests/test_arrow_eval_type_handlers.py
+++ b/python/pyspark/eval_handlers/tests/test_arrow_eval_type_handlers.py
@@ -128,7 +128,7 @@ class ArrowEvalTypeHandlerTestCase(PySparkBaseTestCase):
     def _udf(func, args_offsets, kwargs_offsets=None, return_type=None):
         """A ``UDF`` record as the worker builds it."""
         return UDF(
-            raw_func=func,
+            funcs=[func],
             args_offsets=args_offsets,
             kwargs_offsets=kwargs_offsets or {},
             return_type=return_type or LongType(),
@@ -138,10 +138,9 @@ class ArrowEvalTypeHandlerTestCase(PySparkBaseTestCase):
     @classmethod
     def _grouped_handler(cls, handler_cls, udf, arg_offsets):
         """Build a grouped/cogrouped-map handler with the shared return type. 
The function the
-        handler wraps loses its signature, so the handler reads it from the 
record's
-        ``raw_func``."""
+        handler wraps loses its signature, so the handler reads it from its 
``_raw_func``."""
         return handler_cls(
-            udfs=[cls._udf(udf, arg_offsets, return_type=cls._RETURN_TYPE)],
+            udf_descs=[cls._udf(udf, arg_offsets, 
return_type=cls._RETURN_TYPE)],
             runner_conf=RunnerConf({}),
             eval_conf=None,
         )
@@ -192,10 +191,10 @@ class 
ArrowEvalTypeHandlerRegistrationTests(PySparkBaseTestCase):
 class ArrowBatchedUDFArrowHandlerTests(ArrowEvalTypeHandlerTestCase):
     _INPUT_TYPE = StructType([StructField("a", LongType()), StructField("b", 
LongType())])
 
-    def _handler(self, *udfs, runner_conf=None, input_type=None):
+    def _handler(self, *udf_descs, runner_conf=None, input_type=None):
         """Build the handler from ``(func, args_offsets, kwargs_offsets, 
return_type)`` tuples."""
         return ArrowBatchedUDFArrowHandler(
-            udfs=[self._udf(*u) for u in udfs],
+            udf_descs=[self._udf(*u) for u in udf_descs],
             runner_conf=runner_conf or RunnerConf({}),
             eval_conf=EvalConf({"input_type": (input_type or 
self._INPUT_TYPE).json()}),
         )
@@ -383,7 +382,7 @@ class 
ArrowScalarUDFHandlerTests(ArrowEvalTypeHandlerTestCase):
             return pa.array([v.as_py() + 1 for v in col], type=pa.int64())
 
         handler = ArrowScalarUDFHandler(
-            udfs=[self._udf(add_one, [0])], runner_conf=RunnerConf({}), 
eval_conf=None
+            udf_descs=[self._udf(add_one, [0])], runner_conf=RunnerConf({}), 
eval_conf=None
         )
         out = list(handler.run(0, iter([self._batch(a=[1, 2, 3])])))
         self.assertEqual([b.column(0).to_pylist() for b in out], [[2, 3, 4]])
@@ -394,7 +393,7 @@ class 
ArrowScalarUDFHandlerTests(ArrowEvalTypeHandlerTestCase):
             return pa.array([v.as_py() + 1 for v in col], type=pa.int32())
 
         handler = ArrowScalarUDFHandler(
-            udfs=[self._udf(add_one, [0])], runner_conf=RunnerConf({}), 
eval_conf=None
+            udf_descs=[self._udf(add_one, [0])], runner_conf=RunnerConf({}), 
eval_conf=None
         )
         out = list(handler.run(0, iter([self._batch(a=[10, 20])])))
         self.assertEqual(out[0].schema.field(0).type, pa.int64())
@@ -409,7 +408,7 @@ class 
ArrowScalarIterUDFHandlerTests(ArrowEvalTypeHandlerTestCase):
                 yield pa.array([v.as_py() + 1 for v in col], type=pa.int64())
 
         handler = ArrowScalarIterUDFHandler(
-            udfs=[self._udf(add_one, [0])], runner_conf=RunnerConf({}), 
eval_conf=None
+            udf_descs=[self._udf(add_one, [0])], runner_conf=RunnerConf({}), 
eval_conf=None
         )
         out = list(handler.run(0, iter([self._batch(a=[1, 2]), 
self._batch(a=[3])])))
         self.assertEqual([b.column(0).to_pylist() for b in out], [[2, 3], [4]])
@@ -421,7 +420,7 @@ class 
ArrowScalarIterUDFHandlerTests(ArrowEvalTypeHandlerTestCase):
                 yield pa.array(list(range(len(col) + 1)), type=pa.int64())
 
         handler = ArrowScalarIterUDFHandler(
-            udfs=[self._udf(too_many, [0])], runner_conf=RunnerConf({}), 
eval_conf=None
+            udf_descs=[self._udf(too_many, [0])], runner_conf=RunnerConf({}), 
eval_conf=None
         )
         with self.assertRaises(PySparkRuntimeError):
             list(handler.run(0, iter([self._batch(a=[1, 2])])))
@@ -436,7 +435,7 @@ class ArrowMapUDFHandlerTests(ArrowEvalTypeHandlerTestCase):
                 yield self._batch(v=[c.as_py() * 2 for c in batch.column("v")])
 
         handler = ArrowMapUDFHandler(
-            udfs=[self._udf(double_v, [])], runner_conf=RunnerConf({}), 
eval_conf=None
+            udf_descs=[self._udf(double_v, [])], runner_conf=RunnerConf({}), 
eval_conf=None
         )
         out = list(handler.run(0, iter([self._struct_batch(v=[1, 2, 3])])))
         self.assertEqual(out[0].column(0).field("v").to_pylist(), [2, 4, 6])
@@ -546,7 +545,7 @@ class 
ArrowGroupedAggUDFHandlerTests(ArrowEvalTypeHandlerTestCase):
             return sum(c.as_py() for c in col)
 
         handler = ArrowGroupedAggUDFHandler(
-            udfs=[self._udf(sum_udf, [0])], runner_conf=RunnerConf({}), 
eval_conf=None
+            udf_descs=[self._udf(sum_udf, [0])], runner_conf=RunnerConf({}), 
eval_conf=None
         )
         out = list(handler.run(0, self._one_group(self._batch(v=[1, 2, 3]), 
self._batch(v=[4]))))
         self.assertEqual(out[0].column("_0").to_pylist(), [10])
@@ -560,7 +559,7 @@ class 
ArrowGroupedAggIterUDFHandlerTests(ArrowEvalTypeHandlerTestCase):
             return sum(c.as_py() for col in col_iter for c in col)
 
         handler = ArrowGroupedAggIterUDFHandler(
-            udfs=[self._udf(sum_iter_udf, [0])], runner_conf=RunnerConf({}), 
eval_conf=None
+            udf_descs=[self._udf(sum_iter_udf, [0])], 
runner_conf=RunnerConf({}), eval_conf=None
         )
         out = list(handler.run(0, self._one_group(self._batch(v=[10]), 
self._batch(v=[20]))))
         self.assertEqual(out[0].column("_0").to_pylist(), [30])
@@ -574,7 +573,7 @@ class 
ArrowWindowAggUDFHandlerTests(ArrowEvalTypeHandlerTestCase):
             return sum(c.as_py() for c in col)
 
         handler = ArrowWindowAggUDFHandler(
-            udfs=[self._udf(sum_udf, [0])],
+            udf_descs=[self._udf(sum_udf, [0])],
             runner_conf=RunnerConf({"window_bound_types": "unbounded"}),
             eval_conf=None,
         )
@@ -587,7 +586,7 @@ class 
ArrowWindowAggUDFHandlerTests(ArrowEvalTypeHandlerTestCase):
             return sum(c.as_py() for c in col)
 
         handler = ArrowWindowAggUDFHandler(
-            udfs=[self._udf(sum_udf, [0, 1, 2])],
+            udf_descs=[self._udf(sum_udf, [0, 1, 2])],
             runner_conf=RunnerConf({"window_bound_types": "bounded"}),
             eval_conf=None,
         )
@@ -605,9 +604,9 @@ class 
ArrowGroupedAggIncrementalPartialUDFHandlerTests(ArrowEvalTypeHandlerTestC
     _KEY_SCHEMA = StructType([StructField("k", LongType())])
 
     def _handler(self, cap=10000):
-        # The handler takes the Aggregator itself from the record's raw_func.
+        # The handler takes the Aggregator itself, unchained and unwrapped, as 
its raw function.
         return ArrowGroupedAggIncrementalPartialUDFHandler(
-            udfs=[self._udf(_SumAggregator(), [1])],
+            udf_descs=[self._udf(_SumAggregator(), [1])],
             
runner_conf=RunnerConf({"spark.sql.execution.arrow.maxRecordsPerBatch": 
str(cap)}),
             eval_conf=EvalConf({"grouping_key_schema": 
self._KEY_SCHEMA.json()}),
         )
@@ -665,7 +664,7 @@ class 
ArrowGroupedAggIncrementalPartialUDFHandlerTests(ArrowEvalTypeHandlerTestC
             ["k", "v"],
         )
         handler = ArrowGroupedAggIncrementalPartialUDFHandler(
-            udfs=[self._udf(_SumAggregator(), [1])],
+            udf_descs=[self._udf(_SumAggregator(), [1])],
             runner_conf=RunnerConf({}),
             eval_conf=EvalConf(
                 {"grouping_key_schema": StructType([StructField("k", 
DoubleType())]).json()}
@@ -686,9 +685,9 @@ class 
ArrowGroupedAggIncrementalFinalUDFHandlerTests(ArrowEvalTypeHandlerTestCas
         return pa.RecordBatch.from_arrays([pa.array(buffers, 
type=self._BUFFER_TYPE)], ["buf"])
 
     def _handler(self):
-        # The handler takes the Aggregator itself from the record's raw_func.
+        # The handler takes the Aggregator itself, unchained and unwrapped, as 
its raw function.
         return ArrowGroupedAggIncrementalFinalUDFHandler(
-            udfs=[self._udf(_SumAggregator(), [0])], 
runner_conf=RunnerConf({}), eval_conf=None
+            udf_descs=[self._udf(_SumAggregator(), [0])], 
runner_conf=RunnerConf({}), eval_conf=None
         )
 
     def test_merges_partial_buffers_per_group(self):
@@ -713,7 +712,7 @@ class 
ArrowGroupedAggIncrementalFinalUDFHandlerTests(ArrowEvalTypeHandlerTestCas
 class ArrowWindowAggIncrementalUDFHandlerTests(ArrowEvalTypeHandlerTestCase):
     def _handler(self, agg, bound_type, args_offsets):
         return ArrowWindowAggIncrementalUDFHandler(
-            udfs=[self._udf(agg, args_offsets)],
+            udf_descs=[self._udf(agg, args_offsets)],
             runner_conf=RunnerConf({"window_bound_types": bound_type}),
             eval_conf=None,
         )
diff --git a/python/pyspark/eval_handlers/tests/test_base_eval_type_handlers.py 
b/python/pyspark/eval_handlers/tests/test_base_eval_type_handlers.py
index baff8a51e6d7..b3e0438569ce 100644
--- a/python/pyspark/eval_handlers/tests/test_base_eval_type_handlers.py
+++ b/python/pyspark/eval_handlers/tests/test_base_eval_type_handlers.py
@@ -22,6 +22,7 @@ Handler-flavor tests live alongside their module, e.g. the 
Arrow handlers in
 """
 
 import os
+import unittest
 from unittest.mock import patch
 
 from pyspark.errors import PySparkRuntimeError
@@ -34,19 +35,23 @@ from pyspark.eval_handlers._base import (
     get_eval_type_handler,
 )
 from pyspark.eval_handlers._typing import UDF
-from pyspark.serializers import CloudPickleSerializer
 from pyspark.sql.pandas.serializers import (
     ArrowStreamCoGroupSerializer,
     ArrowStreamGroupSerializer,
     ArrowStreamSerializer,
 )
-from pyspark.sql.types import LongType, StringType
-from pyspark.testing.utils import PySparkBaseTestCase
+from pyspark.sql.types import LongType
+from pyspark.testing.utils import (
+    PySparkBaseTestCase,
+    have_pandas,
+    have_pyarrow,
+    pandas_requirement_message,
+    pyarrow_requirement_message,
+)
 from pyspark.util import PythonEvalType
 
 with patch.dict(os.environ, {"SPARK_PYTHON_RUNTIME": "PYTHON_WORKER"}):
-    from pyspark.worker import unpickle_and_chain
-    from pyspark.worker_util import RunnerConf, pickleSer
+    from pyspark.worker_util import RunnerConf
 
 
 class EvalTypeHandlerTests(PySparkBaseTestCase):
@@ -77,14 +82,20 @@ class EvalTypeHandlerTests(PySparkBaseTestCase):
 
     def test_default_serializer_per_category(self):
         class _Batch(BatchEvalTypeHandler["pa.RecordBatch"]):
+            _multi_udf = True
+
             def run(self, split_index, data):
                 return data
 
         class _Grouped(GroupedEvalTypeHandler["pa.RecordBatch"]):
+            _multi_udf = True
+
             def run(self, split_index, data):
                 return data
 
         class _CoGrouped(CoGroupedEvalTypeHandler["pa.RecordBatch"]):
+            _multi_udf = True
+
             def run(self, split_index, data):
                 return data
 
@@ -98,6 +109,8 @@ class EvalTypeHandlerTests(PySparkBaseTestCase):
 
     def test_run_produces_output(self):
         class _Doubler(BatchEvalTypeHandler["pa.RecordBatch"]):
+            _multi_udf = True
+
             def run(self, split_index, data):
                 for item in data:
                     yield item * 2
@@ -272,6 +285,8 @@ class EvalTypeHandlerTests(PySparkBaseTestCase):
 
 class HandlerUDFsTests(PySparkBaseTestCase):
     class _Handler(BatchEvalTypeHandler["pa.RecordBatch"]):
+        _multi_udf = True
+
         def run(self, split_index, data):
             return data
 
@@ -279,35 +294,34 @@ class HandlerUDFsTests(PySparkBaseTestCase):
     def _stop(x):
         raise StopIteration(x)
 
-    def _new(self, handler_cls, runner_conf=None):
-        return handler_cls(
-            [
-                UDF(
-                    raw_func=self._stop,
-                    args_offsets=[1],
-                    kwargs_offsets={"y": 2},
-                    return_type=LongType(),
-                    result_id=0,
-                )
-            ],
-            runner_conf or RunnerConf({}),
-            None,
+    @staticmethod
+    def _udf(*funcs):
+        return UDF(
+            funcs=list(funcs),
+            args_offsets=[1],
+            kwargs_offsets={"y": 2},
+            return_type=LongType(),
+            result_id=0,
         )
 
+    def _new(self, handler_cls, runner_conf=None):
+        return handler_cls([self._udf(self._stop)], runner_conf or 
RunnerConf({}), None)
+
     def test_wraps_udfs(self):
         handler = self._new(self._Handler)
-        udf = handler._udfs[0]
+        udf = handler._udf_descs[0]
 
         self.assertIsInstance(udf, UDF)
         self.assertEqual(udf.args_offsets, [1])
         self.assertEqual(udf.kwargs_offsets, {"y": 2})
         self.assertEqual(udf.return_type, LongType())
-        # The handler keeps the function wrapped in fail_on_stopiteration; the 
record keeps it
-        # unwrapped.
+        # The handler keeps the function wrapped in fail_on_stopiteration; the 
record and the
+        # handler's raw function keep it unwrapped.
         with self.assertRaises(PySparkRuntimeError):
             handler._funcs[0](1)
         with self.assertRaises(StopIteration):
-            udf.raw_func(1)
+            udf.funcs[0](1)
+        self.assertIs(handler._raw_funcs[0], self._stop)
 
     def test_fail_on_stopiteration_per_handler(self):
         not_wrapped = {PythonEvalType.SQL_ARROW_BATCHED_UDF}
@@ -370,19 +384,87 @@ class HandlerUDFsTests(PySparkBaseTestCase):
                     handler._funcs[0](1)
                 self.assertEqual(cm.exception.getCondition(), 
"STOP_ITERATION_OCCURRED")
 
-
-class UnpickleAndChainTests(PySparkBaseTestCase):
-    def test_chains_in_order(self):
-        ser = CloudPickleSerializer()
-        func, return_type = unpickle_and_chain(
-            pickleSer,
-            [
-                ser.dumps((lambda x: x + 1, LongType())),
-                ser.dumps((lambda x: str(x * 2), StringType())),
-            ],
+    def test_chains_funcs_in_order(self):
+        handler = self._Handler(
+            [self._udf(lambda x: x + 1, lambda x: x * 2, str)], 
RunnerConf({}), None
         )
-        self.assertEqual(func(3), "8")
-        self.assertEqual(return_type, StringType())
+        self.assertEqual(handler._raw_funcs[0](3), "8")
+        self.assertEqual(handler._funcs[0](3), "8")
+
+    def test_single_func_not_chained(self):
+        # A single function is kept as is, so its signature (and an 
incremental Aggregator
+        # object) survives.
+        def f(key, value):
+            return value
+
+        handler = self._Handler([self._udf(f)], RunnerConf({}), None)
+        self.assertIs(handler._raw_funcs[0], f)
+
+    def test_single_udf(self):
+        class _Single(BatchEvalTypeHandler["pa.RecordBatch"]):
+            def run(self, split_index, data):
+                return data
+
+        def f(x):
+            return x + 1
+
+        handler = _Single([self._udf(f)], RunnerConf({}), None)
+        self.assertIs(handler._udf_desc, handler._udf_descs[0])
+        self.assertIs(handler._raw_func, f)
+        self.assertIs(handler._func, handler._funcs[0])
+        self.assertEqual(handler._func(1), 2)
+        for udf_descs in ([], [self._udf(f), self._udf(f)]):
+            with self.subTest(num_udfs=len(udf_descs)):
+                with self.assertRaisesRegex(
+                    PySparkRuntimeError,
+                    "^One _Single UDF expected, but got 
{}.$".format(len(udf_descs)),
+                ):
+                    _Single(udf_descs, RunnerConf({}), None)
+
+        handler = self._Handler([self._udf(f), self._udf(f)], RunnerConf({}), 
None)
+        self.assertFalse(hasattr(handler, "_udf_desc"))
+
+    @unittest.skipIf(
+        not (have_pandas and have_pyarrow),
+        pandas_requirement_message or pyarrow_requirement_message,
+    )
+    def test_single_udf_count_error_names_eval_type(self):
+        for eval_type, expected in (
+            (PythonEvalType.SQL_COGROUPED_MAP_ARROW_UDF, "COGROUPED_MAP_ARROW 
UDF"),
+            (
+                PythonEvalType.SQL_GROUPED_MAP_PANDAS_UDF_WITH_STATE,
+                "GROUPED_MAP_PANDAS_UDF_WITH_STATE UDF",
+            ),
+            (PythonEvalType.SQL_TABLE_UDF, "UDTF"),
+        ):
+            handler_cls = _eval_type_handlers[eval_type][1][None]
+            with self.subTest(handler=handler_cls.__name__):
+                with self.assertRaisesRegex(
+                    PySparkRuntimeError, "^One {} expected, but got 
2.$".format(expected)
+                ):
+                    handler_cls([self._udf(str), self._udf(str)], 
RunnerConf({}), None)
+
+    def test_multi_udf_per_handler(self):
+        multi = {
+            PythonEvalType.SQL_BATCHED_UDF,
+            PythonEvalType.SQL_ARROW_BATCHED_UDF,
+            PythonEvalType.SQL_SCALAR_PANDAS_UDF,
+            PythonEvalType.SQL_SCALAR_ARROW_UDF,
+            PythonEvalType.SQL_ARROW_ELEMENTWISE_UDF,
+            PythonEvalType.SQL_SCALAR_PANDAS_ELEMENTWISE_UDF,
+            PythonEvalType.SQL_SCALAR_ARROW_ELEMENTWISE_UDF,
+            PythonEvalType.SQL_GROUPED_AGG_PANDAS_UDF,
+            PythonEvalType.SQL_GROUPED_AGG_ARROW_UDF,
+            PythonEvalType.SQL_WINDOW_AGG_PANDAS_UDF,
+            PythonEvalType.SQL_WINDOW_AGG_ARROW_UDF,
+            PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF,
+            PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
+            PythonEvalType.SQL_WINDOW_AGG_ARROW_INCREMENTAL_UDF,
+        }
+        for _, variants in list(_eval_type_handlers.values()):
+            for handler_cls in variants.values():
+                with self.subTest(handler=handler_cls.__name__):
+                    self.assertEqual(handler_cls._multi_udf, 
handler_cls.eval_type in multi)
 
 
 if __name__ == "__main__":
diff --git 
a/python/pyspark/eval_handlers/tests/test_elementwise_eval_type_handlers.py 
b/python/pyspark/eval_handlers/tests/test_elementwise_eval_type_handlers.py
index 69a2301e7a64..0542478ff7f0 100644
--- a/python/pyspark/eval_handlers/tests/test_elementwise_eval_type_handlers.py
+++ b/python/pyspark/eval_handlers/tests/test_elementwise_eval_type_handlers.py
@@ -93,22 +93,22 @@ class 
ElementwiseEvalTypeHandlerTestCase(PySparkBaseTestCase):
             ["a", "b", "c"],
         )
 
-    def _handler(self, handler_cls, *udfs, nesting=None, input_type=None, 
runner_conf=None):
+    def _handler(self, handler_cls, *udf_descs, nesting=None, input_type=None, 
runner_conf=None):
         """Build ``handler_cls`` from ``(func, args_offsets, kwargs_offsets, 
return_type)``
         tuples, with ``nesting`` as the per-UDF ``elementwise_nesting``."""
         eval_conf = {"input_type": (input_type or self._INPUT_TYPE).json()}
         if nesting is not None:
             eval_conf["elementwise_nesting"] = ",".join(map(str, nesting))
         return handler_cls(
-            udfs=[
+            udf_descs=[
                 UDF(
-                    raw_func=func,
+                    funcs=[func],
                     args_offsets=args_offsets,
                     kwargs_offsets=kwargs_offsets,
                     return_type=return_type,
                     result_id=0,
                 )
-                for func, args_offsets, kwargs_offsets, return_type in udfs
+                for func, args_offsets, kwargs_offsets, return_type in 
udf_descs
             ],
             runner_conf=RunnerConf(runner_conf or 
{"spark.sql.session.timeZone": "UTC"}),
             eval_conf=EvalConf(eval_conf),
diff --git 
a/python/pyspark/eval_handlers/tests/test_pandas_eval_type_handlers.py 
b/python/pyspark/eval_handlers/tests/test_pandas_eval_type_handlers.py
index 72c101ff901f..d95be4788de6 100644
--- a/python/pyspark/eval_handlers/tests/test_pandas_eval_type_handlers.py
+++ b/python/pyspark/eval_handlers/tests/test_pandas_eval_type_handlers.py
@@ -93,11 +93,11 @@ class PandasEvalTypeHandlerTestCase(PySparkBaseTestCase):
     @classmethod
     def _grouped_handler(cls, handler_cls, udf, arg_offsets, runner_conf=None):
         """Build a grouped/cogrouped-map handler. The function the handler 
wraps loses its
-        signature, so the handler reads it from the record's ``raw_func``."""
+        signature, so the handler reads it from its ``_raw_func``."""
         return handler_cls(
-            udfs=[
+            udf_descs=[
                 UDF(
-                    raw_func=udf,
+                    funcs=[udf],
                     args_offsets=arg_offsets,
                     kwargs_offsets={},
                     return_type=cls._GROUPED_RETURN_TYPE,
@@ -117,7 +117,7 @@ class PandasEvalTypeHandlerTestCase(PySparkBaseTestCase):
     def _udf(func, return_type=None, args=(0,), kwargs=None):
         """A ``UDF`` record for a pandas UDF, as the worker builds it."""
         return UDF(
-            raw_func=func,
+            funcs=[func],
             args_offsets=list(args),
             kwargs_offsets=dict(kwargs or {}),
             return_type=return_type or LongType(),
@@ -125,10 +125,10 @@ class PandasEvalTypeHandlerTestCase(PySparkBaseTestCase):
         )
 
     @staticmethod
-    def _agg_handler(handler_cls, *udfs, runner_conf=None):
+    def _agg_handler(handler_cls, *udf_descs, runner_conf=None):
         """Build a grouped-agg/window-agg handler from one or more ``_udf`` 
records."""
         return handler_cls(
-            udfs=list(udfs), runner_conf=runner_conf or RunnerConf({}), 
eval_conf=None
+            udf_descs=list(udf_descs), runner_conf=runner_conf or 
RunnerConf({}), eval_conf=None
         )
 
     @staticmethod
@@ -172,10 +172,10 @@ class 
PandasEvalTypeHandlerRegistrationTests(PySparkBaseTestCase):
 class ArrowBatchedUDFLegacyPandasHandlerTests(PandasEvalTypeHandlerTestCase):
     _INPUT_TYPE = StructType([StructField("a", LongType()), StructField("b", 
LongType())])
 
-    def _handler(self, *udfs, runner_conf=None, input_type=None):
+    def _handler(self, *udf_descs, runner_conf=None, input_type=None):
         """Build the handler from one or more ``_udf`` records."""
         return ArrowBatchedUDFLegacyPandasHandler(
-            udfs=list(udfs),
+            udf_descs=list(udf_descs),
             runner_conf=runner_conf or RunnerConf({}),
             eval_conf=EvalConf({"input_type": (input_type or 
self._INPUT_TYPE).json()}),
         )
@@ -232,10 +232,10 @@ class 
ArrowBatchedUDFLegacyPandasHandlerTests(PandasEvalTypeHandlerTestCase):
 @unittest.skipIf(not (have_pandas and have_pyarrow), _missing_message)
 class PandasScalarUDFHandlerTests(PandasEvalTypeHandlerTestCase):
     @staticmethod
-    def _handler(*udfs, runner_conf=None):
+    def _handler(*udf_descs, runner_conf=None):
         """Build a PandasScalarUDFHandler from one or more ``_udf`` records."""
         return PandasScalarUDFHandler(
-            udfs=list(udfs), runner_conf=runner_conf or RunnerConf({}), 
eval_conf=None
+            udf_descs=list(udf_descs), runner_conf=runner_conf or 
RunnerConf({}), eval_conf=None
         )
 
     def test_invokes_udf_per_batch(self):
@@ -320,7 +320,7 @@ class 
PandasScalarIterUDFHandlerTests(PandasEvalTypeHandlerTestCase):
     def _handler(udf, return_type=None, args=(0,), runner_conf=None):
         """Build a PandasScalarIterUDFHandler whose one UDF reads the given 
arg offsets."""
         return PandasScalarIterUDFHandler(
-            udfs=[PandasEvalTypeHandlerTestCase._udf(udf, return_type, args)],
+            udf_descs=[PandasEvalTypeHandlerTestCase._udf(udf, return_type, 
args)],
             runner_conf=runner_conf or RunnerConf({}),
             eval_conf=None,
         )
@@ -413,7 +413,7 @@ class 
PandasMapUDFHandlerTests(PandasEvalTypeHandlerTestCase):
     def _handler(udf, return_type, runner_conf=None):
         """Build a PandasMapUDFHandler; map UDFs take no arg offsets."""
         return PandasMapUDFHandler(
-            udfs=[PandasEvalTypeHandlerTestCase._udf(udf, return_type, 
args=())],
+            udf_descs=[PandasEvalTypeHandlerTestCase._udf(udf, return_type, 
args=())],
             runner_conf=runner_conf or RunnerConf({}),
             eval_conf=None,
         )
diff --git 
a/python/pyspark/eval_handlers/tests/test_stateful_eval_type_handlers.py 
b/python/pyspark/eval_handlers/tests/test_stateful_eval_type_handlers.py
index 678561e64d77..ad8160cec554 100644
--- a/python/pyspark/eval_handlers/tests/test_stateful_eval_type_handlers.py
+++ b/python/pyspark/eval_handlers/tests/test_stateful_eval_type_handlers.py
@@ -117,9 +117,9 @@ class 
TransformWithStateHandlerTestCase(PySparkBaseTestCase):
     @classmethod
     def _handler(cls, handler_cls, udf, arg_offsets, runner_conf=None):
         return handler_cls(
-            udfs=[
+            udf_descs=[
                 UDF(
-                    raw_func=udf,
+                    funcs=[udf],
                     args_offsets=arg_offsets,
                     kwargs_offsets={},
                     return_type=cls._RETURN_TYPE,
@@ -235,9 +235,9 @@ class 
PandasGroupedMapWithStateUDFHandlerTests(PySparkBaseTestCase):
     @classmethod
     def _handler(cls, udf, runner_conf=None):
         return PandasGroupedMapWithStateUDFHandler(
-            udfs=[
+            udf_descs=[
                 UDF(
-                    raw_func=udf,
+                    funcs=[udf],
                     args_offsets=cls._ARG_OFFSETS,
                     kwargs_offsets={},
                     return_type=cls._RETURN_TYPE,
diff --git a/python/pyspark/eval_handlers/tests/test_udtf_eval_type_handlers.py 
b/python/pyspark/eval_handlers/tests/test_udtf_eval_type_handlers.py
index 7d9760921418..f4c165bf9b3d 100644
--- a/python/pyspark/eval_handlers/tests/test_udtf_eval_type_handlers.py
+++ b/python/pyspark/eval_handlers/tests/test_udtf_eval_type_handlers.py
@@ -162,7 +162,9 @@ class TableUDTFHandlerTests(UDTFEvalTypeHandlerTestCase):
 
     def _handler(self, udtf_cls, args=(0,), kwargs=None, return_type=None):
         udtf = self._udtf_record(udtf_cls, return_type or 
self._ROW_RETURN_TYPE, args, kwargs)
-        return TableUDTFHandler(udfs=[udtf], runner_conf=RunnerConf({}), 
eval_conf=EvalConf({}))
+        return TableUDTFHandler(
+            udf_descs=[udtf], runner_conf=RunnerConf({}), 
eval_conf=EvalConf({})
+        )
 
     def _run(self, handler, *rows):
         # Each input row is a list of argument values; each output is the 
tuple of rows one
@@ -271,7 +273,9 @@ class ArrowUDTFHandlerTests(UDTFEvalTypeHandlerTestCase):
     ):
         conf = {} if table_arg_offsets is None else {"table_arg_offsets": 
table_arg_offsets}
         udtf = self._udtf_record(udtf_cls, self._RETURN_TYPE, args, kwargs, 
partition_child_indexes)
-        return ArrowUDTFHandler(udfs=[udtf], runner_conf=RunnerConf({}), 
eval_conf=EvalConf(conf))
+        return ArrowUDTFHandler(
+            udf_descs=[udtf], runner_conf=RunnerConf({}), 
eval_conf=EvalConf(conf)
+        )
 
     def test_serializer(self):
         self.assertIsInstance(self._handler(_RecordingUDTF).serializer, 
ArrowStreamSerializer)
@@ -439,7 +443,7 @@ class ArrowTableUDTFHandlerTestsMixin:
     def _handler(self, udtf_cls, args=(0,), kwargs=None, return_type=None):
         udtf = self._udtf_record(udtf_cls, return_type or 
self._ROW_RETURN_TYPE, args, kwargs)
         return self.handler_cls(
-            udfs=[udtf],
+            udf_descs=[udtf],
             runner_conf=RunnerConf({}),
             eval_conf=EvalConf({"input_type": self._ROW_INPUT_TYPE.json()}),
         )
@@ -563,8 +567,10 @@ class UDTFHandlerInitTests(UDTFEvalTypeHandlerTestCase):
 
         handler = self._new(TableUDTFHandler, Udtf)
         self.assertIsInstance(handler._udtf, Udtf)
-        udtf = handler._udfs[0]
+        udtf = handler._udf_descs[0]
         self.assertIsInstance(udtf, UDTF)
+        self.assertIs(handler._udtf_desc, udtf)
+        self.assertFalse(hasattr(handler, "_udf_desc"))
         self.assertEqual(udtf.args_offsets, [0])
         self.assertEqual(udtf.kwargs_offsets, {})
         self.assertEqual(udtf.return_type, self._RETURN_TYPE)
diff --git 
a/python/pyspark/eval_handlers/tests/test_vanilla_eval_type_handlers.py 
b/python/pyspark/eval_handlers/tests/test_vanilla_eval_type_handlers.py
index fe6aafb75143..def01b7ca8a2 100644
--- a/python/pyspark/eval_handlers/tests/test_vanilla_eval_type_handlers.py
+++ b/python/pyspark/eval_handlers/tests/test_vanilla_eval_type_handlers.py
@@ -40,18 +40,18 @@ with patch.dict(os.environ, {"SPARK_PYTHON_RUNTIME": 
"PYTHON_WORKER"}):
 
 class BatchedUDFHandlerTests(PySparkBaseTestCase):
     @staticmethod
-    def _handler(*udfs, kwargs_offsets=None, return_type=None):
+    def _handler(*udf_descs, kwargs_offsets=None, return_type=None):
         """Build the handler from ``(arg_offsets, func)`` pairs."""
         return BatchedUDFHandler(
-            udfs=[
+            udf_descs=[
                 UDF(
-                    raw_func=f,
+                    funcs=[f],
                     args_offsets=arg_offsets,
                     kwargs_offsets=kwargs_offsets or {},
                     return_type=return_type or LongType(),
                     result_id=0,
                 )
-                for arg_offsets, f in udfs
+                for arg_offsets, f in udf_descs
             ],
             runner_conf=RunnerConf({}),
             eval_conf=EvalConf({}),
diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index 563b4d9dfe3d..dc20ee355c7d 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -27,7 +27,7 @@ import json
 import os
 import sys
 import warnings
-from typing import Any, BinaryIO, Callable
+from typing import BinaryIO
 
 from pyspark import shuffle
 from pyspark.accumulators import _accumulatorRegistry
@@ -41,12 +41,11 @@ from pyspark.messages import (
     SparkSocketMessageReceiver,
 )
 from pyspark.serializers import (
-    FramedSerializer,
     SpecialLengths,
     write_int,
     write_with_length,
 )
-from pyspark.sql.types import DataType, StructType, _parse_datatype_json_string
+from pyspark.sql.types import StructType, _parse_datatype_json_string
 from pyspark.taskcontext import BarrierTaskContext, TaskContext
 from pyspark.util import (
     PythonEvalType,
@@ -77,23 +76,6 @@ def report_metrics(outfile: BinaryIO, metrics: dict[str, 
int]) -> None:
     write_with_length(payload, outfile)
 
 
-def chain(f: Callable[..., Any], g: Callable[..., Any]) -> Callable[..., Any]:
-    """chain two functions together"""
-    return lambda *a: g(f(*a))
-
-
-def unpickle_and_chain(
-    pickleSer: FramedSerializer, pickled_udfs: list[memoryview]
-) -> tuple[Callable[..., Any], DataType]:
-    """Unpickle the functions applied in turn to one column and chain them 
into one; returns the
-    chained function and the return type of the last one."""
-    chained_func, return_type = read_command(pickleSer, pickled_udfs[0])
-    for udf in pickled_udfs[1:]:
-        f, return_type = read_command(pickleSer, udf)
-        chained_func = chain(chained_func, f)
-    return chained_func, return_type
-
-
 def invoke_udf(message_receiver: SparkMessageReceiver, outfile: BinaryIO):
     """
     This function is the main processing function for worker.py.
@@ -170,22 +152,28 @@ def invoke_udf(message_receiver: SparkMessageReceiver, 
outfile: BinaryIO):
                     analyze_result=analyze_result,
                     name=init_info.udf_info.name,
                 )
-                handler = handler_cls(udfs=[udtf], runner_conf=runner_conf, 
eval_conf=eval_conf)
+                handler = handler_cls(
+                    udf_descs=[udtf], runner_conf=runner_conf, 
eval_conf=eval_conf
+                )
             else:
                 assert isinstance(init_info.udf_info, list)
-                udfs = []
+                udf_descs = []
                 for udf_info in init_info.udf_info:
-                    chained_func, return_type = unpickle_and_chain(pickleSer, 
udf_info.udfs)
-                    udfs.append(
+                    # The functions applied in turn to one column; the last 
one's return type
+                    # is the UDF's.
+                    funcs, return_types = zip(*(read_command(pickleSer, c) for 
c in udf_info.udfs))
+                    udf_descs.append(
                         UDF(
-                            raw_func=chained_func,
+                            funcs=list(funcs),
                             args_offsets=udf_info.args,
                             kwargs_offsets=udf_info.kwargs,
-                            return_type=return_type,
+                            return_type=return_types[-1],
                             result_id=udf_info.result_id,
                         )
                     )
-                handler = handler_cls(udfs=udfs, runner_conf=runner_conf, 
eval_conf=eval_conf)
+                handler = handler_cls(
+                    udf_descs=udf_descs, runner_conf=runner_conf, 
eval_conf=eval_conf
+                )
             func, serializer = handler.run, handler.serializer
             deserializer = serializer
 


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

Reply via email to