Repository: geofileops/geofileops
Pull request #566 (2024-10-15 19:48:55)

ENH: for dissolve, apply grid_size within union_all

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/geofileops/geofileops/pull/566?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=geofileops) Report
Attention: Patch coverage is `50.00000%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 86.06%. Comparing base [(`5f08842`)](https://app.codecov.io/gh/geofileops/geofileops/commit/5f08842e475a5aaff2f4ace75e7bc77c9c2fe023?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=geofileops) to head [(`218774e`)](https://app.codecov.io/gh/geofileops/geofileops/commit/218774ef0e311aeb12f80c3539e7ba0f46bc555c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=geofileops).
> Report is 113 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/geofileops/geofileops/pull/566?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=geofileops) | Patch % | Lines |
|---|---|---|
| [geofileops/util/\_geoops\_gpd.py](https://app.codecov.io/gh/geofileops/geofileops/pull/566?src=pr&el=tree&filepath=geofileops%2Futil%2F_geoops_gpd.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=geofileops#diff-Z2VvZmlsZW9wcy91dGlsL19nZW9vcHNfZ3BkLnB5) | 50.00% | [3 Missing :warning: ](https://app.codecov.io/gh/geofileops/geofileops/pull/566?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=geofileops) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #566      +/-   ##
==========================================
- Coverage   93.11%   86.06%   -7.06%     
==========================================
  Files          21       22       +1     
  Lines        4113     4486     +373     
==========================================
+ Hits         3830     3861      +31     
- Misses        283      625     +342     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/geofileops/geofileops/pull/566?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=geofileops).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=geofileops).


Patch:
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 1b438f8..49bde6b 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -8,9 +8,9 @@ version: 2
 # Set the version of os and other tools you might need
 # Using mambaforge solves excessive memory use issues
 build:
-  os: "ubuntu-22.04"
+  os: "ubuntu-24.04"
   tools:
-    python: "mambaforge-4.10"
+    python: "miniconda-latest"
     
 # Build documentation in the docs/ directory with Sphinx
 sphinx:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68af636..92cdab1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,8 +7,10 @@
 - `erase` was renamed to `difference`, as most other open source applications/libraries
   use this terminology. `erase` just keeps existing for backwards compatibility for now,
   but a warning is shown that it might be removed in the (distant) future. (#595)
-- in `copy_layer` and `append_to` the default `dst_layer` was, contrary to the
+- In `copy_layer` and `append_to` the default `dst_layer` was, contrary to the
   documentation, not the stem of the destination filename. This is corrected now. (#648)
+- In `copy_layer`, the `append` parameter is deprecated and replaced by the `write_mode`
+  parameter that accepts e.g. "append" as value (#663).
 
 ### Improvements
 
@@ -29,9 +31,11 @@
   column should be retained (#523)
 - Enable "CURVE" geometrytype files to be processed in the general file and
   layer operations (#558)
+- Replace `append` parameter by `write_mode` in `copy_file` (#663)
 - Don't convert to multi-part geometries by default in `copy_layer`,... (#570)
 - In `add_column`, don't add column if update expression is invalid (#650)
 - Add configuration option to only warn on dissolve errors (#561)
+- For `dissolve`, apply `grid_size` within `union_all` (#566)
 - Add some pre-flight checks when geofileops is imported (#573, #627)
 - For `select_two_layers`, add the `gpkg_ogr_contents` table + fill out extents in the
   `gpkg_contents` table in the output file (#647)
diff --git a/benchmark/reporter.py b/benchmark/reporter.py
index 313934a..c9e7442 100644
--- a/benchmark/reporter.py
+++ b/benchmark/reporter.py
@@ -246,7 +246,7 @@ def save_chart(
     if not output_path.exists():
         fig.savefig(str(output_path))
     else:
-        # If it exists already, only save it if it has changed
+        # If it already exists, only save it if it has changed
         with tempfile.TemporaryDirectory() as tmp_dir:
             tmp_output_path = Path(tmp_dir) / output_path.name
             fig.savefig(tmp_output_path)
diff --git a/ci/envs/readthedocs.yml b/ci/envs/readthedocs.yml
index 3e071b2..8c20672 100644
--- a/ci/envs/readthedocs.yml
+++ b/ci/envs/readthedocs.yml
@@ -2,11 +2,9 @@ name: geofileops-doc
 channels:
   - conda-forge
 dependencies:
-  - python =3.9
-  - pip
+  - python =3.12
   # required
   - cloudpickle
-  - fiona
   - gdal >=3.6.3
   - geopandas-base >=0.12
   - libspatialite >=5.0
@@ -15,15 +13,13 @@ dependencies:
   - packaging
   - pandas
   - psutil
-  - pygeoops >=0.4,<0.5
-  - pyogrio >=0.5
+  - pygeoops>=0.4,<0.5
+  - pyogrio>=0.5
   - pyproj
-  - shapely >=2,<2.1
+  - shapely>=2,<2.1
   # optional
   - simplification
   # docs
-  # Restrict sphinx version to < 6 to avoid logo error
-  - sphinx<6
   - pydata-sphinx-theme
-  - pip:
-    - sphinx-automodapi ==0.13
\ No newline at end of file
+  - sphinx
+  - sphinx-automodapi
\ No newline at end of file
diff --git a/docs/conf.py b/docs/conf.py
index c1f7918..1fce60d 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -28,7 +28,7 @@ devdir = "./"
 # -- Project information -----------------------------------------------------
 
 project = "geofileops"
-copyright = "2024, Pieter Roggemans"
+copyright = "2025, Pieter Roggemans"
 author = "Pieter Roggemans"
 
 # The full version, including alpha/beta/rc tags
@@ -78,6 +78,8 @@ exclude_patterns = [
 #
 html_theme = "pydata_sphinx_theme"
 
+html_title = f"{project} {release}"
+
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
 # documentation.
@@ -92,7 +94,7 @@ html_theme_options = {
 }
 
 html_sidebars: dict[str, list[str]] = {
-    "getting_started": [],
+    "installation": [],
     "user_guide": [],
     "reference": [],
     "faq": [],
diff --git a/geofileops/fileops.py b/geofileops/fileops.py
index bb2a62a..e39319e 100644
--- a/geofileops/fileops.py
+++ b/geofileops/fileops.py
@@ -564,10 +564,10 @@ def create_spatial_index(
         cache_size_mb (int, optional): cache memory in MB that can be used while
             creating spatial index for spatialite files (.gpkg or .sqlite). If None,
             the default cache_size from sqlite is used. Defaults to 128.
-        exist_ok (bool, optional): If True and the index exists already, don't
+        exist_ok (bool, optional): If True and the index already exists, don't
             throw an error. Defaults to False.
         force_rebuild (bool, options): True to force rebuild even if index
-            exists already. Defaults to False.
+            already exists. Defaults to False.
         no_geom_ok (bool, options): If True and the file doesn't have a geometry column,
             don't throw an error. Defaults to False.
 
@@ -598,7 +598,7 @@ def create_spatial_index(
                     return
                 else:
                     raise RuntimeError(
-                        f"spatial index exists already on {path}#{layer.name}"
+                        f"spatial index already exists on {path}#{layer.name}"
                     )
 
             if path_info.is_spatialite_based:
@@ -929,7 +929,9 @@ def add_column(
 ):
     """Add a column to a layer of the geofile.
 
-    You can specify an `expression` to use to fill out the value of the column.
+    You can specify an `expression` to use to fill out the value of the column. For file
+    formats that support transactions, the column won't be added if updating the value
+    fails.
 
     Args:
         path (PathLike): Path to the geofile.
@@ -1706,7 +1708,7 @@ def to_file(
         force_multitype (bool, optional): force the geometry type to a multitype
             for file types that require one geometrytype per layer.
             Defaults to False.
-        append (bool, optional): True to append to the file/layer if it exists already.
+        append (bool, optional): True to append to the file/layer if it already exists.
             If it doesn't exist yet, it is created. Defaults to False.
         append_timeout_s (int, optional): The maximum timeout to wait when the
             output file is already being written to by another process.
@@ -2388,7 +2390,7 @@ def append_to(
             on the destination file/layer. If None, the default behaviour by gdal for
             that file type is respected. If the `LAYER_CREATION.SPATIAL_INDEX`
             parameter is specified in options, `create_spatial_index` is ignored. If the
-            destination layer exists already, `create_spatial_index` is also ignored.
+            destination layer already exists, `create_spatial_index` is also ignored.
             Defaults to None.
         append_timeout_s (int, optional): timeout to use if the output file is
             being written to by another process already. Defaults to 600.
@@ -2440,14 +2442,15 @@ def append_to(
     start_time = datetime.now()
     ready = False
     while not ready:
-        if _io_util.create_file_atomic(lockfile) is True:
+        if _io_util.create_file_atomic(lockfile):
             try:
                 # append
-                _append_to_nolock(
+                copy_layer(
                     src=src,
                     dst=dst,
                     src_layer=src_layer,
                     dst_layer=dst_layer,
+                    write_mode="append",
                     src_crs=src_crs,
                     dst_crs=dst_crs,
                     columns=columns,
@@ -2478,118 +2481,6 @@ def append_to(
         time.sleep(1)
 
 
-def _append_to_nolock(
-    src: Path,
-    dst: Path,
-    src_layer: Optional[Union[str, LayerInfo]] = None,
-    dst_layer: Optional[str] = None,
-    src_crs: Union[int, str, None] = None,
-    dst_crs: Union[int, str, None] = None,
-    columns: Optional[Iterable[str]] = None,
-    where: Optional[str] = None,
-    sql_stmt: Optional[str] = None,
-    sql_dialect: Optional[Literal["SQLITE", "OGRSQL"]] = None,
-    reproject: bool = False,
-    explodecollections: bool = False,
-    create_spatial_index: Optional[bool] = None,
-    force_output_geometrytype: Union[GeometryType, str, Iterable[str], None] = None,
-    transaction_size: int = 50000,
-    preserve_fid: Optional[bool] = None,
-    dst_dimensions: Optional[str] = None,
-    options: dict = {},
-):
-    # Check/clean input params
-    if isinstance(columns, str):
-        # If a string is passed, convert to list
-        columns = [columns]
-    if dst_layer is None:
-        dst_layer = get_default_layer(dst)
-
-    options = _ogr_util._prepare_gdal_options(options)
-    if (
-        create_spatial_index is not None
-        and "LAYER_CREATION.SPATIAL_INDEX" not in options
-    ):
-        options["LAYER_CREATION.SPATIAL_INDEX"] = create_spatial_index
-
-    if where is not None:
-        if not isinstance(src_layer, LayerInfo):
-            src_layer = get_layerinfo(src, src_layer, raise_on_nogeom=False)
-        where = where.format(geometrycolumn=src_layer.geometrycolumn)
-
-    if sql_stmt is not None:
-        # Fill out placeholders.
-        sql_stmt = _fill_out_sql_placeholders(
-            path=src, layer=src_layer, sql_stmt=sql_stmt, columns=columns
-        )
-
-    # When creating/appending to a shapefile, some extra things need to be done/checked.
-    if sql_stmt is None and dst.suffix.lower() == ".shp":
-        # If the destination file doesn't exist yet, and the source file has
-        # geometrytype "Geometry", raise because type is not supported by shp (and will
-        # default to linestring).
-        if not isinstance(src_layer, LayerInfo):
-            src_layer = get_layerinfo(src, src_layer, raise_on_nogeom=False)
-        if (
-            force_output_geometrytype is None
-            and src_layer.geometrytypename in ["GEOMETRY", "GEOMETRYCOLLECTION"]
-            and not dst.exists()
-        ):
-            raise ValueError(
-                f"src file {src} has geometrytype {src_layer.geometrytypename} "
-                "which is not supported in .shp. Maybe use force_output_geometrytype?"
-            )
-
-        # Launder the columns names via a SQL statement, otherwise when appending the
-        # laundered columns will get NULL values instead of the data.
-        if columns is None:
-            columns = src_layer.columns
-        columns_laundered = _launder_column_names(columns)
-        columns_aliased = [
-            f'"{column}" AS "{laundered}"' for column, laundered in columns_laundered
-        ]
-        # If there is a where specified, integrate it...
-        where_clause = ""
-        if where is not None:
-            where_clause = f"WHERE {where}"
-            where = None
-        geometrycolumn = ""
-        if src_layer.geometrycolumn is not None:
-            geometrycolumn = f"{src_layer.geometrycolumn}, "
-        sql_stmt = f"""
-            SELECT {geometrycolumn}{", ".join(columns_aliased)}
-              FROM "{src_layer.name}"
-             {where_clause}
-        """
-        sql_dialect = "SQLITE"
-        columns = None
-
-    # Go!
-    src_layername = src_layer.name if isinstance(src_layer, LayerInfo) else src_layer
-    translate_info = _ogr_util.VectorTranslateInfo(
-        input_path=src,
-        output_path=dst,
-        input_layers=src_layername,
-        output_layer=dst_layer,
-        input_srs=src_crs,
-        output_srs=dst_crs,
-        columns=columns,
-        sql_stmt=sql_stmt,
-        sql_dialect=sql_dialect,
-        where=where,
-        reproject=reproject,
-        transaction_size=transaction_size,
-        append=True,
-        update=True,
-        explodecollections=explodecollections,
-        force_output_geometrytype=force_output_geometrytype,
-        options=options,
-        preserve_fid=preserve_fid,
-        dst_dimensions=dst_dimensions,
-    )
-    _ogr_util.vector_translate_by_info(info=translate_info)
-
-
 def convert(
     src: Union[str, "os.PathLike[Any]"],
     dst: Union[str, "os.PathLike[Any]"],
@@ -2631,8 +2522,9 @@ def convert(
 def copy_layer(
     src: Union[str, "os.PathLike[Any]"],
     dst: Union[str, "os.PathLike[Any]"],
-    src_layer: Optional[str] = None,
+    src_layer: Optional[Union[str, LayerInfo]] = None,
     dst_layer: Optional[str] = None,
+    write_mode: str = "create",
     src_crs: Union[str, int, None] = None,
     dst_crs: Union[str, int, None] = None,
     columns: Optional[Iterable[str]] = None,
@@ -2643,6 +2535,7 @@ def copy_layer(
     explodecollections: bool = False,
     force_output_geometrytype: Union[GeometryType, str, None] = None,
     create_spatial_index: Optional[bool] = None,
+    transaction_size: int = 50000,
     preserve_fid: Optional[bool] = None,
     dst_dimensions: Optional[str] = None,
     options: dict = {},
@@ -2654,6 +2547,9 @@ def copy_layer(
     Typically used to convert from one fileformat to another, to reproject or to export
     a subset of the data using the `where` parameter.
 
+    You can also add a layer to an existing file or append rows to an existing layer
+    using the `write_mode` parameter.
+
     The options parameter can be used to pass any type of options to GDAL in
     the following form:
         { "<option_type>.<option_name>": <option_value> }
@@ -2674,6 +2570,16 @@ def copy_layer(
             one layer in the src file, that layer is taken. Defaults to None.
         dst_layer (str, optional): The destination layer. If None, the destination file
             stem is taken as layer name. Defaults to None.
+        write_mode (str, optional): The write mode. Defaults to "create". Valid values:
+
+            - "create": create a new destination file. If the file already exists,
+                behaviour depends on the `force` parameter.
+            - "add_layer": add the source layer to the destination file as a new layer.
+                When using "add_layer", `dst_layer` should be specified. If the layer
+                already exists, behaviour depends on the `force` parameter.
+            - "append": append the source layer to the destination layer, if the
+                layer already exists.
+
         src_crs (Union[str, int], optional): an epsg int or anything supported
             by the OGRSpatialReference.SetFromUserInput() call, which includes
             an EPSG string (eg. "EPSG:4326"), a well known text (WKT) CRS
@@ -2713,6 +2619,7 @@ def copy_layer(
             that file type is respected. If the LAYER_CREATION.SPATIAL_INDEX
             parameter is specified in options, create_spatial_index is ignored.
             Defaults to None.
+        transaction_size (int, optional): Transaction size. Defaults to 50000.
         preserve_fid (bool, optional): True to make an extra effort to preserve fid's of
             the source layer to the destination layer. False not to do any effort. None
             to use the default behaviour of gdal, that already preserves in some cases.
@@ -2722,10 +2629,11 @@ def copy_layer(
             the value specified. Valid values: "XY", "XYZ", "XYM" or "XYZM".
             Defaults to None.
         options (dict, optional): options to pass to gdal.
-        append (bool, optional): True to append to the output file if it exists.
-            Defaults to False.
-        force (bool, optional): overwrite existing output file(s)
-            Defaults to False.
+        append (bool, optional): True to append to the destination layer if it already
+            exists. Deprecated: use write_mode='append'. Defaults to False.
+        force (bool, optional): True to overwrite the output file/layer (depending on
+            `write_mode`) if it already exists. False to just return if the output
+            file/layer exists. Defaults to False.
 
     .. |spatialite_reference_link| raw:: html
 
@@ -2743,37 +2651,132 @@ def copy_layer(
     # If source file doesn't exist, raise error
     if not src.exists():
         raise ValueError(f"src file doesn't exist: {src}")
-    # If dest file exists already and no append
-    if not append and dst.exists():
-        if force:
-            remove(dst)
-        else:
-            logger.info(f"Output file exists already, so stop: {dst}")
-            return
+
+    # The append parameter is deprecated, but keep backwards compatibility
+    if append:
+        if write_mode != "create":
+            raise ValueError("append parameter is deprecated, use write_mode='append'")
+        warnings.warn(
+            "append parameter is deprecated, use write_mode='append'",
+            FutureWarning,
+            stacklevel=2,
+        )
+        write_mode = "append"
+
     if dst_layer is None:
+        if write_mode == "add_layer":
+            raise ValueError("dst_layer is required when write_mode is 'add_layer'")
+
         dst_layer = get_default_layer(dst)
 
-    # Copy
-    logger.info(f"Copy layer from {src} to {dst}")
-    _append_to_nolock(
-        src,
-        dst,
-        src_layer,
-        dst_layer,
-        src_crs=src_crs,
-        dst_crs=dst_crs,
+    # Convert write_mode to the access_mode expected by GDAL + handle existing dst.
+    if write_mode == "create":
+        if dst.exists() and not force:
+            logger.info(f"Destination file already exists, so stop: {dst}")
+            return
+        access_mode = None
+
+    elif write_mode == "add_layer":
+        if force:
+            access_mode = "overwrite"
+        else:
+            if dst.exists() and dst_layer in listlayers(dst, only_spatial_layers=False):
+                logger.info(f"dst_layer already exists, so stop: {dst}#{dst_layer}")
+                return
+            access_mode = "update"
+
+    elif write_mode == "append":
+        access_mode = write_mode
+    else:
+        raise ValueError(f"Invalid write_mode: {write_mode}")
+
+    # Check/clean input params
+    if isinstance(columns, str):
+        # If a string is passed, convert to list
+        columns = [columns]
+
+    options = _ogr_util._prepare_gdal_options(options)
+    if (
+        create_spatial_index is not None
+        and "LAYER_CREATION.SPATIAL_INDEX" not in options
+    ):
+        options["LAYER_CREATION.SPATIAL_INDEX"] = create_spatial_index
+
+    if where is not None:
+        if not isinstance(src_layer, LayerInfo):
+            src_layer = get_layerinfo(src, src_layer, raise_on_nogeom=False)
+        where = where.format(geometrycolumn=src_layer.geometrycolumn)
+
+    if sql_stmt is not None:
+        # Fill out placeholders.
+        sql_stmt = _fill_out_sql_placeholders(
+            path=src, layer=src_layer, sql_stmt=sql_stmt, columns=columns
+        )
+
+    # When creating/appending to a shapefile, some extra things need to be done/checked.
+    if sql_stmt is None and dst.suffix.lower() == ".shp":
+        # If the destination file doesn't exist yet, and the source file has
+        # geometrytype "Geometry", raise because type is not supported by shp (and will
+        # default to linestring).
+        if not isinstance(src_layer, LayerInfo):
+            src_layer = get_layerinfo(src, src_layer, raise_on_nogeom=False)
+        if (
+            force_output_geometrytype is None
+            and src_layer.geometrytypename in ["GEOMETRY", "GEOMETRYCOLLECTION"]
+            and not dst.exists()
+        ):
+            raise ValueError(
+                f"src file {src} has geometrytype {src_layer.geometrytypename} "
+                "which is not supported in .shp. Maybe use force_output_geometrytype?"
+            )
+
+        # Launder the columns names via a SQL statement, otherwise when appending the
+        # laundered columns will get NULL values instead of the data.
+        if columns is None:
+            columns = src_layer.columns
+        columns_laundered = _launder_column_names(columns)
+        columns_aliased = [
+            f'"{column}" AS "{laundered}"' for column, laundered in columns_laundered
+        ]
+        # If there is a where specified, integrate it...
+        where_clause = ""
+        if where is not None:
+            where_clause = f"WHERE {where}"
+            where = None
+        geometrycolumn = ""
+        if src_layer.geometrycolumn is not None:
+            geometrycolumn = f"{src_layer.geometrycolumn}, "
+        sql_stmt = f"""
+            SELECT {geometrycolumn}{", ".join(columns_aliased)}
+              FROM "{src_layer.name}"
+             {where_clause}
+        """
+        sql_dialect = "SQLITE"
+        columns = None
+
+    # Go!
+    src_layername = src_layer.name if isinstance(src_layer, LayerInfo) else src_layer
+    translate_info = _ogr_util.VectorTranslateInfo(
+        input_path=src,
+        output_path=dst,
+        input_layers=src_layername,
+        output_layer=dst_layer,
+        access_mode=access_mode,
+        input_srs=src_crs,
+        output_srs=dst_crs,
         columns=columns,
-        where=where,
         sql_stmt=sql_stmt,
         sql_dialect=sql_dialect,
+        where=where,
         reproject=reproject,
+        transaction_size=transaction_size,
         explodecollections=explodecollections,
         force_output_geometrytype=force_output_geometrytype,
-        create_spatial_index=create_spatial_index,
+        options=options,
         preserve_fid=preserve_fid,
         dst_dimensions=dst_dimensions,
-        options=options,
     )
+    _ogr_util.vector_translate_by_info(info=translate_info)
 
 
 def _launder_column_names(columns: Iterable) -> list[tuple[str, str]]:
diff --git a/geofileops/helpers/layerstyles.py b/geofileops/helpers/layerstyles.py
index 4baf1a9..138b560 100644
--- a/geofileops/helpers/layerstyles.py
+++ b/geofileops/helpers/layerstyles.py
@@ -90,7 +90,7 @@ def add_layerstyle(
             .reset_index()
             .to_dict(orient="records")
         )
-        raise ValueError(f"layer style exists already: {styles_found}")
+        raise ValueError(f"layer style already exists: {styles_found}")
 
     # Insert style
     conn = sqlite3.connect(path)
@@ -172,16 +172,16 @@ def _init_layerstyles(path: Path, exist_ok: bool = False):
 
     Args:
         path (Path): the file to create the table in.
-        exist_ok (bool, options): If True and the index exists already, don't
+        exist_ok (bool, options): If True and the index already exists, don't
             throw an error.
     """
     try:
-        # First check if it exists already
+        # First check if it already exists
         if _has_layerstyles_table(path):
             if exist_ok:
                 return
             else:
-                raise ValueError(f"layer_styles table exists already in {path}")
+                raise ValueError(f"layer_styles table already exists in {path}")
 
         # Doesn't exist yet, so create the table
         datasource = gdal.OpenEx(str(path), nOpenFlags=gdal.OF_UPDATE)
diff --git a/geofileops/util/_geoops_gpd.py b/geofileops/util/_geoops_gpd.py
index 474fd86..f9895fd 100644
--- a/geofileops/util/_geoops_gpd.py
+++ b/geofileops/util/_geoops_gpd.py
@@ -30,7 +30,7 @@ from pygeoops import GeometryType, PrimitiveType
 
 import geofileops as gfo
 from geofileops import LayerInfo, fileops
-from geofileops._compat import GEOPANDAS_GTE_10, PANDAS_GTE_22
+from geofileops._compat import PANDAS_GTE_22
 from geofileops.helpers import _general_helper, _parameter_helper
 from geofileops.helpers._configoptions_helper import ConfigOptions
 from geofileops.util import (
@@ -881,11 +881,12 @@ def _apply_geooperation_to_layer(
                         ):
                             gfo.move(tmp_partial_output_path, tmp_output_path)
                         else:
-                            fileops._append_to_nolock(
+                            fileops.copy_layer(
                                 src=tmp_partial_output_path,
                                 dst=tmp_output_path,
                                 src_layer=output_layer,
                                 dst_layer=output_layer,
+                                write_mode="append",
                                 explodecollections=explodecollections,
                                 create_spatial_index=False,
                                 force_output_geometrytype=force_output_geometrytype,
@@ -945,8 +946,8 @@ def _apply_geooperation(
     if not output_path.parent.exists():
         raise ValueError(f"Output directory does not exist: {output_path.parent}")
     if output_path.exists():
-        if force is False:
-            message = f"Stop, output exists already {output_path}"
+        if not force:
+            message = f"Stop, output already exists {output_path}"
             return message
         else:
             gfo.remove(output_path)
@@ -1215,6 +1216,8 @@ def dissolve(
         )
 
     elif input_layer.geometrytype.to_primitivetype is PrimitiveType.POLYGON:
+        start_time = datetime.now()
+
         # Prepare where_post
         if where_post is not None:
             if where_post == "":
@@ -1620,6 +1623,9 @@ def dissolve(
         finally:
             if ConfigOptions.remove_temp_files:
                 shutil.rmtree(tempdir, ignore_errors=True)
+
+        logger.info(f"Ready, full dissolve took {datetime.now()-start_time}")
+
     else:
         raise NotImplementedError(
             f"Unsupported input geometrytype: {input_layer.geometrytype}"
@@ -1739,11 +1745,12 @@ def _dissolve_polygons_pass(
                         output_notonborder_tmp_partial_path.exists()
                         and output_notonborder_tmp_partial_path.stat().st_size > 0
                     ):
-                        fileops._append_to_nolock(
+                        fileops.copy_layer(
                             src=output_notonborder_tmp_partial_path,
                             dst=output_notonborder_path,
                             src_layer=output_layer,
                             dst_layer=output_layer,
+                            write_mode="append",
                             create_spatial_index=False,
                         )
                         gfo.remove(output_notonborder_tmp_partial_path)
@@ -1756,11 +1763,12 @@ def _dissolve_polygons_pass(
                         output_onborder_tmp_partial_path.exists()
                         and output_onborder_tmp_partial_path.stat().st_size > 0
                     ):
-                        fileops._append_to_nolock(
+                        fileops.copy_layer(
                             src=output_onborder_tmp_partial_path,
                             dst=output_onborder_path,
                             src_layer=output_layer,
                             dst_layer=output_layer,
+                            write_mode="append",
                             create_spatial_index=False,
                         )
                         gfo.remove(output_onborder_tmp_partial_path)
@@ -1906,10 +1914,10 @@ def _dissolve_polygons(
             aggfunc=aggfunc,
             as_index=False,
             dropna=False,
+            grid_size=gridsize,
         )
     except Exception as ex:
-        # If a GEOS exception occurs, it is probably due to invalid geometries.
-        # Try to fix them and try again.
+        # If a GEOS exception occurs, check on_data_error on how to proceed.
         if on_data_error == "warn":
             message = f"Error processing tile, ENTIRE TILE LOST!!!: {ex}"
             warnings.warn(message, UserWarning, stacklevel=3)
@@ -1977,13 +1985,8 @@ def _dissolve_polygons(
 
         perfinfo["time_clip"] = (datetime.now() - start_clip).total_seconds()
 
-    if gridsize != 0.0:
-        diss_gdf.geometry = _geoseries_util.set_precision(
-            diss_gdf.geometry, grid_size=gridsize, raise_on_topoerror=False
-        )
-        assert isinstance(diss_gdf.geometry, gpd.GeoSeries)
-
     # Set empty geometries to None
+    assert isinstance(diss_gdf.geometry, gpd.GeoSeries)
     diss_gdf.loc[diss_gdf.geometry.is_empty, diss_gdf.geometry.name] = None
 
     if not keep_empty_geoms:
@@ -2087,6 +2090,7 @@ def _dissolve(
     sort=True,
     observed=False,
     dropna=True,
+    grid_size: float = 0.0,
 ) -> gpd.GeoDataFrame:
     """Dissolve geometries within `groupby` into single observation.
 
@@ -2228,18 +2232,13 @@ def _dissolve(
             dropped_columns = [
                 column for column in columns_to_agg if column not in agg_data.columns
             ]
-            raise Exception(
+            raise ValueError(
                 f"Column(s) {dropped_columns} are not supported for aggregation, stop"
             )
 
     # Process spatial component
     def merge_geometries(block):
-        if GEOPANDAS_GTE_10:
-            merged_geom = block.union_all()
-        else:
-            merged_geom = block.unary_union
-
-        return merged_geom
+        return shapely.union_all(block, grid_size=grid_size)
 
     g = df.groupby(group_keys=False, **groupby_kwargs)[df.geometry.name].agg(
         merge_geometries
diff --git a/geofileops/util/_geoops_ogr.py b/geofileops/util/_geoops_ogr.py
index 4021213..03a3fa9 100644
--- a/geofileops/util/_geoops_ogr.py
+++ b/geofileops/util/_geoops_ogr.py
@@ -147,6 +147,7 @@ def _run_ogr(
         output_path=output_path,
         input_layers=input_layer.name,
         output_layer=output_layer,
+        access_mode=None,
         input_srs=input_srs,
         output_srs=output_srs,
         reproject=reproject,
@@ -155,8 +156,6 @@ def _run_ogr(
         sql_stmt=sql_stmt,
         sql_dialect=sql_dialect,
         transaction_size=transaction_size,
-        append=append,
-        update=update,
         explodecollections=explodecollections,
         force_output_geometrytype=force_output_geometrytype,
         options=options,
diff --git a/geofileops/util/_geoops_sql.py b/geofileops/util/_geoops_sql.py
index cc0c887..a953c66 100644
--- a/geofileops/util/_geoops_sql.py
+++ b/geofileops/util/_geoops_sql.py
@@ -23,7 +23,6 @@ import shapely.geometry.base
 import geofileops as gfo
 from geofileops import GeometryType, LayerInfo, PrimitiveType, fileops
 from geofileops._compat import SPATIALITE_GTE_51
-from geofileops.fileops import _append_to_nolock
 from geofileops.helpers import _general_helper, _parameter_helper
 from geofileops.helpers._configoptions_helper import ConfigOptions
 from geofileops.util import (
@@ -318,11 +317,11 @@ def makevalid(
     batchsize: int = -1,
     force: bool = False,
 ):
-    # If output file exists already, either clean up or return...
+    # If output file already exists, either clean up or return...
     operation_name = "makevalid"
     logger = logging.getLogger(f"geofileops.{operation_name}")
     if not force and output_path.exists():
-        logger.info(f"Stop, output exists already {output_path}")
+        logger.info(f"Stop, output already exists {output_path}")
         return
 
     # Determine output_geometrytype + make it multitype if it wasn't specified.
@@ -411,7 +410,7 @@ def select(
     force: bool = False,
     operation_prefix: str = "",
 ):
-    # Check if output exists already here, to avoid to much logging to be written
+    # Check if output already exists here, to avoid to much logging to be written
     logger = logging.getLogger(f"geofileops.{operation_prefix}select")
     if _io_util.output_exists(path=output_path, remove_if_exists=force):
         return
@@ -574,7 +573,7 @@ def _single_layer_vector_operation(
     if output_layer is None:
         output_layer = gfo.get_default_layer(output_path)
 
-    # If output file exists already, either clean up or return...
+    # If output file already exists, either clean up or return...
     if _io_util.output_exists(path=output_path, remove_if_exists=force):
         return
 
@@ -834,9 +833,10 @@ def _single_layer_vector_operation(
                         where_post = where_post.format(
                             geometrycolumn=info.geometrycolumn
                         )
-                    fileops._append_to_nolock(
+                    fileops.copy_layer(
                         src=tmp_partial_output_path,
                         dst=tmp_output_path,
+                        write_mode="append",
                         explodecollections=explodecollections,
                         force_output_geometrytype=force_output_geometrytype,
                         where=where_post,
@@ -1451,7 +1451,7 @@ def export_by_location(
     logger = logging.getLogger(f"geofileops.{operation_name}")
     if output_path.exists():
         if force is False:
-            logger.info(f"Stop, output exists already {output_path}")
+            logger.info(f"Stop, output already exists {output_path}")
             return
         else:
             gfo.remove(output_path)
@@ -2315,7 +2315,7 @@ def join_nearest(
     # here already
     logger = logging.getLogger("geofileops.join_nearest")
     if output_path.exists() and force is False:
-        logger.info(f"Stop, output exists already {output_path}")
+        logger.info(f"Stop, output already exists {output_path}")
         return
     if input1_layer is None:
         input1_layer = gfo.get_only_layer(input1_path)
@@ -2611,11 +2611,12 @@ def identity(
         # Now append
         logger.info("Step 4 of 4: finalize")
         # Note: append will never create an index on an already existing layer.
-        _append_to_nolock(
+        fileops.copy_layer(
             src=difference_output_path,
             dst=intersection_output_path,
             src_layer=output_layer,
             dst_layer=output_layer,
+            write_mode="append",
         )
 
         # Convert or add spatial index
@@ -2785,11 +2786,12 @@ def symmetric_difference(
         # Now append
         logger.info("Step 4 of 4: finalize")
         # Note: append will never create an index on an already existing layer.
-        _append_to_nolock(
+        fileops.copy_layer(
             src=diff2_output_path,
             dst=diff1_output_path,
             src_layer=output_layer,
             dst_layer=output_layer,
+            write_mode="append",
         )
 
         # Convert or add spatial index
@@ -2943,11 +2945,12 @@ def union(
             input2_subdivided_path=input1_subdivided_path,
         )
         # Note: append will never create an index on an already existing layer.
-        _append_to_nolock(
+        fileops.copy_layer(
             src=diff1_output_path,
             dst=intersection_output_path,
             src_layer=output_layer,
             dst_layer=output_layer,
+            write_mode="append",
         )
         gfo.remove(diff1_output_path)
 
@@ -2977,11 +2980,12 @@ def union(
             input1_subdivided_path=input1_subdivided_path,
             input2_subdivided_path=input2_subdivided_path,
         )
-        _append_to_nolock(
+        fileops.copy_layer(
             src=diff2_output_path,
             dst=intersection_output_path,
             src_layer=output_layer,
             dst_layer=output_layer,
+            write_mode="append",
         )
         gfo.remove(diff2_output_path)
 
@@ -3490,11 +3494,12 @@ def _two_layer_vector_operation(
                         True if nb_batches == 1 and output_with_spatial_index else False
                     )
 
-                    fileops._append_to_nolock(
+                    fileops.copy_layer(
                         src=tmp_partial_output_path,
                         dst=tmp_output_path,
                         src_layer=output_layer,
                         dst_layer=output_layer,
+                        write_mode="append",
                         explodecollections=explodecollections,
                         force_output_geometrytype=force_output_geometrytype,
                         where=where_post,
diff --git a/geofileops/util/_io_util.py b/geofileops/util/_io_util.py
index 9f587a5..d7453ff 100644
--- a/geofileops/util/_io_util.py
+++ b/geofileops/util/_io_util.py
@@ -114,7 +114,7 @@ def create_file_atomic_wait(
 ):
     """Create a file in an atomic way.
 
-    If it exists already, wait till it can be created.
+    If it already exists, wait till it can be created.
 
     Returns once the file is created.
 
@@ -191,7 +191,7 @@ def output_exists(path: Path, remove_if_exists: bool) -> bool:
             gfo.remove(path)
             return False
         else:
-            logging.info(msg=f"Stop, output exists already {path}", stacklevel=2)
+            logging.info(msg=f"Stop, output already exists {path}", stacklevel=2)
             return True
 
     return False
diff --git a/geofileops/util/_ogr_util.py b/geofileops/util/_ogr_util.py
index b1615fc..d245b3e 100644
--- a/geofileops/util/_ogr_util.py
+++ b/geofileops/util/_ogr_util.py
@@ -232,6 +232,7 @@ class VectorTranslateInfo:
         output_path: Path,
         input_layers: Union[list[str], str, None] = None,
         output_layer: Optional[str] = None,
+        access_mode: Optional[str] = None,
         input_srs: Union[int, str, None] = None,
         output_srs: Union[int, str, None] = None,
         reproject: bool = False,
@@ -241,8 +242,6 @@ class VectorTranslateInfo:
         sql_dialect: Optional[Literal["SQLITE", "OGRSQL"]] = None,
         where: Optional[str] = None,
         transaction_size: int = 65536,
-        append: bool = False,
-        update: bool = False,
         explodecollections: bool = False,
         force_output_geometrytype: Union[GeometryType, str, Iterable[str], None] = None,
         options: dict = {},
@@ -255,6 +254,7 @@ class VectorTranslateInfo:
         self.output_path = output_path
         self.input_layers = input_layers
         self.output_layer = output_layer
+        self.access_mode = access_mode
         self.input_srs = input_srs
         self.output_srs = output_srs
         self.reproject = reproject
@@ -264,8 +264,6 @@ class VectorTranslateInfo:
         self.sql_dialect = sql_dialect
         self.where = where
         self.transaction_size = transaction_size
-        self.append = append
-        self.update = update
         self.explodecollections = explodecollections
         self.force_output_geometrytype = force_output_geometrytype
         self.options = options
@@ -281,6 +279,7 @@ def vector_translate_by_info(info: VectorTranslateInfo):
         output_path=info.output_path,
         input_layers=info.input_layers,
         output_layer=info.output_layer,
+        access_mode=info.access_mode,
         input_srs=info.input_srs,
         output_srs=info.output_srs,
         reproject=info.reproject,
@@ -290,8 +289,6 @@ def vector_translate_by_info(info: VectorTranslateInfo):
         sql_dialect=info.sql_dialect,
         where=info.where,
         transaction_size=info.transaction_size,
-        append=info.append,
-        update=info.update,
         explodecollections=info.explodecollections,
         force_output_geometrytype=info.force_output_geometrytype,
         options=info.options,
@@ -307,6 +304,7 @@ def vector_translate(
     output_path: Path,
     input_layers: Union[list[str], str, None] = None,
     output_layer: Optional[str] = None,
+    access_mode: Optional[str] = None,
     input_srs: Union[int, str, None] = None,
     output_srs: Union[int, str, None] = None,
     reproject: bool = False,
@@ -316,8 +314,6 @@ def vector_translate(
     sql_dialect: Optional[Literal["SQLITE", "OGRSQL"]] = None,
     where: Optional[str] = None,
     transaction_size: int = 65536,
-    append: bool = False,
-    update: bool = False,
     explodecollections: bool = False,
     force_output_geometrytype: Union[GeometryType, str, Iterable[str], None] = None,
     options: dict = {},
@@ -407,16 +403,10 @@ def vector_translate(
 
     # Output basic options
     output_exists = output_path.exists()
-    if output_exists:
-        if append:
-            args.append("-append")
-        if update:
-            args.append("-update")
-
     datasetCreationOptions = []
     # Output dataset creation options are only applicable if a new output file
     # will be created
-    if not output_exists or not update:
+    if not output_exists or access_mode is None:
         dataset_creation_options = gdal_options["DATASET_CREATION"]
         if output_info.driver == "SQLite":
             # If SQLite file, use the spatialite type of sqlite by default
@@ -467,7 +457,7 @@ def vector_translate(
     # Output layer creation options are only applicable if a new layer will be
     # created
     layerCreationOptions = []
-    if not output_exists or (update and not append):
+    if not output_exists or (access_mode is None or access_mode == "overwrite"):
         for option_name, value in gdal_options["LAYER_CREATION"].items():
             layerCreationOptions.extend([f"{option_name}={value}"])
 
@@ -574,7 +564,7 @@ def vector_translate(
             options = gdal.VectorTranslateOptions(
                 options=args_copy,
                 format=output_info.driver,
-                accessMode=None,
+                accessMode=access_mode,
                 srcSRS=input_srs,
                 dstSRS=output_srs,
                 reproject=reproject,
diff --git a/setup.py b/setup.py
index 373decf..a06e518 100644
--- a/setup.py
+++ b/setup.py
@@ -24,7 +24,7 @@ setuptools.setup(
     ),
     install_requires=[
         "cloudpickle",
-        "gdal>=3.6,<3.10",
+        "gdal>=3.6,<3.11",
         "geopandas>=0.12,<1.1",
         "numpy",
         "packaging",


Test patch:
diff --git a/.github/workflows/tests_installed.yml b/.github/workflows/tests_installed.yml
index 19d6b0a..624d60d 100644
--- a/.github/workflows/tests_installed.yml
+++ b/.github/workflows/tests_installed.yml
@@ -4,8 +4,7 @@
 name: TestsOnInstalled
 
 on:
-  #pull_request:
-  #  branches: [ main, v0.** ]
+  workflow_dispatch:
   schedule:
     # every Monday to Friday at 5:40 AM
     - cron: "40 5 * * 1-5"
@@ -40,7 +39,7 @@ jobs:
             # nomkl: openblas instead of mkl saves 600 MB. Linux OK, but 50% slower on Windows and OSX!
             install_extra_args: >-
               nomkl
-              geofileops=0.9.1=pyhd8ed1ab_3
+              geofileops=0.9.2
 
     steps:
       - uses: actions/checkout@v4
diff --git a/benchmark/benchmarks/testdata.py b/benchmark/benchmarks/testdata.py
index 1fbd0ac..a2b5a69 100644
--- a/benchmark/benchmarks/testdata.py
+++ b/benchmark/benchmarks/testdata.py
@@ -356,7 +356,7 @@ def _download_samplefile(
         tmp_dir = dst_path.parent / "tmp"
 
         try:
-            # Remove tmp dir if it exists already
+            # Remove tmp dir if it already exists
             if tmp_dir.exists():
                 shutil.rmtree(tmp_dir)
             tmp_dir.mkdir(parents=True, exist_ok=True)
diff --git a/tests/test_geofile.py b/tests/test_geofile.py
index 98096f5..e9a1b53 100644
--- a/tests/test_geofile.py
+++ b/tests/test_geofile.py
@@ -395,12 +395,26 @@ def test_copy_layer_force_output_geometrytype(tmp_path, testfile, force_geometry
     assert len(result_gdf) == 48
 
 
-def test_copy_layer_invalid_params(tmp_path):
+@pytest.mark.parametrize(
+    "kwargs, expected_error",
+    [
+        ({"src": "non_existing_file.gpkg"}, "src file doesn't exist"),
+        ({"write_mode": "invalid"}, "Invalid write_mode"),
+        ({"write_mode": "add_layer"}, "dst_layer is required when write_mode is"),
+        (
+            {"write_mode": "append", "append": True},
+            "append parameter is deprecated, use write_mode",
+        ),
+    ],
+)
+def test_copy_layer_invalid_params(tmp_path, kwargs, expected_error):
     # Convert
-    src = tmp_path / "nonexisting_file.gpkg"
-    dst = tmp_path / "output.gpkg"
-    with pytest.raises(ValueError, match="src file doesn't exist: "):
-        gfo.copy_layer(src, dst)
+    if "src" not in kwargs:
+        kwargs["src"] = test_helper.get_testfile("polygon-parcel")
+    kwargs["dst"] = tmp_path / "output.gpkg"
+
+    with pytest.raises(ValueError, match=expected_error):
+        gfo.copy_layer(**kwargs)
 
 
 def test_copy_layer_input_open_options(tmp_path):
@@ -580,6 +594,55 @@ def test_copy_layer_where(tmp_path, suffix):
     assert dst_layerinfo.featurecount == exp_featurecount
 
 
+def test_copy_layer_write_mode_add_layer(tmp_path):
+    src = test_helper.get_testfile("polygon-parcel")
+    dst = test_helper.get_testfile("polygon-parcel", dst_dir=tmp_path)
+    dst_layer = "parcels_2"
+
+    # Test
+    gfo.copy_layer(src, dst, dst_layer=dst_layer, write_mode="add_layer")
+
+    # Test if number of rows is correct
+    layers = gfo.listlayers(dst)
+    assert len(layers) == 2
+    assert dst_layer in layers
+
+    info = gfo.get_layerinfo(dst, layer=dst_layer)
+    assert info.featurecount == 48
+
+
+def test_copy_layer_write_mode_append(tmp_path):
+    src = test_helper.get_testfile("polygon-parcel")
+    dst = test_helper.get_testfile("polygon-parcel", dst_dir=tmp_path)
+    dst_layer = gfo.get_only_layer(dst)
+
+    # Test
+    gfo.copy_layer(src, dst, dst_layer=dst_layer, write_mode="append")
+
+    # Test if number of rows is correct
+    info = gfo.get_layerinfo(dst)
+    assert info.featurecount == 96
+
+
+@pytest.mark.parametrize("write_mode", [None, "create", "add_layer"])
+@pytest.mark.parametrize("force", [True, False])
+def test_copy_layer_write_mode_force(tmp_path, write_mode, force):
+    """Test if force parameter is properly handled."""
+    src = test_helper.get_testfile("polygon-parcel")
+    dst = test_helper.get_testfile("polygon-parcel", dst_dir=tmp_path)
+    kwargs = {}
+    if write_mode is not None:
+        kwargs["write_mode"] = write_mode
+    dst_layer = gfo.get_only_layer(dst)
+
+    mtime_orig = dst.stat().st_mtime
+    gfo.copy_layer(src, dst, dst_layer=dst_layer, force=force, **kwargs)
+    if force:
+        assert dst.stat().st_mtime > mtime_orig
+    else:
+        assert dst.stat().st_mtime == mtime_orig
+
+
 @pytest.mark.parametrize("suffix", SUFFIXES_FILEOPS)
 def test_copy(tmp_path, suffix):
     src = test_helper.get_testfile("polygon-parcel", suffix=suffix)
@@ -1364,9 +1427,9 @@ def test_spatial_index(tmp_path, suffix):
     has_spatial_index = gfo.has_spatial_index(path=test_path, layer=layer)
     assert has_spatial_index is True
 
-    # Spatial index if it exists already by default gives error
+    # Spatial index if it already exists by default gives error
     with pytest.raises(
-        Exception, match="create_spatial_index error: spatial index exists already"
+        Exception, match="create_spatial_index error: spatial index already exists"
     ):
         gfo.create_spatial_index(path=test_path, layer=layer)
     gfo.create_spatial_index(path=test_path, layer=layer, exist_ok=True)
diff --git a/tests/test_geoops_dissolve.py b/tests/test_geoops_dissolve.py
index b22f3a1..3654d75 100644
--- a/tests/test_geoops_dissolve.py
+++ b/tests/test_geoops_dissolve.py
@@ -10,6 +10,7 @@ import geopandas as gpd
 import pandas as pd
 import pygeoops
 import pytest
+import shapely
 import shapely.geometry as sh_geom
 
 import geofileops as gfo
@@ -281,7 +282,7 @@ def test_dissolve_linestrings_aggcolumns_json(tmp_path, agg_columns):
     [
         (".gpkg", 31370, False, ["GEWASgroep"], True, 0.0, "", 26),
         (".gpkg", 31370, False, "GEWASgroep", True, 0.0, "", 26),
-        (".gpkg", 31370, False, ["GEWASgroep"], True, 0.01, "", 25),
+        (".gpkg", 31370, False, ["GEWASgroep"], True, 0.01, "", 24),
         (".gpkg", 31370, False, ["GEWASGROEP"], False, 0.0, "", 6),
         (".gpkg", 31370, True, ["GEWASGROEP"], False, 0.0, "", 6),
         (".gpkg", 31370, False, ["gewasGROEP"], False, 0.01, WHERE_AREA_GT_5000, 4),
@@ -304,6 +305,9 @@ def test_dissolve_polygons(
     where_post,
     expected_featurecount,
 ):
+    if gridsize > 0.0:
+        pytest.xfail("Geopandas doesn't support dissolve with gridsize yet")
+
     # Prepare test data
     test_path = test_helper.get_testfile("polygon-parcel", suffix=suffix, epsg=epsg)
     if explode_input:
@@ -374,6 +378,10 @@ def test_dissolve_polygons(
 
     # Compare result expected values using geopandas
     columns = ["geometry"]
+    if gridsize > 0.0:
+        input_gdf.geometry = shapely.set_precision(
+            input_gdf.geometry, grid_size=gridsize
+        )
     if groupby_columns is None or len(groupby_columns) == 0:
         expected_gdf = input_gdf[columns].dissolve()
     else:
diff --git a/tests/test_layerstyles.py b/tests/test_layerstyles.py
index 9d1ec80..75e66a6 100644
--- a/tests/test_layerstyles.py
+++ b/tests/test_layerstyles.py
@@ -41,7 +41,7 @@ def test_add_get_remove_layer_styles(tmp_path):
     assert len(layerstyles_df) == 1
 
     # Adding the same style again should give an error
-    with pytest.raises(ValueError, match="layer style exists already: "):
+    with pytest.raises(ValueError, match="layer style already exists: "):
         gfo.add_layerstyle(
             path=test_path, layer="parcels", name="test_style", qml="test_qml"
         )
diff --git a/tests/test_ogr_util.py b/tests/test_ogr_util.py
index 526be69..267682e 100644
--- a/tests/test_ogr_util.py
+++ b/tests/test_ogr_util.py
@@ -359,20 +359,20 @@ def test_vector_translate_sql_invalid_new_output(tmp_path, input_suffix, output_
 @pytest.mark.parametrize("input_suffix", test_helper.SUFFIXES_GEOOPS)
 @pytest.mark.parametrize("output_suffix", test_helper.SUFFIXES_GEOOPS)
 @pytest.mark.parametrize(
-    "update, append, output_file_expected",
+    "access_mode, output_file_expected",
     [
-        (False, False, False),
-        (True, False, True),
-        (False, True, True),
-        (True, True, True),
+        (None, False),
+        ("update", True),
+        ("append", True),
+        ("append", True),
     ],
 )
 def test_vector_translate_sql_invalid_existing_output(
-    tmp_path, input_suffix, output_suffix, update, append, output_file_expected
+    tmp_path, input_suffix, output_suffix, access_mode, output_file_expected
 ):
     """Run an invalid query that tries to add a layer to an existing file (update).
 
-    If append and update are both False, the existing output file will be overwritten.
+    If access_mode is None, the existing output file will be overwritten.
     In this case, if the sql query is invalid, the output file will effectively be
     removed.
     """
@@ -386,7 +386,7 @@ def test_vector_translate_sql_invalid_existing_output(
     sql_stmt = f'SELECT * FROM "{layer}" WHERE not_existing_column = 1'
     try:
         _ogr_util.vector_translate(
-            input_path, output_path, sql_stmt=sql_stmt, update=update, append=append
+            input_path, output_path, sql_stmt=sql_stmt, access_mode=access_mode
         )
     except Exception:
         pass