Repository: geopandas/geopandas

Issue:
ENH: include CRS in "to_json"
_Note (by @martinfleis): see the discussion below for details on the enhancement proposal._

#### Question about geopandas

gdf.to_json() 

> {"type": "FeatureCollection", 
> "features": [{**"id": "0500"**, "type": "Feature", **"properties": {}**, "geometry": ...}]
> }
> 

gdf.to_file('temp.geojson', driver='GeoJSON')

> {
> "type": "FeatureCollection",
> **"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::32246" } },**
> "features": [
> { "type": "Feature", **"properties": { "id": "0500" }**, "geometry": ...}]
> }


the   "id" is in "properties" for .to_file(driver='GeoJSON')  
and to_json miss the "crs"

Why they have different behavior?

and

How to:

1  let to_json()  output "crs" ?

2 let to_file()  output   each feature  with "id" as  to_json()?

Thanks.
ENH: include CRS in "to_json"
_Note (by @martinfleis): see the discussion below for details on the enhancement proposal._

#### Question about geopandas

gdf.to_json() 

> {"type": "FeatureCollection", 
> "features": [{**"id": "0500"**, "type": "Feature", **"properties": {}**, "geometry": ...}]
> }
> 

gdf.to_file('temp.geojson', driver='GeoJSON')

> {
> "type": "FeatureCollection",
> **"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::32246" } },**
> "features": [
> { "type": "Feature", **"properties": { "id": "0500" }**, "geometry": ...}]
> }


the   "id" is in "properties" for .to_file(driver='GeoJSON')  
and to_json miss the "crs"

Why they have different behavior?

and

How to:

1  let to_json()  output "crs" ?

2 let to_file()  output   each feature  with "id" as  to_json()?

Thanks.


Discussion:
Each of the methods is managed differently. `to_json` is internal GeoPandas method, so the creation of JSON is managed by GeoPandas.

https://github.com/geopandas/geopandas/blob/eabe4049dd8596526b39f2a4beca470f4b84943e/geopandas/geodataframe.py#L645

`to_file()` uses `fiona` package to write files, hence the structure of the JSON depends on `fiona` and there's not much we can do about it.

One thing we may is to report CRS in `to_json`, but I am not sure what the specification is and how to get it from `pyproj`.

> let to_json() output "crs" ?

The method does not return CRS currently. You'll have to edit JSON manually.

> let to_file() output each feature with "id" as to_json()?

I don't think it is possible, but there may a way to control that in fiona.
I also miss the CRS when dumping `.to_json()`, even if, on the `gdf`itself, it's a proper `pyproj.crs.crs.CRS` type containing, e.g. 
```python
>>> gdf.crs    
<Geographic 2D CRS: EPSG:4326>
Name: WGS 84
Axis Info [ellipsoidal]:
- Lat[north]: Geodetic latitude (degree)
- Lon[east]: Geodetic longitude (degree)
Area of Use:
- name: World.
- bounds: (-180.0, -90.0, 180.0, 90.0)
Datum: World Geodetic System 1984
- Ellipsoid: WGS 84
- Prime Meridian: Greenwich
```

You'll have to specify at least:
```python
object.crs = {
    "type": "name",
    "properties": {
        "name": "EPSG:4326"
    }
}
```

Maybe this can help:    

>4.  Coordinate Reference System
>
>   The coordinate reference system for all GeoJSON coordinates is a
   geographic coordinate reference system, using the World Geodetic
   System 1984 (WGS 84) [WGS84] datum, with longitude and latitude units
   of decimal degrees.  This is equivalent to the coordinate reference
   system identified by the Open Geospatial Consortium (OGC) URN
   urn:ogc:def:crs:OGC::CRS84.  An OPTIONAL third-position element SHALL
   be the height in meters above or below the WGS 84 reference
   ellipsoid.  In the absence of elevation values, applications
   sensitive to height or depth SHOULD interpret positions as being at
   local ground or sea level.

Source: https://tools.ietf.org/html/rfc7946#section-4
Any update on this?
@JoakimJoensuu not that I am aware of. I'll put a milestone to it so it is part of the next release. It is in line with our tentative roadmap anyway.

@snowman2 Is there a way to get the CRS in the required format (i.e. `urn:ogc:def:crs:EPSG::32246`) from the `pyproj.CRS`? 
> Is there a way to get the CRS in the required format (i.e. urn:ogc:def:crs:EPSG::32246) from the pyproj.CRS?

The closest thing is `to_authority`|`to_epsg`.
Linking the OGC CRS URN specification here: https://www.ogc.org/ogcUrnPolicy and GeoJSON specs https://gist.github.com/sgillies/1233327 as a reference. 

We should be able to get the string as

```python
authority, code = df.crs.to_authority()
ogc_crs = f"urn:ogc:def:crs:{authority}::{code}"
```

If someone wants to turn this into a PR, feel free to pick it up.
Should `crs` actually be removed from `gdf.to_file('temp.geojson', driver='GeoJSON')` instead of adding it to `gdf.to_json()`?

https://datatracker.ietf.org/doc/html/rfc7946#section-4

```
Note: the use of alternative coordinate reference systems was specified in [GJ2008], but it has been removed from this version of the specification because the use of different coordinate reference systems
```

To respect RFC7946 `crs` key could be removed and instead ability to add `properties` could be added. 
@JoakimJoensuu I wouldn't remove it as per the following bit from the linked spec:

> However, where all
   involved parties have a prior arrangement, alternative coordinate
   reference systems can be used without risk of data being
   misinterpreted.

The change in the spec assuming wgs84 CRS is being dealt with in #416 by the addition of `to_wgs84` keyword. The creation of GeoJSON using `gdf.to_file` is managed by fiona and any change should be implemented there.

Each of the methods is managed differently. `to_json` is internal GeoPandas method, so the creation of JSON is managed by GeoPandas.

https://github.com/geopandas/geopandas/blob/eabe4049dd8596526b39f2a4beca470f4b84943e/geopandas/geodataframe.py#L645

`to_file()` uses `fiona` package to write files, hence the structure of the JSON depends on `fiona` and there's not much we can do about it.

One thing we may is to report CRS in `to_json`, but I am not sure what the specification is and how to get it from `pyproj`.

> let to_json() output "crs" ?

The method does not return CRS currently. You'll have to edit JSON manually.

> let to_file() output each feature with "id" as to_json()?

I don't think it is possible, but there may a way to control that in fiona.
I also miss the CRS when dumping `.to_json()`, even if, on the `gdf`itself, it's a proper `pyproj.crs.crs.CRS` type containing, e.g. 
```python
>>> gdf.crs    
<Geographic 2D CRS: EPSG:4326>
Name: WGS 84
Axis Info [ellipsoidal]:
- Lat[north]: Geodetic latitude (degree)
- Lon[east]: Geodetic longitude (degree)
Area of Use:
- name: World.
- bounds: (-180.0, -90.0, 180.0, 90.0)
Datum: World Geodetic System 1984
- Ellipsoid: WGS 84
- Prime Meridian: Greenwich
```

You'll have to specify at least:
```python
object.crs = {
    "type": "name",
    "properties": {
        "name": "EPSG:4326"
    }
}
```

Maybe this can help:    

>4.  Coordinate Reference System
>
>   The coordinate reference system for all GeoJSON coordinates is a
   geographic coordinate reference system, using the World Geodetic
   System 1984 (WGS 84) [WGS84] datum, with longitude and latitude units
   of decimal degrees.  This is equivalent to the coordinate reference
   system identified by the Open Geospatial Consortium (OGC) URN
   urn:ogc:def:crs:OGC::CRS84.  An OPTIONAL third-position element SHALL
   be the height in meters above or below the WGS 84 reference
   ellipsoid.  In the absence of elevation values, applications
   sensitive to height or depth SHOULD interpret positions as being at
   local ground or sea level.

Source: https://tools.ietf.org/html/rfc7946#section-4
Any update on this?
@JoakimJoensuu not that I am aware of. I'll put a milestone to it so it is part of the next release. It is in line with our tentative roadmap anyway.

@snowman2 Is there a way to get the CRS in the required format (i.e. `urn:ogc:def:crs:EPSG::32246`) from the `pyproj.CRS`? 
> Is there a way to get the CRS in the required format (i.e. urn:ogc:def:crs:EPSG::32246) from the pyproj.CRS?

The closest thing is `to_authority`|`to_epsg`.
Linking the OGC CRS URN specification here: https://www.ogc.org/ogcUrnPolicy and GeoJSON specs https://gist.github.com/sgillies/1233327 as a reference. 

We should be able to get the string as

```python
authority, code = df.crs.to_authority()
ogc_crs = f"urn:ogc:def:crs:{authority}::{code}"
```

If someone wants to turn this into a PR, feel free to pick it up.
Should `crs` actually be removed from `gdf.to_file('temp.geojson', driver='GeoJSON')` instead of adding it to `gdf.to_json()`?

https://datatracker.ietf.org/doc/html/rfc7946#section-4

```
Note: the use of alternative coordinate reference systems was specified in [GJ2008], but it has been removed from this version of the specification because the use of different coordinate reference systems
```

To respect RFC7946 `crs` key could be removed and instead ability to add `properties` could be added. 
@JoakimJoensuu I wouldn't remove it as per the following bit from the linked spec:

> However, where all
   involved parties have a prior arrangement, alternative coordinate
   reference systems can be used without risk of data being
   misinterpreted.

The change in the spec assuming wgs84 CRS is being dealt with in #416 by the addition of `to_wgs84` keyword. The creation of GeoJSON using `gdf.to_file` is managed by fiona and any change should be implemented there.


Resolving patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -766,22 +766,28 @@ def to_json(
           feature individually so that features may have different properties.
         - ``keep``: output the missing entries as NaN.
 
+        If the GeoDataFrame has a defined CRS, its definition will be included
+        in the output unless it is equal to WGS84 (default GeoJSON CRS) or not
+        possible to represent in the URN OGC format, or unless ``to_wgs84=True``
+        is specified.
+
         Examples
         --------
 
         >>> from shapely.geometry import Point
         >>> d = {'col1': ['name1', 'name2'], 'geometry': [Point(1, 2), Point(2, 1)]}
-        >>> gdf = geopandas.GeoDataFrame(d, crs="EPSG:4326")
+        >>> gdf = geopandas.GeoDataFrame(d, crs="EPSG:3857")
         >>> gdf
-            col1                 geometry
-        0  name1  POINT (1.00000 2.00000)
-        1  name2  POINT (2.00000 1.00000)
+            col1             geometry
+        0  name1  POINT (1.000 2.000)
+        1  name2  POINT (2.000 1.000)
 
         >>> gdf.to_json()
         '{"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]}}]}'
+: {"type": "Point", "coordinates": [2.0, 1.0]}}], "crs": {"type": "name", "properti\
+es": {"name": "urn:ogc:def:crs:EPSG::3857"}}}'
 
         Alternatively, you can write GeoJSON to file:
 
@@ -801,9 +807,26 @@ def to_json(
                 )
         else:
             df = self
-        return json.dumps(
-            df._to_geo(na=na, show_bbox=show_bbox, drop_id=drop_id), **kwargs
-        )
+
+        geo = df._to_geo(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"):
+            auth_crsdef = self.crs.to_authority()
+            allowed_authorities = ["EDCS", "EPSG", "OGC", "SI", "UCUM"]
+
+            if auth_crsdef is None or auth_crsdef[0] not in allowed_authorities:
+                warnings.warn(
+                    "GeoDataFrame's CRS is not representable in URN OGC "
+                    "format. Resulting JSON will contain no CRS information.",
+                    stacklevel=2,
+                )
+            else:
+                authority, code = auth_crsdef
+                ogc_crs = f"urn:ogc:def:crs:{authority}::{code}"
+                geo["crs"] = {"type": "name", "properties": {"name": ogc_crs}}
+
+        return json.dumps(geo, **kwargs)
 
     @property
     def __geo_interface__(self):
@@ -814,7 +837,10 @@ def __geo_interface__(self):
         ``FeatureCollection``.
 
         This differs from `_to_geo()` only in that it is a property with
-        default args instead of a method
+        default args instead of a method.
+
+        CRS of the dataframe is not passed on to the output, unlike
+        :meth:`~GeoDataFrame.to_json()`.
 
         Examples
         --------
@@ -833,8 +859,6 @@ def __geo_interface__(self):
 , 2.0)}, 'bbox': (1.0, 2.0, 1.0, 2.0)}, {'id': '1', 'type': 'Feature', 'properties\
 ': {'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)
 


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -1427,3 +1427,45 @@ def test_geodataframe_crs():
     gdf = GeoDataFrame(columns=["geometry"])
     gdf.crs = "IGNF:ETRS89UTM28"
     assert gdf.crs.to_authority() == ("IGNF", "ETRS89UTM28")
+
+
+def test_geodataframe_nocrs_json():
+    # no CRS, no crs field
+    gdf = GeoDataFrame(columns=["geometry"])
+    gdf_geojson = json.loads(gdf.to_json())
+    assert "crs" not in gdf_geojson
+
+    # WGS84, no crs field (default as per spec)
+    gdf.crs = 4326
+    gdf_geojson = json.loads(gdf.to_json())
+    assert "crs" not in gdf_geojson
+
+
+def test_geodataframe_crs_json():
+    gdf = GeoDataFrame(columns=["geometry"])
+    gdf.crs = 25833
+    gdf_geojson = json.loads(gdf.to_json())
+    assert "crs" in gdf_geojson
+    assert gdf_geojson["crs"] == {
+        "type": "name",
+        "properties": {"name": "urn:ogc:def:crs:EPSG::25833"},
+    }
+    gdf_geointerface = gdf.__geo_interface__
+    assert "crs" not in gdf_geointerface
+
+
+@pytest.mark.parametrize(
+    "crs",
+    ["+proj=cea +lon_0=0 +lat_ts=45 +x_0=0 +y_0=0 +ellps=WGS84 +units=m", "IGNF:WGS84"],
+)
+def test_geodataframe_crs_nonrepresentable_json(crs):
+    gdf = GeoDataFrame(
+        [Point(1000, 1000)],
+        columns=["geometry"],
+        crs=crs,
+    )
+    with pytest.warns(
+        UserWarning, match="GeoDataFrame's CRS is not representable in URN OGC"
+    ):
+        gdf_geojson = json.loads(gdf.to_json())
+    assert "crs" not in gdf_geojson