Repository: geopandas/geopandas

Issue:
Make `GeoDataFrame._to_geo` "public"
Currently `GeoDataFrame.to_json` uses `GeoDataFrame._to_geo` to get JSON (string) representation of GeoDataFrame.

But `GeoDataFrame._to_geo` is 'private" (prefixed). So we can get string representation of GeoDataFrame but not dictionary.

Proposition: make `GeoDataFrame._to_geo` "public".


Discussion:
What would be the use case of that? I am just curious, I have nothing against it. Feel free to make a PR.
@martinfleis ,

Well [Plotly's Choropleth Mapbox](https://plotly.github.io/plotly.py-docs/generated/plotly.express.choropleth_mapbox.html) takes result of `GeoDataFrame._to_geo()` instead of unJSONed result of `GeoDataFrame.to_file(..., driver='GeoJSON')` (that's strange, I know, but it wants to have `id` one level up).

I'm also curious why we publish string representation and hide original dictionary :)

BTW, what's the source/format of `GeoDataFrame.to_file(..., driver='GeoJSON')`?

And am I correct that JSON != GeoJSON?

Thanks.
> I'm also curious why we publish string representation and hide original dictionary :)

No one probably thought about it before :).

> what' original of GeoDataFrame.to_file(..., driver='GeoJSON')?

GeoJSON in this case is generated by Fiona based on `df.iterfeatures()`.

> And am I correct that JSON != GeoJSON?

Yes. If you compare result of `to_file` and `to_json`, you can notice that JSON does not include CRS definition - `"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },`. Not sure if there are any other differences between these two, but you certainly want to preserve CRS.
> Not sure if there are any other differences between these two, but you certainly want to preserve CRS.

They are, one of them I pointed above: GeoJSON puts `id` property inside `properties` dictionary but `to_json` puts it beside `properties`.
`GeoDataFrame.__geo_interface__` is a public API for `_to_geo` that returns the original dictionary.
I was testing some performance capabilities of different export methods of `GeoDataFrame` and I've just seen that `_to_geo` was in my test case 20% faster than `__geo_interface__`. The difference seems to come from the arg `show_bbox` set to `True` in `__geo_interface__`. Could it be a reason to reopen this issue ? Thanks !

(my use case was to try to export geoson using the [geobuf](https://github.com/pygeobuf/pygeobuf) lib that needs geoson as dict format) 
I suppose that is fair. There's a workaround using `iterfeatures` which takes the same kwargs as `_to_geo` but it may be best to expose `_to_geo` as `.to_geo_dict` directly to allow this kind of customisation.

```py
geo_dict = {
    "type": "FeatureCollection",
    "features": list(df.iterfeatures()),
}
```
Thanks a lot! Indeed, I also tried the workaround you indicate and ended up creating a new function in my own code :) 

Do you want me to have a look at this and open a PR ?
> Do you want me to have a look at this and open a PR ?

That would be great! I think you can just rename `_to_geo`, change its name in other places and add it to API documentation. I _think_ it should be tested but we should double check that.
Another option could be to partially re-use pandas' `to_dict()` method? (to avoid adding a new method, although that's of course also less discoverable). `to_dict` has an `orient` keyword that determines the layout of the resulting dict, we could add a "geojson" option for that.
@jorisvandenbossche I was thinking about that but would personally prefer a separate method since GeoJSON serialisation of geometries is not compatible with other `orient` options and it feels weird that you get shapely objects in all but one case.
> That would be great! I think you can just rename `_to_geo`, change its name in other places and add it to API documentation. I _think_ it should be tested but we should double check that.

Ok I'm going for it!

The `orient` option does indeed sound interesting but I don't know enough about it to contribute to the discussion. I'll start with the first option by default but let me know if there are any changes :)

The PR is open here: https://github.com/geopandas/geopandas/pull/3132
Feel free to give me any feedback or modify it, it's my first contribution here :)

Concerning the tests, `_to_geo` already has some so I didn't change anything for now (apart from the name). 



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fadd1dd85e..9405275ef2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@ New methods:
 - Added `contains_properly` method from shapely to GeoSeries/GeoDataframe (#3105).
 - Added `snap` method from shapely to GeoSeries/GeoDataframe (#3086).
 - Added `transform` method from shapely to GeoSeries/GeoDataFrame (#3075).
+- Added `to_geo_dict` method to generate GeoJSON-like dictionary from a GeoDataFrame (#3132).
 
 New features and improvements:
 
@@ -51,9 +52,9 @@ Bug fixes:
 
 Deprecations and compatibility notes:
 
-- The deprecation of `geopandas.datasets` has been enforced and the module has been 
-  removed. New sample datasets are now available in the 
-  [geodatasets](https://geodatasets.readthedocs.io/en/latest/) package (#3084). 
+- The deprecation of `geopandas.datasets` has been enforced and the module has been
+  removed. New sample datasets are now available in the
+  [geodatasets](https://geodatasets.readthedocs.io/en/latest/) package (#3084).
 
 ## Version 0.14.2 (Jan 4, 2024)
 
diff --git a/doc/source/docs/reference/geodataframe.rst b/doc/source/docs/reference/geodataframe.rst
index d6a8090a85..8a7c33d23d 100644
--- a/doc/source/docs/reference/geodataframe.rst
+++ b/doc/source/docs/reference/geodataframe.rst
@@ -24,6 +24,7 @@ Serialization / IO / conversion
    GeoDataFrame.from_postgis
    GeoDataFrame.to_file
    GeoDataFrame.to_json
+   GeoDataFrame.to_geo_dict
    GeoDataFrame.to_parquet
    GeoDataFrame.to_feather
    GeoDataFrame.to_postgis
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 751d481417..285499d32c 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -825,7 +825,7 @@ def to_json(
         else:
             df = self
 
-        geo = df._to_geo(na=na, show_bbox=show_bbox, drop_id=drop_id)
+        geo = df.to_geo_dict(na=na, show_bbox=show_bbox, drop_id=drop_id)
 
         # if the geometry is not in WGS84, include CRS in the JSON
         if df.crs is not None and not df.crs.equals("epsg:4326"):
@@ -853,7 +853,7 @@ def __geo_interface__(self):
         represents the ``GeoDataFrame`` as a GeoJSON-like
         ``FeatureCollection``.
 
-        This differs from `_to_geo()` only in that it is a property with
+        This differs from :meth:`to_geo_dict` only in that it is a property with
         default args instead of a method.
 
         CRS of the dataframe is not passed on to the output, unlike
@@ -877,7 +877,7 @@ def __geo_interface__(self):
 ': {'col1': 'name2'}, 'geometry': {'type': 'Point', 'coordinates': (2.0, 1.0)}, 'b\
 box': (2.0, 1.0, 2.0, 1.0)}], 'bbox': (1.0, 1.0, 2.0, 2.0)}
         """
-        return self._to_geo(na="null", show_bbox=True, drop_id=False)
+        return self.to_geo_dict(na="null", show_bbox=True, drop_id=False)
 
     def iterfeatures(self, na="null", show_bbox=False, drop_id=False):
         """
@@ -923,8 +923,8 @@ def iterfeatures(self, na="null", show_bbox=False, drop_id=False):
 
         if self._geometry_column_name not in self:
             raise AttributeError(
-                "No geometry data set (expected in"
-                " column '%s')." % self._geometry_column_name
+                "No geometry data set (expected in column '%s')."
+                % self._geometry_column_name
             )
 
         ids = np.array(self.index, copy=False)
@@ -987,18 +987,60 @@ def iterfeatures(self, na="null", show_bbox=False, drop_id=False):
 
                 yield feature
 
-    def _to_geo(self, **kwargs):
+    def to_geo_dict(self, na="null", show_bbox=False, drop_id=False):
         """
-        Returns a python feature collection (i.e. the geointerface)
-        representation of the GeoDataFrame.
+        Returns a python feature collection representation of the GeoDataFrame
+        as a dictionary with a list of features based on the ``__geo_interface__``
+        GeoJSON-like specification.
+
+        Parameters
+        ----------
+        na : str, optional
+            Options are {'null', 'drop', 'keep'}, default 'null'.
+            Indicates how to output missing (NaN) values in the GeoDataFrame
+
+            - null: output the missing entries as JSON null
+            - drop: remove the property from the feature. This applies to each feature \
+individually so that features may have different properties
+            - keep: output the missing entries as NaN
+
+        show_bbox : bool, optional
+            Include bbox (bounds) in the geojson. Default False.
+        drop_id : bool, default: False
+            Whether to retain the index of the GeoDataFrame as the id property
+            in the generated dictionary. Default is False, but may want True
+            if the index is just arbitrary row numbers.
+
+        Examples
+        --------
+
+        >>> from shapely.geometry import Point
+        >>> d = {'col1': ['name1', 'name2'], 'geometry': [Point(1, 2), Point(2, 1)]}
+        >>> gdf = geopandas.GeoDataFrame(d)
+        >>> gdf
+            col1     geometry
+        0  name1  POINT (1 2)
+        1  name2  POINT (2 1)
+
+        >>> gdf.to_geo_dict()
+        {'type': 'FeatureCollection', 'features': [{'id': '0', 'type': 'Feature', '\
+properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.0, \
+2.0)}}, {'id': '1', 'type': 'Feature', 'properties': {'col1': 'name2'}, 'geometry':\
+ {'type': 'Point', 'coordinates': (2.0, 1.0)}}]}
+
+        See also
+        --------
+        GeoDataFrame.to_json : return a GeoDataFrame as a GeoJSON string
 
         """
         geo = {
             "type": "FeatureCollection",
-            "features": list(self.iterfeatures(**kwargs)),
+            "features": list(
+                self.iterfeatures(na=na, show_bbox=show_bbox, drop_id=drop_id)
+            ),
         }
 
-        if kwargs.get("show_bbox", False):
+        if show_bbox:
             geo["bbox"] = tuple(self.total_bounds)
 
         return geo
@@ -1098,7 +1140,7 @@ def to_parquet(
         engine = kwargs.pop("engine", "auto")
         if engine not in ("auto", "pyarrow"):
             raise ValueError(
-                f"GeoPandas only supports using pyarrow as the engine for "
+                "GeoPandas only supports using pyarrow as the engine for "
                 f"to_parquet: {engine!r} passed instead."
             )
 
@@ -1641,8 +1683,8 @@ def __finalize__(self, other, method=None, **kwargs):
                 raise ValueError(
                     "Concat operation has resulted in multiple columns using "
                     f"the geometry column name '{self._geometry_column_name}'.\n"
-                    f"Please ensure this column from the first DataFrame is not "
-                    f"repeated."
+                    "Please ensure this column from the first DataFrame is not "
+                    "repeated."
                 )
         elif method == "unstack":
             # unstack adds multiindex columns and reshapes data.


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 85cb3c562e..6668071f91 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -911,13 +911,13 @@ def test_geodataframe_iterfeatures_non_scalars(self):
         assert expected == result
 
     def test_geodataframe_geojson_no_bbox(self):
-        geo = self.df._to_geo(na="null", show_bbox=False)
+        geo = self.df.to_geo_dict(na="null", show_bbox=False)
         assert "bbox" not in geo.keys()
         for feature in geo["features"]:
             assert "bbox" not in feature.keys()
 
     def test_geodataframe_geojson_bbox(self):
-        geo = self.df._to_geo(na="null", show_bbox=True)
+        geo = self.df.to_geo_dict(na="null", show_bbox=True)
         assert "bbox" in geo.keys()
         assert len(geo["bbox"]) == 4
         assert isinstance(geo["bbox"], tuple)