|
| 1 | +# Tools for Distributing GeoParquet |
| 2 | + |
| 3 | +This page shows how to produce 'good' GeoParquet with common tools, applying the recommendations in |
| 4 | +[Best Practices for Distributing GeoParquet](distributing-geoparquet.md). See that guide for the reasoning behind these |
| 5 | +recommendations — compression, spatial ordering, row group size, spatial partitioning, native geometry types, and so on. |
| 6 | + |
| 7 | +## Examples in common tools |
| 8 | + |
| 9 | +This section discusses what each tool does by default, and shows the additional options needed to follow the |
| 10 | +[distribution recommendations](distributing-geoparquet.md), including spatial partitioning and STAC metadata where the tool |
| 11 | +supports them. |
| 12 | + |
| 13 | +### GDAL/OGR |
| 14 | + |
| 15 | +Out of the box: |
| 16 | + |
| 17 | +```bash |
| 18 | +ogr2ogr out.parquet in.geojson |
| 19 | +``` |
| 20 | + |
| 21 | +Out of the box GDAL/OGR defaults to snappy compression, with max row group size of 65536. |
| 22 | +Version 3.9 and later will write out the `bbox` column by default, producing GeoParquet 1.1. There is a built-in |
| 23 | +option (`SORT_BY_BBOX=YES`) to spatially order the data that works by creating a temporary GeoPackage file and |
| 24 | +using its r-tree spatial index. It defaults to false since it can be an intensive operation, |
| 25 | +and GDAL is usually translating from formats that already have spatial indexes. |
| 26 | + |
| 27 | +#### GDAL/OGR with recommended settings |
| 28 | + |
| 29 | +These examples are done with the `ogr2ogr` command-line tool, but the layer creation options |
| 30 | +will be the same calling from C or Python. |
| 31 | + |
| 32 | +You can control the compression and the max row group size, and the following command is sufficient |
| 33 | +if your source data is already spatially ordered in a file format with a spatial index (like FlatGeobuf or GeoPackage): |
| 34 | + |
| 35 | +```bash |
| 36 | +ogr2ogr out.parquet -lco "COMPRESSION=ZSTD" -lco "MAX_ROW_GROUP_SIZE=100000" in.fgb |
| 37 | +``` |
| 38 | + |
| 39 | +GDAL 3.12 and above introduces `COMPRESSION_LEVEL` as a [Parquet layer creation option](https://gdal.org/en/latest/drivers/vector/parquet.html#layer-creation-options) and a new |
| 40 | +[CLI](https://gdal.org/en/latest/programs/index.html#general), which is used here. |
| 41 | + |
| 42 | +```bash |
| 43 | +gdal vector convert vegetation.fgb vegetation.parquet --lco compression=zstd --lco compression_level=15 |
| 44 | +``` |
| 45 | + |
| 46 | +If you want to be sure that the output is spatially ordered then you can add `SORT_BY_BBOX=YES`, like in the following example: |
| 47 | + |
| 48 | +```bash |
| 49 | +ogr2ogr out.parquet -lco SORT_BY_BBOX=YES -lco "COMPRESSION=ZSTD" in.geojson |
| 50 | +``` |
| 51 | + |
| 52 | +This operation writes the data to a GeoPackage as an interim step, so it can take additional storage and computation, especially |
| 53 | +with large files, so it's not enabled by default. |
| 54 | + |
| 55 | +#### Writing native geometry types |
| 56 | + |
| 57 | +As of this writing GDAL does not yet write GeoParquet 2.0 metadata: by default it produces GeoParquet 1.1 with a `bbox` |
| 58 | +covering column. GDAL 3.12 and above (built against libarrow 21 or later) does, however, let you write the native Parquet |
| 59 | +`GEOMETRY`/`GEOGRAPHY` logical types via the `USE_PARQUET_GEO_TYPES` layer creation option, which takes `NO` (the default), |
| 60 | +`YES`, or `ONLY`: |
| 61 | + |
| 62 | +* `YES` adds the native geometry logical types **but still** writes GeoParquet 1.1 metadata and the redundant `bbox` covering |
| 63 | + column, so the file is larger than it needs to be and still advertises itself as 1.1. |
| 64 | +* `ONLY` writes **only** the native geometry types — no `bbox` column and no `geo` metadata block. The native column carries the |
| 65 | + Parquet geospatial statistics (the per–row-group bounding box) that give efficient spatial access, and the CRS is written as |
| 66 | + PROJJSON on the logical type's `crs` property. |
| 67 | + |
| 68 | +Until GDAL can emit GeoParquet 2.0 directly, `USE_PARQUET_GEO_TYPES=ONLY` is the closest you can get: it produces the native, |
| 69 | +statistics-bearing geometry column that GeoParquet 2.0 is built on, and any GeoParquet 2.0 reader can read it. |
| 70 | + |
| 71 | +```bash |
| 72 | +ogr2ogr out.parquet -lco USE_PARQUET_GEO_TYPES=ONLY -lco "COMPRESSION=ZSTD" -lco "MAX_ROW_GROUP_SIZE=100000" in.fgb |
| 73 | +``` |
| 74 | + |
| 75 | +> [!NOTE] |
| 76 | +> A file written with `ONLY` is **not conformant GeoParquet 2.0**, because it has no `geo` metadata block (no `version`, and the |
| 77 | +> CRS is only on the native Parquet type rather than restated as PROJJSON in the `geo` metadata). It is plain Parquet with native |
| 78 | +> geospatial types — readable by GeoParquet 2.0 readers, but not a self-described GeoParquet file. Switch to a dedicated 2.0 mode |
| 79 | +> once GDAL adds one. |
| 80 | +
|
| 81 | +#### Spatial partitioning |
| 82 | + |
| 83 | +GDAL is a flexible tool that can split a dataset into multiple files with `gdal vector partition`. It partitions on the values of |
| 84 | +one or more fields (`--field`), writing a `hive` or `flat` directory layout, and can bound the output with `--max-file-size` or |
| 85 | +`--feature-limit`: |
| 86 | + |
| 87 | +```bash |
| 88 | +gdal vector partition in.parquet out_dir --field region --max-file-size 1GB |
| 89 | +``` |
| 90 | + |
| 91 | +GDAL does not compute a spatial partitioning scheme on its own, but you can partition spatially by first adding a column that |
| 92 | +encodes a spatial grouping — an admin region, geohash, or grid cell — and partitioning on that field. `gdal vector sort` can |
| 93 | +spatially order the features beforehand so each partition stays compact. |
| 94 | + |
| 95 | +GDAL does not write STAC metadata. |
| 96 | + |
| 97 | +### DuckDB |
| 98 | + |
| 99 | +Out of the box: |
| 100 | + |
| 101 | +```sql |
| 102 | +COPY (SELECT * FROM geo_table) TO 'out.parquet' (FORMAT 'parquet'); |
| 103 | +``` |
| 104 | + |
| 105 | +In DuckDB 1.5 the `GEOMETRY` type and GeoParquet reading and writing are part of core DuckDB — you do **not** need the |
| 106 | +[spatial extension](https://duckdb.org/docs/stable/core_extensions/spatial/overview.html) just to read a GeoParquet file, |
| 107 | +write one, or convert/recompress/repartition it. CRS information is carried through a read/write round-trip in core as well. |
| 108 | +DuckDB automatically writes GeoParquet metadata for any output containing a geometry column. The default compression is snappy, |
| 109 | +the max row group size is 122,880, by default it writes GeoParquet 1.0.0, and the data is not spatially ordered. |
| 110 | + |
| 111 | +You can choose the GeoParquet version written with the `GEOPARQUET_VERSION` copy option. Pass `GEOPARQUET_VERSION 'V2'` to write |
| 112 | +GeoParquet 2.0: the geometry column is stored using the native Parquet `GEOMETRY`/`GEOGRAPHY` logical types (with the geospatial |
| 113 | +statistics that give efficient spatial access), the CRS is written as PROJJSON, and no `bbox` covering column is added. |
| 114 | + |
| 115 | +```sql |
| 116 | +COPY (SELECT * FROM geo_table) TO 'out.parquet' (FORMAT 'parquet', GEOPARQUET_VERSION 'V2'); |
| 117 | +``` |
| 118 | + |
| 119 | +The spatial *functions* are not in core, however — anything using an `ST_*` function needs `LOAD spatial` first. That includes |
| 120 | +reprojection (`ST_Transform`) and, importantly for distribution, spatially ordering your data (`ST_Hilbert`, shown below). So in |
| 121 | +practice you will still load the extension whenever you spatially order or reproject, even though the GeoParquet writer itself |
| 122 | +does not require it. |
| 123 | + |
| 124 | +#### DuckDB with recommended settings |
| 125 | + |
| 126 | +You can control the [compression](https://duckdb.org/docs/sql/statements/copy.html#parquet-options), compression level and [row group size](https://duckdb.org/docs/data/parquet/tips.html#selecting-a-row_group_size), and write GeoParquet 2.0 with `GEOPARQUET_VERSION 'V2'`: |
| 127 | + |
| 128 | +```sql |
| 129 | +COPY (SELECT * FROM geo_table) TO 'out.parquet' (FORMAT 'parquet', GEOPARQUET_VERSION 'V2', COMPRESSION 'zstd', COMPRESSION_LEVEL 15, ROW_GROUP_SIZE '100000'); |
| 130 | +``` |
| 131 | + |
| 132 | +Interestingly you can also set the row group size in bytes, which would likely be a better way to handle geospatial data since the |
| 133 | +row size can vary so much. |
| 134 | + |
| 135 | +```sql |
| 136 | +COPY (SELECT * FROM geo_table) TO 'out.parquet' (FORMAT 'parquet', GEOPARQUET_VERSION 'V2', COMPRESSION 'zstd', ROW_GROUP_SIZE_BYTES '128mb'); |
| 137 | +``` |
| 138 | + |
| 139 | +The `ROW_GROUP_SIZE_BYTES` option may only be used when [`SET preserve_insertion_order = false;`](https://duckdb.org/docs/stable/guides/performance/how_to_tune_workloads#the-preserve_insertion_order-option) is enabled, which can help when working with large files, but it's not |
| 140 | +clear if it can preserve spatial ordering. |
| 141 | + |
| 142 | +DuckDB also has functionality to spatially order your data, with the [`ST_Hilbert`](https://duckdb.org/docs/extensions/spatial/functions#st_hilbert) |
| 143 | +function. Because this uses `ST_*` functions you need to `LOAD spatial` first. It is strongly recommended to pass in the bounds of |
| 144 | +your entire dataset to the function call or the hilbert curve won't be built right. The following call will dynamically get the |
| 145 | +bounds of your dataset, pass that into the `ST_Hilbert` function, and write the result as GeoParquet 2.0. |
| 146 | + |
| 147 | +```sql |
| 148 | +LOAD spatial; |
| 149 | +COPY ( |
| 150 | + WITH bbox AS ( |
| 151 | + SELECT ST_Extent(ST_Extent_Agg(geometry))::BOX_2D AS b |
| 152 | + FROM geo_table |
| 153 | + ) |
| 154 | + SELECT t.* |
| 155 | + FROM geo_table AS t |
| 156 | + CROSS JOIN bbox |
| 157 | + ORDER BY ST_Hilbert(t.geometry, bbox.b) |
| 158 | +) TO 'out.parquet' (FORMAT 'parquet', GEOPARQUET_VERSION 'V2', COMPRESSION 'zstd', ROW_GROUP_SIZE '100000'); |
| 159 | +``` |
| 160 | + |
| 161 | +DuckDB 1.5 and later preserves CRS information when you read GeoParquet in and write it back out. Earlier versions dropped the |
| 162 | +CRS metadata on write, so if you are on an older DuckDB you may need to add the CRS back in with tools like GDAL or QGIS. |
| 163 | + |
| 164 | +#### Spatial partitioning |
| 165 | + |
| 166 | +DuckDB can write a hive-partitioned dataset with `COPY ... PARTITION_BY`. To partition *spatially*, compute a spatial grid cell |
| 167 | +for each row and partition on it. The [a5](https://github.com/Query-farm/a5) DuckDB community extension provides a global, |
| 168 | +equal-area cell grid that works well for this: |
| 169 | + |
| 170 | +```sql |
| 171 | +INSTALL a5 FROM community; LOAD a5; |
| 172 | +INSTALL spatial; LOAD spatial; |
| 173 | +COPY ( |
| 174 | + SELECT *, a5_u64_to_hex(a5_lonlat_to_cell(ST_X(geometry), ST_Y(geometry), 3)) AS a5_cell |
| 175 | + FROM geo_table |
| 176 | +) TO 'partitioned' (FORMAT 'parquet', PARTITION_BY a5_cell, GEOPARQUET_VERSION 'V2', COMPRESSION 'zstd'); |
| 177 | +``` |
| 178 | + |
| 179 | +Choose the a5 resolution to target a reasonable number of features per partition for your data. For non-point geometries, derive |
| 180 | +the cell from a representative point such as `ST_Centroid(geometry)`. You can also sort within each partition by `ST_Hilbert` for |
| 181 | +tighter row-group bounds. For other approaches, see [this gist using a KD-tree](https://gist.github.com/jwass/8e9b6c16902a05ae66b9688f1a5bb4ff) |
| 182 | +and [this blog post](https://dewey.dunnington.ca/post/2024/partitioning-strategies-for-bigger-than-memory-spatial-data/) that |
| 183 | +discusses the KD-tree along with other options (r-tree, s2 cells). |
| 184 | + |
| 185 | +DuckDB does not write STAC metadata. |
| 186 | + |
| 187 | +### geoparquet-io |
| 188 | + |
| 189 | +[geoparquet-io](https://geoparquet.io) is a command-line tool, built on DuckDB, that is designed to apply the |
| 190 | +[distribution recommendations](distributing-geoparquet.md) by default — it exists specifically to make 'good' GeoParquet without |
| 191 | +having to remember all the options. It produces fully compliant GeoParquet that follows every recommendation in that guide; the |
| 192 | +one area still being finalized is 2.0 output, so by default it writes GeoParquet 1.1. Install it from PyPI (the package is |
| 193 | +`geoparquet-io`): |
| 194 | + |
| 195 | +```bash |
| 196 | +pipx install geoparquet-io # or: pip install geoparquet-io |
| 197 | +``` |
| 198 | + |
| 199 | +A plain conversion applies ZSTD compression at level 15, Hilbert spatial ordering, a `bbox` covering column, and 100,000-row |
| 200 | +row groups, then validates the result: |
| 201 | + |
| 202 | +```bash |
| 203 | +gpio convert geoparquet input.gpkg output.parquet |
| 204 | +``` |
| 205 | + |
| 206 | +It defaults to writing GeoParquet 1.1 (it auto-detects from the input, preserving the input's version and upgrading native geo |
| 207 | +types to 2.0). Pass `--geoparquet-version 2.0` to write GeoParquet 2.0, which stores the geometry in the native Parquet types |
| 208 | +with geospatial statistics and omits the `bbox` column: |
| 209 | + |
| 210 | +```bash |
| 211 | +gpio convert geoparquet input.gpkg output.parquet --geoparquet-version 2.0 |
| 212 | +``` |
| 213 | + |
| 214 | +#### Spatial partitioning |
| 215 | + |
| 216 | +gpio partitions large datasets with `gpio partition`, which supports KD-tree, quadkey, S2, H3, A5, and |
| 217 | +[admin](https://medium.com/radiant-earth-insights/the-admin-partitioned-geoparquet-distribution-59f0ca1c6d96) schemes. The |
| 218 | +KD-tree scheme auto-selects a partition count targeting ~120,000 rows per file, and adds the partition column for you if it is |
| 219 | +missing: |
| 220 | + |
| 221 | +```bash |
| 222 | +gpio partition kdtree input.parquet output/ |
| 223 | +gpio partition kdtree input.parquet output/ --partitions 32 |
| 224 | +``` |
| 225 | + |
| 226 | +The `gpio add` commands can also add just the partitioning column (for example `gpio add h3` or `gpio add admin-divisions`) if |
| 227 | +you want to partition or sort on it yourself. |
| 228 | + |
| 229 | +#### STAC metadata |
| 230 | + |
| 231 | +gpio generates STAC with `gpio publish stac`. A single file produces a STAC Item; a partitioned directory produces a STAC |
| 232 | +Collection plus per-file Items written alongside the data, following STAC best practices: |
| 233 | + |
| 234 | +```bash |
| 235 | +# Single file -> STAC Item |
| 236 | +gpio publish stac input.parquet item.json --bucket s3://my-bucket/roads/ |
| 237 | + |
| 238 | +# Partitioned dataset -> Collection + per-file Items |
| 239 | +gpio publish stac partitions/ . --bucket s3://my-bucket/roads/ |
| 240 | +``` |
| 241 | + |
| 242 | +You can upload the data (and its STAC) to object storage with `gpio publish upload`, and validate any file with `gpio check all`. |
| 243 | + |
| 244 | +### Sedona |
| 245 | + |
| 246 | +[Apache Sedona](https://sedona.apache.org/) is one of the most 'out of the box' options for spatially partitioning large |
| 247 | +datasets, using its [Spatial RDDs](https://sedona.apache.org/latest/tutorial/rdd/). The following code writes out partitions by |
| 248 | +KD-tree: |
| 249 | + |
| 250 | +```python |
| 251 | +import glob |
| 252 | + |
| 253 | +from sedona.spark import SedonaContext, GridType |
| 254 | +from sedona.utils.structured_adapter import StructuredAdapter |
| 255 | +from sedona.sql.st_functions import ST_GeoHash |
| 256 | + |
| 257 | +# Configuring this line to do the right thing can be tricky |
| 258 | +# https://sedona.apache.org/latest/setup/install-python/?h=python#prepare-sedona-spark-jar |
| 259 | +config = ( |
| 260 | + SedonaContext.builder() |
| 261 | + .config("spark.executor.memory", "6G") |
| 262 | + .config("spark.driver.memory", "6G") |
| 263 | + .getOrCreate() |
| 264 | +) |
| 265 | + |
| 266 | +sedona = SedonaContext.create(config) |
| 267 | + |
| 268 | +# Read from GeoParquet or some other datasource + do any spatial ops/transformations |
| 269 | +# using Sedona pyspark or SQL |
| 270 | +df = sedona.read.format("geoparquet").load( |
| 271 | + "/Users/dewey/gh/geoarrow-data/microsoft-buildings/files/microsoft-buildings_point_geo.parquet" |
| 272 | +) |
| 273 | + |
| 274 | +# Create the partitioning. KDBTREE provides a nice balance providing |
| 275 | +# tight (but well-separated) partitions with approximately equal numbers of |
| 276 | +# features in each file. Note that num_partitions is only a suggestion |
| 277 | +# (actual value may differ) |
| 278 | +rdd = StructuredAdapter.toSpatialRdd(df, "geometry") |
| 279 | +rdd.analyze() |
| 280 | + |
| 281 | +# UseWithoutDuplicates() variant to ensure that we don't introduce |
| 282 | +# duplicate features |
| 283 | +rdd.spatialPartitioningWithoutDuplicates(GridType.KDBTREE, num_partitions=8) |
| 284 | +rdd.getPartitioner().getGrids() |
| 285 | +df_partitioned = StructuredAdapter.toSpatialPartitionedDf(rdd, sedona) |
| 286 | + |
| 287 | +# Optional: sort within partitions for tighter rowgroup bounding boxes within files |
| 288 | +df_partitioned = ( |
| 289 | + df_partitioned.withColumn("geohash", ST_GeoHash(df_partitioned.geometry, 12)) |
| 290 | + .sortWithinPartitions("geohash") |
| 291 | + .drop("geohash") |
| 292 | +) |
| 293 | + |
| 294 | +# Write in parallel directly from each executor node. |
| 295 | +# There are several options for geoparquet writing: |
| 296 | +# https://sedona.apache.org/latest/tutorial/files/geoparquet-sedona-spark/ |
| 297 | +df_partitioned.write.format("geoparquet").mode("overwrite").option("compression", "zstd").save( |
| 298 | + "buildings_partitioned" |
| 299 | +) |
| 300 | + |
| 301 | +# The output files have funny names because Spark writes them this way |
| 302 | +files = glob.glob("buildings_partitioned/*.parquet") |
| 303 | +len(files) |
| 304 | +``` |
| 305 | + |
| 306 | +Only spatial partitioning is documented here for now. Sedona can do much more for producing distribution-ready GeoParquet |
| 307 | +(compression, row group size, GeoParquet version, etc.) — documenting those settings still needs a PR, and contributions are |
| 308 | +very welcome. |
| 309 | + |
| 310 | +### Additional Tools |
| 311 | + |
| 312 | +We hope to get more discussion of additional tools that follow the same format as the ones above, especially GPQ, |
| 313 | +GeoPandas, QGIS and Esri. But we'll aim to add those later as their own PR's - contributions are very welcome. |
0 commit comments