You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sedona.apache.org by ji...@apache.org on 2026/08/07 17:53:41 UTC

(sedona) 01/01: [GH-3243] GeoPandas: Implement GeoDataFrame explode

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

jiayuasu pushed a commit to branch feature/geopandas-frame-explode
in repository https://gitbox.apache.org/repos/asf/sedona.git

commit a64b0779a1a349205807aa70c3da94a9e2df26e8
Author: Jia Yu <ji...@apache.org>
AuthorDate: Fri Aug 7 00:05:44 2026 -0700

    [GH-3243] GeoPandas: Implement GeoDataFrame explode
---
 docs/tutorial/geopandas-api.md                     |   6 +
 docs/tutorial/geopandas-api.zh.md                  |   5 +
 python/sedona/spark/geopandas/_explode.py          | 162 +++++++++++++++++++++
 python/sedona/spark/geopandas/geodataframe.py      | 101 +++++++++++++
 python/sedona/spark/geopandas/geoseries.py         |  95 ++----------
 python/tests/geopandas/test_geodataframe.py        | 131 +++++++++++++++++
 .../geopandas/test_match_geopandas_dataframe.py    |  53 +++++++
 7 files changed, 472 insertions(+), 81 deletions(-)

diff --git a/docs/tutorial/geopandas-api.md b/docs/tutorial/geopandas-api.md
index 2fbeff1894a..7b834214b15 100644
--- a/docs/tutorial/geopandas-api.md
+++ b/docs/tutorial/geopandas-api.md
@@ -339,10 +339,16 @@ The GeoPandas API for Apache Sedona implements the most commonly used GeoSeries
 - `make_valid()` - Geometry validation and repair
 - `sample_points()` - Sample polygons by area and lines by length with native
   distributed expressions
+- `GeoDataFrame.explode()` - Expand multipart geometries into rows while
+  retaining their attribute columns and index metadata
 - `cx` - Coordinate-based spatial filtering
 - `clip()` - Distributed geometry clipping
 - `sindex` - Spatial indexing (limited functionality)
 
+`GeoDataFrame.explode()` uses Sedona's native `ST_Dump` expression with
+Spark `posexplode`. Geometry parts and their attributes remain distributed;
+the operation does not collect source rows or use a Python UDF.
+
 ### Distributed Geometry Aggregation
 
 - `GeoDataFrame.dissolve()` - Group rows and union each group's geometries
diff --git a/docs/tutorial/geopandas-api.zh.md b/docs/tutorial/geopandas-api.zh.md
index d235288bade..cb2c535e11b 100644
--- a/docs/tutorial/geopandas-api.zh.md
+++ b/docs/tutorial/geopandas-api.zh.md
@@ -338,10 +338,15 @@ Apache Sedona 的 GeoPandas API 已实现最常用的 GeoSeries 与 GeoDataFrame
 - `intersection()` —— 几何相交
 - `make_valid()` —— 几何校验与修复
 - `sample_points()` —— 使用原生分布式表达式按面积对多边形采样、按长度对线采样
+- `GeoDataFrame.explode()` —— 将多部件几何展开为多行,并保留对应的属性列与索引元数据
 - `cx` —— 基于坐标范围的空间筛选
 - `clip()` —— 分布式几何裁剪
 - `sindex` —— 空间索引(功能有限)
 
+`GeoDataFrame.explode()` 使用 Sedona 原生的 `ST_Dump` 表达式与 Spark
+`posexplode`。几何部件及其属性始终保持分布式处理;该操作不会收集源数据行,
+也不会使用 Python UDF。
+
 ### 分布式几何聚合
 
 - `GeoDataFrame.dissolve()` —— 对行进行分组,并使用 Sedona 原生分布式聚合
diff --git a/python/sedona/spark/geopandas/_explode.py b/python/sedona/spark/geopandas/_explode.py
new file mode 100644
index 00000000000..46c17a7d34f
--- /dev/null
+++ b/python/sedona/spark/geopandas/_explode.py
@@ -0,0 +1,162 @@
+# 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 __future__ import annotations
+
+import typing
+
+from pyspark.pandas.internal import (
+    InternalFrame,
+    NATURAL_ORDER_COLUMN_NAME,
+    SPARK_DEFAULT_INDEX_NAME,
+    SPARK_INDEX_NAME_FORMAT,
+)
+from pyspark.pandas.utils import scol_for, verify_temp_column_name
+from pyspark.sql import functions as F
+
+
+def expand_geometry_column(
+    source_internal: InternalFrame,
+    geometry_position: int,
+    array_builder,
+    ignore_index: bool,
+    index_parts: bool,
+    temp_prefix: str,
+) -> InternalFrame:
+    """Expand one geometry column and carry the complete frame alongside it."""
+
+    internal = source_internal.resolved_copy
+    source_sdf = internal.spark_frame
+    reserved_names = set(source_sdf.columns)
+
+    def temp_column_name(base: str) -> str:
+        suffix = 0
+        candidate = f"__{temp_prefix}_{base}__"
+        while candidate in reserved_names:
+            suffix += 1
+            candidate = f"__{temp_prefix}_{base}_{suffix}__"
+        reserved_names.add(candidate)
+        return typing.cast(str, verify_temp_column_name(source_sdf, candidate))
+
+    index_column_names = [
+        temp_column_name(f"index_{level}")
+        for level in range(len(internal.index_spark_columns))
+    ]
+    data_column_names = [
+        temp_column_name(f"data_{position}")
+        for position in range(len(internal.data_spark_columns))
+    ]
+    parent_order_col = temp_column_name("parent_order")
+    position_col = temp_column_name("position")
+    value_col = temp_column_name("value")
+    sequence_col = temp_column_name("sequence")
+
+    retained_data_columns = [
+        column.alias(name, metadata=field.metadata)
+        for position, (column, name, field) in enumerate(
+            zip(
+                internal.data_spark_columns,
+                data_column_names,
+                internal.data_fields,
+            )
+        )
+        if position != geometry_position
+    ]
+    expanded_sdf = source_sdf.select(
+        *[
+            column.alias(name, metadata=field.metadata)
+            for column, name, field in zip(
+                internal.index_spark_columns,
+                index_column_names,
+                internal.index_fields,
+            )
+        ],
+        *retained_data_columns,
+        scol_for(source_sdf, NATURAL_ORDER_COLUMN_NAME).alias(parent_order_col),
+        F.posexplode(
+            array_builder(internal.data_spark_columns[geometry_position])
+        ).alias(position_col, value_col),
+    ).orderBy(parent_order_col, position_col)
+
+    # The distributed sequence supplies both ignore_index and a stable
+    # natural-order column without a single-partition row-number window.
+    expanded_sdf = InternalFrame.attach_distributed_sequence_column(
+        expanded_sdf, sequence_col
+    )
+
+    if ignore_index:
+        output_index_names = [SPARK_DEFAULT_INDEX_NAME]
+        index_names = [None]
+        index_fields = None
+        index_expressions = [
+            scol_for(expanded_sdf, sequence_col).alias(SPARK_DEFAULT_INDEX_NAME)
+        ]
+    else:
+        output_index_names = list(index_column_names)
+        index_names = list(internal.index_names)
+        index_fields = [
+            field.copy(name=name)
+            for field, name in zip(internal.index_fields, output_index_names)
+        ]
+        index_expressions = [
+            scol_for(expanded_sdf, name) for name in output_index_names
+        ]
+
+        if index_parts:
+            part_index_col = SPARK_INDEX_NAME_FORMAT(len(output_index_names))
+            output_index_names.append(part_index_col)
+            index_names.append(None)
+            index_fields.append(None)
+            index_expressions.append(
+                scol_for(expanded_sdf, position_col).cast("long").alias(part_index_col)
+            )
+
+    data_expressions = []
+    for position, (name, field) in enumerate(
+        zip(data_column_names, internal.data_fields)
+    ):
+        source_name = value_col if position == geometry_position else name
+        data_expressions.append(
+            scol_for(expanded_sdf, source_name).alias(
+                name,
+                metadata=field.metadata,
+            )
+        )
+
+    output_sdf = expanded_sdf.select(
+        *index_expressions,
+        *data_expressions,
+        scol_for(expanded_sdf, sequence_col).alias(NATURAL_ORDER_COLUMN_NAME),
+    )
+    output_schema = output_sdf.schema
+    data_fields = [
+        field.copy(
+            name=name,
+            spark_type=output_schema[name].dataType,
+            nullable=output_schema[name].nullable,
+        )
+        for name, field in zip(data_column_names, internal.data_fields)
+    ]
+
+    return internal.copy(
+        spark_frame=output_sdf,
+        index_spark_columns=[scol_for(output_sdf, name) for name in output_index_names],
+        index_names=index_names,
+        index_fields=index_fields,
+        data_spark_columns=[scol_for(output_sdf, name) for name in data_column_names],
+        data_fields=data_fields,
+    )
diff --git a/python/sedona/spark/geopandas/geodataframe.py b/python/sedona/spark/geopandas/geodataframe.py
index 23124c95c0b..47dea89602e 100644
--- a/python/sedona/spark/geopandas/geodataframe.py
+++ b/python/sedona/spark/geopandas/geodataframe.py
@@ -55,6 +55,7 @@ from pyspark.sql.types import (
 from pyspark.pandas.utils import log_advice
 
 from sedona.spark.geopandas._crs import copy_crs_metadata, with_crs_metadata
+from sedona.spark.geopandas._explode import expand_geometry_column
 from sedona.spark.geopandas._typing import Label
 from sedona.spark.geopandas.base import GeoFrame
 from sedona.spark.sql import st_aggregates as sta
@@ -1951,6 +1952,106 @@ class GeoDataFrame(GeoFrame, pspd.DataFrame):
         """
         return self.to_geopandas().plot(*args, **kwargs)
 
+    def explode(
+        self,
+        column=None,
+        ignore_index: bool = False,
+        index_parts: bool = False,
+        **kwargs,
+    ) -> GeoDataFrame:
+        """Explode multi-part geometries into separate rows.
+
+        Attribute columns remain alongside every geometry part. The operation
+        uses Sedona's native ``ST_Dump`` expression and remains distributed.
+
+        Parameters
+        ----------
+        column : column label, default None
+            Column to explode. ``None`` selects the active geometry column. As
+            in GeoPandas, selecting any geometry-typed column explodes the
+            active geometry column; selecting a non-geometry column delegates
+            to pandas-on-Spark.
+        ignore_index : bool, default False
+            If True, label the result index 0, 1, ..., n - 1 and ignore
+            ``index_parts``.
+        index_parts : bool, default False
+            If True, append a zero-based index level identifying each geometry
+            produced from an input row.
+        **kwargs
+            Additional keyword arguments are forwarded when exploding a
+            non-geometry column.
+
+        Returns
+        -------
+        GeoDataFrame
+            A distributed GeoDataFrame with one row per geometry part.
+
+        Examples
+        --------
+        >>> from sedona.spark.geopandas import GeoDataFrame
+        >>> from shapely.geometry import MultiPoint
+        >>> gdf = GeoDataFrame(
+        ...     {
+        ...         "name": ["a", "b"],
+        ...         "geometry": [
+        ...             MultiPoint([(0, 0), (1, 1)]),
+        ...             MultiPoint([(2, 2), (3, 3)]),
+        ...         ],
+        ...     }
+        ... )
+        >>> gdf.explode(index_parts=True)
+            name     geometry
+        0 0    a  POINT (0 0)
+          1    a  POINT (1 1)
+        1 0    b  POINT (2 2)
+          1    b  POINT (3 3)
+        """
+        if column is None:
+            column = self.geometry.name
+
+        selected = self[column]
+        if not isinstance(selected, sgpd.GeoSeries):
+            exploded = super().explode(
+                column,
+                ignore_index=ignore_index,
+                **kwargs,
+            )
+            return _as_geodataframe_with_geometry(
+                exploded,
+                self._geometry_column_name,
+            )
+
+        # GeoPandas explodes the active geometry even when ``column`` names a
+        # different geometry-typed column. Preserve that compatibility quirk.
+        geometry_label = self.geometry._column_label
+        geometry_position = self._internal.column_labels.index(geometry_label)
+        result_internal = expand_geometry_column(
+            self._internal,
+            geometry_position=geometry_position,
+            array_builder=stf.ST_Dump,
+            ignore_index=ignore_index,
+            index_parts=index_parts,
+            temp_prefix="frame_explode",
+        )
+        # GeoPandas rebuilds a geometry explode by dropping the active column
+        # and adding it back after the attributes, so keep that output order.
+        data_order = [
+            position
+            for position in range(len(result_internal.column_labels))
+            if position != geometry_position
+        ] + [geometry_position]
+        result_internal = result_internal.copy(
+            column_labels=[result_internal.column_labels[i] for i in data_order],
+            data_spark_columns=[
+                result_internal.data_spark_columns[i] for i in data_order
+            ],
+            data_fields=[result_internal.data_fields[i] for i in data_order],
+        )
+        return _as_geodataframe_with_geometry(
+            PandasOnSparkDataFrame(result_internal),
+            self._geometry_column_name,
+        )
+
     def dissolve(
         self,
         by=None,
diff --git a/python/sedona/spark/geopandas/geoseries.py b/python/sedona/spark/geopandas/geoseries.py
index 9afc2cf5333..458c9deee0d 100644
--- a/python/sedona/spark/geopandas/geoseries.py
+++ b/python/sedona/spark/geopandas/geoseries.py
@@ -31,7 +31,7 @@ from pyspark.pandas import Series as PandasOnSparkSeries
 from pyspark.pandas.frame import DataFrame as PandasOnSparkDataFrame
 from pyspark.pandas.internal import InternalField, InternalFrame
 from pyspark.pandas.series import first_series
-from pyspark.pandas.utils import same_anchor, scol_for, verify_temp_column_name
+from pyspark.pandas.utils import same_anchor, scol_for
 from pyspark.sql.types import (
     BooleanType,
     DoubleType,
@@ -61,6 +61,7 @@ from sedona.spark.geopandas._crs import (
     read_crs_metadata,
     with_crs_metadata,
 )
+from sedona.spark.geopandas._explode import expand_geometry_column
 from sedona.spark.geopandas._typing import Label
 from sedona.spark.geopandas.base import GeoFrame
 from sedona.spark.geopandas.geodataframe import GeoDataFrame
@@ -69,7 +70,6 @@ from packaging.version import parse as parse_version
 
 from pyspark.pandas.internal import (
     SPARK_DEFAULT_INDEX_NAME,  # __index_level_0__
-    SPARK_INDEX_NAME_FORMAT,
     NATURAL_ORDER_COLUMN_NAME,
     SPARK_DEFAULT_SERIES_NAME,  # '0'
 )
@@ -997,88 +997,21 @@ class GeoSeries(GeoFrame, pspd.Series):
         temp_prefix: str,
     ):
         """Expand a geometry-array expression while preserving index metadata."""
-        internal = self._internal.resolved_copy
-        source_sdf = internal.spark_frame
-        reserved_names = set(source_sdf.columns)
-
-        def temp_column_name(base: str) -> str:
-            suffix = 0
-            candidate = f"__{temp_prefix}_{base}__"
-            while candidate in reserved_names:
-                suffix += 1
-                candidate = f"__{temp_prefix}_{base}_{suffix}__"
-            reserved_names.add(candidate)
-            return typing.cast(str, verify_temp_column_name(source_sdf, candidate))
-
-        index_column_names = [
-            temp_column_name(f"index_{level}")
-            for level in range(len(internal.index_spark_columns))
-        ]
-        parent_order_col = temp_column_name("parent_order")
-        position_col = temp_column_name("position")
-        value_col = temp_column_name("value")
-        sequence_col = temp_column_name("sequence")
-
-        expanded_sdf = source_sdf.select(
-            *[
-                column.alias(name)
-                for column, name in zip(
-                    internal.index_spark_columns, index_column_names
-                )
-            ],
-            scol_for(source_sdf, NATURAL_ORDER_COLUMN_NAME).alias(parent_order_col),
-            F.posexplode(array_builder(internal.data_spark_columns[0])).alias(
-                position_col, value_col
-            ),
-        ).orderBy(parent_order_col, position_col)
-
-        # The distributed sequence supplies both ignore_index and a stable
-        # natural-order column without a single-partition row-number window.
-        expanded_sdf = InternalFrame.attach_distributed_sequence_column(
-            expanded_sdf, sequence_col
-        )
-
-        if ignore_index:
-            output_index_cols = [SPARK_DEFAULT_INDEX_NAME]
-            index_names = [None]
-            index_fields = None
-            index_expressions = [
-                scol_for(expanded_sdf, sequence_col).alias(SPARK_DEFAULT_INDEX_NAME)
-            ]
-        else:
-            output_index_cols = list(index_column_names)
-            index_names = list(internal.index_names)
-            index_fields = [
-                field.copy(name=name)
-                for field, name in zip(internal.index_fields, output_index_cols)
-            ]
-            index_expressions = [
-                scol_for(expanded_sdf, name) for name in output_index_cols
-            ]
-
-            if index_parts:
-                part_index_col = SPARK_INDEX_NAME_FORMAT(len(output_index_cols))
-                output_index_cols.append(part_index_col)
-                index_names.append(None)
-                index_fields.append(None)
-                index_expressions.append(
-                    scol_for(expanded_sdf, position_col)
-                    .cast("long")
-                    .alias(part_index_col)
-                )
-
-        output_sdf = expanded_sdf.select(
-            *index_expressions,
-            scol_for(expanded_sdf, value_col),
-            scol_for(expanded_sdf, sequence_col).alias(NATURAL_ORDER_COLUMN_NAME),
+        internal = expand_geometry_column(
+            self._internal,
+            geometry_position=0,
+            array_builder=array_builder,
+            ignore_index=ignore_index,
+            index_parts=index_parts,
+            temp_prefix=temp_prefix,
         )
         return (
             internal,
-            output_sdf,
-            output_index_cols,
-            index_names,
-            index_fields,
-            value_col,
+            internal.spark_frame,
+            internal.index_spark_column_names,
+            internal.index_names,
+            internal.index_fields,
+            internal.data_spark_column_names[0],
         )
 
     # ============================================================================
diff --git a/python/tests/geopandas/test_geodataframe.py b/python/tests/geopandas/test_geodataframe.py
index e7c51eb7772..282199b905a 100644
--- a/python/tests/geopandas/test_geodataframe.py
+++ b/python/tests/geopandas/test_geodataframe.py
@@ -31,6 +31,7 @@ from shapely.geometry import (
 import shapely
 
 from sedona.spark.geopandas import GeoDataFrame, GeoSeries
+from sedona.spark.sql import st_functions as stf
 from tests.geopandas.test_geopandas_base import TestGeopandasBase
 import pyspark.pandas as ps
 import pandas as pd
@@ -277,6 +278,136 @@ class TestGeoDataFrame(TestGeopandasBase):
         with pytest.raises(NotImplementedError, match="byte_order"):
             gdf.to_wkb(byte_order=0)
 
+    def test_explode_uses_native_plan_and_preserves_geometry_metadata(self):
+        source = GeoDataFrame(
+            {
+                "name": ["first", "second"],
+                "geometry": [
+                    MultiPoint([(0, 0), (1, 1)]),
+                    GeometryCollection([Point(2, 2), MultiPoint([(3, 3), (4, 4)])]),
+                ],
+            },
+            geometry="geometry",
+            crs="EPSG:3857",
+        )
+
+        result = source.explode(index_parts=True)
+
+        assert isinstance(result, GeoDataFrame)
+        assert result.active_geometry_name == "geometry"
+        assert result.crs.to_epsg() == 3857
+        assert result.index.names == [None, None]
+        assert result.to_geopandas()["name"].tolist() == [
+            "first",
+            "first",
+            "second",
+            "second",
+        ]
+
+        srids = result._internal.spark_frame.select(
+            stf.ST_SRID(result.geometry.spark.column).alias("srid")
+        ).collect()
+        assert {row.srid for row in srids} == {3857}
+
+        spark_frame = result._internal.spark_frame
+        if hasattr(spark_frame, "_jdf"):
+            plan = spark_frame._jdf.queryExecution().executedPlan().toString()
+            assert "Generate" in plan
+            assert "BatchEvalPython" not in plan
+            assert "ArrowEvalPython" not in plan
+            assert "Join" not in plan
+
+    def test_explode_all_rows_dropped_preserves_geometry_metadata(self):
+        source = GeoDataFrame(
+            {
+                "value": [1, 2, 3],
+                "shape": [MultiPoint(), GeometryCollection(), None],
+            },
+            geometry="shape",
+            crs="EPSG:4326",
+        )
+
+        result = source.explode(ignore_index=True)
+
+        assert len(result) == 0
+        assert result.active_geometry_name == "shape"
+        assert isinstance(result["shape"], GeoSeries)
+        assert result.crs.to_epsg() == 4326
+        collected = result.to_geopandas()
+        assert str(collected.dtypes["shape"]) == "geometry"
+        assert collected.crs.to_epsg() == 4326
+
+    def test_explode_non_geometry_column(self):
+        source_gpd = gpd.GeoDataFrame(
+            {
+                "values": [[1, 2], [], [3]],
+                "shape": [Point(0, 0), Point(1, 1), Point(2, 2)],
+            },
+            geometry="shape",
+            crs="EPSG:4326",
+        )
+        result = GeoDataFrame(source_gpd).explode("values", ignore_index=True)
+
+        assert isinstance(result, GeoDataFrame)
+        assert result.active_geometry_name == "shape"
+        assert result.crs.to_epsg() == 4326
+        assert_frame_equal(
+            result.to_geopandas(),
+            source_gpd.explode("values", ignore_index=True),
+            check_dtype=False,
+        )
+
+    def test_explode_geometry_column_uses_active_geometry(self):
+        source_gpd = gpd.GeoDataFrame(
+            {
+                "before": [1],
+                "geometry": [MultiPoint([(0, 0), (1, 1)])],
+                "other": gpd.GeoSeries([MultiPoint([(10, 10), (20, 20)])]),
+                "after": [2],
+            },
+            geometry="geometry",
+            crs="EPSG:4326",
+        )
+
+        expected = source_gpd.explode(column="other", index_parts=True)
+        result = GeoDataFrame(source_gpd).explode(column="other", index_parts=True)
+
+        from geopandas.testing import assert_geodataframe_equal
+
+        assert_geodataframe_equal(
+            result.to_geopandas(),
+            expected,
+            check_index_type=False,
+        )
+
+    def test_explode_internal_name_collisions(self):
+        source_gpd = gpd.GeoDataFrame(
+            {
+                "__frame_explode_index_0__": [1],
+                "__frame_explode_data_0__": [2],
+                "__frame_explode_parent_order__": [3],
+                "__frame_explode_position__": [4],
+                "__frame_explode_value__": [5],
+                "__frame_explode_sequence__": [6],
+                "geometry": [MultiPoint([(0, 0), (1, 1)])],
+            },
+            geometry="geometry",
+        )
+
+        result = GeoDataFrame(source_gpd).explode(index_parts=True).to_geopandas()
+        expected = source_gpd.explode(index_parts=True)
+
+        from geopandas.testing import assert_geodataframe_equal
+
+        assert_geodataframe_equal(result, expected, check_index_type=False)
+
+    def test_explode_docstring_examples_are_syntactically_valid(self):
+        import doctest
+
+        examples = doctest.DocTestParser().get_examples(GeoDataFrame.explode.__doc__)
+        for example in examples:
+            compile(example.source, "<GeoDataFrame.explode>", "single")
+
     def test_getitem(self):
         geoms = [Point(x, x) for x in range(3)]
         ids = [1, 2, 3]
diff --git a/python/tests/geopandas/test_match_geopandas_dataframe.py b/python/tests/geopandas/test_match_geopandas_dataframe.py
index 5659ddf54c8..bbcdc4f0dee 100644
--- a/python/tests/geopandas/test_match_geopandas_dataframe.py
+++ b/python/tests/geopandas/test_match_geopandas_dataframe.py
@@ -130,6 +130,59 @@ class TestMatchGeopandasDataFrame(TestGeopandasBase):
 
         self.check_sgpd_df_equals_gpd_df(sgpd_df, gpd_df)
 
+    @pytest.mark.parametrize(
+        "kwargs",
+        [
+            {},
+            {"index_parts": True},
+            {"ignore_index": True},
+            {"ignore_index": True, "index_parts": True},
+        ],
+    )
+    def test_explode(self, kwargs):
+        from geopandas.testing import assert_geodataframe_equal
+
+        index = pd.MultiIndex.from_tuples(
+            [
+                ("group-a", 1),
+                ("group-a", 1),
+                ("group-b", 2),
+                ("group-b", 3),
+                ("group-c", 4),
+            ],
+            names=["group", "feature_id"],
+        )
+        expected_source = gpd.GeoDataFrame(
+            {
+                "label": ["multi", "single-empty", "multi-empty", "null", "collection"],
+                "secondary": gpd.GeoSeries(
+                    [Point(i, i) for i in range(5)],
+                    index=index,
+                ),
+                "shape": [
+                    MultiPoint([(0, 0), (1, 1)]),
+                    Point(),
+                    MultiPoint(),
+                    None,
+                    GeometryCollection([Point(2, 2), MultiPoint([(3, 3), (4, 4)])]),
+                ],
+            },
+            index=index,
+            geometry="shape",
+            crs="EPSG:4326",
+        )
+
+        expected = expected_source.explode(**kwargs)
+        actual = GeoDataFrame(expected_source).explode(**kwargs).to_geopandas()
+
+        assert_geodataframe_equal(
+            actual,
+            expected,
+            check_index_type=False,
+            check_geom_type=True,
+        )
+        pd.testing.assert_index_equal(actual.index, expected.index, exact=False)
+
     def test_set_geometry(self):
         sgpd_df = GeoDataFrame(self.geometries)
         gpd_df = gpd.GeoDataFrame(self.geometries)