Repository: geopandas/geopandas

Issue:
BUG: to_arrow conversion should write crs as PROJJSON object and not string.
- [x] I have checked that this issue has not already been reported.

- [x] I have confirmed this bug exists on the latest version of geopandas.

- [ ] (optional) I have confirmed this bug exists on the main branch of geopandas.

---

#### Code Sample, a copy-pastable example

```python
import geopandas
import nanoarrow as na

df = geopandas.GeoDataFrame({"geometry": geopandas.GeoSeries.from_wkt(["POINT (0 1)"], crs="OGC:CRS84")})
na.Array(df.to_arrow()).schema.field(0).metadata
#> <nanoarrow._schema.SchemaMetadata>
#> - b'ARROW:extension:name': b'geoarrow.wkb'
#> - b'ARROW:extension:metadata': b'{"crs": "{\\"$schema\\":\\"https://proj.org/sch
```

#### Problem description

The `"crs"` field here is PROJJSON as a string within JSON (it should be a JSON object!). I suspect this is because somewhere there is a `to_json()` that maybe should be a `to_json_dict()`.

cc @jorisvandenbossche @kylebarron 

#### Expected Output

#> - b'ARROW:extension:metadata': b'{"crs": {"$schema":"https://proj.org/sch

(i.e., value of CRS is a JSON object and not a string)

#### Output of ``geopandas.show_versions()``

<details>

SYSTEM INFO
-----------
python     : 3.13.1 (main, Dec  3 2024, 17:59:52) [Clang 16.0.0 (clang-1600.0.26.4)]
executable : [/Users/dewey/gh/arrow/.venv/bin/python](https://file+.vscode-resource.vscode-cdn.net/Users/dewey/gh/arrow/.venv/bin/python)
machine    : macOS-15.3-arm64-arm-64bit-Mach-O

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.11.4
GEOS lib   : None
GDAL       : 3.9.1
GDAL data dir: [/Users/dewey/gh/arrow/.venv/lib/python3.13/site-packages/pyogrio/gdal_data/](https://file+.vscode-resource.vscode-cdn.net/Users/dewey/gh/arrow/.venv/lib/python3.13/site-packages/pyogrio/gdal_data/)
PROJ       : 9.4.1
PROJ data dir: [/Users/dewey/gh/arrow/.venv/lib/python3.13/site-packages/pyproj/proj_dir/share/proj](https://file+.vscode-resource.vscode-cdn.net/Users/dewey/gh/arrow/.venv/lib/python3.13/site-packages/pyproj/proj_dir/share/proj)

PYTHON DEPENDENCIES
-------------------
geopandas  : 1.0.1
numpy      : 2.2.0
pandas     : 2.2.3
pyproj     : 3.7.0
shapely    : 2.0.7
pyogrio    : 0.10.0
geoalchemy2: None
geopy      : None
matplotlib : None
mapclassify: None
fiona      : None
psycopg    : None
psycopg2   : None
pyarrow    : 20.0.0.dev158+g8cb2868569.d20250213

</details>



Discussion:
Can replicate (had pyarrow on hand but it's the same story
```
df = geopandas.GeoDataFrame({"geometry": geopandas.GeoSeries.from_wkt(["POINT (0 1)"], crs="OGC:CRS84")})
metadata = na.table(df.to_arrow()).schema.field(0).metadata
print(metadata[b'ARROW:extension:metadata'])
```

Not as well across the spec as the rest of you but I read it as this is a bug.

We'd need to change this in both the WKB and geoarrow code paths, which both use to_json:
https://github.com/geopandas/geopandas/blob/8c3da4334afbf70b022e029357be2470098dbd17/geopandas/io/_geoarrow.py#L187-L191

https://github.com/geopandas/geopandas/blob/8c3da4334afbf70b022e029357be2470098dbd17/geopandas/io/_geoarrow.py#L320-L323



Patch:
diff --git a/geopandas/io/_geoarrow.py b/geopandas/io/_geoarrow.py
index 9b7ec8f2e2..d6cc783406 100644
--- a/geopandas/io/_geoarrow.py
+++ b/geopandas/io/_geoarrow.py
@@ -186,7 +186,7 @@ def construct_wkb_array(
     extension_metadata = {"ARROW:extension:name": "geoarrow.wkb"}
     if crs is not None:
         extension_metadata["ARROW:extension:metadata"] = json.dumps(
-            {"crs": crs.to_json()}
+            {"crs": crs.to_json_dict()}
         )
     else:
         # In theory this should not be needed, but otherwise pyarrow < 17
@@ -319,7 +319,7 @@ def construct_geometry_array(
     extension_metadata: dict[str, str] = {}
     if crs is not None:
         extension_metadata["ARROW:extension:metadata"] = json.dumps(
-            {"crs": crs.to_json()}
+            {"crs": crs.to_json_dict()}
         )
     else:
         # In theory this should not be needed, but otherwise pyarrow < 17


Test patch:
diff --git a/geopandas/io/tests/test_geoarrow.py b/geopandas/io/tests/test_geoarrow.py
index f01b1e91c1..3e3000cca6 100644
--- a/geopandas/io/tests/test_geoarrow.py
+++ b/geopandas/io/tests/test_geoarrow.py
@@ -210,11 +210,11 @@ def test_geoarrow_multiple_geometry_crs(encoding):
     meta1 = json.loads(
         result.schema.field("geometry").metadata[b"ARROW:extension:metadata"]
     )
-    assert json.loads(meta1["crs"])["id"]["code"] == 4326
+    assert meta1["crs"]["id"]["code"] == 4326
     meta2 = json.loads(
         result.schema.field("geom2").metadata[b"ARROW:extension:metadata"]
     )
-    assert json.loads(meta2["crs"])["id"]["code"] == 3857
+    assert meta2["crs"]["id"]["code"] == 3857
 
     roundtripped = GeoDataFrame.from_arrow(result)
     assert_geodataframe_equal(gdf, roundtripped)
@@ -237,7 +237,7 @@ def test_geoarrow_series_name_crs(encoding):
         else b"geoarrow.polygon"
     )
     meta = json.loads(field.metadata[b"ARROW:extension:metadata"])
-    assert json.loads(meta["crs"])["id"]["code"] == 4326
+    assert meta["crs"]["id"]["code"] == 4326
 
     # ensure it also works without a name
     gser = GeoSeries([box(0, 0, 10, 10)])