Repository: geopandas/geopandas (python)

Issue:
ENH: from_features - allow not passing in "properties"
https://github.com/geopandas/geopandas/blob/5c8ae832e030228b5deec02588c4e1b78dee0f18/geopandas/geodataframe.py#L651

It checks if `properties` is None. Any issues changing it to feature.get("properties") so this scenario could be supported:
```python
collection = {
    "type": "FeatureCollection",
    "features": [
        {
            "type": "Feature",
            "id": "095af6d6-bfcd-4cca-a72c-bf2efd1cf917",
            "geometry": {
                "type": "Point",
                "coordinates": [-10.0, -10.0]
            }
        },
        {
            "type": "Feature",
            "id": "a7ad51b6-ad2d-4f75-806a-d68ab9e07e5b",
            "geometry": {
                "type": "Point",
                "coordinates": [-10.1, -10.1]
            }
        }
    ]
}
gdf = geopandas.GeoDataFrame.from_features(collection["features"])
```

Pull request description:
ENH: support missing `properties` key in `from_features`
Hi! This fixes #2506, an old bug that resulted in a `KeyError` when running `from_features` on data without the `properties` field. I have also updated an old test to be clear that empty or `null` `properties` do not produce any column.

Let me know if anything needs to be adjusted.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 059450d0..e00a5afb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Changelog
 
+## Version 1.2.0
+
+Bug fixes:
+
+- Fix an issue that caused an error in `GeoDataFrame.from_features` when there is no `properties` field (#3599).
+
 ## Version 1.1.1 (June 27, 2025)
 
 Bug fixes:
@@ -167,6 +173,7 @@ files based on a bounding box, and write out a bounding box column to parquet fi
   instead of its current behaviour of assuming EPSG. In the event the spiatal_ref_sys
   table is not present, or the SRID is not present, `read_postgis` will fallback
   on assuming EPSG CRS authority. (#3329)
+- Added ``GeoDataFrame.active_geometry_name`` property returning the active geometry column's name or None if no active geometry column is set (#2943).
 
 Backwards incompatible API changes:
 
@@ -309,7 +316,6 @@ New methods:
 New features and improvements:
 
 - Added ``exclusive`` parameter to ``sjoin_nearest`` method for Shapely >= 2.0 (#2877)
-- Added ``GeoDataFrame.active_geometry_name`` property returning the active geometry column's name or None if no active geometry column is set.
 - The ``to_file()`` method will now automatically detect the FlatGeoBuf driver
   for files with the `.fgb` extension (#2958)
 
diff --git a/doc/source/getting_started/install.rst b/doc/source/getting_started/install.rst
index 30377507..71d9a1f0 100644
--- a/doc/source/getting_started/install.rst
+++ b/doc/source/getting_started/install.rst
@@ -214,6 +214,7 @@ For plotting, these additional packages may be used:
 
 .. _pointpats: https://pysal.org/pointpats/
 
-.. _folium: https://python-visualization.github.io/folium/stable/
+.. _folium: https://python-visualization.github.io/folium/latest/
+
+.. _scipy: https://docs.scipy.org/doc/scipy/
 
-.. _scipy: https://docs.scipy.org/doc/scipy/
\ No newline at end of file
diff --git a/geopandas/_config.py b/geopandas/_config.py
index 5f699818..5ecba51f 100644
--- a/geopandas/_config.py
+++ b/geopandas/_config.py
@@ -16,7 +16,7 @@ Option = namedtuple("Option", "key default_value doc validator callback")
 class Options:
     """Provide attribute-style access to configuration dict."""
 
-    def __init__(self, options):
+    def __init__(self, options: dict[str, Option]) -> None:
         super().__setattr__("_options", options)
         # populate with default values
         config = {}
@@ -25,7 +25,7 @@ class Options:
 
         super().__setattr__("_config", config)
 
-    def __setattr__(self, key, value):
+    def __setattr__(self, key, value) -> None:
         # you can't set new keys
         if key in self._config:
             option = self._options[key]
@@ -44,10 +44,10 @@ class Options:
         except KeyError:
             raise AttributeError("No such option")
 
-    def __dir__(self):
+    def __dir__(self) -> list[str]:
         return list(self._config.keys())
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         cls = self.__class__.__name__
         description = ""
         for key, option in self._options.items():
@@ -67,7 +67,7 @@ class Options:
         return f"{cls}({space}{description})"
 
 
-def _validate_display_precision(value):
+def _validate_display_precision(value: int) -> None:
     if value is not None:
         if not isinstance(value, int) or not (0 <= value <= 16):
             raise ValueError("Invalid value, needs to be an integer [0-16]")
@@ -87,7 +87,7 @@ display_precision = Option(
 )
 
 
-def _warn_use_pygeos_deprecated(_value):
+def _warn_use_pygeos_deprecated(_value) -> None:
     warnings.warn(
         "pygeos support was removed in 1.0. "
         "geopandas.use_pygeos is a no-op and will be removed in geopandas 1.1.",
@@ -95,7 +95,7 @@ def _warn_use_pygeos_deprecated(_value):
     )
 
 
-def _validate_io_engine(value):
+def _validate_io_engine(value: str) -> None:
     if value is not None:
         if value not in ("pyogrio", "fiona"):
             raise ValueError(f"Expected 'pyogrio' or 'fiona', got '{value}'")
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 2cbee29d..5cf37474 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -815,9 +815,7 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
                 "geometry": shape(feature["geometry"]) if feature["geometry"] else None
             }
             # load properties
-            properties = feature["properties"]
-            if properties is None:
-                properties = {}
+            properties = feature.get("properties") or {}
             row.update(properties)
             rows.append(row)
         return cls(rows, columns=columns, crs=crs)
@@ -2094,12 +2092,24 @@ default 'snappy'
 
         # merge operation: using metadata of the left object
         if method == "merge":
+            # pandas-dev/pandas#60357 : merge/concat use input_objs
+            if PANDAS_GE_30:
+                # other is a types.SimpleNameSpace
+                left_obj = other.input_objs[0]
+            else:
+                # other is a _MergeOperation
+                left_obj = other.left
             for name in self._metadata:
-                object.__setattr__(self, name, getattr(other.left, name, None))
+                object.__setattr__(self, name, getattr(left_obj, name, None))
         # concat operation: using metadata of the first object
         elif method == "concat":
+            # pandas-dev/pandas#60357 : merge/concat use input_objs
+            if PANDAS_GE_30:
+                first_obj = other.input_objs[0]
+            else:
+                first_obj = other.objs[0]
             for name in self._metadata:
-                object.__setattr__(self, name, getattr(other.objs[0], name, None))
+                object.__setattr__(self, name, getattr(first_obj, name, None))
 
             if (
                 self.columns.nlevels == 1


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 61eac57b..698251c6 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -774,6 +774,26 @@ class TestDataFrame:
         gdf2 = GeoDataFrame.from_features(gjson_null)
 
         assert_frame_equal(gdf1, gdf2)
+        assert "properties" not in gdf1.columns
+
+    def test_from_features_no_properties(self):
+        data = {
+            "type": "FeatureCollection",
+            "features": [
+                {
+                    "type": "Feature",
+                    "geometry": {"type": "Point", "coordinates": [0.0, 90.0]},
+                },
+                {
+                    "type": "Feature",
+                    "geometry": {"type": "Point", "coordinates": [0.0, -90.0]},
+                },
+            ],
+        }
+
+        gdf = GeoDataFrame.from_features(data)
+        assert gdf.shape == (2, 1)
+        assert "properties" not in gdf.columns
 
     def test_from_features_geom_interface_feature(self):
         class Placemark:
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 4e182051..9638109f 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -861,6 +861,36 @@ def test_preserve_attrs(df):
     assert df3.attrs == attrs
 
 
+def test_attrs_concat():
+    # from pandas-dev/pandas#60357
+    # concat propagates attrs if all input attrs are equal
+    geoms = [Point(2, 2), Point(3, 3)]
+    df1 = GeoDataFrame({"A": [2, 3], "geometry": geoms})
+    df1.attrs = {"a": 1, "b": 2}
+    df2 = GeoDataFrame({"A": [4, 5], "geometry": geoms})
+    df2.attrs = df1.attrs.copy()
+    df3 = GeoDataFrame({"A": [6, 7], "geometry": geoms})
+    df3.attrs = df1.attrs.copy()
+    assert pd.concat([df1, df2, df3]).attrs == df1.attrs
+    # concat does not propagate attrs if input attrs are different
+    df2.attrs = {"c": 3}
+    assert pd.concat([df1, df2, df3]).attrs == {}
+
+
+def test_attrs_merge():
+    # from pandas-dev/pandas#60357
+    geoms = [Point(2, 2), Point(3, 3)]
+    # merge propagates attrs if all input attrs are equal
+    df1 = GeoDataFrame({"key": ["a", "b"], "val1": [1, 2], "geometry": geoms})
+    df1.attrs = {"a": 1, "b": 2}
+    df2 = GeoDataFrame({"key": ["a", "b"], "val2": [3, 4], "geometry": geoms})
+    df2.attrs = df1.attrs.copy()
+    assert pd.merge(df1, df2).attrs == df1.attrs
+    # merge does not propagate attrs if input attrs are different
+    df2.attrs = {"c": 3}
+    assert pd.merge(df1, df2).attrs == {}
+
+
 def test_preserve_flags(df):
     # https://github.com/geopandas/geopandas/issues/1654
     df = df.set_flags(allows_duplicate_labels=False)