Skip to content
GuidesBlogPlaygroundDashboard

Type System & Advanced

PlazaQL's type inference, chain ordering rules, expression language, structural joins, narrowing, and attribute access.

PlazaQL has a fully inferred type system. You never declare types — the compiler tracks them through your chain and catches errors.

search(node, amenity: "cafe") // → PointSet
.around(500, point(38.9, -77.0)) // → PointSet (spatial filter preserves type)
.centroid() // → PointSet (centroid of points = points)
.limit(count: 10); // → PointSet
search(amenity: "cafe") // → GeoSet (no type = mixed)
.within(geometry: $berlin) // → GeoSet
.buffer(50) // → PolygonSet (buffer always → polygons)
.simplify(10); // → PolygonSet

Methods are grouped into three ordering categories:

GroupMethodsRule
Freely orderablespatial (.within(), .around(), .bbox(), …), .filter(), transforms (.buffer(), .simplify(), .centroid()), joins (.member_of(), .has_member()), computed columns (.elevation(), .distance(), …), output shape (.fields(), .precision(), …)Can appear in any order relative to each other
Narrowing.first(), .last(), .index()Converts GeoSetGeoElement; must come after spatial/join filters
Ordering.sort(), .limit(), .offset()Must come after all freely orderable methods
Output mode.count(), .ids(), .tags(), .skel(), .geom()Must be last in chain, max one
Aggregation.sum(), .avg(), .min(), .max(), .group_by().count()Terminal — must be last in chain

Spatial filters, tag filters, transforms, computed columns, and output shape methods can be freely mixed and reordered. This means all of these are valid:

// Buffer before spatial filter
search(amenity: "school")
.buffer(50)
.within(geometry: $city);
// Filter after transform
search(node, amenity: "cafe")
.buffer(50)
.filter(cuisine: "italian");
// Computed column before transform
search(node, amenity: "cafe")
.within(geometry: $berlin)
.elevation()
.buffer(50);

Additional constraints:

  • .limit() must come before .offset()

PlazaQL gives precise, actionable errors with source locations:

error: .buffer() (transform) cannot follow .limit() (ordering) — ordering methods must come after all other methods
--> query:3:5
|
3 | .limit(10).buffer(50)
| ^^^^^^^^^^^ transform after ordering
|
= hint: move .buffer(50) before .limit(10)
error: .count() is terminal — no methods can follow it
--> query:4:5
|
4 | .count().limit(10)
| ^^^^^^^^^ not allowed after terminal
|
= hint: remove .limit(10) or move it before .count()

All functions support both styles. You cannot mix them in a single call:

// Keyword (preferred — self-documenting)
.around(distance: 500, geometry: point(lat: 38.9, lng: -77.0))
// Positional (concise)
.around(500, point(38.9, -77.0))

PlazaQL has a full expression language for computing values from tags, element properties, and geometry. Expressions are used in .filter(), aggregation functions (.sum(), .avg()), and .group_by().

Access any tag value with t["key"]. Tag values are always strings in OSM:

t["name"] // → "Starbucks"
t["amenity"] // → "cafe"
t["building:levels"] // → "5"
t["capacity"] // → "200"

Built-in functions that return element metadata:

FunctionReturnsExample
id()OSM ID (integer)id() > 1000000
type()Element type stringtype() == "way"
lat()Latitude (float, nodes only)lat() > 48.0
lon()Longitude (float, nodes only)lon() < 2.5

Compute geometric properties of each feature:

FunctionReturnsDescription
length()meters (float)Geometric length of a way. Returns 0 for nodes.
area()square meters (float)Area of a closed way or relation. Returns 0 for open ways and nodes.
is_closed()booleanWhether a way’s first and last nodes are the same.

length() is always geometric length in meters. For string length, use size().

Tag values are strings. Use number() to cast them for arithmetic:

number(t["capacity"]) // "200" → 200
number(t["height"]) // "12.5" → 12.5
number(t["lanes"]) // "3" → 3

number() is required for arithmetic on tag values. Without it, t["capacity"] > 50 compares strings, not numbers.

is_number() tests whether a tag value can be parsed as a number (returns boolean):

// Only include elements where population is a valid number
.filter(is_number(t["population"]))
FunctionDescriptionExample
starts_with(str, prefix)True if str starts with prefixstarts_with(t["name"], "St")
ends_with(str, suffix)True if str ends with suffixends_with(t["name"], "Park")
str_contains(str, substr)True if str contains substrstr_contains(t["cuisine"], "italian")
size(str)String length (characters)size(t["name"]) > 3

str_contains() is used instead of contains() to avoid conflict with the spatial .contains() method. size() is used instead of length() to avoid conflict with geometric length().

Arithmetic (requires number() for tag values):

OperatorDescriptionExample
+Additionnumber(t["lanes"]) + 1
-Subtractionnumber(t["height"]) - 3
*Multiplicationnumber(t["width"]) * number(t["length"])
/Divisionarea() / 1000

Comparison:

OperatorDescriptionExample
>Greater thannumber(t["capacity"]) > 50
<Less thanlength() < 100
>=Greater or equalnumber(t["height"]) >= 10
<=Less or equalarea() <= 500
==Equaltype() == "way"
!=Not equalt["name"] != "Unknown"

Logical:

OperatorDescriptionExample
&&Logical ANDnumber(t["lanes"]) > 2 && length() > 500
||Logical ORt["amenity"] == "cafe" || t["amenity"] == "restaurant"
!Logical NOT!is_closed()
  1. ! (unary NOT)
  2. *, /
  3. +, -
  4. >, <, >=, <=
  5. ==, !=
  6. &&
  7. ||

Use parentheses to override: (a || b) && c.

// Roads longer than 1km with more than 2 lanes
search(way, highway: *)
.within(geometry: $city)
.filter(length() > 1000 && number(t["lanes"]) > 2);
// Large buildings (area over 1000 sq meters)
search(way, building: *)
.bbox(40.7, -74.0, 40.8, -73.9)
.filter(area() > 1000);
// Closed ways that aren't tagged as buildings
search(way)
.within(geometry: $area)
.filter(is_closed() && t["building"] == !*);
// Restaurants with valid capacity over 100
search(amenity: "restaurant")
.within(geometry: $city)
.filter(is_number(t["capacity"]) && number(t["capacity"]) > 100);
// Total lane-kilometers of roads in a city
search(way, highway: *)
.within(geometry: $city)
.filter(is_number(t["lanes"]))
.sum(number(t["lanes"]) * length() / 1000);

Structural joins let you navigate the membership graph — relations contain members (nodes, ways, other relations), and ways contain nodes. Two methods express this relationship from opposite directions.

.member_of() — filter to members of a source

Section titled “.member_of() — filter to members of a source”

Keep only elements that are members of the given source set. The first argument is a variable reference or an inline search() expression. Use the optional role: keyword argument to filter by membership role.

// Bus stops that belong to route 42
$$ = search(node, highway: "bus_stop")
.member_of(search(relation, route: "bus", ref: "42"), role: "stop");

.member_of() works with relations (which have typed members) and ways (which contain ordered nodes).

.has_member() — filter to elements containing a source

Section titled “.has_member() — filter to elements containing a source”

The reverse of .member_of(). Keep only elements that contain the given source elements as members.

// All subway lines that pass through a specific station
$my_stop = search(node, railway: "station")
.around(distance: 500, lat: 40.75, lng: -73.99).first();
$$ = search(relation, route: "subway").has_member($my_stop);

Narrowing methods transform a set into a single element (GeoSetGeoElement). This distinction matters for the type system — see Attribute access below.

$stop = search(node, highway: "bus_stop")
.around(distance: 500, lat: 40.75, lng: -73.99).first();
$end = search(node, highway: "bus_stop")
.within(geometry: $route).last();
$second = search(node, amenity: "cafe")
.around(distance: 1000, geometry: point(48.85, 2.35)).index(2);

Extract tag values from variables using bracket syntax: $var[attr].

// Get the ref tag from $stop
$stop[ref]
// Use as a filter value
search(node, highway: "bus_stop", ref: $stop[ref]);

The type of the variable determines how the attribute value is used in SQL:

  • On a GeoElement (after .first(), .last(), or .index()): produces a scalar — exact match with = in SQL.
  • On a GeoSet (no narrowing): produces a value set — multi-value match with ANY(...) in SQL.
// Bus stops on route 42
$$ = search(node, highway: "bus_stop")
.member_of(search(relation, route: "bus", ref: "42"), role: "stop");
// Subway transfer map — all stations on lines through a nearby stop
$my_stop = search(node, railway: "station")
.around(distance: 500, lat: 40.75, lng: -73.99).first();
$lines = search(relation, route: "subway").has_member($my_stop);
$$ = search(node, railway: "station").member_of($lines);
// Attribute join — all stops sharing the same ref as a nearby stop
$stop = search(node, highway: "bus_stop")
.around(distance: 500, lat: 40.75, lng: -73.99).first();
$$ = search(node, highway: "bus_stop", ref: $stop[ref])
.bbox(40.7, -74.0, 40.8, -73.9);
// Cross-source — route ref matched against a custom dataset
$route = search(relation, route: "bus").bbox(40.7, -74.0, 40.8, -73.9).first();
$$ = search(point, dataset: "my_transit", route_ref: $route[ref]);