Repository: geopandas/geopandas

Issue:
gpd.to_file could support GeoDataFrames with multiple geometry types
# Context

Since [Fiona 446](https://github.com/Toblerity/Fiona/issues/446) resolution (see [PR 539](https://github.com/Toblerity/Fiona/pull/539)), `fiona.open` allows to provide a schema with a list of geometry types, or even any types (using `Unknown` keyword)

# Current behaviour
Currently, `gpd.to_file` fails for the following cases (using `GeoJSON` OGR driver):
* a gdf containing a shape type and the corresponding Multi shape type (e.g. `Polygon` and `MultiPolygon`): the inferred schema contains only the simple shape type (e.g. `Polygon`), but then fiona raises the error below:
  ```
     File "fiona/ogrext.pyx", line 1182, in fiona.ogrext.WritingSession.writerecs
   fiona.errors.GeometryTypeValidationError: Record's geometry type does not match collection schema's geometry type: 'MultiPolygon' != 'Polygon'
  ```
* a gdf containing containing heterogeneous geometries (e.g. `Point`s and `Polygon`s) directly fails in `gpd.to_file` with the error below:
  ```
  ValueError: Geometry column cannot contain mutiple geometry types when writing to file.
  ```
# Suggested improvement
I suggest to support writing `GeoDataFrame`s with multiple geometry types (without having to provide a schema): the inferred schema would just contain a list of geometry types (following Fiona new behaviour); 

OGR drivers supporting multiple geometry types would succeed; others would probably fail anyways...

Let me know if there is a case I did not think about.

# Ready to contribute
I would be glad to provide a PR if this functionality is desired (I already have some additional unit tests showing the current behaviour)



Discussion:

Resolving patch:
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -149,27 +149,66 @@ def convert_type(column, in_type):
     if df.empty:
         raise ValueError("Cannot write empty DataFrame to file.")
 
-    geom_type = _common_geom_type(df)
+    # Since https://github.com/Toblerity/Fiona/issues/446 resolution,
+    # Fiona allows a list of geometry types
+    geom_types = _geometry_types(df)
 
-    schema = {'geometry': geom_type, 'properties': properties}
+    schema = {'geometry': geom_types, 'properties': properties}
 
     return schema
 
 
-def _common_geom_type(df):
-    # Need to check geom_types before we write to file...
-    # Some (most?) providers expect a single geometry type:
-    # Point, LineString, or Polygon
-    geom_types = df.geometry.geom_type.unique()
+def _geometry_types(df):
+    """
+    Determine the geometry types in the GeoDataFrame for the schema.
+    """
+    if _FIONA18:
+        # Starting from Fiona 1.8, schema submitted to fiona to write a gdf
+        # can have mixed geometries:
+        # - 3D and 2D shapes can coexist in inferred schema
+        # - Shape and MultiShape types can (and must) coexist in inferred
+        #   schema
+        geom_types_2D = df[~df.geometry.has_z].geometry.geom_type.unique()
+        geom_types_2D = [gtype for gtype in geom_types_2D if gtype is not None]
+        geom_types_3D = df[df.geometry.has_z].geometry.geom_type.unique()
+        geom_types_3D = ["3D " + gtype for gtype in geom_types_3D
+                         if gtype is not None]
+        geom_types = geom_types_3D + geom_types_2D
 
-    from os.path import commonprefix
-    # use reversed geom types and commonprefix to find the common suffix,
-    # then reverse the result to get back to a geom type
-    geom_type = commonprefix([g[::-1] for g in geom_types if g])[::-1]
-    if not geom_type:
+    else:
+        # Before Fiona 1.8, schema submitted to write a gdf should have
+        # one single geometry type whenever possible:
+        # - 3D and 2D shapes cannot coexist in inferred schema
+        # - Shape and MultiShape can not coexist in inferred schema
+        geom_types = _geometry_types_back_compat(df)
+
+    if len(geom_types) == 0:
+        # Default geometry type supported by Fiona
+        # (Since https://github.com/Toblerity/Fiona/issues/446 resolution)
         return 'Unknown'
 
+    if len(geom_types) == 1:
+        geom_types = geom_types[0]
+
+    return geom_types
+
+
+def _geometry_types_back_compat(df):
+    """
+    for backward compatibility with Fiona<1.8 only
+    """
+    unique_geom_types = df.geometry.geom_type.unique()
+    unique_geom_types = [
+        gtype for gtype in unique_geom_types if gtype is not None]
+
+    # merge single and Multi types (eg Polygon and MultiPolygon)
+    unique_geom_types = [
+        gtype for gtype in unique_geom_types
+        if not gtype.startswith('Multi') or gtype[5:] not in unique_geom_types]
+
     if df.geometry.has_z.any():
-        geom_type = "3D " + geom_type
+        # declare all geometries as 3D geometries
+        unique_geom_types = ["3D " + type for type in unique_geom_types]
+    # by default, all geometries are 2D geometries
 
-    return geom_type
+    return unique_geom_types


Test patch:
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -5,8 +5,9 @@
 import os
 
 import numpy as np
-from shapely.geometry import Point, Polygon, box
+
 import fiona
+from shapely.geometry import Point, Polygon, box
 
 import geopandas
 from geopandas import GeoDataFrame, read_file
@@ -96,26 +97,32 @@ def test_to_file_bool(tmpdir, driver, ext):
         assert_geodataframe_equal(result, df, check_column_type=False)
 
 
-def test_to_file_with_point_z(tmpdir):
+@pytest.mark.parametrize(
+    'ext, driver', [('shp', 'ESRI Shapefile'), ('geojson', 'GeoJSON')])
+def test_to_file_with_point_z(tmpdir, ext, driver):
     """Test that 3D geometries are retained in writes (GH #612)."""
 
-    tempfilename = os.path.join(str(tmpdir), 'test_3Dpoint.shp')
+    tempfilename = os.path.join(str(tmpdir), 'test_3Dpoint.' + ext)
     point3d = Point(0, 0, 500)
     point2d = Point(1, 1)
-    df = GeoDataFrame({'a': [1, 2]}, geometry=[point3d, point2d], crs={})
-    df.to_file(tempfilename)
+    df = GeoDataFrame({'a': [1, 2]}, geometry=[point3d, point2d],
+                      crs={'init': 'epsg:4326'})
+    df.to_file(tempfilename, driver=driver)
     df_read = GeoDataFrame.from_file(tempfilename)
     assert_geoseries_equal(df.geometry, df_read.geometry)
 
 
-def test_to_file_with_poly_z(tmpdir):
+@pytest.mark.parametrize(
+    'ext, driver', [('shp', 'ESRI Shapefile'), ('geojson', 'GeoJSON')])
+def test_to_file_with_poly_z(tmpdir, ext, driver):
     """Test that 3D geometries are retained in writes (GH #612)."""
 
-    tempfilename = os.path.join(str(tmpdir), 'test_3Dpoly.shp')
+    tempfilename = os.path.join(str(tmpdir), 'test_3Dpoly.' + ext)
     poly3d = Polygon([[0, 0, 5], [0, 1, 5], [1, 1, 5], [1, 0, 5]])
     poly2d = Polygon([[0, 0], [0, 1], [1, 1], [1, 0]])
-    df = GeoDataFrame({'a': [1, 2]}, geometry=[poly3d, poly2d], crs={})
-    df.to_file(tempfilename)
+    df = GeoDataFrame({'a': [1, 2]}, geometry=[poly3d, poly2d],
+                      crs={'init': 'epsg:4326'})
+    df.to_file(tempfilename, driver=driver)
     df_read = GeoDataFrame.from_file(tempfilename)
     assert_geoseries_equal(df.geometry, df_read.geometry)
 
@@ -132,24 +139,6 @@ def test_to_file_types(tmpdir, df_points):
     df.to_file(tempfilename)
 
 
-def test_to_file_mixed_types(tmpdir):
-    """ Test that mixed geometry types raise error when writing to file """
-    tempfilename = os.path.join(str(tmpdir), 'test.shp')
-    s = GeoDataFrame({'geometry': [Point(0, 0),
-                                   Polygon([(0, 0), (1, 0), (1, 1)])]})
-    # Exception type is different for different `fiona` versions
-    with pytest.raises((ValueError, RuntimeError)):
-        s.to_file(tempfilename)
-
-
-def test_to_file_geojson_mixed_types(tmpdir):
-    """ Test that mixed geometry types can be saved as GeoJSON (GH #827) """
-    tempfilename = os.path.join(str(tmpdir), 'test.geojson')
-    s = GeoDataFrame({'geometry': [Point(0, 0),
-                                   Polygon([(0, 0), (1, 0), (1, 1)])]})
-    s.to_file(tempfilename, driver='GeoJSON')
-
-
 def test_to_file_empty(tmpdir):
     input_empty_df = GeoDataFrame()
     tempfilename = os.path.join(str(tmpdir), 'test.shp')
diff --git a/geopandas/io/tests/test_file_geom_types_drivers.py b/geopandas/io/tests/test_file_geom_types_drivers.py
new file mode 100644
--- /dev/null
+++ b/geopandas/io/tests/test_file_geom_types_drivers.py
@@ -0,0 +1,337 @@
+import os
+import shutil
+import sys
+import tempfile
+from enum import Enum
+
+from shapely.geometry import Point, Polygon, MultiPolygon, MultiPoint, \
+    LineString, MultiLineString
+
+import geopandas
+from geopandas import GeoDataFrame
+from geopandas.io.file import _FIONA18
+
+import pytest
+from geopandas.testing import assert_geodataframe_equal
+
+
+# Credit: Polygons below come from Montreal city Open Data portal
+# http://donnees.ville.montreal.qc.ca/dataset/unites-evaluation-fonciere
+city_hall_boundaries = Polygon((
+    (-73.5541107525234, 45.5091983609661),
+    (-73.5546126200639, 45.5086813829106),
+    (-73.5540185061397, 45.5084409343852),
+    (-73.5539986525799, 45.5084323044531),
+    (-73.5535801792994, 45.5089539203786),
+    (-73.5541107525234, 45.5091983609661)
+))
+vauquelin_place = Polygon((
+    (-73.5542465586147, 45.5081555487952),
+    (-73.5540185061397, 45.5084409343852),
+    (-73.5546126200639, 45.5086813829106),
+    (-73.5548825850032, 45.5084033554357),
+    (-73.5542465586147, 45.5081555487952)
+))
+
+city_hall_walls = [
+    LineString((
+        (-73.5541107525234, 45.5091983609661),
+        (-73.5546126200639, 45.5086813829106),
+        (-73.5540185061397, 45.5084409343852)
+    )),
+    LineString((
+        (-73.5539986525799, 45.5084323044531),
+        (-73.5535801792994, 45.5089539203786),
+        (-73.5541107525234, 45.5091983609661)
+    ))
+]
+
+city_hall_entrance = Point(-73.553785, 45.508722)
+city_hall_balcony = Point(-73.554138, 45.509080)
+city_hall_council_chamber = Point(-73.554246, 45.508931)
+
+point_3D = Point(-73.553785, 45.508722, 300)
+
+
+# *****************************************
+# TEST TOOLING
+
+class _Fiona(Enum):
+    below_1_8 = 'fiona_below_1_8'
+    above_1_8 = 'fiona_above_1_8'
+
+
+class _ExpectedError:
+    def __init__(self, error_type, error_message_match):
+        self.type = error_type
+        self.match = error_message_match
+
+
+class _ExpectedErrorBuilder:
+    def __init__(self, composite_key):
+        self.composite_key = composite_key
+
+    def to_raise(self, error_type, error_match):
+        _expected_exceptions[self.composite_key] = _ExpectedError(error_type,
+                                                                  error_match)
+
+
+def _expect_writing(gdf, ogr_driver, fiona_version):
+    return _ExpectedErrorBuilder(
+        _composite_key(gdf, ogr_driver, fiona_version)
+    )
+
+
+def _composite_key(gdf, ogr_driver, fiona_version):
+    return frozenset([id(gdf), ogr_driver, fiona_version.value])
+
+
+def _expected_error_on(gdf, ogr_driver, is_fiona_above_1_8):
+    if is_fiona_above_1_8:
+        composite_key = _composite_key(gdf, ogr_driver, _Fiona.above_1_8)
+    else:
+        composite_key = _composite_key(gdf, ogr_driver, _Fiona.below_1_8)
+    return _expected_exceptions.get(composite_key, None)
+
+
+# *****************************************
+# TEST CASES
+_geodataframes_to_write = []
+_expected_exceptions = {}
+
+# ------------------
+# gdf with Points
+gdf = GeoDataFrame(
+    {'a': [1, 2]},
+    crs={'init': 'epsg:4326'},
+    geometry=[city_hall_entrance, city_hall_balcony]
+)
+_geodataframes_to_write.append(gdf)
+
+# ------------------
+# gdf with MultiPoints
+gdf = GeoDataFrame(
+    {'a': [1, 2]},
+    crs={'init': 'epsg:4326'},
+    geometry=[
+        MultiPoint([
+            city_hall_balcony,
+            city_hall_council_chamber]),
+        MultiPoint([
+            city_hall_entrance,
+            city_hall_balcony,
+            city_hall_council_chamber]
+        )]
+)
+_geodataframes_to_write.append(gdf)
+
+# ------------------
+# gdf with Points and MultiPoints
+gdf = GeoDataFrame(
+    {'a': [1, 2]},
+    crs={'init': 'epsg:4326'},
+    geometry=[
+        MultiPoint([city_hall_entrance, city_hall_balcony]),
+        city_hall_balcony
+    ]
+)
+_geodataframes_to_write.append(gdf)
+# 'ESRI Shapefile' driver supports writing LineString/MultiLinestring and
+# Polygon/MultiPolygon but does not mention Point/MultiPoint
+# see https://www.gdal.org/drv_shapefile.html
+for driver in ('ESRI Shapefile', 'GPKG'):
+    _expect_writing(gdf, driver, _Fiona.below_1_8).to_raise(
+        ValueError,
+        "Record's geometry type does not match collection schema's geometry "
+        "type: 'MultiPoint' != 'Point'"
+    )
+_expect_writing(gdf, 'ESRI Shapefile', _Fiona.above_1_8).to_raise(
+    RuntimeError,
+    "Failed to write record"
+)
+
+# ------------------
+# gdf with LineStrings
+gdf = GeoDataFrame(
+    {'a': [1, 2]},
+    crs={'init': 'epsg:4326'},
+    geometry=city_hall_walls
+)
+_geodataframes_to_write.append(gdf)
+
+# ------------------
+# gdf with MultiLineStrings
+gdf = GeoDataFrame(
+    {'a': [1, 2]},
+    crs={'init': 'epsg:4326'},
+    geometry=[
+        MultiLineString(city_hall_walls),
+        MultiLineString(city_hall_walls)
+    ]
+)
+_geodataframes_to_write.append(gdf)
+
+# ------------------
+# gdf with LineStrings and MultiLineStrings
+gdf = GeoDataFrame(
+    {'a': [1, 2]},
+    crs={'init': 'epsg:4326'},
+    geometry=[MultiLineString(city_hall_walls), city_hall_walls[0]]
+)
+_geodataframes_to_write.append(gdf)
+_expect_writing(gdf, 'GPKG', _Fiona.below_1_8).to_raise(
+    ValueError,
+    "Record's geometry type does not match collection schema's geometry "
+    "type: 'MultiLineString' != 'LineString'"
+)
+
+# ------------------
+# gdf with Polygons
+gdf = GeoDataFrame(
+    {'a': [1, 2]},
+    crs={'init': 'epsg:4326'},
+    geometry=[city_hall_boundaries, vauquelin_place]
+)
+_geodataframes_to_write.append(gdf)
+
+# ------------------
+# gdf with MultiPolygon
+gdf = GeoDataFrame(
+    {'a': [1]},
+    crs={'init': 'epsg:4326'},
+    geometry=[MultiPolygon((city_hall_boundaries, vauquelin_place))]
+)
+_geodataframes_to_write.append(gdf)
+
+# ------------------
+# gdf with Polygon and MultiPolygon
+gdf = GeoDataFrame(
+    {'a': [1, 2]},
+    crs={'init': 'epsg:4326'},
+    geometry=[
+        MultiPolygon((city_hall_boundaries, vauquelin_place)),
+        city_hall_boundaries
+    ]
+)
+_geodataframes_to_write.append(gdf)
+_expect_writing(gdf, 'GPKG', _Fiona.below_1_8).to_raise(
+    ValueError,
+    "Record's geometry type does not match collection schema's geometry "
+    "type: 'MultiPolygon' != 'Polygon'"
+)
+
+# ------------------
+# gdf with null geometry and Point
+gdf = GeoDataFrame(
+    {'a': [1, 2]},
+    crs={'init': 'epsg:4326'},
+    geometry=[None, city_hall_entrance]
+)
+_geodataframes_to_write.append(gdf)
+
+# ------------------
+# gdf with null geometry and 3D Point
+gdf = GeoDataFrame(
+    {'a': [1, 2]},
+    crs={'init': 'epsg:4326'},
+    geometry=[None, point_3D]
+)
+_geodataframes_to_write.append(gdf)
+
+# ------------------
+# gdf with null geometries only
+gdf = GeoDataFrame(
+    {'a': [1, 2]},
+    crs={'init': 'epsg:4326'},
+    geometry=[None, None]
+)
+_geodataframes_to_write.append(gdf)
+
+# ------------------
+# gdf with all shape types mixed together
+gdf = GeoDataFrame(
+    {'a': [1, 2, 3, 4, 5, 6]},
+    crs={'init': 'epsg:4326'},
+    geometry=[
+        MultiPolygon((city_hall_boundaries, vauquelin_place)),
+        city_hall_entrance,
+        MultiLineString(city_hall_walls),
+        city_hall_walls[0],
+        MultiPoint([city_hall_entrance, city_hall_balcony]),
+        city_hall_balcony
+    ]
+)
+_geodataframes_to_write.append(gdf)
+# Not supported by 'ESRI Shapefile' driver
+for driver in ('ESRI Shapefile', 'GPKG'):
+    _expect_writing(gdf, driver, _Fiona.below_1_8).to_raise(
+        AttributeError,
+        "'list' object has no attribute 'lstrip'"
+    )
+_expect_writing(gdf, 'ESRI Shapefile', _Fiona.above_1_8).to_raise(
+    RuntimeError,
+    "Failed to write record"
+)
+
+# ------------------
+# gdf with all 2D shape types and 3D Point mixed together
+gdf = GeoDataFrame(
+    {'a': [1, 2, 3, 4, 5, 6, 7]},
+    crs={'init': 'epsg:4326'},
+    geometry=[
+        MultiPolygon((city_hall_boundaries, vauquelin_place)),
+        city_hall_entrance,
+        MultiLineString(city_hall_walls),
+        city_hall_walls[0],
+        MultiPoint([city_hall_entrance, city_hall_balcony]),
+        city_hall_balcony,
+        point_3D
+    ]
+)
+_geodataframes_to_write.append(gdf)
+# Not supported by 'ESRI Shapefile' driver
+for driver in ('ESRI Shapefile', 'GPKG'):
+    _expect_writing(gdf, driver, _Fiona.below_1_8).to_raise(
+        AttributeError,
+        "'list' object has no attribute 'lstrip'"
+    )
+_expect_writing(gdf, 'ESRI Shapefile', _Fiona.above_1_8).to_raise(
+    RuntimeError,
+    "Failed to write record"
+)
+
+
+@pytest.fixture(params=_geodataframes_to_write)
+def geodataframe(request):
+    return request.param
+
+
+@pytest.fixture(params=[
+    'GeoJSON', 'ESRI Shapefile',
+    pytest.param('GPKG', marks=pytest.mark.skipif(
+        (sys.version_info < (3, 0)) and sys.platform.startswith('win'),
+        reason="GPKG tests failing on AppVeyor for Python 2.7"))
+    ])
+def ogr_driver(request):
+    return request.param
+
+
+def test_to_file_roundtrip(tmpdir, geodataframe, ogr_driver):
+    output_file = os.path.join(str(tmpdir), 'output_file')
+
+    expected_error = _expected_error_on(geodataframe, ogr_driver, _FIONA18)
+    if expected_error:
+        with pytest.raises(expected_error.type, match=expected_error.match):
+            geodataframe.to_file(output_file, driver=ogr_driver)
+    else:
+        geodataframe.to_file(output_file, driver=ogr_driver)
+
+        reloaded = geopandas.read_file(output_file)
+
+        check_column_type = 'equiv'
+        if sys.version_info[0] < 3:
+            # do not check column types in python 2 (mixed string/unicode)
+            check_column_type = False
+
+        assert_geodataframe_equal(geodataframe, reloaded,
+                                  check_column_type=check_column_type)
diff --git a/geopandas/io/tests/test_infer_schema.py b/geopandas/io/tests/test_infer_schema.py
new file mode 100644
--- /dev/null
+++ b/geopandas/io/tests/test_infer_schema.py
@@ -0,0 +1,350 @@
+from collections import OrderedDict
+
+from shapely.geometry import Point, Polygon, MultiPolygon, MultiPoint, \
+    LineString, MultiLineString
+
+from geopandas import GeoDataFrame
+from geopandas.io.file import infer_schema, _FIONA18
+
+
+# Credit: Polygons below come from Montreal city Open Data portal
+# http://donnees.ville.montreal.qc.ca/dataset/unites-evaluation-fonciere
+city_hall_boundaries = Polygon((
+    (-73.5541107525234, 45.5091983609661),
+    (-73.5546126200639, 45.5086813829106),
+    (-73.5540185061397, 45.5084409343852),
+    (-73.5539986525799, 45.5084323044531),
+    (-73.5535801792994, 45.5089539203786),
+    (-73.5541107525234, 45.5091983609661)
+))
+vauquelin_place = Polygon((
+    (-73.5542465586147, 45.5081555487952),
+    (-73.5540185061397, 45.5084409343852),
+    (-73.5546126200639, 45.5086813829106),
+    (-73.5548825850032, 45.5084033554357),
+    (-73.5542465586147, 45.5081555487952)
+))
+
+city_hall_walls = [
+    LineString((
+        (-73.5541107525234, 45.5091983609661),
+        (-73.5546126200639, 45.5086813829106),
+        (-73.5540185061397, 45.5084409343852)
+    )),
+    LineString((
+        (-73.5539986525799, 45.5084323044531),
+        (-73.5535801792994, 45.5089539203786),
+        (-73.5541107525234, 45.5091983609661)
+    ))
+]
+
+city_hall_entrance = Point(-73.553785, 45.508722)
+city_hall_balcony = Point(-73.554138, 45.509080)
+city_hall_council_chamber = Point(-73.554246, 45.508931)
+
+point_3D = Point(-73.553785, 45.508722, 300)
+linestring_3D = LineString((
+    (-73.5541107525234, 45.5091983609661, 300),
+    (-73.5546126200639, 45.5086813829106, 300),
+    (-73.5540185061397, 45.5084409343852, 300)
+))
+polygon_3D = Polygon((
+    (-73.5541107525234, 45.5091983609661, 300),
+    (-73.5535801792994, 45.5089539203786, 300),
+    (-73.5541107525234, 45.5091983609661, 300)
+))
+
+
+def test_infer_schema_only_points():
+    df = GeoDataFrame(
+        geometry=[city_hall_entrance, city_hall_balcony]
+    )
+
+    assert infer_schema(df) == {
+        'geometry': 'Point',
+        'properties': OrderedDict()
+    }
+
+
+def test_infer_schema_points_and_multipoints():
+    df = GeoDataFrame(
+        geometry=[
+            MultiPoint([city_hall_entrance, city_hall_balcony]),
+            city_hall_balcony
+        ]
+    )
+
+    if _FIONA18:
+        assert infer_schema(df) == {
+            'geometry': ['MultiPoint', 'Point'],
+            'properties': OrderedDict()
+        }
+    else:
+        assert infer_schema(df) == {
+            'geometry': 'Point',
+            'properties': OrderedDict()
+        }
+
+
+def test_infer_schema_only_multipoints():
+    df = GeoDataFrame(
+        geometry=[MultiPoint([
+            city_hall_entrance,
+            city_hall_balcony,
+            city_hall_council_chamber
+        ])]
+    )
+
+    assert infer_schema(df) == {
+        'geometry': 'MultiPoint',
+        'properties': OrderedDict()
+    }
+
+
+def test_infer_schema_only_linestrings():
+    df = GeoDataFrame(geometry=city_hall_walls)
+
+    assert infer_schema(df) == {
+        'geometry': 'LineString',
+        'properties': OrderedDict()
+    }
+
+
+def test_infer_schema_linestrings_and_multilinestrings():
+    df = GeoDataFrame(
+        geometry=[
+            MultiLineString(city_hall_walls),
+            city_hall_walls[0]
+        ]
+    )
+
+    if _FIONA18:
+        assert infer_schema(df) == {
+            'geometry': ['MultiLineString', 'LineString'],
+            'properties': OrderedDict()
+        }
+    else:
+        assert infer_schema(df) == {
+            'geometry': 'LineString',
+            'properties': OrderedDict()
+        }
+
+
+def test_infer_schema_only_multilinestrings():
+    df = GeoDataFrame(geometry=[MultiLineString(city_hall_walls)])
+
+    assert infer_schema(df) == {
+        'geometry': 'MultiLineString',
+        'properties': OrderedDict()
+    }
+
+
+def test_infer_schema_only_polygons():
+    df = GeoDataFrame(
+        geometry=[city_hall_boundaries, vauquelin_place]
+    )
+
+    assert infer_schema(df) == {
+        'geometry': 'Polygon',
+        'properties': OrderedDict()
+    }
+
+
+def test_infer_schema_polygons_and_multipolygons():
+    df = GeoDataFrame(
+        geometry=[
+            MultiPolygon((city_hall_boundaries, vauquelin_place)),
+            city_hall_boundaries
+        ]
+    )
+
+    if _FIONA18:
+        assert infer_schema(df) == {
+            'geometry': ['MultiPolygon', 'Polygon'],
+            'properties': OrderedDict()
+        }
+    else:
+        assert infer_schema(df) == {
+            'geometry': 'Polygon',
+            'properties': OrderedDict()
+        }
+
+
+def test_infer_schema_only_multipolygons():
+    df = GeoDataFrame(
+        geometry=[
+            MultiPolygon((city_hall_boundaries, vauquelin_place))
+        ]
+    )
+
+    assert infer_schema(df) == {
+        'geometry': 'MultiPolygon',
+        'properties': OrderedDict()
+    }
+
+
+def test_infer_schema_multiple_shape_types():
+    df = GeoDataFrame(
+        geometry=[
+            MultiPolygon((city_hall_boundaries, vauquelin_place)),
+            city_hall_boundaries,
+            MultiLineString(city_hall_walls),
+            city_hall_walls[0],
+            MultiPoint([city_hall_entrance, city_hall_balcony]),
+            city_hall_balcony
+        ]
+    )
+
+    if _FIONA18:
+        assert infer_schema(df) == {
+            'geometry': [
+                'MultiPolygon', 'Polygon',
+                'MultiLineString', 'LineString',
+                'MultiPoint', 'Point'
+            ],
+            'properties': OrderedDict()
+        }
+    else:
+        assert infer_schema(df) == {
+            'geometry': [
+                'Polygon',
+                'LineString',
+                'Point'
+            ],
+            'properties': OrderedDict()
+        }
+
+
+def test_infer_schema_mixed_3D_shape_type():
+    df = GeoDataFrame(
+        geometry=[
+            MultiPolygon((city_hall_boundaries, vauquelin_place)),
+            city_hall_boundaries,
+            MultiLineString(city_hall_walls),
+            city_hall_walls[0],
+            MultiPoint([city_hall_entrance, city_hall_balcony]),
+            city_hall_balcony,
+            point_3D
+        ]
+    )
+
+    if _FIONA18:
+        assert infer_schema(df) == {
+            'geometry': [
+                '3D Point',
+                'MultiPolygon', 'Polygon',
+                'MultiLineString', 'LineString',
+                'MultiPoint', 'Point'
+            ],
+            'properties': OrderedDict()
+        }
+    else:
+        assert infer_schema(df) == {
+            'geometry': ['3D Polygon', '3D LineString', '3D Point'],
+            'properties': OrderedDict()
+        }
+
+
+def test_infer_schema_mixed_3D_Point():
+    df = GeoDataFrame(geometry=[city_hall_balcony, point_3D])
+
+    if _FIONA18:
+        assert infer_schema(df) == {
+            'geometry': ['3D Point', 'Point'],
+            'properties': OrderedDict()
+        }
+    else:
+        assert infer_schema(df) == {
+            'geometry': '3D Point',
+            'properties': OrderedDict()
+        }
+
+
+def test_infer_schema_only_3D_Points():
+    df = GeoDataFrame(geometry=[point_3D, point_3D])
+
+    assert infer_schema(df) == {
+        'geometry': '3D Point',
+        'properties': OrderedDict()
+    }
+
+
+def test_infer_schema_mixed_3D_linestring():
+    df = GeoDataFrame(
+        geometry=[city_hall_walls[0], linestring_3D]
+    )
+
+    if _FIONA18:
+        assert infer_schema(df) == {
+            'geometry': ['3D LineString', 'LineString'],
+            'properties': OrderedDict()
+        }
+    else:
+        assert infer_schema(df) == {
+            'geometry': '3D LineString',
+            'properties': OrderedDict()
+        }
+
+
+def test_infer_schema_only_3D_linestrings():
+    df = GeoDataFrame(geometry=[linestring_3D, linestring_3D])
+
+    assert infer_schema(df) == {
+        'geometry': '3D LineString',
+        'properties': OrderedDict()
+    }
+
+
+def test_infer_schema_mixed_3D_Polygon():
+    df = GeoDataFrame(geometry=[city_hall_boundaries, polygon_3D])
+
+    if _FIONA18:
+        assert infer_schema(df) == {
+            'geometry': ['3D Polygon', 'Polygon'],
+            'properties': OrderedDict()
+        }
+    else:
+        assert infer_schema(df) == {
+            'geometry': '3D Polygon',
+            'properties': OrderedDict()
+        }
+
+
+def test_infer_schema_only_3D_Polygons():
+    df = GeoDataFrame(geometry=[polygon_3D, polygon_3D])
+
+    assert infer_schema(df) == {
+        'geometry': '3D Polygon',
+        'properties': OrderedDict()
+    }
+
+
+def test_infer_schema_null_geometry_and_2D_point():
+    df = GeoDataFrame(geometry=[None, city_hall_entrance])
+
+    # None geometry type is then omitted
+    assert infer_schema(df) == {
+        'geometry': 'Point',
+        'properties': OrderedDict()
+    }
+
+
+def test_infer_schema_null_geometry_and_3D_point():
+    df = GeoDataFrame(geometry=[None, point_3D])
+
+    # None geometry type is then omitted
+    assert infer_schema(df) == {
+        'geometry': '3D Point',
+        'properties': OrderedDict()
+    }
+
+
+def test_infer_schema_null_geometry_all():
+    df = GeoDataFrame(geometry=[None, None])
+
+    # None geometry type in then replaced by 'Unknown'
+    # (default geometry type supported by Fiona)
+    assert infer_schema(df) == {
+        'geometry': 'Unknown',
+        'properties': OrderedDict()
+    }