Repository: geopandas/geopandas
Pull request #3487 (2024-12-10 21:53:08)

DOC: Changed documentation for creating scale bars to reflect new method/package available for doing so
(Let me know if I did this right, this is my first time contributing)

My goal was to update the documentation regarding adding scale bars to matplotlib plots: [this page](https://geopandas.org/en/stable/gallery/matplotlib_scalebar.html), made by [this doc](https://github.com/geopandas/geopandas/blob/main/doc/source/gallery/matplotlib_scalebar.ipynb).

The reason for doing so is two fold:

* First, the prior package utilized, `matplotlib-scalebar`, [fails on newer version of Python](https://github.com/ppinard/matplotlib-scalebar/issues/63), and the developer seems unresponsive to updating it (so far),
* Second, I have recently developed a new package, [`matplotlib-map-utils`](https://github.com/moss-xyz/matplotlib-map-utils), which can do the same thing, and improved it in a few ways; this new package also adds support for creating north arrows, which are commonly placed on map plots alongside scale bars.

I haven't changed any other code for `geopandas`, just this documentation to reflect a more up-to-date methodology, in case people are searching for something like "[add scale bar geopandas](https://www.google.com/search?q=add+scale+bar+geopandas)" (which is how I found it!).

I recreated the basic structure of the tutorial as best I could, but tailored to my package's functions/structure, so hopefully it works the same way for generating the HTML version of the page.

Feedback appreciated!

martinfleis: Hi, thanks for the contribution and a nice new package!

Given the geopandas' position in the ecosystem, I'd rather wait a but until `matplotlib-map-uitils` is a bit more established and we know it won't be abandoned next month before linking it from the documentation as the suggested way of generating a scalebar. Especially given `matplotlib-scalebar` released 0.9.0 just yesterday.
moss-xyz: Totally valid, was happy to see the OG package get updated as well!

I still think my package might be a better experience for users for aesthetic reasons + the addition of the North Arrow object, but I understand setting a high bar for linking to it for the documentation. Is there a specific milestone you would want to orient around for deciding when to include? 
martinfleis: There's no milestone set in stone. What we could do is to add the new option to the same example as an alternative solution. That way at least one should always work and you can also include an example of adding a north arrow.
moss-xyz: Sure thing - you would be open to me merging the old documentation with my new documentation then? I would basically:

- Rename the title (so it mentions North Arrows along with Scale Bars)
- Create headings referencing each of `matplotlib-scalebar` and `matplotlib-map-utils` as a way of accomplishing this, and move the documentation for each under these headings
- Demote each subheading to appropriately nest within their new sections (turn H2s to H3s, H3s to H4s, etc.)
- Maybe tweak the wording a bit in the first text blocks to flow more naturally

It looks like a lot because I typed it out in four bullets, but shouldn't really change much, and would preserve the current documentation re: `matplotlib-scalebar` as requested.

If you're happy with it, I'll get it done in the next few days, otherwise let me know what you want discuss further.
moss-xyz: @martinfleis wasn't sure if you got a notification since I had edited my comment instead of posting a new one, but I made the requested changes, see the updated merge above!
moss-xyz: @m-richards thanks for taking the time to leave feedback - I've addressed most of your comments in the latest commit (hyperlinks, in-line comments, unprojected data, and removed an unnecessary plot).

I don't contribute a lot to repos on GitHub so can you walk me through a little more specifically what I need to do for this fork to be merged? It looks like the "Allow edits from maintainers" setting is on from my end:

![image](https://github.com/user-attachments/assets/b4e8ea2a-a484-4f6f-86df-1c8941a1e210)

But I'm seeing a new error crop up now that this has been approved, and I don't know what I need to change:

![image](https://github.com/user-attachments/assets/b7515c2b-a76d-4398-b31f-9747a3b5d4d5)
m-richards: > @m-richards thanks for taking the time to leave feedback - I've addressed most of your comments in the latest commit (hyperlinks, in-line comments, unprojected data, and removed an unnecessary plot; I also removed the embedded outputs).
> 
> I don't contribute a lot to repos on GitHub so can you walk me through a little more specifically what I need to do for this fork to be merged? It looks like the "Allow edits from maintainers" setting is on from my end:

To be honest, that's what I was expecting you to have unchecked, but I still can't push to your repository (and perhaps I'm doing something wrong, but I pushed merged with main on some other forks yesterday too).

I guess instead, you can merge with main directly to pull in the latest changes - including the fix for readthedocs.
If you've added geopandas/geopandas as a remote (if not see https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/configuring-a-remote-repository-for-a-fork),
you can then just run `git fetch upstream; git merge upstream/main` and push from your branch. then we should get a more useful error in the readthedocs build about adding `matplotlib-map-utils` to the environment.yaml.


moss-xyz: @m-richards thanks for the advice, I've merged upstream and it appears the readthedocs build worked, but I still get a big `Merging is blocked; Merge is not an allowed merge method in this repository` error underneath 😅 

At this point I'm unsure if it's an issue with my branch or some setting on GeoPandas? [This help thread](https://github.com/orgs/community/discussions/143787#discussioncomment-11175025) shows other people being confused by this new merge experience - as with them, the "classic" experience shows no issues from my end... so I feel like you should be able to merge it?
martinfleis: @moss-xyz you don't have power to merge here, only we do. No need to worry!

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4224727e..c4adc380 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,9 @@ New features and improvements:
 - Add ``grid_size`` parameter to ``union_all`` and ``dissolve`` (#3445).
 - `GeoDataFrame.plot` now supports `pd.Index` as an input for the `column` keyword (#3463).
 - Avoid change of the plot aspect when plotting missing values (#3438).
+- GeoDataFrame no longer hard-codes the class internally, allowing easier subclassing (#3505).
+- Improve performance of `overlay` with `how=identity` (#3504).
+- Fix ambiguous error when GeoDataFrame is initialised with a column called "crs" (#3502)
 
 Bug fixes:
 
diff --git a/doc/environment.yml b/doc/environment.yml
index 7d37d9e5..e939fba7 100644
--- a/doc/environment.yml
+++ b/doc/environment.yml
@@ -41,3 +41,4 @@ dependencies:
       - contextily
       - pointpats
       - libpysal
+      - matplotlib_map_utils
diff --git a/doc/source/docs/advanced_guide.rst b/doc/source/docs/advanced_guide.rst
index 89738d67..e8e9e28f 100644
--- a/doc/source/docs/advanced_guide.rst
+++ b/doc/source/docs/advanced_guide.rst
@@ -16,6 +16,7 @@ specification in the :doc:`API reference <reference>`.
 .. toctree::
   :maxdepth: 2
 
+  user_guide/spatial_indexing
   user_guide/missing_empty
   user_guide/reproject_fiona
   user_guide/pygeos_to_shapely
diff --git a/doc/source/docs/reference/sindex.rst b/doc/source/docs/reference/sindex.rst
index bc62b711..1b88adac 100644
--- a/doc/source/docs/reference/sindex.rst
+++ b/doc/source/docs/reference/sindex.rst
@@ -10,6 +10,8 @@ GeoPandas will use the STRtree implementation provided by the Shapely
 ``GeoSeries.sindex`` creates a spatial index, which can use the methods and
 properties documented below.
 
+See the User Guide page :doc:`../user_guide/spatial_indexing` for more.
+
 Constructor
 -----------
 .. autosummary::
diff --git a/doc/source/docs/user_guide/spatial_indexing.ipynb b/doc/source/docs/user_guide/spatial_indexing.ipynb
new file mode 100644
index 00000000..51f55943
--- /dev/null
+++ b/doc/source/docs/user_guide/spatial_indexing.ipynb
@@ -0,0 +1,429 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Spatial indexing\n",
+    "\n",
+    "When you want to know a spatial relationship (known as a spatial\n",
+    "predicate) between a set of geometries A and a geometry B (or a set of\n",
+    "them), you can compare geometry B against any geometry in a\n",
+    "set A. However, that is not the most performant approach\n",
+    "in most cases. A spatial index is a more efficient method for pre-filtering comparisons of geometries before using more computationally expensive spatial predicates.\n",
+    "GeoPandas exposes the\n",
+    "Sort-Tile-Recursive R-tree from shapely on any GeoDataFrame and\n",
+    "GeoSeries using the [GeoSeries.sindex](../reference/api/geopandas.GeoSeries.sindex.html) property. This page outlines its options\n",
+    "and common usage patterns.\n",
+    "\n",
+    "Note that for many operations where a spatial index provides significant\n",
+    "performance benefits, GeoPandas already uses it automatically (like [sjoin()](../reference/api/geopandas.GeoDataFrame.sjoin.html),\n",
+    "[overlay()](../reference/api/geopandas.GeoDataFrame.overlay.html), or [clip()](../reference/api/geopandas.GeoDataFrame.clip.html)). However, more advanced use cases may require\n",
+    "a direct interaction with the index."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import geopandas\n",
+    "import matplotlib.pyplot as plt\n",
+    "import shapely\n",
+    "\n",
+    "from geodatasets import get_path"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Load data on New York City subboroughs to illustrate the spatial\n",
+    "indexing."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "nyc = geopandas.read_file(get_path(\"geoda nyc\"))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## R-tree principle\n",
+    "\n",
+    "In principle, any R-tree index builds a hierarchical collection of\n",
+    "bounding boxes (envelopes) representing first individual geometries and then their\n",
+    "most efficient combinations (from a spatial query perspective). When\n",
+    "creating one, you can imagine that your geometries are represented by\n",
+    "their envelopes, as illustrated below."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "fig, axs = plt.subplots(1, 2, sharey=True, figsize=(8, 4))\n",
+    "\n",
+    "nyc.plot(ax=axs[0], edgecolor=\"black\", linewidth=1)\n",
+    "nyc.envelope.boundary.plot(ax=axs[1], color='black');"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The left side of the figure shows the original geometries, while the\n",
+    "right side their bounding boxes, extracted using the [envelope](../reference/api/geopandas.GeoSeries.envelope.html)\n",
+    "property. Typically, the index works on top of those.\n",
+    "\n",
+    "Let’s generate two points now, both intersecting at least one bounding\n",
+    "box but only one intersecting the actual geometry."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "point_inside = shapely.Point(950000, 155000)\n",
+    "point_outside = shapely.Point(1050000, 150000)\n",
+    "points = geopandas.GeoSeries([point_inside, point_outside], crs=nyc.crs)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can verify that visually."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "fig, axs = plt.subplots(1, 2, sharey=True, figsize=(8, 4))\n",
+    "\n",
+    "nyc.plot(ax=axs[0], edgecolor=\"black\", linewidth=1)\n",
+    "nyc.envelope.boundary.plot(ax=axs[1], color='black')\n",
+    "points.plot(ax=axs[0], color=\"limegreen\")\n",
+    "points.plot(ax=axs[1], color=\"limegreen\");"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Querying the index\n",
+    "\n",
+    "### Scalar query\n",
+    "\n",
+    "\n",
+    "You can now use the [sindex](../reference/api/geopandas.GeoSeries.sindex.html) property to query the index. The\n",
+    "[query()](../reference/api/geopandas.sindex.SpatialIndex.query.html) method, by default, returns positions of all geometries\n",
+    "whose bounding boxes intersect the bounding box of the input geometry.\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "bbox_query_inside = nyc.sindex.query(point_inside)\n",
+    "bbox_query_outside = nyc.sindex.query(point_outside)\n",
+    "bbox_query_inside, bbox_query_outside"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Both the point we know is inside a geometry and the one that is outside\n",
+    "a geometry return one hit as each intersects one bounding box in the\n",
+    "tree."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "fig, axs = plt.subplots(1, 2, sharey=True, figsize=(8, 4))\n",
+    "\n",
+    "nyc.plot(ax=axs[0], edgecolor=\"black\", linewidth=1)\n",
+    "nyc.envelope.boundary.plot(ax=axs[1], color='black')\n",
+    "points.plot(ax=axs[0], color=\"limegreen\", zorder=3, edgecolor=\"black\", linewidth=.5)\n",
+    "points.plot(ax=axs[1], color=\"limegreen\", zorder=3, edgecolor=\"black\", linewidth=.5)\n",
+    "nyc.iloc[bbox_query_inside].plot(ax=axs[0], color='orange')\n",
+    "nyc.iloc[bbox_query_outside].plot(ax=axs[0], color='orange')\n",
+    "nyc.envelope.iloc[bbox_query_inside].plot(ax=axs[1], color='orange')\n",
+    "nyc.envelope.iloc[bbox_query_outside].plot(ax=axs[1], color='orange');"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The image above provides a clear illustration of what happens. While you\n",
+    "can see on the left image that only one intersects an orange geometry\n",
+    "marked as a *hit*, the hits are quite clear when looking at the bounding\n",
+    "box.\n",
+    "\n",
+    "Thankfully, the spatial index allows for further filtering based on the\n",
+    "actual geometry. In this case, the tree is first queried as above but\n",
+    "afterwards, each of the possible hits is checked using a spatial predicate."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "pred_inside = nyc.sindex.query(point_inside, predicate=\"intersects\")\n",
+    "pred_outside = nyc.sindex.query(point_outside, predicate=\"intersects\")\n",
+    "pred_inside, pred_outside"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "When you specify ``predicate=\"intersects\"``, the result is indeed\n",
+    "different and the output of the query using the point that lies outside\n",
+    "of any geometry is empty."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "fig, axs = plt.subplots(1, 2, sharey=True, figsize=(8, 4))\n",
+    "\n",
+    "nyc.plot(ax=axs[0], edgecolor=\"black\", linewidth=1)\n",
+    "nyc.envelope.boundary.plot(ax=axs[1], color='black')\n",
+    "points.plot(ax=axs[0], color=\"limegreen\", zorder=3, edgecolor=\"black\", linewidth=.5)\n",
+    "points.plot(ax=axs[1], color=\"limegreen\", zorder=3, edgecolor=\"black\", linewidth=.5)\n",
+    "nyc.iloc[pred_inside].plot(ax=axs[0], color='orange')\n",
+    "nyc.envelope.iloc[pred_inside].plot(ax=axs[1], color='orange');"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can use any of the predicates available in [valid_query_predicates](../reference/api/geopandas.sindex.SpatialIndex.valid_query_predicates.html):"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "nyc.sindex.valid_query_predicates"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Array query\n",
+    "\n",
+    "Checking a single geometry against the tree is nice but not that\n",
+    "efficient if you are interested in many-to-many relationships. The\n",
+    "[query()](../reference/api/geopandas.sindex.SpatialIndex.query.html) method allows passing any 1-D array of geometries to be\n",
+    "checked against the tree. If you do so, the output structure is slightly\n",
+    "different:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "bbox_array_query = nyc.sindex.query(points)\n",
+    "bbox_array_query"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "By default, the method returns a 2-D array of indices where the query\n",
+    "found a hit where the subarrays correspond to the indices of the input\n",
+    "geometries and indices of the tree geometries associated with each. In\n",
+    "the example above, the 0-th geometry in the ``points`` GeoSeries\n",
+    "intersects the bounding box of the geometry at the position 1 from the\n",
+    "``nyc`` GeoDataFrame, while the geometry 1 in the ``points`` matches\n",
+    "geometry 16 in the ``nyc``. You may notice that these are the same\n",
+    "indices as you’ve seen above.\n",
+    "\n",
+    "The other option is to return a boolean array with shape\n",
+    "``(len(tree), n)`` with boolean values marking whether the bounding box\n",
+    "of a geometry in the tree intersects a bounding box of a given geometry.\n",
+    "This can be either a dense numpy array, or a sparse scipy array. Keep in\n",
+    "mind that the output will be, in most cases, mostly filled with\n",
+    "``False`` and the array can become really large, so it is recommended to\n",
+    "use the sparse format, if possible.\n",
+    "\n",
+    "You can specify each using the ``output_format`` keyword:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "bbox_array_query_dense = nyc.sindex.query(points, output_format=\"dense\")\n",
+    "bbox_array_query_dense"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The dense array above has rows aligned with the rows of ``nyc`` and\n",
+    "columns aligned with the rows of ``points`` and indicates all pairs\n",
+    "where a *hit* was found.\n",
+    "\n",
+    "The same array can be represented as a `scipy.sparse.coo_array`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "bbox_array_query_sparse = nyc.sindex.query(points, output_format=\"sparse\")\n",
+    "bbox_array_query_sparse"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "For example, to find the number of neighboring geometries for each subborough, you can\n",
+    "use the spatial index to compare all geometries against each other. Since you are using\n",
+    "``nyc`` on both sides of the query here, the resulting array is square-shaped with\n",
+    "diagonal filled with ``True``."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "neighbors = nyc.sindex.query(nyc.geometry, predicate=\"intersects\", output_format=\"dense\")\n",
+    "neighbors"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Getting the sum along one axis can then give you the answer. Note that\n",
+    "since a geometry always intersects itself, you need to subtract one."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "n_neighbors = neighbors.sum(axis=1) - 1\n",
+    "n_neighbors"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The result is a numpy array you can directly plot on a map."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "nyc.plot(n_neighbors, legend=True);"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Nearest geometry query\n",
+    "\n",
+    "While checking the spatial predicate using the spatial index is indeed extremely useful, GeoPandas\n",
+    "also allows you to use the spatial index to find\n",
+    "the nearest geometry. The API is similar as above:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "nearest_indices = nyc.sindex.nearest(points)\n",
+    "nearest_indices"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can see that the nearest query returns the indices representation.\n",
+    "If you are interested in how “near” the geometries actually are, the method\n",
+    "can also return distances. In this case, the return format is a tuple of\n",
+    "arrays."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "nearest_indices, distance = nyc.sindex.nearest(points, return_distance=True)\n",
+    "distance"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "default",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "name": "python",
+   "version": "3.12.8"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/doc/source/gallery/matplotlib_scalebar.ipynb b/doc/source/gallery/matplotlib_scalebar.ipynb
index 0cec0bc8..1aa5a503 100644
--- a/doc/source/gallery/matplotlib_scalebar.ipynb
+++ b/doc/source/gallery/matplotlib_scalebar.ipynb
@@ -4,8 +4,17 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "# Adding a scale bar to a matplotlib plot\n",
-    "When making a geospatial plot in matplotlib, you can use [maplotlib-scalebar library](https://pypi.org/project/matplotlib-scalebar/) to add a scale bar."
+    "# Adding Scale Bars and North Arrows to a Matplotlib Plot\n",
+    "Scale bars and north arrows are common elements added to maps to indicate the scale and orientation of the map, respectively. \n",
+    "\n",
+    "Two packages exist for easily adding these elements to the default `matplotlib` plots generated by GeoPandas' `plot()` function: [matplotlib-scalebar](https://pypi.org/project/matplotlib-scalebar/) and [matplotlib-map-utils](https://github.com/moss-xyz/matplotlib-map-utils). The use of each is described below."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Using `matplotlib-scalebar`"
    ]
   },
   {
@@ -14,30 +23,27 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "import geopandas as gpd\n",
+    "from geodatasets import get_path\n",
     "from matplotlib_scalebar.scalebar import ScaleBar\n",
-    "from geodatasets import get_path"
+    "\n",
+    "import geopandas as gpd"
    ]
   },
   {
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "## Creating a ScaleBar object\n",
+    "### Creating a ScaleBar object\n",
     "The only required parameter for creating a ScaleBar object is `dx`. This is equal to a size of one pixel in real world. Value of this parameter depends on units of your CRS.\n",
     "\n",
-    "### Projected coordinate system (meters)\n",
+    "#### Projected coordinate system (meters)\n",
     "The easiest way to add a scale bar is using a projected coordinate system with meters as units. Just set `dx = 1`:"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {
-    "tags": [
-     "nbsphinx-thumbnail"
-    ]
-   },
+   "metadata": {},
    "outputs": [],
    "source": [
     "nybb = gpd.read_file(get_path(\"nybb\"))\n",
@@ -52,7 +58,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "### Geographic coordinate system (degrees)\n",
+    "#### Geographic coordinate system (degrees)\n",
     "With a geographic coordinate system with degrees as units, `dx` should be equal to a distance in meters of two points with the same latitude (Y coordinate) which are one full degree of longitude (X) apart. You can calculate this distance by online calculator [(e.g. the Great Circle calculator)](http://edwilliams.org/gccalc.htm) or in geopandas.\\\n",
     "\\\n",
     "Firstly, we will create a GeoSeries with two points that have roughly the coordinates of NYC. They are located on the same latitude but one degree of longitude from each other. Their initial coordinates are specified in a geographic coordinate system (geographic WGS 84). They are then converted to a projected system for the calculation:"
@@ -98,9 +104,7 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {
-    "scrolled": true
-   },
+   "metadata": {},
    "outputs": [],
    "source": [
     "nybb = gpd.read_file(get_path(\"nybb\"))\n",
@@ -114,7 +118,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "## Using other units \n",
+    "### Using other units \n",
     "The default unit for `dx` is m (meter). You can change this unit by the `units` and `dimension` parameters. There is a list of some possible `units` for various values of `dimension` below:\n",
     "\n",
     "| dimension | units |\n",
@@ -143,15 +147,13 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "## Customization of the scale bar"
+    "### Customization of the scale bar"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {
-    "scrolled": true
-   },
+   "metadata": {},
    "outputs": [],
    "source": [
     "nybb = gpd.read_file(get_path(\"nybb\")).to_crs(32619)\n",
@@ -200,12 +202,238 @@
     "\\\n",
     "For more information about matplotlib-scalebar library, see the [PyPI](https://pypi.org/project/matplotlib-scalebar/) or [GitHub](https://github.com/ppinard/matplotlib-scalebar) page."
    ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Using `matplotlib-map-utils`"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Using this package, north arrows and scale bars can be made with either **functions** or **classes**; for the purposes of this tutorial, only the *functions* will be used, though the *classes* work in much the same way. Tutorials for customizing each object are available within the [docs](https://github.com/moss-xyz/matplotlib-map-utils/tree/main/matplotlib_map_utils/docs) directory of the repository."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from geodatasets import get_path\n",
+    "from matplotlib_map_utils.core.north_arrow import NorthArrow, north_arrow\n",
+    "from matplotlib_map_utils.core.scale_bar import ScaleBar, scale_bar\n",
+    "\n",
+    "import geopandas as gpd"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Set-Up"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "nybb = gpd.read_file(get_path(\"nybb\"))\n",
+    "# By default, the data is projected in feet\n",
+    "nybb.crs"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "If you are working with a common plot size, both `NorthArrow` and `ScaleBar` have a function called `set_size()` that bulk-updates a variety of settings so that the relevant object looks \"better\" at that size.\n",
+    "\n",
+    "GeoPandas' `plot()` function will create figures of `6.4\"x4.8\"` with the data used in this tutorial, which corresponds to a size of `small`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "NorthArrow.set_size(\"small\")\n",
+    "ScaleBar.set_size(\"small\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### North Arrows\n",
+    "\n",
+    "The `north_arrow()` function takes in the following arguments:\n",
+    "\n",
+    "* `ax`: the axis on which to plot the north arrow\n",
+    "\n",
+    "* `location`: a string indicating the location of the north arrow on the plot (see `loc` under [matplotlib.pyplot.legend](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.legend.html) e.g., \"upper left\", \"lower right\", etc.)\n",
+    "\n",
+    "* `scale`: the desired height of the north arrow, in *inches*\n",
+    "\n",
+    "* `rotation`: a dictionary containing either a value for `degrees` (if rotation will be set manually), or arguments for `crs`, `reference`, and `coords` (if rotation will be calculated based on the provided CRS)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Making a basic arrow using the minimum amount of arguments\n",
+    "ax = nybb.plot()\n",
+    "north_arrow(\n",
+    "    ax, location=\"upper left\", rotation={\"crs\": nybb.crs, \"reference\": \"center\"}\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Optional additional arguments can be passed to `base`, `fancy`, `shadow`, `label`, `pack`, and `aob` to change the styling of the arrow; see the documentation under [docs/howto_north_arrow.ipynb](https://github.com/moss-xyz/matplotlib-map-utils/blob/main/matplotlib_map_utils/docs/howto_north_arrow.ipynb) in the GitHub repository for details."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Making a more customized arrow\n",
+    "ax = nybb.plot()\n",
+    "north_arrow(\n",
+    "    ax,\n",
+    "    location=\"upper left\",\n",
+    "    scale=0.4,\n",
+    "    rotation={\"crs\": nybb.crs, \"reference\": \"center\"},\n",
+    "    base={\"edgecolor\": \"blue\", \"linewidth\": 2},\n",
+    "    fancy=False,\n",
+    "    shadow=False,  # this turns off the component\n",
+    "    label={\"position\": \"top\", \"text\": \"North\", \"fontsize\": 8},\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Scale Bars\n",
+    "\n",
+    "The `scale_bar()` function takes in the following arguments:\n",
+    "\n",
+    "* `ax`: the axis on which to plot the scale bar\n",
+    "\n",
+    "* `location`: a string indicating the location of the scale bar on the plot (see `loc` under [matplotlib.pyplot.legend](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.legend.html) e.g., \"upper left\", \"lower right\", etc.)\n",
+    "\n",
+    "* `style`: the appearance of the arrow: can be either `ticks` or `boxes` (default)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Making a basic scale bar using the minimum amount of arguments\n",
+    "# Note that the data is auto-converted to miles\n",
+    "ax = nybb.plot()\n",
+    "scale_bar(ax, location=\"upper left\", style=\"boxes\", bar={\"projection\": nybb.crs})"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Making the same scale bar but in the other style (ticks)\n",
+    "ax = nybb.plot()\n",
+    "scale_bar(ax, location=\"upper left\", style=\"ticks\", bar={\"projection\": nybb.crs})"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The scale bar can handle converting between common unit types, as shown in the table below. \n",
+    "\n",
+    "| Unit Type      | Conversion Factor | Accepted Inputs                                        |\n",
+    "|----------------|-------------------|--------------------------------------------------------|\n",
+    "| Meters         | 1                 | `m`,`meter`,`metre`,`meters`,`meters`                  |\n",
+    "| Kilometers     | 1000              | `km`,`kilometer`,`kilometre`,`kilometers`,`kilometers` |\n",
+    "| Feet           | 0.3048            | `ft`,`ftUS`,`foot`,`feet`,`US survey foot`             |\n",
+    "| Yards          | 0.9144            | `yd`,`yard`,`yards`                                    |\n",
+    "| Miles          | 1609.34           | `mi`,`mile`,`miles`                                    |\n",
+    "| Nautical Miles | 1852              | `nmi`,`nautical`,`nautical mile`,`nautical miles`      |\n",
+    "\n",
+    "The scale bar can also handle *unprojected* data (degrees) - it will convert it to metres using *great circle distance*, and then convert it into the units selected by the user. This will happen automatically when `projection` is set to a CRS of `4326` or similar."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Making a scale bar in kilometers instead, by changing bar[\"units\"]\n",
+    "# Note that the data did not have to be reprojected to do this\n",
+    "ax = nybb.plot()\n",
+    "scale_bar(\n",
+    "    ax, location=\"upper left\", style=\"boxes\", bar={\"projection\": nybb.crs, \"unit\": \"km\"}\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Optional additional arguments can be passed to `bar`, `labels`, `units`, `text`, and `aob` to change the styling of the bar; see the documentation under [docs/howto_scale_bar.ipynb](https://github.com/moss-xyz/matplotlib-map-utils/blob/main/matplotlib_map_utils/docs/howto_scale_bar.ipynb) in the GitHub repository for details.\n",
+    "\n",
+    "*Note that control of the length of the bar, as well as the number of major and minor divisions, is handled within the `bar` style dictionary.*"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Making a more formatted scale bar (ticks)\n",
+    "ax = nybb.plot()\n",
+    "scale_bar(\n",
+    "    ax,\n",
+    "    location=\"upper left\",\n",
+    "    style=\"ticks\",\n",
+    "    bar={\n",
+    "        \"projection\": nybb.crs,\n",
+    "        \"max\": 12,\n",
+    "        \"major_div\": 2,\n",
+    "        \"minor_div\": 3,\n",
+    "        \"minor_type\": \"first\",\n",
+    "        \"tick_loc\": \"middle\",\n",
+    "        \"tickcolors\": \"blue\",\n",
+    "        \"basecolors\": \"blue\",\n",
+    "        \"tickwidth\": 1.5,\n",
+    "    },\n",
+    "    labels={\"loc\": \"above\", \"style\": \"major\"},\n",
+    "    units={\"loc\": \"bar\", \"fontsize\": 8},\n",
+    "    text={\"fontfamily\": \"monospace\"},\n",
+    ")"
+   ]
   }
  ],
  "metadata": {
-  "interpreter": {
-   "hash": "9914e2881520d4f08a067c2c2c181121476026b863eca2e121cd0758701ab602"
-  },
   "kernelspec": {
    "display_name": "Python 3",
    "language": "python",
@@ -221,9 +449,9 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.11.0"
+   "version": "3.12.3"
   }
  },
  "nbformat": 4,
- "nbformat_minor": 4
+ "nbformat_minor": 2
 }
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index f716ea8c..1c98bc99 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -43,22 +43,6 @@ if typing.TYPE_CHECKING:
     )
 
 
-def _geodataframe_constructor_with_fallback(
-    *args, **kwargs
-) -> pd.DataFrame | GeoDataFrame:
-    """
-    A flexible constructor for GeoDataFrame._constructor, which falls back
-    to returning a DataFrame (if a certain operation does not preserve the
-    geometry column)
-    """
-    df = GeoDataFrame(*args, **kwargs)
-    geometry_cols_mask = df.dtypes == "geometry"
-    if len(geometry_cols_mask) == 0 or geometry_cols_mask.sum() == 0:
-        df = pd.DataFrame(df)
-
-    return df
-
-
 def _ensure_geometry(data, crs: Any | None = None) -> GeoSeries | GeometryArray:
     """
     Ensure the data is of geometry dtype or converted to it.
@@ -1924,7 +1908,7 @@ default 'snappy'
             result.__class__ = GeoSeries
         elif isinstance(result, DataFrame):
             if (result.dtypes == "geometry").sum() > 0:
-                result.__class__ = GeoDataFrame
+                result.__class__ = type(self)
                 if geo_col in result:
                     result._geometry_column_name = geo_col
             else:
@@ -1961,19 +1945,22 @@ default 'snappy'
         ):
             if pd.api.types.is_scalar(value) or isinstance(value, BaseGeometry):
                 value = [value] * self.shape[0]
+
+            crs = getattr(self, "crs", None)
+            # if we don't have a GeoDataframe yet and there is a column named crs,
+            # don't try to use that as a crs
+            if isinstance(crs, pd.Series | pd.DataFrame):
+                crs = None
             try:
-                if self._geometry_column_name is not None:
-                    crs = getattr(self, "crs", None)
-                else:  # don't use getattr, because a col "crs" might exist
-                    crs = None
                 value = _ensure_geometry(value, crs=crs)
-                if key == "geometry":
-                    self._persist_old_default_geometry_colname()
             except TypeError:
                 warnings.warn(
                     "Geometry column does not contain geometry.",
                     stacklevel=2,
                 )
+            else:
+                if key == "geometry":
+                    self._persist_old_default_geometry_colname()
         super().__setitem__(key, value)
 
     #
@@ -1983,7 +1970,7 @@ default 'snappy'
     def copy(self, deep: bool = True) -> GeoDataFrame:
         copied = super().copy(deep=deep)
         if type(copied) is pd.DataFrame:
-            copied.__class__ = GeoDataFrame
+            copied.__class__ = type(self)
             copied._geometry_column_name = self._geometry_column_name
         return copied
 
@@ -2027,18 +2014,36 @@ default 'snappy'
 
         return result
 
+    @classmethod
+    def _geodataframe_constructor_with_fallback(
+        cls, *args, **kwargs
+    ) -> pd.DataFrame | GeoDataFrame:
+        """
+        A flexible constructor for GeoDataFrame._constructor, which falls back
+        to returning a DataFrame (if a certain operation does not preserve the
+        geometry column)
+        """
+        df = cls(*args, **kwargs)
+
+        geometry_cols_mask = df.dtypes == "geometry"
+
+        if len(geometry_cols_mask) == 0 or geometry_cols_mask.sum() == 0:
+            df = pd.DataFrame(df)
+
+        return df
+
     @property
     def _constructor(self) -> DataFrame | GeoDataFrame:
-        return _geodataframe_constructor_with_fallback
+        return self._geodataframe_constructor_with_fallback
 
     def _constructor_from_mgr(self, mgr, axes) -> DataFrame | GeoDataFrame:
         # replicate _geodataframe_constructor_with_fallback behaviour
         # unless safe to skip
         if not any(isinstance(block.dtype, GeometryDtype) for block in mgr.blocks):
-            return _geodataframe_constructor_with_fallback(
+            return self._geodataframe_constructor_with_fallback(
                 pd.DataFrame._from_mgr(mgr, axes)
             )
-        gdf = GeoDataFrame._from_mgr(mgr, axes)
+        gdf = self._from_mgr(mgr, axes)
         # _from_mgr doesn't preserve metadata (expect __finalize__ to be called)
         # still need to mimic __init__ behaviour with geometry=None
         if (gdf.columns == "geometry").sum() == 1:  # only if "geometry" is single col
@@ -2258,7 +2263,7 @@ default 'snappy'
         )
 
         # Aggregate
-        aggregated_geometry = GeoDataFrame(g, geometry=self.geometry.name, crs=self.crs)
+        aggregated_geometry = type(self)(g, geometry=self.geometry.name, crs=self.crs)
         # Recombine
         aggregated = aggregated_geometry.join(aggregated_data)
 
@@ -2831,11 +2836,11 @@ chicago_w_groceries[chicago_w_groceries["community"] == "UPTOWN"]
 
         >>> df1.overlay(df2, how='identity')
            df1_data  df2_data                                           geometry
-        0       1.0       1.0                POLYGON ((2 2, 2 1, 1 1, 1 2, 2 2))
-        1       2.0       1.0                POLYGON ((2 2, 2 3, 3 3, 3 2, 2 2))
-        2       2.0       2.0                POLYGON ((4 4, 4 3, 3 3, 3 4, 4 4))
-        3       1.0       NaN      POLYGON ((2 0, 0 0, 0 2, 1 2, 1 1, 2 1, 2 0))
-        4       2.0       NaN  MULTIPOLYGON (((3 4, 3 3, 2 3, 2 4, 3 4)), ((4...
+        0         1       1.0                POLYGON ((2 2, 2 1, 1 1, 1 2, 2 2))
+        1         2       1.0                POLYGON ((2 2, 2 3, 3 3, 3 2, 2 2))
+        2         2       2.0                POLYGON ((4 4, 4 3, 3 3, 3 4, 4 4))
+        3         1       NaN      POLYGON ((2 0, 0 0, 0 2, 1 2, 1 1, 2 1, 2 0))
+        4         2       NaN  MULTIPOLYGON (((3 4, 3 3, 2 3, 2 4, 3 4)), ((4...
 
         See also
         --------
diff --git a/geopandas/sindex.py b/geopandas/sindex.py
index 159539c4..2dfe5122 100644
--- a/geopandas/sindex.py
+++ b/geopandas/sindex.py
@@ -109,6 +109,8 @@ class SpatialIndex:
         Any input geometry that is None or empty will never match geometries in
         the tree.
 
+        See the User Guide page :doc:`../../user_guide/spatial_indexing` for more.
+
         Parameters
         ----------
         geometry : shapely.Geometry or array-like of geometries \
diff --git a/geopandas/tools/overlay.py b/geopandas/tools/overlay.py
index 6332d425..66338419 100644
--- a/geopandas/tools/overlay.py
+++ b/geopandas/tools/overlay.py
@@ -110,6 +110,21 @@ def _overlay_difference(df1, df2):
     return dfdiff
 
 
+def _overlay_identity(df1, df2):
+    """
+    Overlay Identity operation used in overlay function.
+    """
+    dfintersection = _overlay_intersection(df1, df2)
+    dfdifference = _overlay_difference(df1, df2)
+    dfdifference = _ensure_geometry_column(dfdifference)
+    result = pd.concat([dfintersection, dfdifference], ignore_index=True, sort=False)
+    # keep geometry column last
+    columns = list(dfintersection.columns)
+    columns.remove("geometry")
+    columns.append("geometry")
+    return result.reindex(columns=columns)
+
+
 def _overlay_symmetric_diff(df1, df2):
     """
     Overlay Symmetric Difference operation used in overlay function
@@ -225,11 +240,11 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
 
     >>> geopandas.overlay(df1, df2, how='identity')
        df1_data  df2_data                                           geometry
-    0       1.0       1.0                POLYGON ((2 2, 2 1, 1 1, 1 2, 2 2))
-    1       2.0       1.0                POLYGON ((2 2, 2 3, 3 3, 3 2, 2 2))
-    2       2.0       2.0                POLYGON ((4 4, 4 3, 3 3, 3 4, 4 4))
-    3       1.0       NaN      POLYGON ((2 0, 0 0, 0 2, 1 2, 1 1, 2 1, 2 0))
-    4       2.0       NaN  MULTIPOLYGON (((3 4, 3 3, 2 3, 2 4, 3 4)), ((4...
+    0         1       1.0                POLYGON ((2 2, 2 1, 1 1, 1 2, 2 2))
+    1         2       1.0                POLYGON ((2 2, 2 3, 3 3, 3 2, 2 2))
+    2         2       2.0                POLYGON ((4 4, 4 3, 3 3, 3 4, 4 4))
+    3         1       NaN      POLYGON ((2 0, 0 0, 0 2, 1 2, 1 1, 2 1, 2 0))
+    4         2       NaN  MULTIPOLYGON (((3 4, 3 3, 2 3, 2 4, 3 4)), ((4...
 
     See also
     --------
@@ -333,8 +348,7 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
         elif how == "union":
             result = _overlay_union(df1, df2)
         elif how == "identity":
-            dfunion = _overlay_union(df1, df2)
-            result = dfunion[dfunion["__idx1"].notnull()].copy()
+            result = _overlay_identity(df1, df2)
 
         if how in ["intersection", "symmetric_difference", "union", "identity"]:
             result.drop(["__idx1", "__idx2"], axis=1, inplace=True)


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 0b558410..a9b7ac04 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -1572,6 +1572,22 @@ def test_geodataframe_crs_colname():
     assert gdf["crs"].iloc[0] == 1
     assert getattr(gdf, "crs") is None
 
+    # https://github.com/geopandas/geopandas/issues/3501
+    gdf = GeoDataFrame({"crs": [1]}, geometry=[Point(1, 1)])
+    assert gdf.crs is None
+    assert gdf["crs"].iloc[0] == 1
+    assert getattr(gdf, "crs") is None
+
+    # test multiindex handling
+    df = pd.DataFrame([[1, 0], [0, 1]], columns=[["crs", "crs"], ["x", "y"]])
+    x_col = df["crs", "x"]
+    y_col = df["crs", "y"]
+
+    gdf = GeoDataFrame(df, geometry=points_from_xy(x_col, y_col))
+    assert gdf.crs is None
+    assert gdf["crs"].iloc[0].to_list() == [1, 0]
+    assert getattr(gdf, "crs") is None
+
 
 @pytest.mark.parametrize("geo_col_name", ["geometry", "polygons"])
 def test_set_geometry_supply_colname(dfs, geo_col_name):
@@ -1653,3 +1669,40 @@ def test_reduce_geometry_array():
     This warning is issued with pandas 2.2.2 (tested).
     """
     GeoDataFrame({"geometry": []}).all()
+
+
+class GDFChild(GeoDataFrame):
+    def custom_method(self):
+        return "this is a custom output"
+
+
+def test_inheritance(dfs):
+    df, _ = dfs
+    df.loc[:, "col2"] = ["a"] * len(df)
+
+    dfc = GDFChild(df)
+
+    dfc2 = dfc.rename_geometry("geometry2")
+
+    children = [
+        dfc,
+        dfc.iloc[[0]],
+        dfc.loc[dfc.col1 == 1],
+        dfc.dissolve(),
+        dfc[["col2", "geometry"]],
+        dfc.copy(),
+        dfc2,
+        dfc2.iloc[[0]],
+        dfc2.loc[dfc.col1 == 1],
+        dfc2.dissolve(),
+        dfc2[["col2", "geometry2"]],
+        dfc2.copy(),
+    ]
+
+    for v in children:
+        assert isinstance(v, GDFChild)
+        assert v.custom_method() == "this is a custom output"
+
+    df2 = dfc2.drop(columns=["geometry2"])
+
+    assert not isinstance(df2, GDFChild)
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index c9f8dad0..ee35bcc4 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -90,7 +90,6 @@ def test_overlay(dfs_index, how):
         expected = pd.concat(
             [expected_intersection, expected_difference], ignore_index=True, sort=False
         )
-        expected["col1"] = expected["col1"].astype(float)
     else:
         expected = _read(how)
 
@@ -174,6 +173,10 @@ def test_overlay_nybb(how, nybb_filename):
 
     if how == "identity":
         expected = expected[expected.BoroCode.notnull()].copy()
+        boro_code_dtype = result["BoroCode"].dtype
+        if boro_code_dtype in ("int32", "int64"):
+            # Depending on the pandas version, the dtype might be int32 or int64
+            expected["BoroCode"] = expected["BoroCode"].astype(boro_code_dtype)
 
     # Order GeoDataFrames
     expected = expected.sort_values(cols).reset_index(drop=True)
@@ -786,7 +789,7 @@ def test_non_overlapping(how):
     elif how == "identity":
         expected = GeoDataFrame(
             {
-                "col1": [1.0],
+                "col1": [1],
                 "col2": [np.nan],
                 "geometry": [p1],
             }