Skip to content
GuidesBlogPlaygroundDashboard

Set Operations & Outputs

Combine result sets, produce multiple outputs, and control sorting, pagination, output modes, and aggregation in PlazaQL.

// All food and drink places
$food = search(amenity: "restaurant") + search(amenity: "cafe") + search(amenity: "bar");
$food.within(geometry: $city).limit(count: 100);

Union preserves types when possible: PointSet + PointSet stays PointSet. Mixed types become GeoSet.

// All cafes except Starbucks
search(amenity: "cafe") - search(name: "Starbucks");
// All food places except fast food
$all_food = search(amenity: "restaurant") + search(amenity: "cafe");
$fast = search(cuisine: "burger") + search(cuisine: "pizza");
($all_food - $fast).within(geometry: $downtown);

Difference preserves the left operand’s type.

Intersection (&) — keep only shared results

Section titled “Intersection (&) — keep only shared results”
// Cafes that are also Italian
$cafes = search(amenity: "cafe");
$italian = search(cuisine: "italian");
$cafes & $italian;
// Wheelchair-accessible restaurants with outdoor seating
$accessible = search(amenity: "restaurant", wheelchair: "yes");
$outdoor = search(amenity: "restaurant", outdoor_seating: "yes");
($accessible & $outdoor).within(geometry: $city);

Intersection keeps only elements present in both sets. Useful for combining independent filter criteria that can’t be expressed in a single search().

A single query can produce multiple named result sets using $$.name:

$nyc = boundary(name: "New York City");
$$.museums = search(tourism: "museum").within(geometry: $nyc);
$$.parks = search(leisure: "park").within(geometry: $nyc);
$$.transit = search(node, railway: "station").within(geometry: $nyc);

Each named output appears as a separate key in the response. This is more efficient than running three separate queries because the area resolution happens once.

Named outputs ($$.name) can be referenced as values in later statements — assign once, then use the same name downstream:

$$.route = route(origin: point(40.71, -74.00), destination: point(40.75, -73.98), mode: "foot");
$$.stops = search(node, amenity: "cafe").around(distance: 200, geometry: $$.route);

$$.route appears in the response AND serves as the geometry for the .around() filter — no intermediate variable needed.

Rules:

  • Named outputs are immutable — you cannot reassign $$.name once set
  • No forward references — define $$.name before referencing it
  • Use regular $var variables for intermediate values that don’t need to appear in the response

For single-output queries, a bare expression works the same as assigning to $$:

// These are equivalent
search(amenity: "cafe").limit(count: 10);
$$ = search(amenity: "cafe").limit(count: 10);

When a query has multiple outputs, you often want to apply the same spatial filter or limit to all of them. Global directives let you do this with the # prefix — any chainable method can be used as a directive:

#within(boundary("Berlin"))
#limit(100)
$$.cafes = search(amenity: "cafe");
$$.bars = search(amenity: "bar");
$$.restaurants = search(amenity: "restaurant");

This is equivalent to calling .within(geometry: boundary(name: "Berlin")).limit(count: 100) on each output individually. Directives go at the top of the query and apply to every output statement.

The # prefix isn’t a separate set of functions — it’s any method you’d normally chain, written as a directive:

#within(boundary("Berlin"))
#bbox(47.0, 10.0, 48.0, 11.0)
#filter(amenity: *)
#limit(100)
#precision(5)

Directives stack and accumulate with AND semantics. For single-value directives like #limit, last-one-wins:

#within(boundary("Germany"))
#within(boundary("Bavaria")) // Further narrows — both apply (AND)
#limit(200)
#limit(100) // Last-one-wins — limit is 100
$$.parks = search(leisure: "park");
$$.lakes = search(natural: "water", water: "lake");

Directives are most useful with multiple outputs. For single-output queries, just chain methods directly — there’s no benefit to using directives.

.sort() takes an expression — the same expression language used in .filter():

// Sort by tag value
search(amenity: "school")
.within(geometry: $city)
.sort(t["name"]);
// Sort by distance from a point
search(amenity: "cafe")
.around(distance: 1000, geometry: point(48.85, 2.35))
.sort(distance(point(48.85, 2.35)));
// Sort descending
search(way, leisure: "park")
.within(geometry: $city)
.sort(area(), order: :desc);
// Sort by numeric tag value
search(node, amenity: "restaurant")
.within(geometry: $city)
.sort(number(t["capacity"]), order: :desc);
// Quadtile sort (spatial clustering for efficient map rendering)
search(way, building: *)
.bbox(40.7, -74.0, 40.8, -73.9)
.sort(by: :qt);

Sort expressions: t["key"] (tag value), distance(point(...)), area(), length(), elevation(), number(t["key"]), id(). Default order is ascending; use order: :desc for descending.

Both positional and keyword forms work: .sort(t["name"]) and .sort(by: t["name"]) are equivalent.

Quadtile sort (:qt) orders results by spatial locality — features near each other on the map are adjacent in the result. This is useful for map rendering and tiling workflows where spatial clustering improves performance.

search(amenity: "cafe")
.around(distance: 500, geometry: point(48.85, 2.35))
.limit(count: 20);
// Page 2 (results 21-40)
search(amenity: "cafe")
.within(geometry: $city)
.sort(t["name"])
.limit(count: 20)
.offset(skip: 20);

By default, queries return full features with geometry and all tags. Output mode methods change what’s returned:

search(amenity: "cafe").within(geometry: $berlin).count();
// Returns: { "count": 4521 }
search(amenity: "cafe").within(geometry: $berlin).ids();
// Returns: [123456, 789012, ...]
search(amenity: "cafe").within(geometry: $berlin).tags();
search(way, building: *).bbox(40.7, -74.0, 40.8, -73.9).skel();
search(way, building: *).bbox(40.7, -74.0, 40.8, -73.9).geom();

.geom() returns full geometry for each feature. This is an alias for the default output behavior — it’s useful when you want to be explicit about requesting geometry, especially in multi-output queries where other outputs use .tags() or .ids().

Output modes are terminal — they must be the last method in the chain, and only one is allowed per output.

Aggregation functions compute summary statistics over a result set. They are terminal — like output modes, they end the chain.

Plain .count() returns a single number. Combined with .group_by(), it returns counts per group:

// Total cafes in Berlin
search(amenity: "cafe").within(geometry: $berlin).count();
// Returns: { "count": 4521 }
// Cafes grouped by cuisine
search(amenity: "cafe")
.within(geometry: $berlin)
.group_by(t["cuisine"])
.count();
// Returns: { "italian": 150, "french": 89, "japanese": 42, ... }
// Total seating capacity of restaurants in a neighborhood
search(amenity: "restaurant")
.within(geometry: $neighborhood)
.sum(number(t["capacity"]));
// Returns: { "sum": 12450 }
// Average rating of restaurants by cuisine
search(amenity: "restaurant")
.within(geometry: $city)
.group_by(t["cuisine"])
.avg(number(t["rating"]));
// Returns: { "italian": 4.2, "japanese": 4.5, "mexican": 3.8, ... }
// Shortest and tallest buildings
search(way, building: *)
.within(geometry: $downtown)
.min(number(t["height"]));
search(way, building: *)
.within(geometry: $downtown)
.max(number(t["height"]));

.group_by() takes a tag access expression and partitions results before applying an aggregation function:

// Count amenities by type
search(amenity: *)
.within(geometry: $city)
.group_by(t["amenity"])
.count();
// Returns: { "cafe": 1200, "restaurant": 890, "bar": 450, ... }
// Average building height by type
search(way, building: *)
.within(geometry: $downtown)
.group_by(t["building"])
.avg(number(t["height"]));
// Returns: { "residential": 12.5, "commercial": 28.3, "industrial": 8.1, ... }

Tag values are strings in OSM. Use number() to cast them for arithmetic aggregations like .sum(), .avg(), .min(), and .max(). .count() doesn’t need number() since it just counts elements. See Expressions for details on number() and t["key"].