Object table schema
Reserved structural columns, typed attribute columns, and addresses.
Each row of an object table represents one city object — a feature of the CityGML conceptual model. The table stores identity, hierarchy, attributes, addresses, geometry, semantics, appearance references, implicit geometries, and spatial metadata.
Reserved columns
Reserved columns appear in the order below, before any attribute columns. “Req.” = required and non-null on every row. Types are written in the type notation defined further down.
| Column | Logical type | Req. | Physical / nullability notes | Description |
|---|---|---|---|---|
id |
VARCHAR |
✔ | non-null; MUST be unique across the package’s object tables | The city object’s identifier, or a generated one |
feature_id |
VARCHAR |
✔ | non-null | Root-parent id of the object’s feature family: equals id for a root object, else the id of its root parent (see rule below) |
object_type |
VARCHAR |
✔ | non-null; SHOULD be dictionary-encoded | The object’s CityGML 3.0 feature class (Building, BuildingPart, Road, Square, …) — see the note below |
parents |
LIST<VARCHAR> |
nullable list, nullable items | Parent city object ids | |
children |
LIST<VARCHAR> |
nullable list, nullable items | Child city object ids | |
children_roles |
LIST<VARCHAR> |
nullable; when present its length MUST equal children, with null for a child that has no role |
One role per child, positionally aligned to children |
|
address |
LIST<STRUCT<…>> |
nullable | Addresses of the object (see below) | |
bbox |
STRUCT<xmin,ymin,zmin,xmax,ymax,zmax DOUBLE> |
column nullable; the six struct fields are non-null | Bounding box in the geometry CRS (see spatial metadata) | |
geometry_lod* |
BLOB |
nullable | WKB 3D geometry; one column per LoD, named per the LoD suffix grammar | |
geometry_properties_lod* |
STRUCT<type VARCHAR, surfaces JSON, face_semantics LIST<INT>, shells LIST<LIST<INT>>> |
nullable | The CityGML CM geometry information WKB cannot encode — chiefly the semantic surfaces — plus geometry type and shell structure; one per LoD (details) | |
material_lod* |
MAP<VARCHAR, LIST<BIGINT>> |
nullable | Per-theme material references for the geometry in the matching geometry_lod* cell: theme → one materials.parquet id (or null) per WKB face (see appearance & implicit geometries); one per LoD |
|
texture_lod* |
MAP<VARCHAR, LIST<LIST<STRUCT<id BIGINT, uv LIST<LIST<DOUBLE>>>>>> |
nullable | Per-theme texture references for the geometry in the matching geometry_lod* cell: theme → per WKB face → per ring → the textures.parquet id and the ring’s [u, v] pairs; one per LoD |
|
implicit_geometry |
STRUCT<id BIGINT, point BLOB, transformationMatrix LIST<DOUBLE>> |
column and all fields nullable | The object’s implicit geometry: the implicit_geometries.parquet id of the shared relative geometry, the reference point, and the transformation matrix (a flat 16-element row-major 4×4) |
|
other |
JSON |
nullable | Source data with no column of its own — a member the format does not map. A reader restores every entry into the object’s attributes; see the escape-hatch rule |
The other column
other is the single escape hatch for source data no column carries, and its
reader contract is one rule: every entry is restored into the object’s
attributes, keyed by its map key.
An other cell MUST hold a JSON object. A cell that does not — a JSON array or
scalar, or text that is not valid JSON at all — is invalid input, and a reader MUST
treat it as an error rather than ignore the cell. A NULL cell remains the ordinary
“nothing to restore” case, not an error.
A reader MUST NOT infer anything from an entry’s key. A CityParquet file may come
from any writer, and how a writer chose to encode an unmapped source member is outside
this specification; a reader that inferred intent from a key would be reading one
writer’s convention rather than the format. An attribute whose name collides with a
reserved column does not go to other: it keeps a column of its own under an
escaped name.
An other entry whose key duplicates an attribute already decoded from its own
column is likewise invalid input: the two copies cannot be reconciled without
inventing a preference the format does not state. A reader MUST treat the
collision as an error rather than silently keeping one copy and discarding the
other, since it signals a corrupt or foreign file rather than a legitimate encoding.
A writer MUST NOT emit an other entry whose key duplicates an attribute the
same row carries in a column — such a file is exactly what the reader-side rule
above rejects.
Optional data is NULL, not an omitted column. address and implicit_geometry
remain part of the schema even when no row has a value for them, and every LoD that has a
geometry_lod* column also has its matching geometry_properties_lod*,
material_lod* and texture_lod* columns, whether or not any row carries values for
them.
The per-LoD column set is a different matter: which LoDs exist is a property of the dataset, so a table carries exactly the LoD columns its data needs — and a table whose objects have no analysis geometry at all carries none of them.
Example (real records). Read with DuckDB from a CityParquet building.parquet
produced by the reference implementation from a 3DBAG subset. A Building and its
BuildingPart are shown; ids are abbreviated (…711926 =
NL.IMBAG.Pand.0200100000711926), coordinates rounded. Note the feature_id rule in
real data — the root Building has feature_id == id, and the child inherits it. Note
also that although the Building itself carries only the LoD0 footprint while the 3D
solids live on its BuildingPart, its bbox still spans the part’s full z-range: bbox
is unioned across the whole subtree, so a consumer
pruning on a parent’s bbox never misses geometry held by its descendants.
| id | feature_id | object_type | parents | children | bbox (xmin, ymin, zmin, xmax, ymax, zmax) |
|---|---|---|---|---|---|
…711926 |
…711926 |
Building |
NULL |
[…711926-0] |
{111970.858, 443555.458, 45.648, 111989.694, 443588.204, 59.142} |
…711926-0 |
…711926 |
BuildingPart |
[…711926] |
NULL |
{111970.858, 443555.458, 53.290, 111989.694, 443588.204, 59.142} |
Column naming and reservation rules
- Reserved column names are exactly those in the table above, with the per-LoD
geometry_lod*/geometry_properties_lod*/material_lod*/texture_lod*forms expanded per the LoD suffix grammar. - Attribute column names MUST NOT collide with a reserved name, and names are
compared case-insensitively (ASCII case folding), since common readers — DuckDB
among them — cannot hold two columns whose names differ only in case.
ID,BboxandCHILDRENcollide withid,bboxandchildren; so does any name matching a per-LoD reserved form (Geometry_LoD2_2).otheris a reserved name like any other. - A colliding source attribute is escaped with a leading
_. Let the base of a name be the name with every leading_removed. A writer MUST store a source attribute whose base collides with a reserved name as the column_+ its source name:ID→_ID,children→_children, and a source attribute already named_ID→__ID, so that no two source names meet. Every other attribute keeps its source name unchanged. - A reader reverses the escape. For an attribute column (one listed in
attributes) whose base collides with a reserved name, the source name is the column name with one leading_removed:_ID→ID,__ID→_ID. An attribute column whose own name collides with a reserved name cannot come from a conforming writer and is invalid input. - Extension attributes carry their extension’s namespace as a prefix
(
energy_heatCapacity), and a core attribute MUST NOT begin with a declared namespace followed by_— see extensions. - A reader distinguishes reserved from attribute columns using the
attributesmetadata key: a column listed there is an attribute, and every other column is a reserved structural column whose name is fixed by this specification.
Physical encoding and conformance
CityParquet is defined at the level of Parquet logical types. A column’s identity is its name, its logical type, and its nullability; how a writer encodes that column physically is not part of the format.
A writer MAY therefore choose any Parquet-legal physical encoding — dictionary or plain, any compression codec, any page and row-group sizing — and a reader MUST NOT depend on that choice. In particular, a reader MUST NOT require:
- a particular in-memory representation of a
VARCHARcolumn, dictionary-encoded or not; - a particular child field name inside a
LISTcolumn; - the presence of a writer-specific schema-mirroring key in the Parquet footer, such
as Arrow’s
ARROW:schema.
A reader MUST match columns by name, never by ordinal position. The reserved
column order above is normative for writers, so that files from different writers
look alike; but several reserved columns share a logical type — parents, children
and children_roles are all LIST<VARCHAR> — so a reader that trusted position
rather than name would misread a differently ordered file silently, producing
plausible values attached to the wrong column.
Type notation
Column types on these pages are written in DuckDB/Arrow logical-type notation:
VARCHAR, INT, BIGINT, DOUBLE, BOOLEAN, BLOB, JSON, LIST<T>,
STRUCT<…>, MAP<K, V>. Their physical Parquet encoding is:
| Logical type | Physical Parquet encoding |
|---|---|
VARCHAR |
BYTE_ARRAY (UTF8) |
BIGINT |
INT64 |
INT |
INT32 |
DOUBLE |
DOUBLE |
BOOLEAN |
BOOLEAN |
DATE |
INT32 with the DATE logical type |
TIME |
INT64 with the TIME logical type (microseconds, UTC-normalised) |
TIMESTAMP |
INT64 with the TIMESTAMP logical type (microseconds, UTC-normalised) |
BLOB |
BYTE_ARRAY (no logical type) |
JSON |
BYTE_ARRAY (UTF8), tagged with the Arrow field-metadata extension ARROW:extension:name = arrow.json. A reader without Arrow extension support sees a UTF8 string. |
LIST<T> |
Parquet LIST of nullable items |
MAP<K, V> |
Parquet MAP logical type: a required key of type K, unique within the map, a nullable value of type V, entries in no defined order |
STRUCT<…> |
Parquet group (see the individual column for field types and nullability) |
A column MAY additionally be dictionary-encoded. That is a writer’s choice and
changes nothing about the logical type; where it is particularly worthwhile —
object_type, the appearance enumerations — this specification says so, but never
requires it.
Bloom filters
Writers MAY include standard Parquet bloom filters. Writers targeting
selective identifier lookups SHOULD include filters on id and
feature_id. Filters MUST conform to the Parquet bloom-filter
specification; writers SHOULD set bloom_filter_length. Readers MUST
accept files without filters, MUST NOT depend on filter placement, and
MUST NOT treat a positive bloom result as a match.
The recommended column set, false-positive probability and placement are in recommended writer defaults; the reasoning is in the bloom-filter decision.
Attribute types and promotion
CityParquet stores one typed column per attribute where practical — not a single JSON field, and not a 3DCityDB-style entity–attribute–value table (see the schema-strategy decision). A source value maps to a Parquet column type as follows.
Recommended primitive mapping:
| Source value | Column type |
|---|---|
| boolean | BOOLEAN |
| integer | BIGINT |
| floating point | DOUBLE |
| string | VARCHAR |
| date/time-like string | DATE, TIME, or TIMESTAMP when confidently detected |
| array of strings | LIST<VARCHAR> |
| object or heterogeneous array | JSON |
| inconsistent types across objects | promote if safe, else fall back |
Promotion lattice, when one attribute name carries mixed types across objects: BIGINT
→ DOUBLE (safe widening); anything not safely unifiable → VARCHAR, or JSON for
structured values. The mapping SHOULD be deterministic for a given input. Reserved
columns always take precedence over attribute columns.
Temporal columns. A TIMESTAMP column MUST be marked as UTC-adjusted
(isAdjustedToUTC = true): CityJSON date-times are ISO 8601 instants carrying an
offset, so a stored value denotes an instant, never a timezone-naive wall-clock
reading, and a column that failed to say so would be read as a different quantity
than it holds. The unit is a writer’s choice between MILLIS and MICROS; a
reader MUST accept either and MUST NOT treat a differing unit as an error.
A DATE column stores days since the Unix epoch.
Example (real records). Attribute columns from the same 3DBAG rows as the reserved
example — the column type is shown under each name. In this dataset the source
attributes sit on the Building row while the detailed geometry sits on the
BuildingPart, so the part’s attribute cells are NULL — an illustration of the
wide-table sparsity the design accepts.
| id | object_type | oorspronkelijkbouwjaar BIGINT |
status VARCHAR |
b3_dak_type VARCHAR |
b3_h_dak_max DOUBLE |
b3_volume_lod22 DOUBLE |
b3_succes BOOLEAN |
|---|---|---|---|---|---|---|---|
…711926 |
Building |
1996 |
Pand in gebruik |
slanted |
13.555 |
1377.762 |
true |
…711926-0 |
BuildingPart |
NULL |
NULL |
NULL |
NULL |
NULL |
NULL |
The 3DBAG attribute names (oorspronkelijkbouwjaar, the b3_* reconstruction outputs)
are the source’s own; their column types follow from the source values.
Addresses
An address is a property of the city object, so it is stored inline in a reserved
address column — an array of address structs (LIST<STRUCT<…>>), since a city
object may have several — rather than in a separate address table that would force a
join on read.
Each list element is a STRUCT holding the common structured address fields (a lean
subset of 3DCityDB v5’s ADDRESS table):
| Field | Type | Description |
|---|---|---|
street |
VARCHAR |
Street or road name |
house_number |
VARCHAR |
Building or house number |
po_box |
VARCHAR |
Post office box |
zip_code |
VARCHAR |
Postal or ZIP code |
city |
VARCHAR |
City or locality |
state |
VARCHAR |
State, province, or region |
country |
VARCHAR |
Country |
free_text |
VARCHAR |
Address as unstructured text |
location |
BLOB |
WKB MultiPointZ — the address’s geolocation |
All fields are nullable.
location is geometry, not a boundary. It is a WKB MultiPointZ in the file CRS,
encoded exactly as any other geometry — not a reference into a vertex pool. This is
the one point where CityParquet’s address differs structurally from CityJSON’s, where
address[].location is a MultiPoint geometry whose boundaries index the source
vertex list. Because CityParquet resolves and discards that vertex list, an index
would dangle; a coordinate does not.
Scope. CityJSON deliberately does not prescribe address members (“the members of an
address JSON object are not prescribed, to accommodate the different ways addresses are
structured in different countries”), and 3DCityDB’s ADDRESS table carries additional
bookkeeping (an id, cross-system identifiers, an original-payload blob). CityParquet
keeps only the fields above; free_text absorbs unstructured content. A source address
member outside this set is not separately retained — a deliberate simplification
(see the round-trip note).