Skip to content
CityParquet is v0.1.0-draft — the specification is still changing.
Esc
↑↓navigate↵open⌘Jpreview
On this page

Metadata

The city and geo Parquet key-value metadata objects, and the STAC Item.

CityParquet carries two layers of metadata: the Parquet footer key-value metadata (authoritative for decoding a single file) and the package-level metadata.json STAC Item (the discovery mirror). The footer holds two JSON objects — city and, for interoperability, geo.

Parquet key-value metadata

Two keys live in the footer:

  • city — CityParquet’s own metadata object: version, provenance, CRS, the geometry-column registry, the attribute list, and extensions. A CityParquet reader uses this; it is authoritative.
  • geo — a GeoParquet object, so GeoParquet-aware tools recognise the geometry columns they can read. Conditional: omitted when no column qualifies.

Every column geo declares MUST also carry the Parquet GEOMETRY logical type annotating its BYTE_ARRAY, which is what GeoParquet 2.0 requires of a geometry column and what makes a Parquet writer accumulate GeospatialStatistics for it. Columns geo withholds carry no annotation. This is the declaration rule applied at a second level, not a second rule: annotated if and only if declared, so the two can never disagree about which columns a GeoParquet reader is invited into.

Why two keys, not one. The city object registers every geometry column, including Solid-family ones. GeoParquet’s geo object cannot — declaring a PolyhedralSurfaceZ column there risks making the whole file unreadable to GeoParquet tools (see the declaration rule). So geo carries only the GeoParquet-legal subset for interop, while city carries the full picture. The two overlap on the legal columns; the duplication is deliberate and small.

Both objects are JSON, so there is no value-encoding ambiguity. Where the footer and the STAC Item disagree, the footer is authoritative for decoding.

The footer describes the file it lives in — nothing wider. city/geo are per-Parquet-file metadata: a dataset’s building.parquet and transportation.parquet each carry their own footer, and — because each module’s table carries only the LoD/appearance columns its own rows need — two files’ columns/geometry_types legitimately differ, without that being an inconsistency to reconcile. The metadata.json STAC Item is the dataset-level view: it aggregates across every file in the package (e.g. city3d:lods is the union over all tables), where the footer answers only for the one file a reader has open.

The city object

Field Req. Description
version ✔ CityParquet format version, e.g. "0.1.0-draft".
source_format Provenance — one of CityJSON, CityJSONSeq, CityGML, or an open-ended other-source token (e.g. "3DCityDB") for a source this document doesn’t enumerate. Optional: a table may be authored natively, with no single source, and omits this field entirely — omission, not an "Other" placeholder value, is how “no source” is expressed.
source_version Source schema version (e.g. CityJSON 2.0), when there is a source.
crs cond. The file CRS, following GeoParquet’s convention for the key itself. Three states: a PROJJSON object when the CRS is known; an explicit null when the file holds CRS-bearing coordinates whose CRS is unknown or unresolvable (“no CRS / unknown”, exactly GeoParquet’s meaning for a null crs); absent only when the file holds no CRS-bearing coordinate at all (e.g. an implicit_geometries.parquet sidecar whose relative geometries are stored in purely local, unplaced coordinates) — per GeoParquet an absent key is read as OGC, so a conforming writer never relies on the default. CRS-bearing coordinates are object geometry, an address location, a bbox, and an implicit geometry’s reference point. When present as an object, it is the one CRS every geometry and the bbox column share.
primary_column cond. Name of the primary geometry column — the one a reader uses with no LoD preference. Required whenever the table has any geometry column. It MAY name a Solid column; it is independent of geo.primary_column (see the note under geo). A consumer MUST read this rather than assume a column name.
columns cond. A list of column-metadata structs, one per geometry column — every geometry column, Solid-family included. See below. Present whenever the table has any geometry column.
attributes cond. The list of inferred attribute columns (object tables). Every other column is a reserved structural column.
extensions cond. The extensions the file uses: a JSON object keyed by each extension’s namespace, each value referencing the extension’s schema document (name, url, optional version, xmlns, xmlAttributes, the extra* members naming what it adds, and an ADE’s substitutionGroups, containers and propertyOrder). Required whenever the file carries any extension name (a prefixed attribute column, object_type value or semantic surface type); consulted to identify the extension behind a prefix and to restore the source names on export (see extensions).
appearance_defaults Source default material / texture theme, when present.
other Free-form object for anything not covered above — the source transform, the source’s own dataset-level metadata header, or any producer-defined field. Informational only: a reader MUST NOT need other to decode the file. Mirrors the object-table other column.

Requirements depend on the file’s role. The marks above are for an object table. A sidecar (materials.parquet, textures.parquet, implicit_geometries.parquet) carries city.version plus only what is meaningful to it (crs when it has CRS-bearing coordinates, extensions when it carries extension names), and none of source_format, attributes, primary_column or columns.

The bbox column is always named bbox, so a reader checks the schema for it; a reader finds the package’s files by listing the directory or reading the STAC Item’s assets, which already signal whether semantics, textures and materials are present.

city.columns entries

Each entry describes one geometry column. Unlike geo, this list covers all geometry columns, so it is where a Solid column’s type and winding are recorded.

Field Req. Value
name ✔ The column name (geometry_lod0_0, geometry_lod2_2, …).
encoding ✔ "WKB" — the only encoding CityParquet defines, and the only value GeoParquet 2.0 permits. The key is written explicitly rather than assumed, so a reader can reject a token it does not understand instead of guessing the encoding from the column’s physical type.
geometry_types ✔ The WKB type names present in the column, with the " Z" (3D) suffix — e.g. ["MultiPolygon Z"], ["PolyhedralSurface Z"], ["GeometryCollection Z"]. May name Solid-family types (which geo may not). This vocabulary names the logical shape category, never a CityJSON type name: a MultiSurface column reads "MultiPolygon Z" here, never "Solid".
crs opt. PROJJSON; defaults to the file-level city.crs.
orientation_3d ✔ 3D surface winding: "right-handed" (each exterior ring wound so the right-hand rule gives the outward-facing normal, interior rings reversed) or "left-handed". A writer MUST state it explicitly on every entry — including the common "right-handed" case — rather than relying on an absent-field default; a value absent from an older file (written before this field was required) is still read as "right-handed", but a conforming writer never relies on that fallback itself. This replaces GeoParquet’s planar orientation, which cannot express 3D winding.
edges opt. "planar" — CityParquet coordinates are Cartesian in a projected CRS.
bbox opt. Dataset-level bounding box of the column’s geometries.
epoch opt. Coordinate epoch (decimal year) for a dynamic CRS, when relevant.

The geo object — GeoParquet compatibility

CityParquet also embeds a geo object so GeoParquet-aware tools recognise the geometry columns they can read (see files, CRS & metadata). It is a pure GeoParquet object — no CityParquet extension fields (3D winding lives in city.columns[].orientation_3d). It carries version, primary_column, and a columns map, restricted to the GeoParquet-legal columns.

geo.version reports 1.1.0, and the declared columns carry the GEOMETRY logical type as well. That pairing is deliberate: the version string is a single value and cannot claim both specifications, so a file states the older one in metadata while satisfying the newer one structurally. A GeoParquet 1.1 reader finds exactly the object it expects; a 2.0 reader finds the native annotation it requires and need not consult geo at all.

Field Req. CityParquet value
version ✔ GeoParquet spec version implemented, e.g. "1.1.0".
primary_column ✔ The geometry column a GeoParquet reader uses by default; MUST be one of the declared (legal) columns. Independent of city.primary_column — see the note below.
columns ✔ Map from a legal geometry column name to a GeoParquet column-metadata object (encoding, geometry_types, crs, edges, bbox, epoch). One entry per GeoParquet-legal column.

CRS rules

  • A known CRS MUST be encoded as PROJJSON. A source identifier (an EPSG code, or an OGC CRS URL such as https://www.opengis.net/def/crs/EPSG/0/7415) MUST be resolved to PROJJSON at import.
  • The GEOMETRY logical type’s own crs parameter carries that same PROJJSON, inline. Parquet permits four spellings — inline PROJJSON, <authority>:<code>, srid:<id>, projjson:<key> — and GeoParquet 2.0 narrows them to the first two, recommending the authority code only for writers that cannot readily produce PROJJSON. CityParquet always can, because geo requires inline PROJJSON regardless. Inline is also the only spelling that survives the write unchanged: CRS-aware tooling resolves an authority code to PROJJSON when it is present and leaves it verbatim when it is not, so a package written with an authority code would differ byte for byte depending on which components the writing session happened to load. A writer MUST write the annotation’s crs as inline PROJJSON whenever it writes one, and an annotated column’s crs MUST agree with city.crs.
  • city.crs is the one CRS every geometry column and the bbox column share; a per-column crs (in city.columns or geo.columns) is optional, and when present it mirrors the file-level state — including a null. An absent per-column crs defaults to the file-level city.crs (a sibling key in the same footer, so no CRS84 ambiguity arises). There is no per-row or per-column CRS escape hatch.
  • The key is tri-state, exactly as in GeoParquet: object, null, or absent. Per GeoParquet, an absent crs is assumed to be OGC (WGS 84 lon/lat), and an explicit null means the CRS is unknown or unspecified. CityParquet adopts both readings for city.crs, so the city and geo objects carry one shared convention. The CRS84 default speaks to interpreting coordinates: in a conforming file the key is absent only when there are no CRS-bearing coordinates, so the default has nothing to apply to and such a file exports with no reference system. Only a foreign file that carries coordinates and an absent key — conforming GeoParquet, nonconforming CityParquet — is read as CRS84.
  • A writer never relies on the absent-CRS default. Because city models are almost always in a projected national CRS (e.g. EPSG / EPSG), a file whose coordinates really were CRS84 would still declare it explicitly: whenever the file holds any CRS-bearing coordinate, a writer MUST write the key — as a PROJJSON object or as null — never omit it. Absence is legitimate only for files with no CRS-bearing coordinates at all.
  • An unresolvable CRS is declared, not fatal. A source that carries CRS-bearing coordinates (geometry, bbox, address location, implicit-geometry reference point) but declares no CRS the writer can resolve to PROJJSON is encoded with an explicit city.crs: null. A writer MUST NOT guess a CRS and MUST NOT omit the key in this situation (absence would assert CRS84, silently mis-georeferencing the data), and SHOULD surface a conversion diagnostic. A reader treats such a file’s coordinates as bare Cartesian values with no georeference; on export the reconstructed model carries no reference system — matching the source. Any geo mirror uses the same null, which is GeoParquet-legal. (This amends an earlier draft in which an unresolvable CRS was a hard conversion error.)

Example

The footer of the building.parquet of the Delft package (https://cityparquet.open3d.city/data/delft/building.parquet), read with DuckDB’s parquet_kv_metadata and abridged. city registers all four geometry columns, including the PolyhedralSurface Z solids; geo declares only the footprint, the one GeoParquet-legal column, and makes it the GeoParquet primary while city names the LoD 2.2 solid.

// key "city"
{
  "version": "0.1.0-draft",
  "source_format": "CityJSONSeq",
  "source_version": "2.0",
  "crs": { "type": "CompoundCRS",
           "name": "Amersfoort / RD New + NAP height", /* … */ },
  "primary_column": "geometry_lod2_2",
  "columns": [
    { "name": "geometry_lod0_0", "encoding": "WKB",
      "geometry_types": ["MultiPolygon Z"], "orientation_3d": "right-handed" },
    { "name": "geometry_lod1_2", "encoding": "WKB",
      "geometry_types": ["PolyhedralSurface Z"], "orientation_3d": "right-handed" },
    { "name": "geometry_lod1_3", "encoding": "WKB",
      "geometry_types": ["PolyhedralSurface Z"], "orientation_3d": "right-handed" },
    { "name": "geometry_lod2_2", "encoding": "WKB",
      "geometry_types": ["PolyhedralSurface Z"], "orientation_3d": "right-handed" }
  ],
  "attributes": ["b3_bag_bag_overlap", "b3_bouwlagen", "b3_dak_type", /* … */],
  "other": {
    "transform": { "scale": [0.001, 0.001, 0.001],
                   "translate": [85088.390625, 446394.25, 45.64800262451172] },
    "source_metadata": { "title": "3DBAG",
                         "referenceSystem": "https://www.opengis.net/def/crs/EPSG/0/7415",
                         /* … */ }
  }
}

// key "geo"
{
  "version": "1.1.0",
  "primary_column": "geometry_lod0_0",
  "columns": {
    "geometry_lod0_0": { "encoding": "WKB", "geometry_types": ["MultiPolygon Z"],
                         "crs": { /* the same PROJJSON */ }, "edges": "planar" }
  }
}

The source’s quantisation transform and its metadata header survive in city.other, informationally: the coordinates are already world doubles, so decoding needs neither.

metadata.json — STAC Item

The package-level metadata.json MUST be a valid STAC Item describing the dataset, and MUST use the 3D city models STAC extension (city3d:*) — cityjson/stac-city3d. This makes CityParquet packages discoverable through the same STAC layer used elsewhere in the project.

  • Populate city3d: fields from the source where available: city3d:version, city3d:lods, city3d:co_types, city3d:city_objects, city3d:semantic_surfaces, city3d:textures, city3d:materials, city3d:attributes. These already signal whether semantics, textures and materials are present — which is why the footer needs no sidecar manifest. city3d:co_types uses the source type vocabulary — including CityJSON’s + prefix on extension types — not object_type’s CityGML-class form with its namespace-prefixed extension types; the STAC Item is a discovery mirror of the source model’s type inventory, independent of how object_type/ModuleKey internally route rows to files.
  • city3d:lods is the opposite case: it carries the normalised LoD spelling, as produced by the LoD suffix grammar — a source LoD of "0" appears as "0.0", matching the geometry_lod0_0 column it names. Unlike co_types, the source spelling is not recoverable: a CityParquet package records an LoD only in a column name, so there is no second place the original string survives. Readers MUST NOT expect city3d:lods to reproduce the source file’s LoD strings verbatim.
  • Each .parquet file SHOULD be a STAC Asset; the Item’s assets map is the package’s file inventory. The Item SHOULD also use the standard Projection (CRS) and File (checksums/sizes) extensions.
  • Asset roles carry the package’s file inventory. Every .parquet asset MUST declare its role in the package: cityparquet-objects for an object table, cityparquet-sidecar for a sidecar (materials.parquet, textures.parquet, implicit_geometries.parquet), each alongside the conventional STAC data role. This is how a reader separates object tables from sidecars without parsing filenames — which matters because the by-module layout means an object table’s name is drawn from the CityGML module vocabulary and is not a closed set. A reader SHOULD fall back to inspecting each asset’s schema when a package declares no roles at all.
  • No per-asset row count is written. Earlier drafts also called for a “Statistics” extension; no registered STAC extension by that name defines a row count, and the one that does — Table (table:row_count) — would restate at asset level what city3d:city_objects already carries for the package. A writer SHOULD NOT declare the Table extension merely to publish a row count. (Counting rows and counting city objects coincide here: the object tables hold one row per city object.)
  • Dataset-level collection.json. A dataset spanning multiple CityParquet packages (e.g. tiles) SHOULD be curated by a STAC Collection (collection.json) that aggregates the individual Items.
  • Precedence: where the STAC Item and the Parquet footer disagree, the footer is authoritative for decoding; the STAC Item and Collection are the discovery mirror.

Example. The STAC Item of the 3DBAG package (https://cityparquet.open3d.city/data/3dbag/metadata.json), abridged: one object table, so one asset with the cityparquet-objects role.

{
  "type": "Feature",
  "stac_version": "1.1.0",
  "stac_extensions": [
    "https://cityjson.github.io/stac-city3d/v0.2.0/schema.json",
    "https://stac-extensions.github.io/projection/v2.0.0/schema.json",
    "https://stac-extensions.github.io/file/v2.1.0/schema.json"
  ],
  "id": "3dbag",
  "geometry": { "type": "Polygon", "coordinates": [ /* … */ ] },
  "properties": {
    "city3d:version": "2.0",
    "city3d:city_objects": 21555522,
    "city3d:lods": ["0.0", "1.2", "1.3", "2.2"],
    "city3d:co_types": ["Building", "BuildingPart"],
    "city3d:semantic_surfaces": true,
    "city3d:textures": false,
    "city3d:materials": false,
    "city3d:attributes": [ { "name": "b3_dak_type", "type": "String" }, /* … */ ],
    "proj:code": "EPSG:7415"
  },
  "assets": {
    "building.parquet": {
      "href": "./building.parquet",
      "type": "application/vnd.apache.parquet",
      "roles": ["data", "cityparquet-objects"],
      "file:size": 16400304209
    }
  },
  "links": [ { "rel": "collection", "href": "./collection.json" }, /* … */ ]
}

As implementation guidance, not a format requirement: zstd level 3 compression and 65 536-row row groups for the object table tend to balance size against spatial-pruning selectivity; smaller row groups prune better at some size cost (see appearance & shared resources and the metadata layout decision). A conforming file may use any Parquet-legal compression and row-group sizing.

Bloom filters, at a false-positive probability of 0.01: on every object table’s id and feature_id, and on each scalar VARCHAR attribute column (not JSON) that holds at least one non-null value and whose estimated distinct count — a HyperLogLog estimate is ample — reaches at least 0.2 × its non-null count across the dataset. An all-null column carries none. The rule is decided once for the whole dataset, before the first row group is written, so every file of a package — and every partition of a partitioned dataset — carries the same column set. All filters sit together after the last row group, before the page indexes and footer, with bloom_filter_length set, so a reader can ask for every filter of a column in one batched range read, which object storage coalesces into few requests. A filter whose column chunk declares no bloom_filter_length is not in that batch and is fetched separately. Low-cardinality strings, numeric, list, JSON and geometry columns, and the sidecars, carry none.

Last updated on October 15, 2026

Was this page helpful?