Concepts

August 26, 2026 · View on GitHub

Background on why the Overture schema looks the way it does. None of this is needed to get work doneSCHEMA_GUIDE.md is the path through the examples, and it stands on its own. Read a section here when you hit something whose why you want.

Not to be confused with GLOSSARY.md, which defines the same vocabulary in a sentence or two each. If you want to know what envelope or workspace or tag means, the glossary is faster. This page is for why they exist.

QuestionSection
What principles is the schema designed under?The tenets
Why have a schema at all?Beyond raw data
Why Pydantic instead of JSON Schema?Why Pydantic
Why is the schema split into a dozen packages?Many packages
What is an "envelope"? Why do Overture fields sit under properties?The GeoJSON envelope
Why does type appear three times in one file?Three keys named type
If the schema is Pydantic now, why does it still look like GeoJSON?Why there's an envelope at all
Why can a model be named two ways?Two names per model
What decides whether a field is required?What makes a field required
What are those overture:theme= tags?How tags work
What is the example file the guide validates?The example file
Why are the examples YAML?Why examples are YAML
Why isn't the generated PySpark code in git?Generated code

The tenets

Six principles the schema is designed under, set down by the working group early in the project under the heading "These are our tenets unless you know better ones". They still decide arguments, so they are worth knowing before you propose a change.

  1. Address the core, enable the periphery. The Overture schema doesn't solve every problem. It describes fully-formed solutions only for the most fundamental use cases ("the core") while enabling less common use cases ("the periphery") via extensibility.
  2. Invent across the gap. Many excellent solutions — published standards, best practices, open-source tools — already exist and are well understood in the community. The Overture schema reuses them to maximize compatibility and to focus effort on unaddressed high-priority pain points.
  3. Backward-compatible is forward-compatible. No design is future-proof, but good designs stay relevant by adding features without breaking existing use cases.
  4. The world is neither flat nor still. The Overture schema links representations of 2- and 3-dimensional objects in space and time.
  5. Empower, don't dictate. The schema provides a framework that lets users bring together the data they need for their own use cases — Overture and non-Overture sources alike — according to their own viewpoints and perspectives.
  6. Always open, never closed. The schema and format aim for compatibility with free and open-source tools, and avoid depending on closed-source or proprietary ones.

Where they show up in this repository:

TenetIn practice
Address the core, enable the peripheryYour own feature types register through entry points and become first-class — nothing in the tooling special-cases Overture. See AUTHORING.md.
Invent across the gapJSON Schema, OGC geometries, GeoJSON, GeoParquet, and Pydantic are all reused rather than reinvented. See Why Pydantic and The GeoJSON envelope.
Backward-compatible is forward-compatibleBackward-compatible changes land in both the Pydantic models and the deprecated YAML while both are live; major changes wait for vnext. See CONTRIBUTING.md.
Always open, never closedEvery published artifact — JSON Schema, PySpark expressions, documentation — is generated by open tooling in this repository.

The doctrine the working group built on these tenets in 2023 is recorded in the project history.

Why this exists

This project provides type-safe Python models for validating and working with Overture data. Use these schemas to:

  • Validate Overture data
  • Build data processing pipelines with type safety
  • Extend schemas with custom fields and validation rules

Beyond raw data

This project addresses a fundamental challenge in data consumption: bridging the semantic gap between raw data and human understanding while enabling machine-actionable workflows.

Take a column like pop_2020. Is it total population? Population density per square kilometer? Working-age population? Without a schema, you're left sampling values and guessing from column names.

Compare this to OpenStreetMap's approach: features use well-known key/value pairs like building=residential or addr:housenumber=42 that have semantic meaning and can be looked up on the OSM wiki. This creates a step toward a schema - shared vocabulary with documented semantics used across a vast dataset. However, OSM tags remain free-form: multiple valid ways to express the same concept, no built-in validation, and complex downstream validation because of undocumented keys that might have meaning to someone, somewhere. A schema provides the structured alternative: explicit types, clear validation rules, and semantic meaning that both humans and systems can rely on.

Data files containing only column names and values aren't fully documented. External metadata files typically focus on how data was collected and encoded, not on semantic meaning or validation rules. Data consumers struggle to understand what datasets contain and which columns they need for their goals.

Why Pydantic rather than JSON Schema

We initially chose JSON Schema because it aligned with our mental model and promised to solve our problems as we understood them. But JSON Schema surfaced several pain points:

  • Authoring difficulty: Hard to write correctly, difficult to verify, limited IDE support, no refactoring capabilities
  • Tooling gaps: Generic tools can't tailor output for specific applications like ours
  • Development friction: Schema changes required manual coordination across multiple artifacts

Pydantic addresses these systematically: author in Python with full IDE support, generate tailored documentation, and automatically produce the specific artifacts each workflow needs. Pydantic can also produce JSON Schema, so any application that requires it can use it while we gain all the Python benefits during authoring.

The result

Instead of spending time deciphering what columns mean and whether data matches expectations, users can focus on their actual goals: analysis, visualization, integration. Quality improves because validation happens automatically rather than through manual inspection.

The fundamental approach - human-readable authoring that generates machine-actionable outputs - has broader applications beyond Overture and geospatial data. We hope others will adapt these patterns for linking with Overture data or modeling their own domains entirely.


Why the schema is many packages

Why split the schema into so many packages? Because what you install determines what exists at runtime. The models register themselves through Python entry points, so installing only the buildings theme means the CLI only knows about buildings. That's the extension mechanism — see Using the packages from your own project and Register your own feature types. If you just want everything, that's fine too.

What "workspace" means

A uv workspace is one repository containing several packages that are developed together, sharing one lockfile and one virtual environment. If you've used Cargo workspaces, npm workspaces, or a monorepo, it's the same idea.

The root pyproject.toml declares it:

[project]
name = "overture-schema-workspace"     # ← a container, not something you install
version = "0.0.0"

[tool.uv.workspace]
members = ["packages/*"]               # ← every directory under packages/ is a member

Two things follow from this:

1. You never install or import overture-schema-workspace. It's scaffolding. It exists so uv knows which directories are members. There is no import overture_schema_workspace.

2. The packages depend on each other locally, not through PyPI. Look at packages/overture-schema-cli/pyproject.toml:

[tool.uv.sources]
overture-schema-common = { workspace = true }
overture-schema-system = { workspace = true }

workspace = true means "use the copy in this repo." This is why none of this needs to be published to PyPI for you to work with it — the packages find each other.


The GeoJSON envelope

An envelope is an outer wrapper that carries a payload plus a little standard information about it. The term is borrowed from mail: the address and stamp go on the outside and are the same on every envelope; the letter inside is whatever you wrote.

GeoJSON works exactly that way. Every GeoJSON Feature has the same four outer keys, fixed by RFC 7946 — that's the envelope. Everything specific to your data goes in one of them, properties — that's the payload.

{
  "type": "Feature",                          <- envelope: what kind of object this is
  "id": "overture:buildings:building:1234",   <- envelope: identity
  "geometry": { "type": "Polygon", ... },     <- envelope: where it is
  "properties": {                             <- envelope: the pocket for everything else
      "theme": "buildings",                       payload: Overture's fields
      "type": "building",
      "height": 21.34,
      "num_floors": 4,
      "class": "parking"
  }
}

Split them apart for yourself:

import json, yaml
from overture.schema.buildings import Building

b = Building.model_validate_json(
    json.dumps(yaml.safe_load(open("examples/buildings/building-polygon.yaml")))
)
gj = b.model_dump(mode="json", by_alias=True, exclude_none=True)

print("envelope keys:", sorted(gj))
print("payload keys :", sorted(gj["properties"]))
envelope keys: ['geometry', 'id', 'properties', 'type']
payload keys : ['class', 'ext_bar', 'ext_foo', 'height', 'is_underground', 'level',
                'num_floors', 'num_floors_underground', 'sources', 'subtype',
                'theme', 'type', 'version']

Four keys outside, thirteen inside. A GeoJSON Feature for a road, a lake, or a mailbox has the same four outer keys — that's what makes it universally readable. Only the payload differs.

So when this guide says "the envelope owns id and geometry," it means those two are outer keys, placed there by GeoJSON's rules rather than by Overture's. And "the fields are nested under the envelope" means the Overture fields sit inside properties rather than at the top.

One consequence worth carrying forward: the envelope only exists in the GeoJSON rendering. In the Pydantic model, and in Parquet, there is no envelope — id, geometry, and height are all just fields side by side.

Three keys named type

A key is a field name — the part left of the colon. This file uses the key type three times, at three nesting levels, meaning three unrelated things:

WhereValueComes fromMeans
top levelFeatureGeoJSON spec"this object is a GeoJSON Feature"
inside geometryPolygonGeoJSON spec"this shape is a polygon"
inside propertiesbuildingOverture"this feature is a building"

List them yourself:

import yaml

d = yaml.safe_load(open("examples/buildings/building-polygon.yaml"))
print("type            =", d["type"])
print("geometry.type   =", d["geometry"]["type"])
print("properties.type =", d["properties"]["type"])
type            = Feature
geometry.type   = Polygon
properties.type = building

Only the third is Overture's. The first two belong to GeoJSON, the envelope Overture data is wrapped in when written as JSON. Whenever this guide says "the feature's type" it means properties.type — the one holding building, place, or segment.

That layering is the single most confusing thing about this data, and section 3 is largely about it.

"Validating" means: the CLI parses the file, reads theme: buildings and type: building to decide which model to check against, then checks every field against that model — types, numeric bounds, enum membership, required fields, and cross-field rules.

You can watch it pick the model. Delete the theme: line and it no longer knows:

⚠ Ambiguous: Data matches multiple types equally. Consider:
  • Specifying --tag or --type to narrow validation
  • Adding discriminator fields to clarify intent

Why there's an envelope at all

If the schema is now Pydantic, why does any of this still look like GeoJSON? Because those two things answer different questions, and only one of them changed.

What it isDid it change?
PydanticHow the schema is authored and enforced, in PythonYes — it replaced hand-written JSON Schema YAML
GeoJSONOne way a feature can be written out as JSON, per RFC 7946No — it's an interchange format, not an authoring choice

Pydantic replaced JSON Schema as the authoring language. It has nothing to say about how data is serialized, so GeoJSON was never in scope to replace.

But GeoJSON is not how Overture ships bulk data — Parquet is. The release bucket is Hive-partitioned Parquet:

s3a://overturemaps-us-west-2/release/2026-07-22.0/theme=buildings/type=building/

That is a columnar table: id, geometry, height, and the rest are columns, flat, no envelope. It's what overture-validate reads, what DuckDB attaches to, and what you'd query for anything at scale.

So why does GeoJSON appear at all? Because JSON Schema describes JSON documents, and when a geospatial feature is written as a single JSON document, GeoJSON is the format every GIS tool already reads. Parquet has no JSON representation to describe — it has a columnar schema instead, which is exactly what section 6 generates as a Spark StructType.

The honest summary is that there are two serializations and neither is subordinate:

SerializationShapePydantic modeWhere you meet it
Parquetflat / columnarpythonthe release bucket, Spark, DuckDB — all bulk data
GeoJSONnested envelopejsonsingle features, extracts, examples in this repo, web tooling

The model supports both deliberately. The JSON Schema you're reading in this subsection describes the second one, which is the only reason an envelope shows up here at all.

Interchange format vs storage format

An interchange format is one whose job is handing data to a program you didn't write. It's optimized for being understood by anything, not for being stored efficiently. A storage format is the opposite: optimized for holding a lot of data and querying it fast, at the cost of needing specific software to read it at all.

GeoJSONParquet
Optimized forbeing read by anythingstoring and scanning millions of rows
Text or binaryplain textbinary, columnar, compressed
Read it withany JSON parsera Parquet library
Self-describingyes — the file says what it isschema in the footer, not human-readable
Good atone feature, an extract, a web mapa whole theme, a whole planet

The cost of being universally readable is that GeoJSON repeats every field name on every feature:

import json, yaml
from overture.schema.buildings import Building

b = Building.model_validate_json(
    json.dumps(yaml.safe_load(open("examples/buildings/building-polygon.yaml")))
)
gj = b.model_dump(mode="json", by_alias=True, exclude_none=True)
flat = b.model_dump(mode="python", by_alias=True, exclude_none=True)
flat["geometry"] = str(flat["geometry"])

n = 10_000
doc = json.dumps({"type": "FeatureCollection", "features": [gj] * n})
cols = list(flat)
tbl = json.dumps({"columns": cols, "rows": [[flat[k] for k in cols]] * n})
print(f"{n:,} features as GeoJSON      : {len(doc):>10,} bytes")
print(f"{n:,} features, names stored once: {len(tbl):>10,} bytes")
10,000 features as GeoJSON      :  7,040,043 bytes
10,000 features, names stored once:  4,520,199 bytes

A 1.56x penalty before compression even enters the picture, and that comparison is still generous to GeoJSON — real Parquet also compresses each column and lets a reader skip columns it doesn't need. Multiply by a planet's worth of buildings and the reason bulk data isn't shipped as GeoJSON is obvious.

"Interchange" is a role, not a ranking. GeoJSON is the right tool for handing one feature to a web map; Parquet is the right tool for handing a continent to Spark.

Is Pydantic wrapped around GeoJSON?

Short answer: no. But the question has three reasonable readings, and one of them is a qualified yes, so it's worth taking them separately.

"Is the model built on top of a GeoJSON structure?" No. A model is a flat list of fields, declared one at a time in Python. You can build one and use it without JSON ever entering the picture:

from overture.schema.buildings import Building
from overture.schema.system.geometric import Geometry

b = Building(
    id="my-building-1",
    geometry=Geometry.from_wkt("POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))"),
    theme="buildings",
    type="building",
    version=1,
    height=12.5,
)
print(b.id, b.height)
print(sorted(b.model_dump(mode="python", by_alias=True, exclude_none=True)))
my-building-1 12.5
['geometry', 'height', 'id', 'level', 'theme', 'type', 'version']

No GeoJSON was parsed, produced, or consulted. If GeoJSON were the substrate, that wouldn't be possible.

"Is there GeoJSON code inside the Pydantic classes?" Yes — in exactly one of them. Counting mentions across everything Building inherits from:

Building             (building  )  0
OvertureFeature      (feature   )  0
Identified           (id        )  0
Feature              (feature   ) 17     <- the base class in overture-schema-system
Named                (names     )  0
Stacked              (level     )  0
Appearance           (_common   )  0

All of it lives in Feature, in one serializer and one validator — the code that reads GeoJSON in and writes GeoJSON out. Zero mentions in the six classes that actually define what a building is. GeoJSON is an I/O concern parked at the base of the hierarchy, not a structure the schema is built on.

"Is GeoJSON what's really being validated?" No. What gets validated is the model. A GeoJSON document is one accepted input shape — a flat dict is the other, and both end up as the same Python object.

An analogy: a word processor's document isn't "wrapped around .docx." It has a document model, and it can read and write .docx. Deleting that import/export code would not change what a document is. Same here — delete the ten lines below and Overture models still work; they just stop speaking GeoJSON.

The entire GeoJSON transformation is those ten lines, in Feature (packages/overture-schema-system/src/overture/schema/system/feature.py):

@model_serializer(mode="wrap")
def __serialize_with_geo_json_support__(self, serializer, info):
    data = serializer(self)  # <- the flat dict, produced first

    if info.mode == "json":  # <- only in JSON mode
        return {
            "type": "Feature",
            **({"id": data.pop("id")} if "id" in data else {}),
            **({"bbox": data.pop("bbox")} if "bbox" in data else {}),
            "geometry": data.pop("geometry"),
            "properties": data,  # <- everything else goes here
        }

    return data  # <- Python mode: flat, untouched

Read the first line: Pydantic produces the flat dictionary, and only then does this function move id, bbox, and geometry to the top and sweep the remainder into properties. In python mode the flat dict is returned unchanged and none of this runs.

So the layering is:

            Pydantic model  (flat — the actual schema)
                   |
        +----------+----------+
        |                     |
   python mode            json mode
        |                     |
   flat dict            GeoJSON envelope
   -> Parquet           -> .geojson

GeoJSON is a costume the model puts on for one specific audience. It isn't the body.

The Pydantic model itself has no envelope. In Python it's completely flat:

from overture.schema.buildings import Building

print("id in model_fields       :", "id" in Building.model_fields)
print("geometry in model_fields :", "geometry" in Building.model_fields)
print("a 'properties' field?    :", "properties" in Building.model_fields)
id in model_fields       : True
geometry in model_fields : True
a 'properties' field?    : False

There is no properties field on Building, and id and geometry sit alongside height and num_floors like any other field. The envelope is not part of the model. It appears only when serializing to JSON, because that is what GeoJSON requires.

You can watch the same object take both shapes:

import json, yaml
from overture.schema.buildings import Building

b = Building.model_validate_json(
    json.dumps(yaml.safe_load(open("examples/buildings/building-polygon.yaml")))
)

print(
    "python mode:",
    sorted(b.model_dump(mode="python", by_alias=True, exclude_none=True))[:8],
)
print(
    "json mode  :", sorted(b.model_dump(mode="json", by_alias=True, exclude_none=True))
)
python mode: ['class', 'ext_bar', 'ext_foo', 'geometry', 'height', 'id', 'is_underground', 'level']
json mode  : ['geometry', 'id', 'properties', 'type']

One model, two renderings — and the flat one is the shape of the data you'd actually download. Neither is more "real"; the model is what's real, and both are projections of it.

So which answer to "what's required" is correct? The model's:

['geometry', 'id', 'theme', 'type', 'version']

The JSON Schema's two required arrays are that same list, split across the envelope because that's where those fields land in that particular output format. Nobody decided geometry belongs somewhere different from theme; GeoJSON did, in 2016.

This distinction is the single most important thing in this guide, and it returns in force in section 3 — where using the wrong mode for your data shape is the most common way to get a confusing ValidationError.


Two names per model

Because the registry has to stay correct when packages it has never heard of register their own models.

The canonical key is the entry-point stringoverture.schema.buildings:Building. It includes the module path, so it is globally unique: no two packages can collide.

The short name is a derived alias. It is just the class name after the colon, snake-cased:

from overture.schema.system.discovery.entry_point import entry_point_class_alias

entry_point_class_alias("overture.schema.divisions:DivisionArea")  # 'division_area'
entry_point_class_alias("overture.schema.places:Place")  # 'place'

Short names are not guaranteed unique. Anyone can register their own feature types, and nothing stops a third party from shipping its own Place. So the short name can't be the identity — it's a convenience, because validate_model(df, "overture.schema.buildings:Building") is miserable to type.

The nice part is how it degrades. The alias is offered only while it stays unambiguous. model_names() counts aliases and includes only those appearing once, and the resolver tries an exact key match first, then the alias:

from overture.schema.system.discovery.entry_point import resolve_entry_point_key

registry = {"overture.schema.places:Place": ..., "acme.parks:Place": ...}

resolve_entry_point_key("place", registry)
# ValueError: Entry-point alias 'place' is ambiguous.
#             Specify one of: acme.parks:Place, overture.schema.places:Place

resolve_entry_point_key(
    "acme.parks:Place", registry
)  # 'acme.parks:Place' — always works

Install a package that collides and place simply stops being accepted, with an error naming both candidates — rather than silently validating against the wrong model. The fully-qualified key never stops working.

Two functions expose the two views:

FunctionReturnsUse when
model_keys()the 15 canonical entry-point keysyou want the authoritative list
model_names()all 30 accepted namesyou want everything validate_model will take

Skipping this step does not produce an error message. It produces an empty registry. If validate_model(df, "building") raises a KeyError, or model_names() is empty, this is why.


What makes a field required

Nobody maintains a list of required fields. It's derived — in Pydantic, a field with no default is required, and a field with a default is optional:

from overture.schema.buildings import Building
from pydantic_core import PydanticUndefined

for n in ["version", "theme", "height", "num_floors"]:
    f = Building.model_fields[n]
    d = "no default" if f.default is PydanticUndefined else f"default={f.default!r}"
    print(f"{n:12} {d:16} -> {'REQUIRED' if f.is_required() else 'optional'}")
version      no default       -> REQUIRED
theme        no default       -> REQUIRED
height       default=None     -> optional
num_floors   default=None     -> optional

So "who decided" becomes "where is the field declared." For a building, five fields are required and four of them come from a shared base class rather than from buildings at all:

from overture.schema.buildings import Building

for n, f in Building.model_fields.items():
    if not f.is_required():
        continue
    for cls in Building.__mro__:
        if n in getattr(cls, "__annotations__", {}):
            print(f"{n:10} -> {cls.__name__}")
            break
id         -> OvertureFeature
geometry   -> Building
theme      -> OvertureFeature
type       -> OvertureFeature
version    -> OvertureFeature

id, theme, type, and version are required of every Overture feature, declared once in OvertureFeature:

id: Id = Field(description="A feature ID. ...")
theme: ThemeT
type: TypeT
# Superclass `Feature` provides `geometry` and `bbox`.
version: FeatureVersion

None carries = None, so all four are mandatory. Building adds only geometry, narrowing the inherited one to the polygon types a building may have.

In the generated JSON Schema those same five get split across the GeoJSON envelope — id and geometry at the top, theme, type, and version inside properties — which is why that document appears to have two answers to one question. It doesn't; it has one answer written in the shape GeoJSON demands.

The human answer: the Overture Schema Working Group decides, and changes go through the process in CONTRIBUTING.md — a PR plus a changelog fragment. Making a field required is a breaking change, so it targets the vnext branch and waits for a major release; making one optional is not, and can go to main.


How tags work

Every feature type carries a handful of tags, and overture-schema list-types prints them after the type name:

building           feature  overture  overture:theme=buildings

Three tags there. feature says this is a map feature rather than some other kind of model. overture says Overture defined it. overture:theme=buildings says which theme it belongs to.

Why a tag rather than a field called theme? Because the tooling has to work on feature types it has never heard of. A field named theme would only mean something to code that already knows Overture has themes; the CLI would have to hardcode that. A tag is just a label the type declares about itself, and the CLI's job is only to match labels — so --tag overture:theme=buildings and --tag acme:product=parks go through exactly the same code path.

The namespace:key=value shape exists so that two organizations can both tag their types without colliding. Everything Overture defines is namespaced under overture:; if you register your own feature types, you pick your own namespace and your tags sit alongside Overture's rather than competing with them. That is the whole extension mechanism — see Register your own feature types.

The bare tags (feature, overture) have no namespace because they are not claims about a vendor's taxonomy — they are the two facts every Overture feature type shares.


What the example file actually is

The guide validates examples/buildings/building-polygon.yaml as its first real command. examples/buildings/building-polygon.yaml is a file in the repo you just cloned. It is not data you downloaded. The examples/ tree is the project's own corpus of hand-written sample features, used as test fixtures and pulled into the documentation site. This one describes a single building — a parking structure in Washington DC:

id: overture:buildings:building:1234
type: Feature                      # ← GeoJSON envelope
geometry:
  type: Polygon
  coordinates: [[ [-77.036873, 38.897804], ... ]]
properties:
  ext_foo: I am a customer user property.   # ← custom, non-Overture
  theme: buildings                 # ← which theme
  type: building                   # ← which feature type
  version: 1
  height: 21.34
  num_floors: 4
  subtype: transportation
  class: parking
  sources:
  - property: ""
    dataset: microsoftMLBuildings

See it actually catch something

A success message proves the command ran, not that it's checking anything. Copy the file and break it:

cp examples/buildings/building-polygon.yaml /tmp/broken.yaml

Change class: parking to class: skyscraper:

class "skyscraper" ← Input should be 'agricultural', 'allotment_house',
                     'apartments', 'barn', 'beach_hut', ...

Change height: 21.34 to height: -5:

height -5 ← Input should be greater than 0

Change num_floors: 4 to num_floors: 4.7:

num_floors 4.7 ← Input should be a valid integer, got a number with a
                 fractional part

Enum membership, numeric bounds, integer-ness — each from the model definition, none of it written by hand for this file.

What it does not catch: free-form string fields accept any string. The real building-polygon.yaml in the repo has a stray trailing comma — dataset: microsoftMLBuildings, — which YAML reads as part of the value. It parses to the string 'microsoftMLBuildings,' and validates clean, because dataset has no constraint beyond "is a string." Validation enforces the schema, not your typing.


Why examples are YAML

No — real Overture data is GeoJSON or Parquet. YAML here is purely an authoring convenience for the example files: it allows comments (# Custom user properties.) and is easier to hand-edit than JSON.

The CLI accepts JSON, YAML, and GeoJSON, and YAML is a superset of JSON, so the format is irrelevant to the validation. Convert the same file to JSON and you get the same result:

uv run python -c "
import json, yaml
json.dump(yaml.safe_load(open('examples/buildings/building-polygon.yaml')),
          open('/tmp/same-building.json','w'), indent=2)"

uv run overture-schema validate /tmp/same-building.json
✓ Successfully validated /tmp/same-building.json

Same bytes of meaning, different serialization, identical outcome. Pick whichever is convenient — you'll mostly hand JSON or GeoJSON to this command in real use.


Why generated code is gitignored

Not because it's optional, and not because it's for a subset of users. It's build output. The commit that introduced the package says so directly:

The generated trees under expressions/generated/ and tests/generated/ are regenerable output of make generate-pyspark and are not tracked in git; make check and make test-all regenerate before running.

Three reasons that's the right call:

One source of truth. The Pydantic models define the schema; these expressions are a derivative of them. Committing the derivative creates a second copy that can silently drift — change a constraint, forget to regenerate, and the committed expressions keep enforcing the old rule. Deleting them from git makes that failure impossible.

Scale. A full generation is 32 files and roughly 23,000 lines:

15 expression modules   (one per feature type)
17 test modules         (conformance tests, split per union arm)

Every schema change would produce a mechanical diff of that size, burying the actual change and guaranteeing merge conflicts.

The build regenerates regardless. make check and make test-all both depend on generate-pyspark, which begins with clean-pyspark (rm -rf). The tree is rebuilt from the current models every time, so a committed copy would never be read.

It's the same reasoning that keeps dist/, *.o, and node_modules/ out of git.

"Gitignored" does not mean "not shipped"

This is the part worth being clear about, since it sounds like these files are somehow optional for users. They are not. Published wheels contain them.

Both publish workflows (.github/workflows/main-publish.yaml and release-publish.yaml) run a package's prebuild script, if it has one, before uv build --package <package>:

- name: Run package's prebuild script, if any
  run: |
    script="packages/${PACKAGE}/scripts/prebuild.sh"
    if [ -f "$script" ]; then
      bash "$script"
    fi

Neither workflow knows that PySpark is special. The knowledge lives in the package: packages/overture-schema-pyspark/scripts/prebuild.sh regenerates the tree and refuses to hand a hollow package to the build:

rm -rf "$output_dir"
uv run overture-codegen generate --format pyspark --output-dir "$output_dir"

if ! find "$output_dir" -name '*.py' -print -quit | grep -q .; then
  echo "::error::No expressions generated under ${output_dir} -- codegen produced nothing." >&2
  exit 1
fi

Every other package simply has no prebuild.sh, so the step is a no-op for them.

So the only people who ever run make generate-pyspark are people working from a git clone — because a clone is the one place these files don't already exist. Install from a package index and they arrive with the package, like any other module.