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

HyukjinKwon 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 b18b7d316721 [SPARK-58736][SQL][PYTHON][CONNECT] Support incremental 
Python aggregators via Arrow
b18b7d316721 is described below

commit b18b7d316721f9d7f479fc08e8d8f4383b51111a
Author: Hyukjin Kwon <[email protected]>
AuthorDate: Wed Aug 19 19:22:26 2026 +0900

    [SPARK-58736][SQL][PYTHON][CONNECT] Support incremental Python aggregators 
via Arrow
    
    ### What changes were proposed in this pull request?
    
    Adds a Python analog of the Scala typed 
`org.apache.spark.sql.expressions.Aggregator[IN, BUF, OUT]`
    with **true incremental (partial) aggregation** — i.e. map-side combine, 
not whole-group
    materialization.
    
    Users subclass a new `Aggregator` base class (`zero` / `reduce` / `merge` / 
`finish` +
    `bufferSchema`) and wrap it with `udaf(...)` for use in 
`groupBy().agg(...)`:
    
    ```python
    from pyspark.sql.aggregator import Aggregator, udaf
    from pyspark.sql.types import StructType, StructField, DoubleType, LongType
    
    class Mean(Aggregator):
        property
        def bufferSchema(self):
            return StructType([StructField("sum", DoubleType()), 
StructField("count", LongType())])
        property
        def outputType(self):
            return DoubleType()
        def zero(self):           return (0.0, 0)
        def reduce(self, buf, v): return (buf[0] + v[0], buf[1] + 1)
        def merge(self, a, b):    return (a[0] + b[0], a[1] + b[1])
        def finish(self, buf):    return buf[0] / buf[1] if buf[1] else None
    
    df.groupBy("k").agg(udaf(Mean())(df.v))
    ```
    
    Unlike grouped-agg pandas/arrow UDFs (`PythonUDAF` + 
`ArrowAggregatePythonExec`), which collect the
    whole group and call Python once, this is planned as a **two-stage 
aggregation**:
    
    - a map-side **PARTIAL** stage folds each group's input rows into a 
per-group buffer via `reduce`;
    - the buffers are shuffled by the grouping key (as an Arrow struct column);
    - a **FINAL** stage merges the partial buffers via `merge` and produces the 
output via `finish`.
    
    Because `merge` is associative/commutative, the result is independent of 
partition count.
    
    Class hierarchy / trace:
    
    - `PythonEvalType`: new `SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF` 
(255) and `..._FINAL_UDF`
      (256), added on both the Python (`pyspark.util`) and JVM 
(`api.python.PythonEvalType`) sides.
    - Catalyst: new `PythonAggregate` expression (an 
`UnevaluableAggregateFunc`, like `PythonUDAF`)
      carrying the intermediate `bufferSchema`.
    - Planning: `SparkStrategies.Aggregation` routes an all-`PythonAggregate` 
aggregate to
      `PythonIncrementalAggregateExec.plan(...)`, which builds
      `PythonIncrementalAggregatePartialExec` -> (Exchange, inserted by 
`EnsureRequirements`) ->
      `PythonIncrementalAggregateFinalExec`. Both operators reuse 
`ArrowPythonWithNamedArgumentRunner`
      + `GroupedPythonArrowInput`.
    - Worker (`worker.py`): a PARTIAL handler that folds input batches into a 
buffer via `reduce`, and
      a FINAL handler that merges partial-buffer rows via `merge` then `finish`.
    - The buffer schema is threaded to the JVM via a new nullable `bufferType` 
on
      `UserDefinedPythonFunction` (an auxiliary constructor preserves the 
existing Py4J arity).
    
    **Spark Connect**: also supported. A new optional `buffer_type` field on 
the `PythonUDF` proto
    message carries the buffer schema to the server; the Connect client 
(`connect/udf.py`,
    `connect/expressions.py`) serializes it and `udaf` dispatches on 
`is_remote()`; the server
    `SparkConnectPlanner` threads `buffer_type` into 
`UserDefinedPythonFunction` and builds
    `PythonAggregate`, after which execution reuses the same operators/worker 
code as classic.
    
    **SQL registration**: `spark.udf.register("my_agg", udaf(agg))` works in 
both classic and Connect,
    so the aggregator is usable from SQL text (`SELECT my_agg(v) FROM t GROUP 
BY k`) — the counterpart
    of Scala's `spark.udf.register(name, functions.udaf(agg))`.
    
    Out of scope (planned follow-ups): `DISTINCT`, mixing with SQL aggregate 
functions in
    one `Aggregate`, window/streaming, real disk spill (currently the map side 
bounds memory by
    per-partition grouping; associativity makes early partial emission safe), 
and a typed-columnar vs.
    pickled buffer performance variant.
    
    ### Why are the changes needed?
    
    PySpark has no incremental user-defined aggregator: every 
custom-aggregation path (grouped-agg
    `pandas_udf`/`arrow_udf`, `applyInPandas`) materializes the whole group and 
invokes Python once,
    with no map-side combine or partial/merge across the shuffle. This adds the 
missing
    `Aggregator`-style abstraction with genuine partial aggregation, matching 
the Scala typed
    `Aggregator`.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes — a new public API: `pyspark.sql.aggregator.Aggregator` and 
`udaf(...)`, usable in
    `groupBy().agg(...)` and registrable via `spark.udf.register(...)` for use 
in SQL. No existing
    behavior changes.
    
    ### How was this patch tested?
    
    - Compilation verified: `sql/compile` (catalyst + core + sql) builds 
cleanly with the new
      expression, operators, and planner routing.
    - Added `python/pyspark/sql/tests/arrow/test_arrow_python_aggregator.py`
      (`ArrowPythonAggregatorTests`): checks the incremental aggregator matches 
built-in `avg`/`sum`,
      a no-group case, a custom buffer, and that results are independent of 
partition count (exercising
      partial + merge), plus `test_sql_registration` (register via 
`spark.udf.register`, invoke from
      SQL text). A Connect parity suite (`ArrowPythonAggregatorParityTests`) 
runs the same mixin
      under `ReusedConnectTestCase`.
    - Compilation verified for both classic and Connect: `sql/compile` and 
`connect/compile` build
      cleanly (including proto regeneration). Full test execution runs in this 
PR's CI.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8)
    
    Closes #57952 from HyukjinKwon/SPARK-python-arrow-incremental-aggregator.
    
    Authored-by: Hyukjin Kwon <[email protected]>
    Signed-off-by: Hyukjin Kwon <[email protected]>
    (cherry picked from commit 9c5094adda0008bd4d683c387b613da9afab46ef)
    Signed-off-by: Hyukjin Kwon <[email protected]>
---
 .../src/main/resources/error/error-conditions.json |  12 +
 .../org/apache/spark/api/python/PythonRunner.scala |  12 +
 dev/sparktestsupport/modules.py                    |   2 +
 python/pyspark/sql/aggregator.py                   | 223 ++++++++++
 python/pyspark/sql/connect/client/core.py          |   3 +
 python/pyspark/sql/connect/expressions.py          |   5 +
 .../pyspark/sql/connect/proto/expressions_pb2.py   |  48 +--
 .../pyspark/sql/connect/proto/expressions_pb2.pyi  |  24 +-
 python/pyspark/sql/connect/udf.py                  |  30 +-
 python/pyspark/sql/pandas/_typing/__init__.pyi     |   2 +
 .../tests/arrow/test_arrow_python_aggregator.py    | 456 +++++++++++++++++++++
 .../arrow/test_parity_arrow_python_aggregator.py   |  29 ++
 .../sql/tests/pandas/test_pandas_grouped_map.py    |   3 +-
 python/pyspark/sql/udf.py                          |  45 +-
 python/pyspark/util.py                             |   9 +
 python/pyspark/worker.py                           | 217 ++++++++++
 .../spark/sql/catalyst/analysis/Analyzer.scala     |   3 +-
 .../sql/catalyst/analysis/FunctionResolution.scala |   8 +-
 .../sql/catalyst/analysis/WindowResolution.scala   |  10 +
 .../spark/sql/catalyst/expressions/PythonUDF.scala |  49 +++
 .../spark/sql/catalyst/plans/NormalizePlan.scala   |   2 +
 .../spark/sql/errors/QueryCompilationErrors.scala  |  17 +
 .../main/protobuf/spark/connect/expressions.proto  |   3 +
 .../sql/connect/planner/SparkConnectPlanner.scala  |  13 +-
 .../spark/sql/execution/SparkStrategies.scala      |  46 ++-
 .../python/PythonIncrementalAggregateExec.scala    | 393 ++++++++++++++++++
 .../python/UserDefinedPythonFunction.scala         |  27 +-
 27 files changed, 1644 insertions(+), 47 deletions(-)

diff --git a/common/utils/src/main/resources/error/error-conditions.json 
b/common/utils/src/main/resources/error/error-conditions.json
index 1d29a257e312..1053b7c5a8d1 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -4897,6 +4897,18 @@
     ],
     "sqlState" : "42602"
   },
+  "INVALID_PYTHON_AGGREGATOR_BUFFER_SCHEMA" : {
+    "message" : [
+      "The incremental Python aggregate function <functionName> requires a 
struct (StructType) intermediate buffer schema, but got <bufferType>."
+    ],
+    "sqlState" : "42000"
+  },
+  "INVALID_PYTHON_UDF_PLACEMENT" : {
+    "message" : [
+      "The Python user-defined aggregate function(s) <functionList> cannot be 
invoked together with other kinds of aggregate function in the same 
aggregation. Multiple such functions may be used together, but not mixed with 
SQL or differently-typed aggregate functions."
+    ],
+    "sqlState" : "0A000"
+  },
   "INVALID_QUERY_MIXED_QUERY_PARAMETERS" : {
     "message" : [
       "Parameterized query must either use positional, or named parameters, 
but not both."
diff --git a/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala 
b/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
index d7800b1147e1..015067766c9c 100644
--- a/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
+++ b/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
@@ -87,6 +87,14 @@ private[spark] object PythonEvalType {
   val SQL_WINDOW_AGG_ARROW_UDF = 253
   val SQL_GROUPED_AGG_ARROW_ITER_UDF = 254
 
+  // Incremental (partial + final) Arrow aggregator. Unlike the whole-group 
grouped-agg UDFs
+  // above, these support true partial aggregation: the PARTIAL eval type 
folds input rows into a
+  // per-group buffer (via the aggregator's `reduce`) on the map side, and the 
FINAL eval type
+  // merges partial buffers across the shuffle (via `merge`) and produces the 
output (via `finish`).
+  // See PythonIncrementalAggregateExec and the Python `Aggregator` API.
+  val SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF = 255
+  val SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF = 256
+
   val SQL_TABLE_UDF = 300
   val SQL_ARROW_TABLE_UDF = 301
   val SQL_ARROW_UDTF = 302
@@ -130,6 +138,10 @@ private[spark] object PythonEvalType {
     case SQL_GROUPED_AGG_ARROW_UDF => "SQL_GROUPED_AGG_ARROW_UDF"
     case SQL_WINDOW_AGG_ARROW_UDF => "SQL_WINDOW_AGG_ARROW_UDF"
     case SQL_GROUPED_AGG_ARROW_ITER_UDF => "SQL_GROUPED_AGG_ARROW_ITER_UDF"
+    case SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF =>
+      "SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF"
+    case SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF =>
+      "SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF"
   }
 
   // The eval types produced by ExtractPythonUDFFromLambda: a scalar UDF 
lifted out of a
diff --git a/dev/sparktestsupport/modules.py b/dev/sparktestsupport/modules.py
index e70a5983d931..d5ef8e7b54ab 100644
--- a/dev/sparktestsupport/modules.py
+++ b/dev/sparktestsupport/modules.py
@@ -611,6 +611,7 @@ pyspark_sql = Module(
         "pyspark.sql.tests.arrow.test_arrow_cogrouped_map",
         "pyspark.sql.tests.arrow.test_arrow_cogrouped_map_misc",
         "pyspark.sql.tests.arrow.test_arrow_grouped_map",
+        "pyspark.sql.tests.arrow.test_arrow_python_aggregator",
         "pyspark.sql.tests.arrow.test_arrow_python_udf",
         "pyspark.sql.tests.arrow.test_arrow_python_udf_cached",
         "pyspark.sql.tests.arrow.test_arrow_udf",
@@ -1256,6 +1257,7 @@ pyspark_connect = Module(
         "pyspark.sql.tests.connect.arrow.test_parity_arrow_grouped_map",
         "pyspark.sql.tests.connect.arrow.test_parity_arrow_cogrouped_map",
         "pyspark.sql.tests.connect.arrow.test_parity_arrow_cogrouped_map_misc",
+        "pyspark.sql.tests.connect.arrow.test_parity_arrow_python_aggregator",
         "pyspark.sql.tests.connect.arrow.test_parity_arrow_python_udf",
         "pyspark.sql.tests.connect.arrow.test_parity_arrow_udf",
         "pyspark.sql.tests.connect.arrow.test_parity_arrow_udf_scalar",
diff --git a/python/pyspark/sql/aggregator.py b/python/pyspark/sql/aggregator.py
new file mode 100644
index 000000000000..77110b8f88a3
--- /dev/null
+++ b/python/pyspark/sql/aggregator.py
@@ -0,0 +1,223 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""
+Incremental user-defined aggregators for PySpark, the Python analog of Scala's
+``org.apache.spark.sql.expressions.Aggregator``.
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any, Tuple
+
+from pyspark.errors import PySparkNotImplementedError, PySparkTypeError, 
PySparkValueError
+from pyspark.sql.types import DataType, StructType
+from pyspark.util import PythonEvalType
+
+__all__ = ["Aggregator", "udaf"]
+
+
+class Aggregator(ABC):
+    """
+    Base class for a user-defined *incremental* aggregator, the Python analog 
of Scala's
+    :class:`org.apache.spark.sql.expressions.Aggregator`.
+
+    Unlike a grouped-aggregate ``pandas_udf`` (which materializes the whole 
group and is invoked
+    once), an :class:`Aggregator` is executed as a genuine two-stage 
aggregation with map-side
+    combine: :meth:`reduce` folds input rows into a per-group *buffer* on the 
map side, the buffers
+    are shuffled by the grouping key, :meth:`merge` combines the partial 
buffers of each group, and
+    :meth:`finish` produces the final output value.
+
+    The buffer is represented as a Python :class:`tuple` whose elements 
correspond, in order, to the
+    fields of :attr:`bufferSchema`. An input row is likewise a tuple of the 
argument values passed
+    to the aggregator call. :meth:`merge` must be associative and commutative 
(the framework may
+    combine partial buffers in any order), and :meth:`zero` must be its 
identity element -- see
+    :meth:`zero` for the identity law that makes the result independent of the 
partition count.
+
+    .. versionadded:: 4.4.0
+
+    Examples
+    --------
+    A mean aggregator::
+
+        from pyspark.sql.aggregator import Aggregator, udaf
+        from pyspark.sql.types import StructType, StructField, DoubleType, 
LongType
+
+        class Mean(Aggregator):
+            @property
+            def bufferSchema(self):
+                return StructType([
+                    StructField("sum", DoubleType()),
+                    StructField("count", LongType()),
+                ])
+
+            @property
+            def outputType(self):
+                return DoubleType()
+
+            def zero(self):
+                return (0.0, 0)
+
+            def reduce(self, buffer, value):
+                (v,) = value
+                if v is None:  # ignore null inputs, like SQL aggregates do
+                    return buffer
+                return (buffer[0] + v, buffer[1] + 1)
+
+            def merge(self, b1, b2):
+                return (b1[0] + b2[0], b1[1] + b2[1])
+
+            def finish(self, buffer):
+                return buffer[0] / buffer[1] if buffer[1] else None
+
+        mean = udaf(Mean())
+        df.groupBy("k").agg(mean(df.v)).show()
+    """
+
+    @property
+    @abstractmethod
+    def bufferSchema(self) -> StructType:
+        """The schema of the intermediate buffer that crosses the shuffle."""
+        ...
+
+    @property
+    @abstractmethod
+    def outputType(self) -> DataType:
+        """The data type of the aggregator's output value."""
+        ...
+
+    @abstractmethod
+    def zero(self) -> Tuple[Any, ...]:
+        """The initial (identity) buffer value, as a tuple matching 
:attr:`bufferSchema`.
+
+        This must be the identity element for :meth:`merge`::
+
+            merge(buffer, zero()) == buffer
+            merge(zero(), buffer) == buffer
+
+        A fresh ``zero()`` seeds every partition -- and every early-flushed 
chunk of the map-side
+        combine -- so associativity and commutativity of :meth:`merge` alone 
do not guarantee a
+        partition-independent result; the identity law above is what makes the 
aggregate value
+        independent of how the input is split across partitions and batches.
+        """
+        ...
+
+    @abstractmethod
+    def reduce(self, buffer: Tuple[Any, ...], value: Tuple[Any, ...]) -> 
Tuple[Any, ...]:
+        """Fold a single input row ``value`` into ``buffer`` and return the 
updated buffer."""
+        ...
+
+    @abstractmethod
+    def merge(self, buffer1: Tuple[Any, ...], buffer2: Tuple[Any, ...]) -> 
Tuple[Any, ...]:
+        """Merge two partial buffers into one. Must be associative and 
commutative."""
+        ...
+
+    @abstractmethod
+    def finish(self, buffer: Tuple[Any, ...]) -> Any:
+        """Produce the output value from the final merged buffer."""
+        ...
+
+    # The aggregator instance is shipped to the worker as the UDF "function"; 
making it callable
+    # lets it satisfy ``UserDefinedFunction``'s ``callable`` check. It is 
never actually invoked as
+    # a function -- the worker calls 
:meth:`zero`/:meth:`reduce`/:meth:`merge`/:meth:`finish`.
+    def __call__(self, *args: Any, **kwargs: Any) -> Any:
+        raise PySparkNotImplementedError(
+            errorClass="NOT_IMPLEMENTED",
+            messageParameters={"feature": "calling an Aggregator directly; 
wrap it with udaf(...)"},
+        )
+
+
+def udaf(agg: "Aggregator") -> Any:
+    """
+    Turn an :class:`Aggregator` instance into a callable usable in 
``groupBy().agg(...)``, the
+    Python counterpart of Scala's ``functions.udaf``.
+
+    The aggregator is executed with true incremental (partial) aggregation and 
transfers its
+    intermediate buffer as Arrow; PyArrow is therefore required.
+
+    .. versionadded:: 4.4.0
+
+    Parameters
+    ----------
+    agg : :class:`Aggregator`
+        The aggregator instance.
+
+    Returns
+    -------
+    function
+        A callable that, applied to input columns, produces an aggregate 
:class:`Column`.
+
+    Raises
+    ------
+    :class:`PySparkImportError`
+        If a supported version of PyArrow is not installed.
+    :class:`PySparkTypeError`
+        If ``agg`` is not an :class:`Aggregator`, or its ``bufferSchema`` is 
not a
+        :class:`StructType`.
+    """
+    from pyspark.sql.pandas.utils import require_minimum_pyarrow_version
+    from pyspark.sql.utils import is_remote
+
+    require_minimum_pyarrow_version()
+
+    if is_remote():
+        from pyspark.sql.connect.udf import UserDefinedFunction
+    else:
+        # The classic UserDefinedFunction is a distinct class from the Connect 
one above;
+        # both provide the same interface used below, so silence mypy's 
reassignment check.
+        from pyspark.sql.udf import UserDefinedFunction  # type: 
ignore[assignment]
+
+    if not isinstance(agg, Aggregator):
+        raise PySparkTypeError(
+            errorClass="NOT_EXPECTED_TYPE",
+            messageParameters={
+                "arg_name": "agg",
+                "expected_type": "Aggregator",
+                "arg_type": type(agg).__name__,
+            },
+        )
+    if not isinstance(agg.bufferSchema, StructType):
+        raise PySparkTypeError(
+            errorClass="NOT_EXPECTED_TYPE",
+            messageParameters={
+                "arg_name": "bufferSchema",
+                "expected_type": "StructType",
+                "arg_type": type(agg.bufferSchema).__name__,
+            },
+        )
+    # The buffer crosses the shuffle as an Arrow struct whose children are 
matched by name, and the
+    # worker keys the buffer tuple back by field name. Duplicate names would 
silently collapse
+    # fields on the map side and then fail with an opaque Arrow error 
post-shuffle, so reject them
+    # up front where the aggregator is created.
+    field_names = [field.name for field in agg.bufferSchema.fields]
+    if len(field_names) != len(set(field_names)):
+        duplicates = sorted({name for name in field_names if 
field_names.count(name) > 1})
+        raise PySparkValueError(
+            errorClass="DUPLICATED_FIELD_NAME_IN_ARROW_STRUCT",
+            messageParameters={"field_names": ", ".join(duplicates)},
+        )
+
+    # ``bufferSchema`` is a first-class ``UserDefinedFunction`` field 
(threaded to the JVM in
+    # ``_create_judf`` so ``PythonAggregate`` can plan the two-stage 
aggregation), so it survives
+    # ``_wrapped()`` and ``spark.udf.register`` without being re-attached.
+    udf_obj = UserDefinedFunction(
+        agg,
+        returnType=agg.outputType,
+        name=agg.__class__.__name__,
+        evalType=PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
+        deterministic=True,
+        bufferSchema=agg.bufferSchema,
+    )
+    return udf_obj._wrapped()
diff --git a/python/pyspark/sql/connect/client/core.py 
b/python/pyspark/sql/connect/client/core.py
index 0ff6f0ae90f9..462ad8fe2670 100644
--- a/python/pyspark/sql/connect/client/core.py
+++ b/python/pyspark/sql/connect/client/core.py
@@ -1081,6 +1081,7 @@ class SparkConnectClient(object):
         name: Optional[str] = None,
         eval_type: int = PythonEvalType.SQL_BATCHED_UDF,
         deterministic: bool = True,
+        buffer_type: Optional["DataType"] = None,
     ) -> str:
         """
         Create a temporary UDF in the session catalog on the other side. We 
generate a
@@ -1096,6 +1097,8 @@ class SparkConnectClient(object):
             eval_type=eval_type,
             func=function,
             python_ver="%d.%d" % sys.version_info[:2],
+            # Set for the incremental aggregator (see pyspark.sql.aggregator).
+            buffer_type=buffer_type,
         )
 
         # construct a CommonInlineUserDefinedFunction
diff --git a/python/pyspark/sql/connect/expressions.py 
b/python/pyspark/sql/connect/expressions.py
index 57270398118f..bbe0dfa39478 100644
--- a/python/pyspark/sql/connect/expressions.py
+++ b/python/pyspark/sql/connect/expressions.py
@@ -741,6 +741,7 @@ class PythonUDF:
         eval_type: int,
         func: Callable[..., Any],
         python_ver: str,
+        buffer_type: Optional[DataType] = None,
     ) -> None:
         self._output_type: DataType = (
             UnparsedDataType(output_type) if isinstance(output_type, str) else 
output_type
@@ -748,6 +749,8 @@ class PythonUDF:
         self._eval_type = eval_type
         self._func = func
         self._python_ver = python_ver
+        # Intermediate buffer schema for an incremental Python aggregator; 
None otherwise.
+        self._buffer_type = buffer_type
 
     def to_plan(self, session: "SparkConnectClient") -> proto.PythonUDF:
         if isinstance(self._output_type, UnparsedDataType):
@@ -763,6 +766,8 @@ class PythonUDF:
         expr.eval_type = self._eval_type
         expr.command = CloudPickleSerializer().dumps((self._func, output_type))
         expr.python_ver = self._python_ver
+        if self._buffer_type is not None:
+            
expr.buffer_type.CopyFrom(pyspark_types_to_proto_types(self._buffer_type))
         return expr
 
     def __repr__(self) -> str:
diff --git a/python/pyspark/sql/connect/proto/expressions_pb2.py 
b/python/pyspark/sql/connect/proto/expressions_pb2.py
index aa51c393c043..5bb6335cfe3b 100644
--- a/python/pyspark/sql/connect/proto/expressions_pb2.py
+++ b/python/pyspark/sql/connect/proto/expressions_pb2.py
@@ -41,7 +41,7 @@ from pyspark.sql.connect.proto import common_pb2 as 
spark_dot_connect_dot_common
 
 
 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
-    
b'\n\x1fspark/connect/expressions.proto\x12\rspark.connect\x1a\x19google/protobuf/any.proto\x1a\x19spark/connect/types.proto\x1a\x1aspark/connect/common.proto"\x90<\n\nExpression\x12\x37\n\x06\x63ommon\x18\x12
 
\x01(\x0b\x32\x1f.spark.connect.ExpressionCommonR\x06\x63ommon\x12=\n\x07literal\x18\x01
 
\x01(\x0b\x32!.spark.connect.Expression.LiteralH\x00R\x07literal\x12\x62\n\x14unresolved_attribute\x18\x02
 
\x01(\x0b\x32-.spark.connect.Expression.UnresolvedAttributeH\x00R\x13unresolvedAtt
 [...]
+    
b'\n\x1fspark/connect/expressions.proto\x12\rspark.connect\x1a\x19google/protobuf/any.proto\x1a\x19spark/connect/types.proto\x1a\x1aspark/connect/common.proto"\x90<\n\nExpression\x12\x37\n\x06\x63ommon\x18\x12
 
\x01(\x0b\x32\x1f.spark.connect.ExpressionCommonR\x06\x63ommon\x12=\n\x07literal\x18\x01
 
\x01(\x0b\x32!.spark.connect.Expression.LiteralH\x00R\x07literal\x12\x62\n\x14unresolved_attribute\x18\x02
 
\x01(\x0b\x32-.spark.connect.Expression.UnresolvedAttributeH\x00R\x13unresolvedAtt
 [...]
 )
 
 _globals = globals()
@@ -135,27 +135,27 @@ if not _descriptor._USE_C_DESCRIPTORS:
     _globals["_COMMONINLINEUSERDEFINEDFUNCTION"]._serialized_start = 7899
     _globals["_COMMONINLINEUSERDEFINEDFUNCTION"]._serialized_end = 8296
     _globals["_PYTHONUDF"]._serialized_start = 8299
-    _globals["_PYTHONUDF"]._serialized_end = 8503
-    _globals["_SCALARSCALAUDF"]._serialized_start = 8506
-    _globals["_SCALARSCALAUDF"]._serialized_end = 8720
-    _globals["_JAVAUDF"]._serialized_start = 8723
-    _globals["_JAVAUDF"]._serialized_end = 8872
-    _globals["_TYPEDAGGREGATEEXPRESSION"]._serialized_start = 8874
-    _globals["_TYPEDAGGREGATEEXPRESSION"]._serialized_end = 8973
-    _globals["_CALLFUNCTION"]._serialized_start = 8975
-    _globals["_CALLFUNCTION"]._serialized_end = 9083
-    _globals["_NAMEDARGUMENTEXPRESSION"]._serialized_start = 9085
-    _globals["_NAMEDARGUMENTEXPRESSION"]._serialized_end = 9177
-    _globals["_MERGEACTION"]._serialized_start = 9180
-    _globals["_MERGEACTION"]._serialized_end = 9692
-    _globals["_MERGEACTION_ASSIGNMENT"]._serialized_start = 9402
-    _globals["_MERGEACTION_ASSIGNMENT"]._serialized_end = 9508
-    _globals["_MERGEACTION_ACTIONTYPE"]._serialized_start = 9511
-    _globals["_MERGEACTION_ACTIONTYPE"]._serialized_end = 9678
-    _globals["_SUBQUERYEXPRESSION"]._serialized_start = 9695
-    _globals["_SUBQUERYEXPRESSION"]._serialized_end = 10404
-    _globals["_SUBQUERYEXPRESSION_TABLEARGOPTIONS"]._serialized_start = 10001
-    _globals["_SUBQUERYEXPRESSION_TABLEARGOPTIONS"]._serialized_end = 10235
-    _globals["_SUBQUERYEXPRESSION_SUBQUERYTYPE"]._serialized_start = 10238
-    _globals["_SUBQUERYEXPRESSION_SUBQUERYTYPE"]._serialized_end = 10382
+    _globals["_PYTHONUDF"]._serialized_end = 8582
+    _globals["_SCALARSCALAUDF"]._serialized_start = 8585
+    _globals["_SCALARSCALAUDF"]._serialized_end = 8799
+    _globals["_JAVAUDF"]._serialized_start = 8802
+    _globals["_JAVAUDF"]._serialized_end = 8951
+    _globals["_TYPEDAGGREGATEEXPRESSION"]._serialized_start = 8953
+    _globals["_TYPEDAGGREGATEEXPRESSION"]._serialized_end = 9052
+    _globals["_CALLFUNCTION"]._serialized_start = 9054
+    _globals["_CALLFUNCTION"]._serialized_end = 9162
+    _globals["_NAMEDARGUMENTEXPRESSION"]._serialized_start = 9164
+    _globals["_NAMEDARGUMENTEXPRESSION"]._serialized_end = 9256
+    _globals["_MERGEACTION"]._serialized_start = 9259
+    _globals["_MERGEACTION"]._serialized_end = 9771
+    _globals["_MERGEACTION_ASSIGNMENT"]._serialized_start = 9481
+    _globals["_MERGEACTION_ASSIGNMENT"]._serialized_end = 9587
+    _globals["_MERGEACTION_ACTIONTYPE"]._serialized_start = 9590
+    _globals["_MERGEACTION_ACTIONTYPE"]._serialized_end = 9757
+    _globals["_SUBQUERYEXPRESSION"]._serialized_start = 9774
+    _globals["_SUBQUERYEXPRESSION"]._serialized_end = 10483
+    _globals["_SUBQUERYEXPRESSION_TABLEARGOPTIONS"]._serialized_start = 10080
+    _globals["_SUBQUERYEXPRESSION_TABLEARGOPTIONS"]._serialized_end = 10314
+    _globals["_SUBQUERYEXPRESSION_SUBQUERYTYPE"]._serialized_start = 10317
+    _globals["_SUBQUERYEXPRESSION_SUBQUERYTYPE"]._serialized_end = 10461
 # @@protoc_insertion_point(module_scope)
diff --git a/python/pyspark/sql/connect/proto/expressions_pb2.pyi 
b/python/pyspark/sql/connect/proto/expressions_pb2.pyi
index c613ade2f43f..22b65357c234 100644
--- a/python/pyspark/sql/connect/proto/expressions_pb2.pyi
+++ b/python/pyspark/sql/connect/proto/expressions_pb2.pyi
@@ -1826,6 +1826,7 @@ class PythonUDF(google.protobuf.message.Message):
     COMMAND_FIELD_NUMBER: builtins.int
     PYTHON_VER_FIELD_NUMBER: builtins.int
     ADDITIONAL_INCLUDES_FIELD_NUMBER: builtins.int
+    BUFFER_TYPE_FIELD_NUMBER: builtins.int
     @property
     def output_type(self) -> pyspark.sql.connect.proto.types_pb2.DataType:
         """(Required) Output type of the Python UDF"""
@@ -1840,6 +1841,11 @@ class PythonUDF(google.protobuf.message.Message):
         self,
     ) -> 
google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]:
         """(Optional) Additional includes for the Python UDF."""
+    @property
+    def buffer_type(self) -> pyspark.sql.connect.proto.types_pb2.DataType:
+        """(Optional) Intermediate buffer schema for an incremental Python 
aggregator
+        (see PythonAggregate). Set only for the incremental aggregator eval 
types.
+        """
     def __init__(
         self,
         *,
@@ -1848,15 +1854,28 @@ class PythonUDF(google.protobuf.message.Message):
         command: builtins.bytes = ...,
         python_ver: builtins.str = ...,
         additional_includes: collections.abc.Iterable[builtins.str] | None = 
...,
+        buffer_type: pyspark.sql.connect.proto.types_pb2.DataType | None = ...,
     ) -> None: ...
     def HasField(
-        self, field_name: typing_extensions.Literal["output_type", 
b"output_type"]
+        self,
+        field_name: typing_extensions.Literal[
+            "_buffer_type",
+            b"_buffer_type",
+            "buffer_type",
+            b"buffer_type",
+            "output_type",
+            b"output_type",
+        ],
     ) -> builtins.bool: ...
     def ClearField(
         self,
         field_name: typing_extensions.Literal[
+            "_buffer_type",
+            b"_buffer_type",
             "additional_includes",
             b"additional_includes",
+            "buffer_type",
+            b"buffer_type",
             "command",
             b"command",
             "eval_type",
@@ -1867,6 +1886,9 @@ class PythonUDF(google.protobuf.message.Message):
             b"python_ver",
         ],
     ) -> None: ...
+    def WhichOneof(
+        self, oneof_group: typing_extensions.Literal["_buffer_type", 
b"_buffer_type"]
+    ) -> typing_extensions.Literal["buffer_type"] | None: ...
 
 global___PythonUDF = PythonUDF
 
diff --git a/python/pyspark/sql/connect/udf.py 
b/python/pyspark/sql/connect/udf.py
index c9848634eb30..7acf255e2f06 100644
--- a/python/pyspark/sql/connect/udf.py
+++ b/python/pyspark/sql/connect/udf.py
@@ -32,7 +32,7 @@ from pyspark.sql.connect.expressions import (
     PythonUDF,
 )
 from pyspark.sql.connect.column import Column
-from pyspark.sql.types import DataType, StringType, _parse_datatype_string
+from pyspark.sql.types import DataType, StringType, StructType, 
_parse_datatype_string
 from pyspark.sql.udf import (
     UDFRegistration as PySparkUDFRegistration,
     UserDefinedFunction as PySparkUserDefinedFunction,
@@ -113,10 +113,16 @@ def _create_udf(
     evalType: int,
     name: Optional[str] = None,
     deterministic: bool = True,
+    bufferSchema: Optional[StructType] = None,
 ) -> "UserDefinedFunctionLike":
     # Set the name of the UserDefinedFunction object to be the name of 
function f
     udf_obj = UserDefinedFunction(
-        f, returnType=returnType, name=name, evalType=evalType, 
deterministic=deterministic
+        f,
+        returnType=returnType,
+        name=name,
+        evalType=evalType,
+        deterministic=deterministic,
+        bufferSchema=bufferSchema,
     )
     return udf_obj._wrapped()
 
@@ -139,6 +145,7 @@ class UserDefinedFunction:
         name: Optional[str] = None,
         evalType: int = PythonEvalType.SQL_BATCHED_UDF,
         deterministic: bool = True,
+        bufferSchema: Optional[StructType] = None,
     ):
         if not callable(func):
             raise PySparkTypeError(
@@ -178,6 +185,10 @@ class UserDefinedFunction:
         )
         self.evalType = evalType
         self.deterministic = deterministic
+        # Intermediate aggregation buffer schema, set only for an incremental 
Python aggregator
+        # (see :class:`pyspark.sql.aggregator.Aggregator`); ``None`` 
otherwise. A first-class field
+        # so it survives ``_wrapped()``, ``asNondeterministic()`` and 
``spark.udf.register``.
+        self.bufferSchema = bufferSchema
 
     @property
     def returnType(self) -> DataType:
@@ -210,6 +221,8 @@ class UserDefinedFunction:
             eval_type=self.evalType,
             func=self.func,
             python_ver="%d.%d" % sys.version_info[:2],
+            # Set for incremental Python aggregators (see 
pyspark.sql.aggregator).
+            buffer_type=self.bufferSchema,
         )
         return CommonInlineUserDefinedFunction(
             function_name=self._name,
@@ -253,6 +266,7 @@ class UserDefinedFunction:
         wrapper.returnType = self.returnType  # type: ignore[attr-defined]
         wrapper.evalType = self.evalType  # type: ignore[attr-defined]
         wrapper.deterministic = self.deterministic  # type: 
ignore[attr-defined]
+        wrapper.bufferSchema = self.bufferSchema  # type: ignore[attr-defined]
         wrapper.asNondeterministic = functools.wraps(  # type: 
ignore[attr-defined]
             self.asNondeterministic
         )(lambda: self.asNondeterministic()._wrapped())
@@ -303,6 +317,7 @@ class UDFRegistration:
                 PythonEvalType.SQL_GROUPED_AGG_ARROW_UDF,
                 PythonEvalType.SQL_GROUPED_AGG_PANDAS_ITER_UDF,
                 PythonEvalType.SQL_GROUPED_AGG_ARROW_ITER_UDF,
+                PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
             ]:
                 raise PySparkTypeError(
                     errorClass="INVALID_UDF_EVAL_TYPE",
@@ -311,11 +326,18 @@ class UDFRegistration:
                         "SQL_SCALAR_PANDAS_UDF, SQL_SCALAR_ARROW_UDF, "
                         "SQL_SCALAR_PANDAS_ITER_UDF, 
SQL_SCALAR_ARROW_ITER_UDF, "
                         "SQL_GROUPED_AGG_PANDAS_UDF, 
SQL_GROUPED_AGG_ARROW_UDF, "
-                        "SQL_GROUPED_AGG_PANDAS_ITER_UDF or 
SQL_GROUPED_AGG_ARROW_ITER_UDF"
+                        "SQL_GROUPED_AGG_PANDAS_ITER_UDF, 
SQL_GROUPED_AGG_ARROW_ITER_UDF "
+                        "or SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF"
                     },
                 )
             self.sparkSession._client.register_udf(
-                f.func, f.returnType, name, f.evalType, f.deterministic
+                f.func,
+                f.returnType,
+                name,
+                f.evalType,
+                f.deterministic,
+                # Set for the incremental aggregator (see 
pyspark.sql.aggregator).
+                buffer_type=getattr(f, "bufferSchema", None),
             )
             return f
         else:
diff --git a/python/pyspark/sql/pandas/_typing/__init__.pyi 
b/python/pyspark/sql/pandas/_typing/__init__.pyi
index f989443a4dd9..442849bcbf0a 100644
--- a/python/pyspark/sql/pandas/_typing/__init__.pyi
+++ b/python/pyspark/sql/pandas/_typing/__init__.pyi
@@ -68,6 +68,8 @@ ArrowScalarIterUDFType = Literal[251]
 ArrowGroupedAggUDFType = Literal[252]
 ArrowWindowAggUDFType = Literal[253]
 ArrowGroupedAggIterUDFType = Literal[254]
+ArrowGroupedAggIncrementalPartialUDFType = Literal[255]
+ArrowGroupedAggIncrementalFinalUDFType = Literal[256]
 
 # Arrow stream types
 # A single group of Arrow batches (e.g., one key group in groupBy).
diff --git a/python/pyspark/sql/tests/arrow/test_arrow_python_aggregator.py 
b/python/pyspark/sql/tests/arrow/test_arrow_python_aggregator.py
new file mode 100644
index 000000000000..3965d804ae84
--- /dev/null
+++ b/python/pyspark/sql/tests/arrow/test_arrow_python_aggregator.py
@@ -0,0 +1,456 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+import unittest
+from decimal import Decimal
+
+from pyspark.errors import AnalysisException, PySparkValueError
+from pyspark.sql import functions as sf
+from pyspark.sql.window import Window
+from pyspark.sql.types import (
+    DecimalType,
+    DoubleType,
+    LongType,
+    StructField,
+    StructType,
+)
+from pyspark.util import PythonEvalType
+from pyspark.testing.sqlutils import ReusedSQLTestCase
+from pyspark.testing.utils import (
+    have_pyarrow,
+    pyarrow_requirement_message,
+)
+
+
+if have_pyarrow:
+    from pyspark.sql.aggregator import Aggregator, udaf
+
+    class Mean(Aggregator):
+        @property
+        def bufferSchema(self):
+            return StructType([StructField("sum", DoubleType()), 
StructField("count", LongType())])
+
+        @property
+        def outputType(self):
+            return DoubleType()
+
+        def zero(self):
+            return (0.0, 0)
+
+        def reduce(self, buffer, value):
+            (v,) = value
+            if v is None:  # ignore nulls, like SQL avg
+                return buffer
+            return (buffer[0] + v, buffer[1] + 1)
+
+        def merge(self, b1, b2):
+            return (b1[0] + b2[0], b1[1] + b2[1])
+
+        def finish(self, buffer):
+            return buffer[0] / buffer[1] if buffer[1] else None
+
+    class DecimalSum(Aggregator):
+        # Non-trivial output/buffer type: the result column and the 
intermediate buffer are both
+        # DecimalType, exercising explicit Arrow typing of the emitted arrays 
(a bare
+        # ``pa.array([Decimal(...)])`` would infer a decimal type whose 
precision/scale need not
+        # match the declared one).
+        @property
+        def bufferSchema(self):
+            return StructType([StructField("total", DecimalType(20, 4))])
+
+        @property
+        def outputType(self):
+            return DecimalType(20, 4)
+
+        def zero(self):
+            return (Decimal(0),)
+
+        def reduce(self, buffer, value):
+            (v,) = value
+            return buffer if v is None else (buffer[0] + Decimal(str(v)),)
+
+        def merge(self, b1, b2):
+            return (b1[0] + b2[0],)
+
+        def finish(self, buffer):
+            return buffer[0]
+
+    class SumSquares(Aggregator):
+        @property
+        def bufferSchema(self):
+            return StructType([StructField("sumsq", DoubleType())])
+
+        @property
+        def outputType(self):
+            return DoubleType()
+
+        def zero(self):
+            return (0.0,)
+
+        def reduce(self, buffer, value):
+            (v,) = value
+            return (buffer[0] + float(v) * float(v),)
+
+        def merge(self, b1, b2):
+            return (b1[0] + b2[0],)
+
+        def finish(self, buffer):
+            return buffer[0]
+
+
[email protected](not have_pyarrow, pyarrow_requirement_message)
+class ArrowPythonAggregatorTestsMixin:
+    def _data(self):
+        # 100 rows across 5 keys; repartition so each key is split across 
partitions,
+        # exercising map-side PARTIAL combine + post-shuffle FINAL merge.
+        return (
+            self.spark.range(0, 100)
+            .select((sf.col("id") % 5).alias("k"), 
sf.col("id").cast("double").alias("v"))
+            .repartition(4, sf.col("v") % 3)
+        )
+
+    def test_incremental_aggregator_matches_builtin_mean(self):
+        df = self._data()
+        result = 
df.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).orderBy("k").collect()
+        expected = 
df.groupBy("k").agg(sf.avg("v").alias("m")).orderBy("k").collect()
+        got = {r["k"]: r["m"] for r in result}
+        exp = {r["k"]: r["m"] for r in expected}
+        self.assertEqual(got, exp)
+
+    def test_incremental_aggregator_no_group(self):
+        df = self._data()
+        result = df.agg(udaf(Mean())(sf.col("v")).alias("m")).collect()
+        expected = df.agg(sf.avg("v").alias("m")).collect()
+        self.assertAlmostEqual(result[0]["m"], expected[0]["m"], places=6)
+
+    def test_incremental_aggregator_empty_global_input(self):
+        # A global aggregation over empty input must still return one identity 
row: finish(zero).
+        empty = self._data().limit(0)
+        result = empty.agg(udaf(Mean())(sf.col("v")).alias("m")).collect()
+        expected = empty.agg(sf.avg("v").alias("m")).collect()
+        self.assertEqual(len(result), 1)
+        self.assertEqual(result[0]["m"], expected[0]["m"])
+
+    def test_incremental_aggregator_custom_buffer(self):
+        df = self._data()
+        result = (
+            
df.groupBy("k").agg(udaf(SumSquares())(sf.col("v")).alias("s")).orderBy("k").collect()
+        )
+        expected = (
+            df.groupBy("k").agg(sf.sum(sf.col("v") * 
sf.col("v")).alias("s")).orderBy("k").collect()
+        )
+        got = {r["k"]: r["s"] for r in result}
+        exp = {r["k"]: r["s"] for r in expected}
+        for k in exp:
+            self.assertAlmostEqual(got[k], exp[k], places=6)
+
+    def test_incremental_aggregator_decimal_output(self):
+        # Non-trivial output/buffer type (DecimalType), crossing the shuffle 
as a decimal buffer
+        # and emitted as a decimal result -- guards the explicit Arrow typing 
of both stages.
+        df = self._data()
+        result = (
+            
df.groupBy("k").agg(udaf(DecimalSum())(sf.col("v")).alias("s")).orderBy("k").collect()
+        )
+        expected = 
df.groupBy("k").agg(sf.sum("v").alias("s")).orderBy("k").collect()
+        got = {r["k"]: r["s"] for r in result}
+        exp = {r["k"]: r["s"] for r in expected}
+        for k in exp:
+            self.assertIsInstance(got[k], Decimal)
+            self.assertAlmostEqual(float(got[k]), exp[k], places=4)
+
+    def test_incremental_aggregator_null_inputs(self):
+        # reduce must tolerate null input values; the null-skipping Mean 
should match SQL avg,
+        # including a group whose values are all null (identity buffer -> 
finish returns None).
+        df = self.spark.createDataFrame(
+            [("a", 1.0), ("a", None), ("a", 3.0), ("b", None), ("b", None)],
+            "k string, v double",
+        )
+        result = 
df.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).orderBy("k").collect()
+        expected = 
df.groupBy("k").agg(sf.avg("v").alias("m")).orderBy("k").collect()
+        got = {r["k"]: r["m"] for r in result}
+        exp = {r["k"]: r["m"] for r in expected}
+        self.assertEqual(got, exp)
+
+    def test_multiple_incremental_aggregators(self):
+        # Two aggregators with different buffer schemas over the same input in 
one agg call.
+        df = self._data()
+        result = (
+            df.groupBy("k")
+            .agg(
+                udaf(Mean())(sf.col("v")).alias("m"),
+                udaf(SumSquares())(sf.col("v")).alias("s"),
+            )
+            .orderBy("k")
+            .collect()
+        )
+        expected = (
+            df.groupBy("k")
+            .agg(
+                sf.avg("v").alias("m"),
+                sf.sum(sf.col("v") * sf.col("v")).alias("s"),
+            )
+            .orderBy("k")
+            .collect()
+        )
+        got_m = {r["k"]: r["m"] for r in result}
+        got_s = {r["k"]: r["s"] for r in result}
+        for r in expected:
+            self.assertAlmostEqual(got_m[r["k"]], r["m"], places=6)
+            self.assertAlmostEqual(got_s[r["k"]], r["s"], places=6)
+
+    def test_result_independent_of_partition_count(self):
+        # Partial buffers must merge to the same result regardless of how keys 
are split.
+        base = self.spark.range(0, 60).select(
+            (sf.col("id") % 3).alias("k"), 
sf.col("id").cast("double").alias("v")
+        )
+        results = []
+        for n in (1, 2, 7):
+            rows = (
+                base.repartition(n, sf.col("v"))
+                .groupBy("k")
+                .agg(udaf(Mean())(sf.col("v")).alias("m"))
+                .orderBy("k")
+                .collect()
+            )
+            results.append({r["k"]: r["m"] for r in rows})
+        self.assertEqual(results[0], results[1])
+        self.assertEqual(results[1], results[2])
+
+    def test_sql_registration(self):
+        # Register the aggregator and invoke it from SQL text.
+        df = self._data()
+        df.createOrReplaceTempView("agg_input")
+        self.spark.udf.register("my_mean", udaf(Mean()))
+        result = self.spark.sql(
+            "SELECT k, my_mean(v) AS m FROM agg_input GROUP BY k ORDER BY k"
+        ).collect()
+        expected = 
df.groupBy("k").agg(sf.avg("v").alias("m")).orderBy("k").collect()
+        got = {r["k"]: r["m"] for r in result}
+        exp = {r["k"]: r["m"] for r in expected}
+        self.assertEqual(got, exp)
+
+    def test_named_arguments(self):
+        # A named argument (both DataFrame and SQL forms) must feed the 
aggregator's value tuple,
+        # not be silently dropped.
+        df = self._data()
+        result = 
df.groupBy("k").agg(udaf(Mean())(v=sf.col("v")).alias("m")).orderBy("k").collect()
+        expected = 
df.groupBy("k").agg(sf.avg("v").alias("m")).orderBy("k").collect()
+        self.assertEqual({r["k"]: r["m"] for r in result}, {r["k"]: r["m"] for 
r in expected})
+
+        df.createOrReplaceTempView("agg_input")
+        self.spark.udf.register("my_mean", udaf(Mean()))
+        sql_result = self.spark.sql(
+            "SELECT k, my_mean(v => v) AS m FROM agg_input GROUP BY k ORDER BY 
k"
+        ).collect()
+        self.assertEqual({r["k"]: r["m"] for r in sql_result}, {r["k"]: r["m"] 
for r in expected})
+
+    def test_distinct_and_filter_rejected(self):
+        # Neither DISTINCT nor FILTER is honored by the two-stage operator, so 
both must be
+        # rejected at analysis rather than silently returning the 
non-distinct/unfiltered result.
+        df = self._data()
+        df.createOrReplaceTempView("agg_input")
+        self.spark.udf.register("my_mean", udaf(Mean()))
+        with self.assertRaises(AnalysisException):
+            self.spark.sql("SELECT my_mean(DISTINCT v) FROM agg_input GROUP BY 
k").collect()
+        with self.assertRaises(AnalysisException):
+            self.spark.sql(
+                "SELECT my_mean(v) FILTER (WHERE v > 0) FROM agg_input GROUP 
BY k"
+            ).collect()
+
+    def test_pivot_rejected(self):
+        # ResolvePivot must reject the incremental aggregator (its 
null-ignoring fallback rewrite
+        # would produce wrong results), like it already rejects pandas UDAFs.
+        df = self.spark.createDataFrame(
+            [("a", "x", 1.0), ("a", "y", 2.0), ("b", "x", 3.0)],
+            "k string, p string, v double",
+        )
+        with self.assertRaises(AnalysisException):
+            df.groupBy("k").pivot("p").agg(udaf(Mean())(sf.col("v"))).collect()
+
+    def test_window_rejected(self):
+        # The incremental aggregator has no window operator; using it over a 
window must fail
+        # analysis rather than crash with an internal error at execution.
+        df = self._data()
+        with self.assertRaises(AnalysisException):
+            
df.select(udaf(Mean())(sf.col("v")).over(Window.partitionBy("k"))).collect()
+
+    def test_mixed_with_other_aggregate_rejected(self):
+        # An incremental aggregator mixed with another aggregate in one 
Aggregate is unsupported;
+        # the error must be the dedicated (non-pandas) placement error.
+        df = self._data()
+        with self.assertRaises(AnalysisException) as ctx:
+            df.groupBy("k").agg(
+                udaf(Mean())(sf.col("v")).alias("m"), sf.count("*").alias("c")
+            ).collect()
+        self.assertEqual(ctx.exception.getCondition(), 
"INVALID_PYTHON_UDF_PLACEMENT")
+
+    def test_duplicate_buffer_field_names_rejected(self):
+        # Duplicate buffer field names silently collapse on the map side and 
then fail with an
+        # opaque Arrow error post-shuffle; reject them where the aggregator is 
created.
+        class DupBuffer(Mean):
+            @property
+            def bufferSchema(self):
+                return StructType([StructField("x", DoubleType()), 
StructField("x", LongType())])
+
+        with self.assertRaises(PySparkValueError) as ctx:
+            udaf(DupBuffer())
+        self.check_error(
+            exception=ctx.exception,
+            errorClass="DUPLICATED_FIELD_NAME_IN_ARROW_STRUCT",
+            messageParameters={"field_names": "x"},
+        )
+
+    def test_float_grouping_keys_normalized(self):
+        # 0.0 / -0.0 must fall into one group, and NaN keys (unequal to 
themselves) must group
+        # together, matching SQL aggregate semantics -- guards grouping-key 
normalization and the
+        # NaN handling of the map-side hash combine.
+        zeros = self.spark.createDataFrame(
+            [(0.0, 1.0), (-0.0, 2.0), (0.0, 3.0)], "k double, v double"
+        )
+        zero_rows = 
zeros.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).collect()
+        self.assertEqual(len(zero_rows), 1)
+        self.assertAlmostEqual(zero_rows[0]["m"], 2.0, places=6)
+
+        nans = self.spark.createDataFrame(
+            [(float("nan"), 1.0), (float("nan"), 3.0)], "k double, v double"
+        )
+        nan_rows = 
nans.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).collect()
+        self.assertEqual(len(nan_rows), 1)
+        self.assertAlmostEqual(nan_rows[0]["m"], 2.0, places=6)
+
+    def test_complex_grouping_key(self):
+        # A struct grouping key exercises the map-side hash combine's 
canonicalization of complex
+        # keys (dict -> hashable) as well as the authoritative FINAL 
re-grouping.
+        df = self.spark.createDataFrame(
+            [(1, "a", 1.0), (1, "a", 3.0), (2, "b", 10.0)],
+            "i int, s string, v double",
+        ).select(sf.struct("i", "s").alias("k"), sf.col("v"))
+        result = 
df.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).collect()
+        got = {(r["k"]["i"], r["k"]["s"]): r["m"] for r in result}
+        self.assertEqual(got, {(1, "a"): 2.0, (2, "b"): 10.0})
+
+    def test_bounded_map_side_combine(self):
+        # A small maxRecordsPerBatch forces the map-side PARTIAL stage to 
flush its per-key buffer
+        # in bounded chunks (emitting duplicate keys that the FINAL stage 
re-merges authoritatively)
+        # instead of holding every key for the whole partition. The result 
must be unchanged --
+        # this guards the cap/flush + chunked-emission path against OOM on 
high-cardinality keys.
+        df = self._data()
+        with self.sql_conf({"spark.sql.execution.arrow.maxRecordsPerBatch": 
2}):
+            result = (
+                
df.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).orderBy("k").collect()
+            )
+        expected = 
df.groupBy("k").agg(sf.avg("v").alias("m")).orderBy("k").collect()
+        self.assertEqual({r["k"]: r["m"] for r in result}, {r["k"]: r["m"] for 
r in expected})
+
+    def test_missing_buffer_schema_rejected(self):
+        # udaf() enforces a struct buffer schema up front, but a low-level 
construction (or a
+        # malformed Connect proto) can build an incremental aggregator UDF 
without one. That must
+        # surface a classed planner error, not a bare IllegalArgumentException 
/ ClassCastException.
+        from pyspark.sql.utils import is_remote
+
+        if is_remote():
+            from pyspark.sql.connect.udf import UserDefinedFunction
+        else:
+            from pyspark.sql.udf import UserDefinedFunction  # type: 
ignore[assignment]
+
+        bad = UserDefinedFunction(
+            Mean(),
+            returnType=DoubleType(),
+            name="bad_mean",
+            
evalType=PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
+            deterministic=True,
+        )._wrapped()
+        df = self._data()
+        with self.assertRaises(AnalysisException) as ctx:
+            df.groupBy("k").agg(bad(sf.col("v"))).collect()
+        self.assertEqual(ctx.exception.getCondition(), 
"INVALID_PYTHON_AGGREGATOR_BUFFER_SCHEMA")
+
+    def test_mixed_pandas_udaf_and_incremental_rejected(self):
+        # Mixing a grouped-agg pandas UDAF with an incremental aggregator in 
one Aggregate is
+        # unsupported. It falls through to the dedicated placement error, 
which must name BOTH
+        # offending functions rather than dropping the co-offending pandas 
UDAF.
+        from pyspark.sql.functions import pandas_udf, PandasUDFType
+
+        @pandas_udf("double", PandasUDFType.GROUPED_AGG)
+        def pandas_mean(v):
+            return v.mean()
+
+        df = self._data()
+        with self.assertRaises(AnalysisException) as ctx:
+            df.groupBy("k").agg(
+                udaf(Mean())(sf.col("v")).alias("m"),
+                pandas_mean(sf.col("v")).alias("pm"),
+            ).collect()
+        self.assertEqual(ctx.exception.getCondition(), 
"INVALID_PYTHON_UDF_PLACEMENT")
+        message = ctx.exception.getMessage()
+        self.assertIn("Mean", message)
+        self.assertIn("pandas_mean", message)
+
+
+class ArrowPythonAggregatorTests(ArrowPythonAggregatorTestsMixin, 
ReusedSQLTestCase):
+    pass
+
+
[email protected](not have_pyarrow, pyarrow_requirement_message)
+class ArrowPythonAggregatorProfilerTests(unittest.TestCase):
+    # Profiling is not supported for incremental aggregators: their ``func`` 
is an ``Aggregator``
+    # object, so the profiler wrappers would either break the worker (a plain 
function has no
+    # ``bufferSchema`` / ``zero`` / ``reduce``) or fail on the driver in
+    # ``inspect.getsourcelines(f.__code__)``. Enabling a profiler must 
therefore fall back to the
+    # non-profiled path (with a warning) and still compute the correct result, 
not crash. These
+    # confs are set at session creation, so this needs its own session 
(classic only).
+    def _run(self, conf_key):
+        import warnings
+
+        from pyspark import SparkConf
+        from pyspark.sql import SparkSession
+
+        conf = SparkConf().set(conf_key, "true")
+        spark = (
+            SparkSession.builder.master("local[4]")
+            .config(conf=conf)
+            .appName(self.__class__.__name__)
+            .getOrCreate()
+        )
+        try:
+            df = spark.range(0, 20).select(
+                (sf.col("id") % 3).alias("k"), 
sf.col("id").cast("double").alias("v")
+            )
+            with warnings.catch_warnings(record=True) as caught:
+                warnings.simplefilter("always")
+                result = (
+                    
df.groupBy("k").agg(udaf(Mean())(sf.col("v")).alias("m")).orderBy("k").collect()
+                )
+            expected = 
df.groupBy("k").agg(sf.avg("v").alias("m")).orderBy("k").collect()
+            self.assertEqual({r["k"]: r["m"] for r in result}, {r["k"]: r["m"] 
for r in expected})
+            self.assertTrue(
+                any("incremental Python aggregators" in str(w.message) for w 
in caught),
+                "expected an unsupported-profiling warning",
+            )
+        finally:
+            spark.stop()
+
+    def test_cpu_profiler_falls_back(self):
+        self._run("spark.python.profile")
+
+    def test_memory_profiler_falls_back(self):
+        self._run("spark.python.profile.memory")
+
+
+if __name__ == "__main__":
+    from pyspark.testing import main
+
+    main()
diff --git 
a/python/pyspark/sql/tests/connect/arrow/test_parity_arrow_python_aggregator.py 
b/python/pyspark/sql/tests/connect/arrow/test_parity_arrow_python_aggregator.py
new file mode 100644
index 000000000000..5b49582e8391
--- /dev/null
+++ 
b/python/pyspark/sql/tests/connect/arrow/test_parity_arrow_python_aggregator.py
@@ -0,0 +1,29 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+from pyspark.sql.tests.arrow.test_arrow_python_aggregator import 
ArrowPythonAggregatorTestsMixin
+from pyspark.testing.connectutils import ReusedConnectTestCase
+
+
+class ArrowPythonAggregatorParityTests(ArrowPythonAggregatorTestsMixin, 
ReusedConnectTestCase):
+    pass
+
+
+if __name__ == "__main__":
+    from pyspark.testing import main
+
+    main()
diff --git a/python/pyspark/sql/tests/pandas/test_pandas_grouped_map.py 
b/python/pyspark/sql/tests/pandas/test_pandas_grouped_map.py
index 92fb2d0cb06a..d0031545d7b2 100644
--- a/python/pyspark/sql/tests/pandas/test_pandas_grouped_map.py
+++ b/python/pyspark/sql/tests/pandas/test_pandas_grouped_map.py
@@ -243,7 +243,8 @@ class ApplyInPandasTestsMixin:
                     "SQL_SCALAR_PANDAS_UDF, SQL_SCALAR_ARROW_UDF, "
                     "SQL_SCALAR_PANDAS_ITER_UDF, SQL_SCALAR_ARROW_ITER_UDF, "
                     "SQL_GROUPED_AGG_PANDAS_UDF, SQL_GROUPED_AGG_ARROW_UDF, "
-                    "SQL_GROUPED_AGG_PANDAS_ITER_UDF or 
SQL_GROUPED_AGG_ARROW_ITER_UDF"
+                    "SQL_GROUPED_AGG_PANDAS_ITER_UDF, 
SQL_GROUPED_AGG_ARROW_ITER_UDF "
+                    "or SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF"
                 },
             )
 
diff --git a/python/pyspark/sql/udf.py b/python/pyspark/sql/udf.py
index dbfc1b465086..51bdc58791c5 100644
--- a/python/pyspark/sql/udf.py
+++ b/python/pyspark/sql/udf.py
@@ -76,11 +76,17 @@ def _create_udf(
     evalType: int,
     name: Optional[str] = None,
     deterministic: bool = True,
+    bufferSchema: Optional[StructType] = None,
 ) -> "UserDefinedFunctionLike":
     """Create a regular(non-Arrow-optimized) Python UDF."""
     # Set the name of the UserDefinedFunction object to be the name of 
function f
     udf_obj = UserDefinedFunction(
-        f, returnType=returnType, name=name, evalType=evalType, 
deterministic=deterministic
+        f,
+        returnType=returnType,
+        name=name,
+        evalType=evalType,
+        deterministic=deterministic,
+        bufferSchema=bufferSchema,
     )
     return udf_obj._wrapped()
 
@@ -165,6 +171,7 @@ class UserDefinedFunction:
         name: Optional[str] = None,
         evalType: int = PythonEvalType.SQL_BATCHED_UDF,
         deterministic: bool = True,
+        bufferSchema: Optional[StructType] = None,
     ):
         if not callable(func):
             raise PySparkTypeError(
@@ -206,6 +213,12 @@ class UserDefinedFunction:
         )
         self.evalType = evalType
         self.deterministic = deterministic
+        # Schema of the intermediate aggregation buffer, set only for an 
incremental Python
+        # aggregator (see :class:`pyspark.sql.aggregator.Aggregator`); 
``None`` otherwise. It is a
+        # first-class field so it survives reconstruction paths such as 
``_wrapped()``,
+        # ``asNondeterministic()`` and ``spark.udf.register``, and is threaded 
to the JVM in
+        # ``_create_judf`` so ``PythonAggregate`` can plan the two-stage 
aggregation.
+        self.bufferSchema = bufferSchema
         # Extract Python UDF details if transpilation is enabled.
         self.transpiled: list = []
         self._transpiled_param_names: list[str] = []
@@ -517,6 +530,15 @@ class UserDefinedFunction:
         assert sc._jvm is not None
         transpiled = self.transpiled if include_transpiled else []
         input_categories = self._transpiled_input_categories if 
include_transpiled else []
+        # Incremental Python aggregators additionally carry the intermediate 
buffer schema, which
+        # the JVM needs at planning time to build the two-stage aggregation 
(see PythonAggregate).
+        # Everyone else passes ``None`` here, which Py4J maps to the JVM 
``null`` the ``bufferType``
+        # parameter already defaults to.
+        jbuf = (
+            spark._jsparkSession.parseDataType(self.bufferSchema.json())
+            if self.bufferSchema is not None
+            else None
+        )
         judf = getattr(sc._jvm, 
"org.apache.spark.sql.execution.python.UserDefinedPythonFunction")(
             self._name,
             wrapped_func,
@@ -525,6 +547,7 @@ class UserDefinedFunction:
             self.deterministic,
             map(_to_java_column_opt, transpiled),
             input_categories,
+            jbuf,
         )
         return judf
 
@@ -569,6 +592,19 @@ class UserDefinedFunction:
         memory_profiler_enabled = sc._conf.get("spark.python.profile.memory", 
"false") == "true"
 
         if profiler_enabled or memory_profiler_enabled:
+            # Profiling is not supported for incremental Python aggregators. 
Their ``self.func`` is
+            # an ``Aggregator`` object, not a plain function: the profiler 
wrappers below would
+            # replace it with a function the worker cannot drive (it has no 
``zero``/``reduce``/
+            # ``bufferSchema``), and the memory profiler's 
``inspect.getsourcelines(f.__code__)``
+            # fails on the driver because an ``Aggregator`` instance has no 
``__code__``.
+            if self.evalType == 
PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF:
+                warnings.warn(
+                    "Profiling incremental Python aggregators is not 
supported.",
+                    UserWarning,
+                )
+                judf = self._judf
+                return Column(judf.apply(_to_seq(sc, jcols)))
+
             # Disable profiling Pandas UDFs with iterators as input/output.
             if self.evalType in [
                 PythonEvalType.SQL_SCALAR_PANDAS_ITER_UDF,
@@ -676,6 +712,7 @@ class UserDefinedFunction:
         wrapper.returnType = self.returnType  # type: ignore[attr-defined]
         wrapper.evalType = self.evalType  # type: ignore[attr-defined]
         wrapper.deterministic = self.deterministic  # type: 
ignore[attr-defined]
+        wrapper.bufferSchema = self.bufferSchema  # type: ignore[attr-defined]
         wrapper.asNondeterministic = functools.wraps(  # type: 
ignore[attr-defined]
             self.asNondeterministic
         )(lambda: self.asNondeterministic()._wrapped())
@@ -839,6 +876,7 @@ class UDFRegistration:
                 PythonEvalType.SQL_GROUPED_AGG_ARROW_UDF,
                 PythonEvalType.SQL_GROUPED_AGG_PANDAS_ITER_UDF,
                 PythonEvalType.SQL_GROUPED_AGG_ARROW_ITER_UDF,
+                PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
             ]:
                 raise PySparkTypeError(
                     errorClass="INVALID_UDF_EVAL_TYPE",
@@ -847,7 +885,8 @@ class UDFRegistration:
                         "SQL_SCALAR_PANDAS_UDF, SQL_SCALAR_ARROW_UDF, "
                         "SQL_SCALAR_PANDAS_ITER_UDF, 
SQL_SCALAR_ARROW_ITER_UDF, "
                         "SQL_GROUPED_AGG_PANDAS_UDF, 
SQL_GROUPED_AGG_ARROW_UDF, "
-                        "SQL_GROUPED_AGG_PANDAS_ITER_UDF or 
SQL_GROUPED_AGG_ARROW_ITER_UDF"
+                        "SQL_GROUPED_AGG_PANDAS_ITER_UDF, 
SQL_GROUPED_AGG_ARROW_ITER_UDF "
+                        "or SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF"
                     },
                 )
             source_udf = _create_udf(
@@ -856,6 +895,8 @@ class UDFRegistration:
                 name=name,
                 evalType=f.evalType,
                 deterministic=f.deterministic,
+                # Preserve the incremental aggregator's buffer schema (None 
for other UDFs).
+                bufferSchema=getattr(f, "bufferSchema", None),
             )
             register_udf = source_udf._unwrapped  # type: ignore[attr-defined]
             return_udf = register_udf
diff --git a/python/pyspark/util.py b/python/pyspark/util.py
index 76be9bcc9998..81bea6152f98 100644
--- a/python/pyspark/util.py
+++ b/python/pyspark/util.py
@@ -86,6 +86,8 @@ if typing.TYPE_CHECKING:
         ArrowScalarIterUDFType,
         ArrowGroupedAggUDFType,
         ArrowGroupedAggIterUDFType,
+        ArrowGroupedAggIncrementalPartialUDFType,
+        ArrowGroupedAggIncrementalFinalUDFType,
         ArrowWindowAggUDFType,
     )
     from pyspark.sql._typing import (
@@ -699,6 +701,13 @@ class PythonEvalType:
     SQL_WINDOW_AGG_ARROW_UDF: "ArrowWindowAggUDFType" = 253
     SQL_GROUPED_AGG_ARROW_ITER_UDF: "ArrowGroupedAggIterUDFType" = 254
 
+    # Incremental (partial + final) Arrow aggregator. See 
``pyspark.sql.aggregator``.
+    # PARTIAL folds input rows into a per-group buffer via 
``Aggregator.reduce`` on the map side;
+    # FINAL merges partial buffers via ``Aggregator.merge`` and produces 
output via
+    # ``Aggregator.finish`` after the shuffle.
+    SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF: 
"ArrowGroupedAggIncrementalPartialUDFType" = 255
+    SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF: 
"ArrowGroupedAggIncrementalFinalUDFType" = 256
+
     SQL_TABLE_UDF: "SQLTableUDFType" = 300
     SQL_ARROW_TABLE_UDF: "SQLArrowTableUDFType" = 301
     SQL_ARROW_UDTF: "SQLArrowUDTFType" = 302
diff --git a/python/pyspark/worker.py b/python/pyspark/worker.py
index 5aa781a4c5e5..565323365c33 100644
--- a/python/pyspark/worker.py
+++ b/python/pyspark/worker.py
@@ -256,6 +256,42 @@ def chain(f, g):
     return lambda *a: g(f(*a))
 
 
+# Sentinel standing in for NaN grouping-key values in the map-side 
incremental-aggregate combine.
+# ``float('nan') != float('nan')``, so distinct NaN objects would never 
collide in a dict; mapping
+# them to one sentinel lets the map-side combine collapse them (correctness 
does not depend on it,
+# as the FINAL stage re-groups authoritatively).
+_NAN_GROUPING_KEY = object()
+
+
+def _hashable_grouping_key(key_values: Tuple[Any, ...]) -> Any:
+    """
+    Canonicalize a grouping-key value tuple (extracted from Arrow via 
``to_pylist``) into a
+    hashable form usable as a ``dict`` key for the map-side PARTIAL combine of 
incremental Python
+    aggregators.
+
+    Lists (from ``array`` columns) become tuples and dicts (from ``struct`` 
columns) become tuples
+    of ``(name, value)`` pairs, so nested complex keys hash by value; NaN 
floats map to a sentinel.
+    This is a best-effort combine only: an exotic unhashable value falls back 
to a unique object so
+    the row forms its own group, and the FINAL stage merges any keys left 
uncollapsed here.
+    """
+
+    def canon(v: Any) -> Any:
+        if isinstance(v, float) and v != v:
+            return _NAN_GROUPING_KEY
+        if isinstance(v, list):
+            return tuple(canon(x) for x in v)
+        if isinstance(v, dict):
+            return tuple((k, canon(x)) for k, x in v.items())
+        return v
+
+    try:
+        canonical = tuple(canon(v) for v in key_values)
+        hash(canonical)
+        return canonical
+    except TypeError:
+        return object()
+
+
 def verify_return_type(result: T, expected_type: Type[T]) -> T:
     """
     Verify a UDF return value against an expected type.
@@ -637,6 +673,15 @@ def read_single_udf(pickleSer, udf_info, eval_type, 
runner_conf, udf_index):
     # The last returnType will be the return type of UDF. Eval types are 
grouped below by the
     # shape of the value they return.
 
+    # Incremental Python aggregators: the pickled "function" is the Aggregator 
object itself, whose
+    # zero/reduce/merge/finish methods the worker calls directly. Return it 
unwrapped (not through
+    # fail_on_stopiteration, which would treat it as a plain callable).
+    if eval_type in (
+        PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF,
+        PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
+    ):
+        return chained_func, args_offsets, kwargs_offsets, return_type
+
     # Scalar, aggregation and window UDFs: (func, args_offsets, 
kwargs_offsets, return_type).
     if eval_type in (
         PythonEvalType.SQL_ARROW_BATCHED_UDF,
@@ -2019,6 +2064,8 @@ def read_udfs(pickleSer, udf_info_list, eval_type, 
runner_conf, eval_conf):
         PythonEvalType.SQL_TRANSFORM_WITH_STATE_PANDAS_INIT_STATE_UDF,
         PythonEvalType.SQL_TRANSFORM_WITH_STATE_PYTHON_ROW_UDF,
         PythonEvalType.SQL_TRANSFORM_WITH_STATE_PYTHON_ROW_INIT_STATE_UDF,
+        PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF,
+        PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
     ):
         # NOTE: if timezone is set here, that implies respectSessionTimeZone 
is True
         if eval_type in (
@@ -2026,6 +2073,10 @@ def read_udfs(pickleSer, udf_info_list, eval_type, 
runner_conf, eval_conf):
             PythonEvalType.SQL_GROUPED_AGG_ARROW_UDF,
             PythonEvalType.SQL_GROUPED_AGG_PANDAS_ITER_UDF,
             PythonEvalType.SQL_GROUPED_AGG_PANDAS_UDF,
+            # The map-side PARTIAL stage streams ordinary (multi-group) 
batches and hash-combines
+            # inside the worker, so it uses the plain (non-grouped) stream 
serializer below. Only
+            # the post-shuffle FINAL stage receives one Arrow stream per group.
+            PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
             PythonEvalType.SQL_GROUPED_MAP_ARROW_ITER_UDF,
             PythonEvalType.SQL_GROUPED_MAP_ARROW_UDF,
             PythonEvalType.SQL_GROUPED_MAP_PANDAS_ITER_UDF,
@@ -2272,6 +2323,172 @@ def read_udfs(pickleSer, udf_info_list, eval_type, 
runner_conf, eval_conf):
         # profiling is not supported for UDF
         return grouped_func, None, ser, ser
 
+    if eval_type == 
PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF:
+        import pyarrow as pa
+
+        # Map-side PARTIAL stage: hash-combine input rows into a per-group 
buffer via the
+        # aggregator's `reduce`. Ordinary (multi-group) batches are streamed 
in; the worker keeps
+        # one running buffer per distinct grouping key -- never whole groups 
of rows -- and, at end
+        # of partition, emits one row per key: the grouping key columns 
followed by one buffer
+        # struct column per aggregator. Because the FINAL stage re-groups 
these authoritatively
+        # after the shuffle, the worker's grouping only needs to be a 
best-effort combine: any keys
+        # it fails to collapse (e.g. NaN, which compares unequal to itself) 
are merged downstream.
+        #
+        # 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
+        num_grouping_keys = (
+            len(grouping_key_schema.fields) if grouping_key_schema is not None 
else 0
+        )
+
+        buffer_col_names = ["_%d" % i for i in range(len(udfs))]
+        buffer_arrow_types = [
+            to_arrow_type(
+                agg.bufferSchema,
+                timezone="UTC",
+                prefers_large_types=runner_conf.use_large_var_types,
+            )
+            for agg, _, _, _ in udfs
+        ]
+        # Buffer field names are invariant across groups; compute them once 
per aggregator.
+        field_names_by_udf = [[f.name for f in agg.bufferSchema.fields] for 
agg, _, _, _ in udfs]
+
+        # The aggregator's `reduce` receives a single positional tuple, so any 
named arguments at
+        # the call site are appended after the positional ones, in call order 
(kwargs_offsets
+        # preserves that order). This mirrors how a Python call `f(*args, 
**kwargs)` would order
+        # them into one value tuple.
+        input_offsets_by_udf = [
+            list(args_offsets) + list(kwargs_offsets.values())
+            for _, args_offsets, kwargs_offsets, _ in udfs
+        ]
+        # 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
+        # dedups its projection), so converting by distinct offset avoids 
repeated `to_pylist()`.
+        needed_offsets = sorted(
+            set(range(num_grouping_keys)) | {o for offsets in 
input_offsets_by_udf for o in offsets}
+        )
+
+        # Cap the map-side buffer so a high-cardinality partition -- exactly 
where partial
+        # aggregation degenerates -- cannot grow the per-key dict without 
bound and OOM the worker.
+        # When the distinct-key count reaches the cap we flush the whole map 
as one batch and start
+        # fresh; end-of-partition buffers are emitted in equally bounded 
chunks. This is safe
+        # because the FINAL stage re-groups the emitted partial buffers 
authoritatively after the
+        # shuffle and merges any duplicate keys that early flushes produce. A 
non-positive
+        # maxRecordsPerBatch means "unbounded" (mirroring the reader side).
+        max_records = runner_conf.arrow_max_records_per_batch
+        cap = max_records if max_records > 0 else None
+
+        def func(split_index: int, data: Iterator[pa.RecordBatch]) -> 
Iterator[pa.RecordBatch]:
+            # hashable key -> (representative key value tuple, list of 
per-aggregator buffers)
+            groups: "dict[Any, tuple]" = {}
+            key_field_types: Optional[list] = None
+
+            def make_batch(entries: list) -> pa.RecordBatch:
+                arrays = []
+                names = []
+                for j in range(num_grouping_keys):
+                    arrays.append(
+                        pa.array(
+                            [e[0][j] for e in entries],
+                            type=key_field_types[j],  # type: ignore
+                        )
+                    )
+                    names.append("k_%d" % j)
+                for i, (agg, _, _, _) in enumerate(udfs):
+                    field_names = field_names_by_udf[i]
+                    structs = [
+                        {name: e[1][i][t] for t, name in 
enumerate(field_names)} for e in entries
+                    ]
+                    arrays.append(pa.array(structs, 
type=buffer_arrow_types[i]))
+                    names.append(buffer_col_names[i])
+                return pa.RecordBatch.from_arrays(arrays, names)
+
+            for batch in data:
+                if key_field_types is None:
+                    key_field_types = [batch.schema.field(j).type for j in 
range(num_grouping_keys)]
+                pylist_by_offset = {o: batch.column(o).to_pylist() for o in 
needed_offsets}
+                key_cols = [pylist_by_offset[j] for j in 
range(num_grouping_keys)]
+                udf_cols = [
+                    [pylist_by_offset[o] for o in input_offsets_by_udf[i]] for 
i in range(len(udfs))
+                ]
+                for r in range(batch.num_rows):
+                    key_values = tuple(key_cols[j][r] for j in 
range(num_grouping_keys))
+                    hashable_key = _hashable_grouping_key(key_values)
+                    entry = groups.get(hashable_key)
+                    if entry is None:
+                        buffers = [agg.zero() for agg, _, _, _ in udfs]
+                        groups[hashable_key] = (key_values, buffers)
+                    else:
+                        buffers = entry[1]
+                    for i, (agg, _, _, _) in enumerate(udfs):
+                        cols_i = udf_cols[i]
+                        buffers[i] = agg.reduce(buffers[i], tuple(c[r] for c 
in cols_i))
+                if cap is not None and len(groups) >= cap:
+                    yield make_batch(list(groups.values()))
+                    groups = {}
+
+            if not groups:
+                # Empty partition (or fully flushed above): emit nothing more. 
The FINAL stage
+                # supplies the global-aggregation identity row when there is 
no input at all.
+                return
+
+            entries = list(groups.values())
+            if cap is None:
+                yield make_batch(entries)
+            else:
+                for start in range(0, len(entries), cap):
+                    yield make_batch(entries[start : start + cap])
+
+        # profiling is not supported for UDF
+        return func, None, ser, ser
+
+    if eval_type == PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF:
+        import pyarrow as pa
+
+        # Post-shuffle FINAL stage: merge each group's partial buffers via the 
aggregator's `merge`
+        # and produce the output via `finish`. Buffers are streamed and merged 
one batch at a time.
+        # Every group the JVM sends yields exactly one output row; null 
partial-buffer rows are
+        # skipped, so an empty global aggregation (a single all-null buffer 
row injected by the
+        # operator) still produces `finish(zero)`.
+        col_names = ["_%d" % i for i in range(len(udfs))]
+        return_schema = to_arrow_schema(
+            StructType([StructField(name, rt) for name, (_, _, _, rt) in 
zip(col_names, udfs)]),
+            timezone="UTC",
+            prefers_large_types=runner_conf.use_large_var_types,
+        )
+        # Buffer field names are invariant across groups and batches; compute 
once per aggregator.
+        field_names_by_udf = [[f.name for f in agg.bufferSchema.fields] for 
agg, _, _, _ in udfs]
+
+        def grouped_func(
+            split_index: int, data: Iterator["GroupedBatch"]
+        ) -> Iterator[pa.RecordBatch]:
+            for group in data:
+                merged: list = [None] * len(udfs)
+                for batch in group:
+                    for i, (agg, args_offsets, _, _) in enumerate(udfs):
+                        field_names = field_names_by_udf[i]
+                        m = merged[i]
+                        for row in batch.column(args_offsets[0]).to_pylist():
+                            if row is None:
+                                continue
+                            partial = tuple(row[name] for name in field_names)
+                            m = partial if m is None else agg.merge(m, partial)
+                        merged[i] = m
+                results = []
+                for i, (agg, _, _, _) in enumerate(udfs):
+                    m = merged[i] if merged[i] is not None else agg.zero()
+                    results.append(agg.finish(m))
+                # Type each output array explicitly (mirroring the PARTIAL 
stage) so a non-trivial
+                # outputType or an all-None column does not depend on Arrow 
type inference.
+                result_arrays = [
+                    pa.array([r], type=return_schema.field(i).type) for i, r 
in enumerate(results)
+                ]
+                batch = pa.RecordBatch.from_arrays(result_arrays, col_names)
+                yield ArrowBatchTransformer.enforce_schema(batch, 
return_schema)
+
+        # profiling is not supported for UDF
+        return grouped_func, None, ser, ser
+
     if eval_type == PythonEvalType.SQL_GROUPED_AGG_PANDAS_UDF:
         import pyarrow as pa
         import pandas as pd
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
index 176980e1fafd..af4f185d39aa 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala
@@ -968,7 +968,8 @@ class Analyzer(
     // TODO: Support Pandas UDF.
     private def checkValidAggregateExpression(expr: Expression): Unit = expr 
match {
       case a: AggregateExpression =>
-        if (a.aggregateFunction.isInstanceOf[PythonUDAF]) {
+        if (a.aggregateFunction.isInstanceOf[PythonUDAF] ||
+            a.aggregateFunction.isInstanceOf[PythonAggregate]) {
           throw 
QueryCompilationErrors.pandasUDFAggregateNotSupportedInPivotError()
         } else {
           // OK and leave the argument check to CheckAnalysis.
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala
index 40cf4471c12a..8dac7858724d 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/FunctionResolution.scala
@@ -530,8 +530,12 @@ class FunctionResolution(
         }
       // We get an aggregate function, we need to wrap it in an 
AggregateExpression.
       case agg: AggregateFunction =>
-        // Note: PythonUDAF does not support these advanced clauses.
-        if (agg.isInstanceOf[PythonUDAF]) checkUnsupportedAggregateClause(agg, 
unresolvedFunc)
+        // Note: neither PythonUDAF nor the incremental PythonAggregate 
support these advanced
+        // clauses (DISTINCT / FILTER / ORDER BY / IGNORE NULLS). They have 
dedicated physical
+        // operators that do not honor them, so reject rather than silently 
drop the clause.
+        if (agg.isInstanceOf[PythonUDAF] || agg.isInstanceOf[PythonAggregate]) 
{
+          checkUnsupportedAggregateClause(agg, unresolvedFunc)
+        }
         // After parse, the functions not set the ordering within group yet.
         val newAgg = agg match {
           case owg: SupportsOrderingWithinGroup
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/WindowResolution.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/WindowResolution.scala
index a477fe4398f6..6c81d2ab7f52 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/WindowResolution.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/WindowResolution.scala
@@ -21,6 +21,7 @@ import org.apache.spark.sql.catalyst.expressions.{
   AggregateWindowFunction,
   CurrentRow,
   FrameLessOffsetWindowFunction,
+  PythonAggregate,
   RangeFrame,
   RankLike,
   RowFrame,
@@ -163,6 +164,15 @@ object WindowResolution {
           errorClass = "INVALID_WINDOW_SPEC_FOR_AGGREGATION_FUNC",
           messageParameters = Map("aggFunc" -> 
toSQLExpr(agg.aggregateFunction))
         )
+      case AggregateExpression(_: PythonAggregate, _, _, _, _) =>
+        // The incremental Python aggregator has a dedicated two-stage 
physical operator and is not
+        // supported as a window function. Reject it here with a clear 
analysis error, rather than
+        // letting it be misclassified as a SQL aggregate 
(`WindowFunctionType.functionType` only
+        // routes `PythonUDAF` to the Python path) and fail with an internal 
error at execution.
+        windowExpression.failAnalysis(
+          errorClass = "UNSUPPORTED_EXPR_FOR_WINDOW",
+          messageParameters = Map("sqlExpr" -> 
toSQLExpr(windowExpression.windowFunction))
+        )
       case _: AggregateExpression | _: FrameLessOffsetWindowFunction | _: 
AggregateWindowFunction =>
       case other =>
         other.failAnalysis(
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala
index c7d9c49b640c..d62131183c49 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/PythonUDF.scala
@@ -404,6 +404,55 @@ case class PythonUDAF(
     copy(children = newChildren)
 }
 
+/**
+ * A serialized Python aggregator that supports true incremental (partial) 
aggregation, the
+ * analog of the Scala typed `org.apache.spark.sql.expressions.Aggregator[IN, 
BUF, OUT]`. Unlike
+ * [[PythonUDAF]] (which materializes the whole group and calls Python once), 
this is planned as a
+ * two-stage aggregation by
+ * [[org.apache.spark.sql.execution.python.PythonIncrementalAggregateExec]]: a 
map-side PARTIAL
+ * stage folds input rows into a per-group buffer via the aggregator's 
`reduce`, and a post-shuffle
+ * FINAL stage
+ * merges the partial buffers via `merge` and produces the output via `finish`.
+ *
+ * `bufferSchema` is the schema of the intermediate buffer that crosses the 
shuffle between the two
+ * stages (the analog of the Scala aggregator's `bufferEncoder`). It is 
exposed here rather than via
+ * [[aggBufferAttributes]] because, like [[PythonUDAF]], this expression is 
unevaluable in the JVM;
+ * the physical operator derives the buffer attributes from `bufferSchema` 
directly.
+ */
+case class PythonAggregate(
+    name: String,
+    func: PythonFunction,
+    dataType: DataType,
+    children: Seq[Expression],
+    udfDeterministic: Boolean,
+    bufferSchema: StructType,
+    evalType: Int = PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF,
+    resultId: ExprId = NamedExpression.newExprId)
+  extends UnevaluableAggregateFunc with PythonFuncExpression {
+
+  override def sql(isDistinct: Boolean): String = {
+    val distinct = if (isDistinct) "DISTINCT " else ""
+    s"$name($distinct${children.mkString(", ")})"
+  }
+
+  override def toAggString(isDistinct: Boolean): String = {
+    val start = if (isDistinct) "(distinct " else "("
+    name + children.mkString(start, ", ", ")") + s"#${resultId.id}$typeSuffix"
+  }
+
+  override lazy val canonicalized: Expression = {
+    val canonicalizedChildren = children.map(_.canonicalized)
+    // `resultId` can be seen as cosmetic variation, as it doesn't affect the 
result.
+    this.copy(resultId = ExprId(-1)).withNewChildren(canonicalizedChildren)
+  }
+
+  final override val nodePatterns: Seq[TreePattern] = Seq(PYTHON_UDF)
+
+  override protected def withNewChildrenInternal(
+      newChildren: IndexedSeq[Expression]): PythonAggregate =
+    copy(children = newChildren)
+}
+
 abstract class UnevaluableGenerator extends Generator {
   final override def eval(input: InternalRow): IterableOnce[InternalRow] =
     throw QueryExecutionErrors.cannotEvaluateExpressionError(this)
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/NormalizePlan.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/NormalizePlan.scala
index ff471cd6f00f..7242c39f07dd 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/NormalizePlan.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/NormalizePlan.scala
@@ -111,6 +111,8 @@ object NormalizePlan extends PredicateHelper {
         udf.copy(resultId = ExprId(0))
       case udaf: PythonUDAF =>
         udaf.copy(resultId = ExprId(0))
+      case agg: PythonAggregate =>
+        agg.copy(resultId = ExprId(0))
       case a: FunctionTableSubqueryArgumentExpression =>
         a.copy(plan = normalizeExprIds(a.plan), exprId = ExprId(0))
     }
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
index 42351b07a688..5db80e86d50c 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
@@ -2436,6 +2436,23 @@ private[sql] object QueryCompilationErrors extends 
QueryErrorsBase with Compilat
         "functionList" -> groupAggPandasUDFNames.map(toSQLId).mkString(", ")))
   }
 
+  def invalidPythonAggregatePlacementError(
+      pythonAggregateNames: Seq[String]): Throwable = {
+    new AnalysisException(
+      errorClass = "INVALID_PYTHON_UDF_PLACEMENT",
+      messageParameters = Map(
+        "functionList" -> pythonAggregateNames.map(toSQLId).mkString(", ")))
+  }
+
+  def invalidIncrementalPythonAggregatorBufferError(
+      name: String, bufferType: DataType): Throwable = {
+    new AnalysisException(
+      errorClass = "INVALID_PYTHON_AGGREGATOR_BUFFER_SCHEMA",
+      messageParameters = Map(
+        "functionName" -> toSQLId(name),
+        "bufferType" -> Option(bufferType).map(toSQLType).getOrElse("NULL")))
+  }
+
   def ambiguousAttributesInSelfJoinError(
       ambiguousAttrs: Seq[AttributeReference]): Throwable = {
     new AnalysisException(
diff --git 
a/sql/connect/common/src/main/protobuf/spark/connect/expressions.proto 
b/sql/connect/common/src/main/protobuf/spark/connect/expressions.proto
index 18f8f294f0c0..432bc13e918d 100644
--- a/sql/connect/common/src/main/protobuf/spark/connect/expressions.proto
+++ b/sql/connect/common/src/main/protobuf/spark/connect/expressions.proto
@@ -475,6 +475,9 @@ message PythonUDF {
   string python_ver = 4;
   // (Optional) Additional includes for the Python UDF.
   repeated string additional_includes = 5;
+  // (Optional) Intermediate buffer schema for an incremental Python aggregator
+  // (see PythonAggregate). Set only for the incremental aggregator eval types.
+  optional DataType buffer_type = 6;
 }
 
 message ScalarScalaUDF {
diff --git 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
index 841ce26402cf..78562e8ebf92 100644
--- 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
+++ 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
@@ -2201,6 +2201,15 @@ class SparkConnectPlanner(
     createUserDefinedPythonFunction(fun)
       .builder(fun.getArgumentsList.asScala.map(transformExpression).toSeq) 
match {
       case udaf: PythonUDAF => udaf.toAggregateExpression()
+      case agg: PythonAggregate =>
+        // The two-stage incremental aggregation operators do not implement 
DISTINCT. The SQL path
+        // rejects it in FunctionResolution, but a Connect aggregate is 
already resolved and skips
+        // that guard, so reject `is_distinct` here rather than silently 
dropping it and returning a
+        // non-distinct result.
+        if (fun.getIsDistinct) {
+          throw 
QueryCompilationErrors.functionWithUnsupportedSyntaxError(agg.name, "DISTINCT")
+        }
+        agg.toAggregateExpression()
       case other => other
     }
   }
@@ -2214,7 +2223,9 @@ class SparkConnectPlanner(
       func = function,
       dataType = transformDataType(udf.getOutputType),
       pythonEvalType = udf.getEvalType,
-      udfDeterministic = fun.getDeterministic)
+      udfDeterministic = fun.getDeterministic,
+      // Set only for incremental Python aggregators (see PythonAggregate).
+      bufferType = if (udf.hasBufferType) transformDataType(udf.getBufferType) 
else null)
   }
 
   private def transformPythonFunction(fun: proto.PythonUDF): 
SimplePythonFunction = {
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
index 42c1121f85da..d395b3f986e8 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
@@ -579,7 +579,9 @@ abstract class SparkStrategies extends 
QueryPlanner[SparkPlan] {
       case PhysicalAggregation(
         namedGroupingExpressions, aggregateExpressions, 
rewrittenResultExpressions, child) =>
 
-        if 
(aggregateExpressions.exists(_.aggregateFunction.isInstanceOf[PythonUDAF])) {
+        if (aggregateExpressions.exists(ae =>
+            ae.aggregateFunction.isInstanceOf[PythonUDAF] ||
+            ae.aggregateFunction.isInstanceOf[PythonAggregate])) {
           throw new AnalysisException(
             errorClass = "_LEGACY_ERROR_TEMP_3067",
             messageParameters = Map.empty)
@@ -719,7 +721,8 @@ abstract class SparkStrategies extends 
QueryPlanner[SparkPlan] {
   object Aggregation extends Strategy {
     def apply(plan: LogicalPlan): Seq[SparkPlan] = plan match {
       case PhysicalAggregation(groupingExpressions, aggExpressions, 
resultExpressions, child)
-        if 
!aggExpressions.exists(_.aggregateFunction.isInstanceOf[PythonUDAF]) =>
+        if !aggExpressions.exists(ae => 
ae.aggregateFunction.isInstanceOf[PythonUDAF] ||
+          ae.aggregateFunction.isInstanceOf[PythonAggregate]) =>
         val (functionsWithDistinct, functionsWithoutDistinct) =
           aggExpressions.partition(_.isDistinct)
         val distinctAggChildSets = functionsWithDistinct.map { ae =>
@@ -798,13 +801,40 @@ abstract class SparkStrategies extends 
QueryPlanner[SparkPlan] {
           resultExpressions,
           planLater(child)))
 
+      case PhysicalAggregation(groupingExpressions, aggExpressions, 
resultExpressions, child)
+          if 
aggExpressions.forall(_.aggregateFunction.isInstanceOf[PythonAggregate]) =>
+        // Ideally this should be done in `NormalizeFloatingNumbers`, but we 
do it here because
+        // `groupingExpressions` is not extracted during logical phase. 
Without this, 0.0/-0.0 (and
+        // distinct NaN bit patterns) would split one logical group across 
output rows.
+        val normalizedGroupingExpressions = groupingExpressions.map { e =>
+          NormalizeFloatingNumbers.normalize(e) match {
+            case n: NamedExpression => n
+            // Keep the name of the original expression.
+            case other => Alias(other, e.name)(exprId = e.exprId)
+          }
+        }
+        Seq(execution.python.PythonIncrementalAggregateExec.plan(
+          normalizedGroupingExpressions,
+          aggExpressions,
+          resultExpressions,
+          planLater(child)))
+
       case PhysicalAggregation(_, aggExpressions, _, _) =>
-        val groupAggPandasUDFNames = aggExpressions
-          .map(_.aggregateFunction)
-          .filter(_.isInstanceOf[PythonUDAF])
-          .map(_.asInstanceOf[PythonUDAF].name)
-        // If cannot match the two cases above, then it's an error
-        throw 
QueryCompilationErrors.invalidPandasUDFPlacementError(groupAggPandasUDFNames.distinct)
+        // Reached when Python aggregate UDFs cannot be planned by the two 
cases above -- e.g. a
+        // grouped-agg pandas/arrow UDF or an incremental Python aggregator is 
mixed with other
+        // (SQL or differently-typed Python) aggregate functions in the same 
Aggregate.
+        val aggFunctions = aggExpressions.map(_.aggregateFunction)
+        val incrementalNames = aggFunctions.collect { case p: PythonAggregate 
=> p.name }
+        val pandasUDFNames = aggFunctions.collect { case p: PythonUDAF => 
p.name }
+        if (incrementalNames.nonEmpty) {
+          // The message for pandas UDFs is misleading for an Arrow-based 
incremental aggregator, so
+          // report the dedicated, aggregator-neutral error. Name every 
offending Python aggregate
+          // function -- both the incremental aggregators and any grouped-agg 
pandas/arrow UDAFs
+          // mixed in -- so the diagnostic is complete rather than dropping 
the co-offenders.
+          throw QueryCompilationErrors.invalidPythonAggregatePlacementError(
+            (incrementalNames ++ pandasUDFNames).distinct)
+        }
+        throw 
QueryCompilationErrors.invalidPandasUDFPlacementError(pandasUDFNames.distinct)
 
       case _ => Nil
     }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonIncrementalAggregateExec.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonIncrementalAggregateExec.scala
new file mode 100644
index 000000000000..9d8931c7f919
--- /dev/null
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonIncrementalAggregateExec.scala
@@ -0,0 +1,393 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution.python
+
+import java.io.File
+
+import scala.collection.mutable.ArrayBuffer
+import scala.jdk.CollectionConverters._
+
+import org.apache.spark.{JobArtifactSet, SparkEnv, TaskContext}
+import org.apache.spark.api.python.{ChainedPythonFunctions, PythonEvalType}
+import org.apache.spark.internal.config.Python.PYTHON_UDF_PIPELINED_EXECUTION
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions._
+import org.apache.spark.sql.catalyst.expressions.aggregate.AggregateExpression
+import org.apache.spark.sql.catalyst.plans.physical.{AllTuples, 
ClusteredDistribution, Distribution, Partitioning, UnspecifiedDistribution}
+import org.apache.spark.sql.execution.{GroupedIterator, SparkPlan, 
UnaryExecNode}
+import org.apache.spark.sql.execution.python.EvalPythonExec.ArgumentMetadata
+import org.apache.spark.sql.types.{DataType, StructField, StructType}
+import org.apache.spark.util.Utils
+
+/**
+ * Execution logic for the post-shuffle FINAL stage of an incremental Python 
aggregation (see
+ * [[org.apache.spark.sql.catalyst.expressions.PythonAggregate]]). It groups 
the (shuffled) child
+ * rows by the grouping expressions via a local sort + [[GroupedIterator]], 
sends each group's
+ * intermediate-buffer columns to the Python worker as Arrow record batches, 
and joins the single
+ * result row the worker returns per group back with the grouping key.
+ *
+ * The map-side PARTIAL stage does not extend this base: it hash-combines many 
groups per batch
+ * inside the worker and needs no sort (see 
[[PythonIncrementalAggregatePartialExec]]). This base is
+ * kept as an abstract class so the per-stage inputs, eval type, output 
attributes and final
+ * projection stay explicit hooks.
+ */
+abstract class PythonIncrementalAggregateExecBase extends UnaryExecNode with 
PythonSQLMetrics {
+
+  def groupingExpressions: Seq[NamedExpression]
+  def aggExpressions: Seq[AggregateExpression]
+
+  protected val udfExpressions: Seq[PythonAggregate] =
+    aggExpressions.map(_.aggregateFunction.asInstanceOf[PythonAggregate])
+
+  /** The Python eval type for this stage. */
+  protected def evalType: Int
+
+  /** Per-UDF input expressions to project out of the child and send to the 
Python worker. */
+  protected def udfInputs: Seq[Seq[Expression]]
+
+  /** Attributes of the row the Python worker returns per group (right side of 
the join). */
+  protected def pythonOutputAttributes: Seq[Attribute]
+
+  /** Expressions producing this operator's output from (groupingKey ++ 
pythonOutput). */
+  protected def outputExpressions: Seq[NamedExpression]
+
+  /** The grouping attributes as seen in the child's output. */
+  protected def groupingAttributes: Seq[Attribute] = 
groupingExpressions.map(_.toAttribute)
+
+  /**
+   * Whether to still invoke Python on an empty partition. Only the FINAL 
stage of a *global*
+   * (no grouping) aggregation sets this: it must emit the identity row 
`finish(zero)` for empty
+   * input, matching SQL aggregate semantics. Everywhere else an empty 
partition yields no rows.
+   */
+  protected def emitOnEmptyPartition: Boolean = false
+
+  override def output: Seq[Attribute] = outputExpressions.map(_.toAttribute)
+
+  override def producedAttributes: AttributeSet = AttributeSet(output)
+
+  override def requiredChildOrdering: Seq[Seq[SortOrder]] =
+    Seq(groupingExpressions.map(SortOrder(_, Ascending)))
+
+  override protected def doExecute(): RDD[InternalRow] = {
+    val inputRDD = child.execute()
+
+    val sessionLocalTimeZone = conf.sessionLocalTimeZone
+    val largeVarTypes = conf.arrowUseLargeVarTypes
+    val pythonRunnerConf = ArrowPythonRunner.getPythonRunnerConfMap(conf)
+
+    val pyFuncs = udfExpressions.map { u =>
+      (ChainedPythonFunctions(Seq(u.func)), u.resultId.id)
+    }
+
+    // Filter child output attributes down to only those that are UDF inputs, 
and eliminate
+    // duplicates, mirroring ArrowAggregatePythonExec.
+    val allInputs = new ArrayBuffer[Expression]
+    val dataTypes = new ArrayBuffer[DataType]
+    val argMetas = PythonIncrementalAggregateExec.buildArgMetas(udfInputs, 
allInputs, dataTypes)
+
+    val aggInputSchema = StructType(dataTypes.zipWithIndex.map { case (dt, i) 
=>
+      StructField(s"_$i", dt)
+    }.toArray)
+
+    val jobArtifactUUID = JobArtifactSet.getCurrentJobArtifactState.map(_.uuid)
+    val sessionUUID = Option(session).collect {
+      case s if s.sessionState.conf.pythonWorkerLoggingEnabled => s.sessionUUID
+    }
+
+    val groupingExprs = groupingExpressions
+    val childOutput = child.output
+    val joinedAttributes = groupingAttributes ++ pythonOutputAttributes
+    val resultExprs = outputExpressions
+    val localEvalType = evalType
+
+    val emitIdentityOnEmpty = emitOnEmptyPartition
+    inputRDD.mapPartitionsInternal { iter => if (iter.isEmpty && 
!emitIdentityOnEmpty) iter else {
+      val prunedProj = UnsafeProjection.create(allInputs.toSeq, childOutput)
+
+      val groupedItr = if (groupingExprs.isEmpty) {
+        Iterator((new UnsafeRow(), iter))
+      } else {
+        GroupedIterator(iter, groupingExprs, childOutput)
+      }
+
+      // For a global aggregation with empty input, feed one all-null buffer 
row so the Python
+      // worker still emits `finish(zero)`. An empty group cannot be sent 
through
+      // GroupedPythonArrowInput (it asserts a non-empty batch per group), and 
the worker treats a
+      // null partial buffer as contributing nothing to `merge`.
+      lazy val nullInputRow: UnsafeRow =
+        UnsafeProjection.create(aggInputSchema.map(_.dataType).toArray)
+          .apply(new GenericInternalRow(aggInputSchema.length)).copy()
+      val grouped = groupedItr.map { case (key, rows) =>
+        val projected = rows.map(prunedProj)
+        val toSend = if (emitIdentityOnEmpty && groupingExprs.isEmpty && 
!projected.hasNext) {
+          Iterator(nullInputRow)
+        } else {
+          projected
+        }
+        (key, toSend)
+      }
+
+      val context = TaskContext.get()
+
+      // In pipelined mode the queue's add() runs in the writer thread and 
remove() in the task
+      // thread; use lock-free mode to skip per-row synchronization (as 
ArrowAggregatePythonExec).
+      val pipelined = SparkEnv.get.conf.get(PYTHON_UDF_PIPELINED_EXECUTION)
+      val queue = HybridRowQueue(context.taskMemoryManager(),
+        new File(Utils.getLocalDir(SparkEnv.get.conf)), groupingExprs.length, 
lockFree = pipelined)
+      context.addTaskCompletionListener[Unit] { _ => queue.close() }
+
+      val projectedRowIter = grouped.map { case (groupingKey, rows) =>
+        queue.add(groupingKey.asInstanceOf[UnsafeRow])
+        rows
+      }
+
+      val runner = new ArrowPythonWithNamedArgumentRunner(
+        pyFuncs,
+        localEvalType,
+        argMetas,
+        aggInputSchema,
+        sessionLocalTimeZone,
+        largeVarTypes,
+        pythonRunnerConf,
+        pythonMetrics,
+        jobArtifactUUID,
+        sessionUUID) with GroupedPythonArrowInput
+
+      val columnarBatchIter = runner.compute(projectedRowIter, 
context.partitionId(), context)
+
+      val joined = new JoinedRow
+      val resultProj = UnsafeProjection.create(resultExprs, joinedAttributes)
+
+      columnarBatchIter.map(_.rowIterator.next()).map { pythonOutputRow =>
+        val leftRow = queue.remove()
+        resultProj(joined(leftRow, pythonOutputRow))
+      }
+    }}
+  }
+}
+
+/**
+ * Map-side PARTIAL stage: hash-combines the input rows of each group into a 
per-group intermediate
+ * buffer via the aggregator's `reduce`, inside the Python worker. Unlike the 
FINAL stage it needs
+ * neither a clustered distribution nor an ordering: the whole point of the 
map-side combine is to
+ * avoid a full pre-shuffle sort. It streams ordinary (multi-group) Arrow 
batches to the worker,
+ * which maintains one running buffer per distinct grouping key and emits, at 
end of partition, one
+ * row per key -- the grouping key columns followed by one intermediate-buffer 
struct column per
+ * aggregator.
+ *
+ * Because keys may be split across partitions (no shuffle here) and the 
worker's map-side grouping
+ * is only a combine optimization, correctness does not depend on it being 
exhaustive:
+ * [[PythonIncrementalAggregateFinalExec]] re-groups the emitted partial 
buffers authoritatively
+ * (by JVM `UnsafeRow` key, after the shuffle) and merges any that share a key.
+ */
+case class PythonIncrementalAggregatePartialExec(
+    groupingExpressions: Seq[NamedExpression],
+    aggExpressions: Seq[AggregateExpression],
+    bufferAttributes: Seq[Attribute],
+    child: SparkPlan) extends UnaryExecNode with PythonSQLMetrics {
+
+  private val udfExpressions: Seq[PythonAggregate] =
+    aggExpressions.map(_.aggregateFunction.asInstanceOf[PythonAggregate])
+
+  private def groupingAttributes: Seq[Attribute] = 
groupingExpressions.map(_.toAttribute)
+
+  override def output: Seq[Attribute] = groupingAttributes ++ bufferAttributes
+
+  override def producedAttributes: AttributeSet = AttributeSet(output)
+
+  override def requiredChildDistribution: Seq[Distribution] = 
Seq(UnspecifiedDistribution)
+
+  // No ordering: the worker hash-combines rather than relying on grouped 
input.
+  override def requiredChildOrdering: Seq[Seq[SortOrder]] = Seq(Nil)
+
+  override def outputPartitioning: Partitioning = child.outputPartitioning
+
+  override protected def doExecute(): RDD[InternalRow] = {
+    val inputRDD = child.execute()
+
+    val sessionLocalTimeZone = conf.sessionLocalTimeZone
+    val largeVarTypes = conf.arrowUseLargeVarTypes
+    val pythonRunnerConf = ArrowPythonRunner.getPythonRunnerConfMap(conf)
+
+    val pyFuncs = udfExpressions.map { u =>
+      (ChainedPythonFunctions(Seq(u.func)), u.resultId.id)
+    }
+
+    // The columns sent to Python are the grouping keys first (so the worker 
can hash-group by them
+    // and echo them back with each partial buffer), followed by the 
deduplicated aggregator input
+    // columns. A UDF input that coincides with a grouping key simply reuses 
that leading column.
+    val allInputs = new ArrayBuffer[Expression]
+    val dataTypes = new ArrayBuffer[DataType]
+    groupingExpressions.foreach { g =>
+      allInputs += g
+      dataTypes += g.dataType
+    }
+    val numGroupingKeys = groupingExpressions.length
+
+    val argMetas = PythonIncrementalAggregateExec.buildArgMetas(
+      udfExpressions.map(_.children), allInputs, dataTypes)
+
+    val aggInputSchema = StructType(dataTypes.zipWithIndex.map { case (dt, i) 
=>
+      StructField(s"_$i", dt)
+    }.toArray)
+    // The leading `numGroupingKeys` columns are the grouping keys; hand their 
schema to the worker.
+    val groupingKeySchemaJson = 
StructType(aggInputSchema.fields.take(numGroupingKeys)).json
+
+    val jobArtifactUUID = JobArtifactSet.getCurrentJobArtifactState.map(_.uuid)
+    val sessionUUID = Option(session).collect {
+      case s if s.sessionState.conf.pythonWorkerLoggingEnabled => s.sessionUUID
+    }
+
+    val childOutput = child.output
+    val outputAttrs = output
+
+    inputRDD.mapPartitionsInternal { iter => if (iter.isEmpty) Iterator.empty 
else {
+      val prunedProj = UnsafeProjection.create(allInputs.toSeq, childOutput)
+      val projectedRowIter = iter.map(prunedProj)
+
+      val context = TaskContext.get()
+
+      val runner = new ArrowPythonWithNamedArgumentRunner(
+        pyFuncs,
+        PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_PARTIAL_UDF,
+        argMetas,
+        aggInputSchema,
+        sessionLocalTimeZone,
+        largeVarTypes,
+        pythonRunnerConf,
+        pythonMetrics,
+        jobArtifactUUID,
+        sessionUUID) with BatchedPythonArrowInput {
+        // Tell the worker how many leading columns are grouping keys, so it 
can hash-group by them
+        // and re-emit them alongside each partial buffer.
+        override protected def evalConf: Map[String, String] =
+          super.evalConf + ("grouping_key_schema" -> groupingKeySchemaJson)
+      }
+
+      val columnarBatchIter = runner.compute(
+        Iterator(projectedRowIter), context.partitionId(), context)
+
+      // Each batch the worker returns holds (grouping key columns ++ one 
buffer struct column per
+      // aggregator), i.e. this operator's output columns; copy each row out 
as an UnsafeRow.
+      val resultProj = UnsafeProjection.create(outputAttrs, outputAttrs)
+      columnarBatchIter.flatMap(_.rowIterator().asScala).map(resultProj)
+    }}
+  }
+
+  override protected def withNewChildInternal(newChild: SparkPlan): SparkPlan =
+    copy(child = newChild)
+}
+
+/**
+ * Post-shuffle FINAL stage: clusters the partial buffers by the grouping key, 
merges the buffers
+ * of each group via the aggregator's `merge`, and produces the output via 
`finish`. Its input is
+ * the [[PythonIncrementalAggregatePartialExec]] output (grouping key columns 
followed by the
+ * intermediate-buffer columns); it sends the buffer columns to Python and 
outputs
+ * `resultExpressions`.
+ */
+case class PythonIncrementalAggregateFinalExec(
+    groupingExpressions: Seq[NamedExpression],
+    aggExpressions: Seq[AggregateExpression],
+    bufferAttributes: Seq[Attribute],
+    resultExpressions: Seq[NamedExpression],
+    child: SparkPlan) extends PythonIncrementalAggregateExecBase {
+
+  override protected def evalType: Int =
+    PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF
+
+  // Each aggregator reads its own intermediate-buffer column from the 
(shuffled) child.
+  override protected def udfInputs: Seq[Seq[Expression]] = 
bufferAttributes.map(Seq(_))
+
+  override protected def pythonOutputAttributes: Seq[Attribute] =
+    aggExpressions.map(_.resultAttribute)
+
+  override protected def outputExpressions: Seq[NamedExpression] = 
resultExpressions
+
+  // A global (no-grouping) aggregation must return the identity row even for 
empty input. This
+  // stage runs on a single partition (AllTuples), so exactly one identity row 
is produced.
+  override protected def emitOnEmptyPartition: Boolean = 
groupingExpressions.isEmpty
+
+  override def requiredChildDistribution: Seq[Distribution] = {
+    if (groupingExpressions.isEmpty) {
+      AllTuples :: Nil
+    } else {
+      ClusteredDistribution(groupingExpressions) :: Nil
+    }
+  }
+
+  override def outputPartitioning: Partitioning = child.outputPartitioning
+
+  override protected def withNewChildInternal(newChild: SparkPlan): SparkPlan =
+    copy(child = newChild)
+}
+
+object PythonIncrementalAggregateExec {
+
+  /**
+   * Deduplicates the per-aggregator input expressions into a shared column 
list (matched by
+   * [[Expression.semanticEquals]]), returning one [[ArgumentMetadata]] array 
per aggregator that
+   * points into that list. `allInputs`/`dataTypes` may be pre-seeded -- the 
PARTIAL stage prepends
+   * its grouping-key columns so a UDF input equal to a grouping key reuses 
that leading column --
+   * and any newly seen columns are appended to them in place. Shared by both 
stages (and mirrors
+   * the same dedup in [[ArrowAggregatePythonExec]]).
+   */
+  private[python] def buildArgMetas(
+      udfInputs: Seq[Seq[Expression]],
+      allInputs: ArrayBuffer[Expression],
+      dataTypes: ArrayBuffer[DataType]): Array[Array[ArgumentMetadata]] = {
+    udfInputs.map { input =>
+      input.map { e =>
+        val (key, value) = e match {
+          case NamedArgumentExpression(key, value) => (Some(key), value)
+          case _ => (None, e)
+        }
+        if (allInputs.exists(_.semanticEquals(value))) {
+          ArgumentMetadata(allInputs.indexWhere(_.semanticEquals(value)), key)
+        } else {
+          allInputs += value
+          dataTypes += value.dataType
+          ArgumentMetadata(allInputs.length - 1, key)
+        }
+      }.toArray
+    }.toArray
+  }
+
+  /**
+   * Builds the two-stage physical plan (PARTIAL -> [Exchange, inserted by 
EnsureRequirements] ->
+   * FINAL) for a logical aggregation whose aggregate functions are all 
[[PythonAggregate]].
+   */
+  def plan(
+      groupingExpressions: Seq[NamedExpression],
+      aggExpressions: Seq[AggregateExpression],
+      resultExpressions: Seq[NamedExpression],
+      child: SparkPlan): SparkPlan = {
+    // One intermediate-buffer attribute per aggregator, threaded from the 
PARTIAL output into the
+    // FINAL inputs (matched by expression id).
+    val bufferAttributes = aggExpressions.map { ae =>
+      val agg = ae.aggregateFunction.asInstanceOf[PythonAggregate]
+      AttributeReference(s"buf_${agg.resultId.id}", agg.bufferSchema, nullable 
= true)()
+    }
+    val partial = PythonIncrementalAggregatePartialExec(
+      groupingExpressions, aggExpressions, bufferAttributes, child)
+    // After the PARTIAL stage the grouping expressions are materialized as 
plain attributes.
+    val groupingAttributes = groupingExpressions.map(_.toAttribute)
+    PythonIncrementalAggregateFinalExec(
+      groupingAttributes, aggExpressions, bufferAttributes, resultExpressions, 
partial)
+  }
+}
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/UserDefinedPythonFunction.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/UserDefinedPythonFunction.scala
index cfd1fb4ea222..9f53f078b917 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/UserDefinedPythonFunction.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/UserDefinedPythonFunction.scala
@@ -28,7 +28,7 @@ import net.razorvine.pickle.Pickler
 import org.apache.spark.api.python.{PythonEvalType, PythonFunction, 
PythonWorkerUtils, SpecialLengths}
 import org.apache.spark.sql.{Column, TableArg}
 import org.apache.spark.sql.catalyst.analysis.UnresolvedAttribute
-import org.apache.spark.sql.catalyst.expressions.{Alias, Ascending, 
Descending, Expression, FunctionTableSubqueryArgumentExpression, 
NamedArgumentExpression, NullsFirst, NullsLast, PythonUDAF, PythonUDF, 
PythonUDTF, PythonUDTFAnalyzeResult, PythonUDTFSelectedExpression, SortOrder, 
TranspiledPythonUDF, UnresolvedPolymorphicPythonUDTF, UnresolvedTableArgPlanId}
+import org.apache.spark.sql.catalyst.expressions.{Alias, Ascending, 
Descending, Expression, FunctionTableSubqueryArgumentExpression, 
NamedArgumentExpression, NullsFirst, NullsLast, PythonAggregate, PythonUDAF, 
PythonUDF, PythonUDTF, PythonUDTFAnalyzeResult, PythonUDTFSelectedExpression, 
SortOrder, TranspiledPythonUDF, UnresolvedPolymorphicPythonUDTF, 
UnresolvedTableArgPlanId}
 import org.apache.spark.sql.catalyst.parser.ParserInterface
 import org.apache.spark.sql.catalyst.plans.logical.{Generate, LogicalPlan, 
NamedParametersSupport, OneRowRelation}
 import org.apache.spark.sql.classic.{DataFrame, Dataset, SparkSession}
@@ -56,7 +56,11 @@ case class UserDefinedPythonFunction(
     // categories match the bound argument types; when none match, the call
     // falls back to the plain Python UDF. `builder` requires the two lists to
     // be parallel and skips transpilation otherwise.
-    transpiledInputTypes: JList[JList[String]] = Nil.asJava) {
+    transpiledInputTypes: JList[JList[String]] = Nil.asJava,
+    // Schema of the intermediate aggregation buffer, set only for the 
incremental Python
+    // aggregator eval types (see [[PythonAggregate]]); `null` otherwise. 
Nullable rather than
+    // `Option` so it can be passed positionally from Python over Py4J.
+    bufferType: DataType = null) {
 
   def builder(e: Seq[Expression]): Expression = {
     if (pythonEvalType == PythonEvalType.SQL_BATCHED_UDF
@@ -66,7 +70,8 @@ case class UserDefinedPythonFunction(
         || pythonEvalType == PythonEvalType.SQL_GROUPED_AGG_PANDAS_ITER_UDF
         || pythonEvalType == PythonEvalType.SQL_SCALAR_ARROW_UDF
         || pythonEvalType == PythonEvalType.SQL_GROUPED_AGG_ARROW_UDF
-        || pythonEvalType == PythonEvalType.SQL_GROUPED_AGG_ARROW_ITER_UDF) {
+        || pythonEvalType == PythonEvalType.SQL_GROUPED_AGG_ARROW_ITER_UDF
+        || pythonEvalType == 
PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF) {
       /*
        * Check if the named arguments:
        * - don't have duplicated names
@@ -87,6 +92,21 @@ case class UserDefinedPythonFunction(
       || pythonEvalType == PythonEvalType.SQL_GROUPED_AGG_ARROW_UDF
       || pythonEvalType == PythonEvalType.SQL_GROUPED_AGG_ARROW_ITER_UDF) {
       PythonUDAF(name, func, dataType, e, udfDeterministic, pythonEvalType)
+    } else if (pythonEvalType == 
PythonEvalType.SQL_GROUPED_AGG_ARROW_INCREMENTAL_FINAL_UDF) {
+      // The incremental Python aggregator. `bufferType` (the intermediate 
buffer schema) must have
+      // been supplied as a struct when the UDF was created. The single 
expression carries the
+      // aggregator for both the PARTIAL and FINAL stages; the physical 
operator picks the per-stage
+      // eval type. `udaf()` enforces this up front, but a malformed Connect 
proto (eval type set,
+      // `buffer_type` missing or non-struct) or a direct 
`UserDefinedFunction(f, evalType=...)` can
+      // reach here without it, so return a classed error rather than a bare 
require /
+      // ClassCastException.
+      val bufferStruct = bufferType match {
+        case s: StructType => s
+        case _ =>
+          throw 
QueryCompilationErrors.invalidIncrementalPythonAggregatorBufferError(
+            name, bufferType)
+      }
+      PythonAggregate(name, func, dataType, e, udfDeterministic, bufferStruct)
     } else {
       PythonUDF(name, func, dataType, e, pythonEvalType, udfDeterministic)
     }
@@ -172,6 +192,7 @@ case class UserDefinedPythonFunction(
       case TranspiledPythonUDF(name, udaf: PythonUDAF, transpiled, 
inputCategories) =>
         TranspiledPythonUDF(name, udaf.toAggregateExpression(), transpiled, 
inputCategories)
       case udaf: PythonUDAF => udaf.toAggregateExpression()
+      case agg: PythonAggregate => agg.toAggregateExpression()
       case _ => expr
     })
   }


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

Reply via email to