Deep Dives / Geometry
Geospatial, but make it fast
Spiral runs geometry operations directly on columns of coordinates, so we can compute without first decoding geometry into individual objects. Across our polygon benchmarks, that gave us a 5x overall speedup.[1]
Part 1 is a general introduction to geospatial workloads. You can skip to part 2 if you are only interested in how Spiral does columnar geospatial.
Part 1
Introduction
We look at maps all the time to answer questions. Is this address inside a delivery area? Which coffee shops are within a mile? Will this route take us through a construction zone? Each one is a spatial query, even if we don't think about them this way. We answer these questions by looking at constructs such as places, paths, and areas.
We can be more specific here. A point is simply a location in space, usually
measured along two axes called x and y.[2] A path is an ordered
list of those points, with straight line segments connecting each neighboring
pair. And a polygon is the area enclosed by a closed path.
Suppose we run a grocery delivery service and need to assign deliveries to couriers. We already ask a database which orders are ready, which stores have an item in stock, and which couriers are available. But availability is only part of the question. An "available" courier on the other side of the city might not be much help!
Ideally, the database should help us find a courier who's available and nearby. But few databases treat geometry as a first-class type. Without that support, we have to decide how to represent locations ourselves and write the code to interpret those values, calculate distances, and check containment.
What if we could store those locations directly in the database, without having to encode them as strings or split them across numeric columns?
A column could hold a point or polygon, just as it holds an integer or string. A courier's location could be a point, and a store's delivery area could be a polygon. The database would provide operations for distance and containment, so we could compare those locations in the same query that checks availability or inventory.
Once the database can work with geometry directly, finding nearby locations can be one step in a larger query. We can combine that result with other tables and calculations to answer questions like these:
Which available couriers are within a mile of this store? We can use a filter to find them, checking each courier's availability and distance from the store and keeping the rows that satisfy both conditions.
Which couriers are nearby for each store? We can use a join to match each store with the available couriers within a mile, giving us a row for every matching store-courier pair. The distance condition stays the same, but the store's location now comes from a table instead of a single query parameter.
How many nearby couriers does that give each store? We can group those pairs by store and count them, using the result of the spatial join as the input to an ordinary aggregate.
These are the same filters, joins, and aggregates we already use in relational databases, with geometry supplying another way to compare values.
We'll work through these operations, then look at how Spiral stores geometry and uses that layout to do less work when executing these queries. In part two, we'll also share benchmark results that put numbers to the speedups from Spiral's design and implementation.
Pickups, routes, and taxi zones
We can use the operations we introduced above to analyze taxi trips: match each pickup to a taxi zone, then add up fares by zone.
The examples below use pickups, routes, and zones in Lower Manhattan, New York
City. A pickup gives us a point, a route gives us a path, and a zone gives us a
polygon. The pickup, route, and zone tables each have a geometry column beside
columns such as a trip_id or zone (name of the zone).
New York City's Taxi and Limousine Commission (TLC) uses areas called taxi zones to report pickup and drop-off locations in its public trip data. Our examples use nineteen simplified zone boundaries from the official taxi-zone dataset. The illustrated route follows a section of the eastbound M8 bus, from Hudson Street to Avenue A, using MTA route data. The pickup locations are illustrative.[3]
You don't need to know your way around Manhattan to follow along!
Spatial filter
Let's start with the pickup location for trip 1042 and find the zone that
contains it. We supply that location, compare it with each zone's polygon, and
only keep the zone rows that contain the point. Notice how this is just a
filter!
In SQL, the condition goes in WHERE: st.within($pickup, boundary). The
$pickup parameter is the point we're looking up, and boundary is the
geometry column in the zone table storing the boundaries of the different zones.
The st. prefix identifies spatial functions.[4]
The other tabs in the example below show off different spatial conditions. Nearby points finds pickups within 400 meters of a location you can move on the map. Routes crossing zones searches the zone table for polygons that the route intersects. Both of these are filters as well!
Interactive example
Which taxi zone contains this pickup?
Drag the outlined pickup to see which zone contains it. The matching zone is highlighted and appears in the result table.
Pickup 1042 at (-73.9920°, 40.7424°). 1 matching zone: Union Sq.
Outlined: 19 zones in this example. Faded areas are outside these zones.
$pickup: trip 1042 at (-73.9920°, 40.7424°)
You can also select a point and use the arrow keys. Hold Shift for larger steps.
-- $pickup: point SELECT location_id, zone FROM taxi_zones WHERE st.within( $pickup, boundary );
| location_id | zone |
|---|---|
| 234 | Union Sq |
Spatial join
The point-in-zone filter takes just one location as a query parameter. But what if we want to look up the zone for every pickup in a table?
A spatial join uses the same st.within condition, but both inputs now come
from tables. Each matching pair gives us a row with the trip ID from one table
and the zone name from the other.
The example starts with nine pickups and three selected zones. All nineteen zones are available: try changing the selection or moving a pickup to see how it changes the result. Note that pickups outside the selected zones won't appear in the results.
Interactive example
Match pickups to zones
Drag a pickup to move it. Click a zone to include or exclude it. The result shows which zone each pickup is in.
Inputs: 9 pickups, 3 zones
Colored: included. Dashed: excluded. Faded areas are outside the nineteen available zones.
1042 -> Union Sq
Choose zones by name
Arrow keys move the focused pickup; hold Shift for larger steps. OpenStreetMap contributors
Output: 7 rows
st.within(pickup, boundary)
7 of 9 pickups match. Each row combines a trip ID with a zone.
| trip_id | zone |
|---|---|
| 1042 | Union Sq |
| 1043 | East Village |
| 1045 | Union Sq |
| 1046 | East Village |
| 1048 | Flatiron |
| 1049 | Flatiron |
| 1050 | East Village |
As humans, we can simply look at the map and see which zone each pickup is in. A spatial join gives us that result as rows we can use in the rest of a query.
Spatial aggregate
The spatial join tells us which zone each pickup is in. Now we can use that result to answer a question about fares: how much did passengers pay by card in each zone?
We can filter the joined rows to keep card payments, then group them by zone and sum their fares. That combines a spatial join with an ordinary filter and aggregate, all in one SQL query.
For this example, we'll use a larger set of 48 illustrative trips in and around four selected taxi zones. Trips outside those zones don't match the join, so they don't contribute to the totals. Change the payment method or minimum fare to filter the remaining trips, then play the aggregation to follow their fares into the zone totals.
Total fares by zone
One total per zone.
Payment method
Select a point to inspect its fare.
| zone | Fares to add | COUNT | SUM |
|---|---|---|---|
| East Village | +++++ | 6 | $135 |
| Hudson Sq | ++++ | 5 | $108 |
| Union Sq | +++ | 4 | $125 |
| World Trade Center | ++++ | 5 | $144 |
20 of 48 trips contribute to the totals.
SELECT zones.zone, COUNT(*) AS trip_count,
SUM(trips.fare_amount) AS total_fares
FROM trips
JOIN taxi_zones AS zones
ON st.within(trips.pickup, zones.boundary)
WHERE zones.location_id IN (234, 79, 125, 261)
AND trips.payment_method = 'card'
AND trips.fare_amount >= 0
GROUP BY zones.zone
ORDER BY zones.zone;Part 2
Do less, go faster.
Hopefully these examples make it clearer why we'd want geospatial support in a data system. We can query geospatial information alongside fares and payment methods, using the same filters, joins, and aggregates.
But supporting these queries and executing them efficiently are different problems. There's still a lot of performance to gain from how we represent geometry and compute on it.
Let's look at the different ways geometry can be stored, what that means for the work the engine has to do, and how we optimize these workloads in Spiral.
What's inside the geometry column?
A common way to store geometry is
Well-Known Binary, or WKB. It
stores each geometry as one binary value containing its type and coordinates.
For example, a path is represented by a point count followed by the path's
(x, y) pairs. This gives systems a common format for exchanging complete
geometries, letting a single binary column hold points, paths, and polygons.
However, suppose we only want to read every x coordinate in the geometry
column. Later, we're going to use each geometry's minimum and maximum x values
to find the left and right edges of its bounding box, so this is not an uncommon
query.
WKB stores each x beside its corresponding y, with type identifiers and
counts between coordinate sequences. The reader uses those identifiers and
counts to locate each sequence. It can skip decoding the y values, but CPUs
fetch memory in blocks, so reading the x values also brings in neighboring y
bytes.
This is a familiar tradeoff in database design. A row store keeps each row's values together, much as WKB keeps a geometry's type, counts, and coordinates together. That's useful when a query retrieves complete rows. But analytics often reads just a few columns across many rows. When those values share a storage block with other columns, reading that block brings in values the query doesn't need.
A column store, on the other hand, keeps each column's values together, letting the query read only the columns it needs. Here are a few of the benefits for analytical queries:[5]
- Keeping a column's values together improves locality: each memory block holds more values the query needs, so scans fetch fewer blocks.
- Values in a column often share patterns that compress well, reducing storage space and the number of bytes read.
- SIMD instructions can process several adjacent numeric values at once.[6]
- Multiple CPU cores can scan separate ranges of a column, each reading adjacent values.[7]
Essentially, placing WKB values in a column doesn't make the geometry inside
them columnar. To make the coordinates columnar as well, we can store x and
y in separate numeric arrays. Then the engine can scan every x coordinate
without reading the y array.
Because these are ordinary numeric arrays, we can use the database's existing compression techniques on them. But to get the benefit during computation, the geometry operations need to work with that layout too.
Coordinates are columns too
For points, we only need two arrays: one for the x coordinates and another for
the y coordinates. Row i gets its point from x[i] and y[i].
A path, or more formally a LineString, has a variable number of points, so we
also need to know which coordinates belong to each path. We keep the x and y
arrays and add integer offsets that mark where each path starts and ends. A
four-point path uses offsets [0, 4], which means "read coordinates starting at
position 0, stopping before position 4". If the next path has three points,
the column's offsets become [0, 4, 7].
Polygons add another level: coordinates form rings, and rings form polygons. The first ring encloses the area; any further rings cut holes out of it. Each ring repeats its first coordinate at the end to close the boundary.
Because a polygon can have several rings, we need offsets at both levels: ring
offsets locate coordinates in the x and y arrays, and polygon offsets locate
rings. In the example below, we use a small four-vertex polygon on a 0-to-10
grid so we can follow every coordinate. Select or move a vertex to see where it
lives in the arrays and in WKB:
Storing a polygon
Drag a vertex to change its coordinates in both layouts. Add a hole to see how another ring is stored.
Drag a vertex anywhere on the grid. Arrow keys move by 0.1; hold Shift to move by 1.
5 coordinate pairs. Coordinates stay f64; offsets use 32 bits.
- x
- 40 B
- y
- 40 B
- offsets
- 16 B
[0, 5)| index | |||||
|---|---|---|---|---|---|
| x | 1 | 9 | 8 | 1 | 1 |
| y | 1 | 1 | 9 | 8 | 1 |
ring offsets[0, 5]
polygon offsets[0, 1]
Highlighted offsets select coordinates [0, 5) for the vertex's ring. End indices are excluded. The closing slot repeats the first vertex to close the ring.
Sizes exclude metadata, validity bitmaps, and padding.
Little-endian WKB, shown in hex. Two digits per byte.
Vertex A: bytes -> coordinatesbytes 13..29 and 77..93
00 00 00 00 00 00 f0 3f-> 100 00 00 00 00 00 f0 3f-> 1Each pair stores an 8-byte x, then an 8-byte y. Byte ranges exclude the end. Both highlighted pairs contain these same bytes.
The reader uses the headers and counts to locate each ring, then steps through its x, y pairs. The arrays keep x and y separate.
The starting polygon has four vertices. Repeating the first one closes its ring
(A, B, C, D, A), giving us five stored coordinate pairs. The ring offsets
[0, 5] mark that coordinate range, and the polygon offsets [0, 1] say that
this polygon contains one ring.
Turn on Include hole to add a second ring (E, F, G, E). Now we have nine
coordinate pairs: the ring offsets become [0, 5, 9], and the polygon offsets
become [0, 2]. Moving a vertex changes its coordinates but leaves these
offsets alone, because the number of coordinates in each ring hasn't changed.
The offsets do more than keep the rings together. They also let us go straight
to part of a geometry: the hole starts at coordinate index 5, so its third
vertex is at index 7. We don't have to scan through the exterior ring first!
This is random access within a geometry. With WKB, a count precedes each
ring, so we have to follow the counts of the earlier rings to locate a later
one.
SQL still treats the whole polygon as one value. Underneath, it's a list of
rings, and each ring is a list of coordinate pairs stored in the x and y
arrays. The
GeoArrow specification
describes this layout.
Keeping offsets in their own arrays lets us compress them without changing the
coordinates. The demo above starts with 32-bit offsets, which take four
bytes each. With the hole included, the largest offset is only nine, so each
offset fits in one byte. Switch to 8-bit offsets to shrink the offset arrays
from 20 bytes to 5 (though do note that the x and y arrays stay the same
size).[8]
Because these are numeric arrays, we can additionally use the database's existing integer and floating-point compression techniques.
Keep the computation columnar
GeoArrow gives us a columnar representation, but that alone doesn't determine how a spatial function runs. An implementation can convert those arrays into individual geometry objects and call an existing geometry library. That works, but allocating those objects and copying coordinates into them adds work before the calculation even begins.
Spiral does the computation differently. We implement geometry operations directly on the coordinate and offset arrays, so calculating bounds or checking containment doesn't first require rebuilding each polygon as a separate object. This is columnar compute: the layout we use to store geometry is also the layout our operations work on.
Avoiding that conversion means fewer allocations and less coordinate copying before the operation can produce a result. That's why Spiral can be faster even when both implementations start with the same columnar data: our geometry implementations have less work to do.
Let's return to the minimum and maximum coordinates we wanted to find earlier.
For a polygon, its minimum and maximum x and y give us its bounding box:
the smallest rectangle with edges parallel to the axes that encloses it. The
next example shows those four values alongside the coordinates they come from.
Calculating a bounding box
Polygon and bounding box
Cartesian grid · 0 to 10Drag a vertex or edit its coordinates. Arrow keys move a focused vertex by 0.1; hold Shift to move by 1.
Vertex A: x 1, y 1. Bounds: x 1 to 9, y 1 to 9.
Coordinate arrays -> min / max -> box edges
[0, 2][0, 5, 9][0, 5)| index | |||||
|---|---|---|---|---|---|
| x | 1 | 9 | 8 | 1 | 1 |
| y | 1 | 1 | 9 | 8 | 1 |
[5, 9)| index | ||||
|---|---|---|---|---|
| x | 3 | 5 | 7 | 3 |
| y | 3 | 7 | 3 | 3 |
The closing slot repeats the first vertex to close each ring.
The offsets tell the engine which coordinates belong to each polygon, so it can apply ordinary numeric min and max operations to those ranges. The arrays we used to store the polygon are now the inputs to the computation, without parsing WKB or constructing a polygon object first.[9]
Across our polygon microbenchmarks, columnar compute gave us a 5x overall
speedup on x86. Individual coordinate scans saw larger gains: up to 17x
for bounding boxes and 30x for the minimum x coordinate.[1]
Reject the obvious misses
Suppose a taxi pickup point is further north than an entire zone. Comparing the
point's y coordinate with ymax, the top edge of the zone's bounding box, is
enough to rule out a match, without inspecting any of the rings.
The same reasoning works in all four directions. A point outside the bounding box must also be outside the polygon. At most four coordinate comparisons can spare us a point-in-polygon check.
The edge case (as shown above) is that a point inside the box may still be inside one of the polygon's holes, or in the area between the polygon and the bounding box. So checking a point against a zone's bounding box may result in a false positive (the box test passes even though the point is outside the zone), so we still need to do a point-in-polygon check.
Skip whole chunks
Let's return to the pickup point 1042 at its starting position in Union
Square. A bounding box lets us rule out a polygon without checking its rings,
and the same idea can let us skip reading a whole group of polygons.
Suppose we store the zone rows in chunks, groups of rows that we read together. Reading many rows in one storage request spreads the overhead of that request across those rows, so data systems will often do this to their stored data.
We can store a bounding box alongside each chunk, enclosing all of its zones instead of just one. If the pickup is outside that box, none of those zones can contain it, so we can skip the chunk without reading its geometry. This works much like timestamp min/max statistics: a query can skip a chunk whose time range doesn't overlap the one it's looking for.[10]
But the order of the rows matters. If you put zones from opposite ends of Manhattan in the same chunk, its box will stretch across most of the map. A pickup can land inside that box while being nowhere near any of the zones it contains.
Spatial ordering puts nearby shapes together. In this example, we take the
center of each geometry's bounding box and give it a Morton key, also called
a Z-order key. We place the center on a grid, then combine the bits of its grid
coordinates into one sortable number, alternating between x and y. Sorting
by that number tends to bring nearby centers together, which can give the chunks
tighter bounds.[11]
Sorting the zone rows
Find the zone containing pickup 1042.
We read fourteen rows in source order and ten after sorting.
Pickup 1042 is the query point. We fetch a chunk if its box contains that point; otherwise, we skip it. Select a chunk to inspect its zones and box.
Source order
14 rows read3 chunks fetched
Morton order
10 rows read2 chunks fetched
Amber: fetch the chunk. Gray: skip it. The fetched zones still need individual checks.
For pickup 1042, sorting reduces the zone rows we read from fourteen to ten.
Note that the query and the polygons haven't changed, only which zones share a
chunk.
Don't check every pair
So far, we've used bounding boxes to skip reading zones that cannot contain a pickup. But in the spatial join from part one, we need to find the matching zones for every pickup in a table.
An R-tree can make these lookups faster by ruling out groups of zones at once. It applies the same bounding-box idea at several levels: we put the zone boxes into groups, enclose each group in another box, and repeat until one box encloses the whole tree.
If a pickup is outside a group's box, none of the zones in that group can contain it, so we can skip their individual boxes too! Otherwise, we continue searching inside that group.
We can build the tree once for the zone table and reuse it for every pickup in the join.[12]
The example below uses a small tree over all nineteen taxi zones. Its groups are
separate from the storage chunks above. Try moving pickup 1042, then play the
search to see which groups we skip and which zones still need a polygon check.
Find the zones for pickup 1042
Search all nineteen zones using the same containment condition as the spatial join above.
Return (1042, Union Sq)
Click the map or drag 1042 to update the result. Play search shows which boxes and polygons the tree checks. Arrow keys move the selected point; hold Shift for larger steps.
Blue: testing. Amber: keep searching. Faded outlines: rejected.
1. Test the group bounds
- L1Inside: inspect 6 zones
- L2Outside: skip 6 zones
- L3Inside: inspect 6 zones
- L4Outside: skip 1 zone
1 root bound + 4 group bounds tested. 7 zone bounds skipped.
2. Test the remaining zone bounds
12 boxes tested, 2 candidates.
L1 · 6 zone bounds
L3 · 6 zone bounds
× outside the box · ? needs a polygon check
3. Check the candidate polygons
2 polygon checks, 1 match. 17 polygons need no exact check.
Result (trip_id, zone)
(1042, "Union Sq")
An illustrative tree over all nineteen taxi zones, separate from the chunk comparison above. Matches use st.within(pickup, boundary).
At the starting position, the group boxes let us skip seven of the nineteen zone boxes. Checking the remaining twelve leaves two candidates, Flatiron and Union Square, whose boxes contain the pickup.[13]
Notice how Flatiron's box contains the pickup even though its polygon doesn't.
This is the same kind of false positive we saw earlier. Checking both polygons
leaves just (1042, "Union Sq") in the result.
For the remaining polygon checks, we can work directly with our coordinate
arrays. The polygon offsets tell us which rings to read, and the ring offsets
tell us where their coordinates are in the x and y arrays. That gives the
containment test the edges it needs to check.[14]
Columnar geo in Spiral
In most systems, a polygon is still just one opaque value in a column. Spiral is able to take advantage of the internal structure of the values, allowing it to store, compress, query, and compute over geospatial data much more efficiently and effectively.
If you're working with geospatial data, get in touch! We'd love to hear what you're trying to query, and we can likely help you do it faster.
Footnotes
-
The 5x figure rounds the 4.92x geometric mean across eighteen dense cases on Linux x86. The baseline decodes WKB into owned GeoRust geometry objects, including decoding and allocation before computation. The suite combines existing spatial kernels with benchmark-local bounding-box and
xminscans, which produce the largest gains. ↩ ↩2 -
In the Manhattan examples, the coordinate readouts use longitude for
xand latitude fory, both measured in degrees. To calculate distances in meters, we can project those locations onto a suitable coordinate system. The distance examples use a local approximation for this small area of Manhattan; their coordinate readouts stay in longitude and latitude. ↩ -
Trip records from the New York City Taxi and Limousine Commission (TLC) identify pickup and drop-off zones, not precise pickup coordinates like those in these examples. ↩
-
What does
STstand for? Originally, "Spatial and Temporal", but the temporal part of the standard never developed. It's also interpreted as "Spatial Type", as the PostGIS manual explains. Ourst.prefix follows that SQL naming convention. ↩ -
For more detail, see CMU's Column-Store Databases lecture and Abadi et al.'s The Design and Implementation of Modern Column-Oriented Database Systems, especially sections 2.3 (performance tradeoffs) and 4.2 (compression). ↩
-
SIMD stands for "single instruction, multiple data". The Arrow format documentation explains how numeric arrays can be laid out for SIMD instructions. ↩
-
Row stores can parallelize scans too. The columnar layout keeps each core's input values adjacent in memory, so each core gets the locality benefit. ↩
-
With the hole included, the coordinates stay as uncompressed
f64values and take 144 bytes. Adding the five one-byte offsets gives 149 bytes in total. This illustrates offset compression; Arrow's in-memory list layout uses 32-bit or 64-bit offsets. The displayed sizes count data buffers, excluding metadata and padding. The WKB size is uncompressed, so this isn't a comparison of compressed file sizes. ↩ -
We can calculate bounding boxes from WKB too, even without constructing polygon objects. The reader still has to interpret WKB's headers and counts to find the coordinates. Our arrays let the engine apply its existing numeric min/max operations directly, without that parsing step. Once the bounding box is available, testing a point against it uses the same comparisons regardless of how the geometry is stored. ↩
-
Analytical databases often call these summaries zone maps or min/max indexes. If every chunk mixes timestamps from across a year, a query for one day may overlap every chunk's range, leaving nothing to skip. Sorting by time, or grouping nearby timestamps together, makes those ranges narrower. Perfect sorting isn't necessary, but random order often makes min/max pruning ineffective. DuckDB's guide to ordering and zone maps explains this tradeoff. ↩
-
Spatial ordering works with WKB too. Given the same chunk boundaries and stored boxes, either representation can skip the same chunks. GeoParquet supports bounding-box columns alongside WKB for this purpose. Its native coordinate encodings instead expose numbers to Parquet's existing min/max statistics. This is the benefit of our layout too: the engine can compute bounds using its numeric operations and use the coordinate arrays directly for the geometry checks that remain. Sorting doesn't help every query; another pickup can still require as many chunks, or more. ↩
-
An R-tree can reduce comparisons relative to a nested-loop join, including one that checks bounding boxes first. As with a hash join, we build a lookup structure on one input and probe it with the other. The savings depend on how well the boxes separate the data and whether they outweigh the cost of building the tree. DuckDB's spatial join explanation describes this approach. ↩
-
The tree performs seventeen box checks here: one root, four groups, and twelve zones. Scanning all nineteen zone boxes would find the same two candidates. The tree skips seven zone boxes but adds five root and group checks, so the net saving is only two box checks in this small example. These counts describe the work done; they aren't query timings. Large or overlapping group boxes can leave more branches to search. And if several polygons contain a pickup, the join must return all of those matches. ↩
-
An R-tree works with WKB too. The same boxes produce the same candidates, regardless of how the polygons are stored. The difference comes when we check those candidates: a WKB reader must interpret its headers and counts to locate the coordinates. Our layout already exposes them as numeric arrays, so the engine can use those coordinates directly without parsing WKB. ↩