Skip to content

Spatial Polars Blog

A blog about the usage of spatial polars/ polars / spatial stuff / other musings.

...

Lazy Spatial Joins

When I initially started spatial polars, one of the things I really didn't like about what I had created was that it wasn't capable of doing a spatial join from a lazyframe. I messed around with some attempts at registering a spatial lazyframe namespace/adding spatial join functionality to it. My original attempt involved collecting the data from the lazy frames within the join function. The problem with that approach, is that it was collecting the query to build the spatial join's intermediate frame which joins the two input frames immediately, before the rest of the query was executed. That defeated the entire idea of the lazy spatial join. Clearly, I wasn't going to go with that. I knew there likely was a way to do what I wanted, but how to implement it evaded my thoughts until I bumped into the pl.defer() function a few weeks ago. Defer was added to the polars API over a year ago, I'm not sure if I missed it in the release notes when it got added, or if I simply didn't realize how useful it could/would be, but it's relatively new to me, and it's GREAT πŸ’₯!

Tell me what you're planning, I'll do it later.

Defer, per the docs, Takes a function that produces a DataFrame but defers execution until the LazyFrame is collected. It's absolutely perfect for what I needed! Inside the lazyframe's join function, I have another function wrapped with defer which collects the geometries of the left and right frames, then builds out a STRtree, and transforms the results into an intermediate dataframe that is used to join the left and right frames together. Because that joining frame is created by a function called with pl.defer, it doesn't get executed until the query is actually collected! lazy spatial join problem solved! well kinda...

What can't it do?

Streaming from traditional GIS sources

So one of the really cool things about polars lazyframes and the streaming engine is it's ability to work with datasets that are bigger than the RAM on the box processing the query. If you can't fit your data into RAM, this lazy spatial join still wont work, it needs to know all the geometries from both frames at the same time. There's no spilling to disk or anything like that. For the datasets I've worked with for the past 20+ years, that's not an issue, but if you've got super huge data you're working with, and/or limited RAM you're still out of luck here. That's not an issue I run into much/ever, so I'm honestly not motivated to try to solve it(1). I like being upfront and realistic πŸ˜„. That out of the way, I do like the idea of being able to use the polars streaming engine to write batches of data, so perhaps I'll try and figure it out at some point πŸ€”(2). Worth noting here, if your data is in parquet format, not a more "traditional" format (eg shapefiles, file geodatabase, geojson... stuff that spatial polars uses pyogrio to access) in my experimentation thus far, collecting queries which involve a lazy spatial join with the streaming engine works as expected.

  1. Just being real πŸ˜„, if you're in need of spatial ops on billions of rows, I doubt this will ever be the appropriate package
  2. I think there's something going sideways between polars and pyogrio...

So what CAN it do?

Regular spatial joins, join nearest and centroid KNN joins are all now available for your spatial data processing delight.

The syntax for the joins are the same as for the normal dataframes, we'll just operate on lazyframes instead of dataframes:

Lazy Spatial Join
import polars as pl
from spatial_polars import scan_spatial

lake_lf = scan_spatial("https://naciscdn.org/naturalearth/110m/physical/ne_110m_lakes.zip") # (1)!
boundary_lf = scan_spatial("https://naciscdn.org/naturalearth/110m/cultural/ne_110m_admin_0_countries.zip")  # (2)!

lake_boundary_lf = (
    lake_lf.spatial.join(  # (3)!
        other=boundary_lf,  # (4)!
        how="inner",  # (5)!
        predicate="intersects",  # (6)!
        on="geometry",  # (7)!
        suffix="_boundary",  # (8)!
    )
    .select(
        pl.col("name"),  # (9)!
        pl.col("SOVEREIGNT"),
        pl.col("geometry"),
        pl.col("geometry_boundary"),
    )
    .sort("name")  # (10)!
)

lake_boundary_df = lake_boundary_lf.collect(engine="in-memory")  # (11)!
print(lake_boundary_df)
  1. Scanning the lakes
  2. Scanning the boundaries
  3. Starting with the lake_df we'll start our spatial join
  4. Specifying to join the lakes to this boundary_df
  5. We'll use an inner join so as to only return rows for lakes that actually intersect a boundary. If a lake does not intersect a boundary polygon we won't have a row for that lake in our output dataframe. Likewise, if a boundary doesn't intersect a lake, the resulting dataframe won't have a row for that boundary.

    Note

    This could have been left off, because how='inner' is the default

  6. Use the 'intersects' spatial predicate so if any part of the lake shares any space with the boundary we'll join the lake to the boundary. Since we've specified an inner join, if a lake intersects more than one boundary, we'll get more than one row for the lake since it's joined to more than one boundary.

    Note

    This could have been left off, because predicate='intersects' is the default

  7. Since the name of the geometry struct is 'geometry' in both of our dataframes, we will use the on parameter, if we wanted to use a different column name for each of the dataframes we could use the left_on or right_on parameters.

    Note

    This could have been left off, because on='geometry' is the default

  8. Since we're joining the dataframes with a common column name (geometry), a suffix must be applied to the columns of the right dataframe that have names that exist in the left fram, because we can't have two columns with the same name. we'll use "_boundary" as the suffix to clarify that the geometry of the right frame came from the boundaries dataframe.

  9. Selecting the columns to make the lake name and SOVEREIGNT columns show up before the lake and boundary geometry columns.
  10. Sort by the lake name just to make the results look nice in our output dataframe.
  11. Note that "in-memory" engine.. streaming isn't working here for some reason. not sure what's causing it, but in my testing thus far my python kernel crashes when I try to use the streaming engine. I suspect something with pyogrio and polars aren't playing well together here, but haven't gotten to the root of the problem yet.

Why lazy?

When we execute a lazy query, we gain the optimizations from the polars query engine. In the example above, the lakes data has 39 columns, and the boundaries has 170. The result of our query only returns two string columns, and the geometries from the lakes and the boundary. If we look at the graph for the query, we can see that polars sees that we only need a two columns from each of our sources!(1), so the query engine tells our io-plugin to only read the name/geometry from the lakes, and the SOVEREIGNT/geometry from the boundaries. If we had to read all the columns from each of the source and then do our join and only keep a few fields at the end that's just wasting time/resources.

  1. Note the PYTHON SCAN 2/38 and 2/169 boxes at the bottom of the graph indicating only two columns will be read from the sources.

Lazy spatial join show graph output

That's it for now

I really like building stuff, and I enjoy writing up blogs like this about the stuff I've created. I do NOT like writing the end of them though. Like, what do I write here? I'm not trying to sell anything(1), I'm not necessarialy trying to get anyone to use this package(2), I don't have some sort of closing slogan(3). I guess I'll just channel my high school English class informative writing coursework, and tell you what I've already told you. Spatial polars can now do a spatial join from a lazyframe without the need to collect the query into a dataframe first! πŸ₯³ (4)

  1. It's free.
  2. I do think it's useful, and hopefully you do too!
  3. Ohhhh... maybe I need some sort of closing line like when Matt O-Dowd ends episodes of spacetime with a cleverly worded sentence that ends with "Spacetime", and then the episode ends, that'd be pretty rad, but I don't think I'm clever enough to pull off a lot of different ways of plugging "spatial polars" into the end of a sentence...
  4. High school english class didnt tell me anything about using emojis in my informative essays... because... well they didn't exist yet πŸ˜† πŸ‘΄

Hey look, a spatial polars blog! (or: why I made this)

I've been a huge fan of using polars for data engineering work since I started using it in early 2023. I had read about how polars was faster than pandas (1) for various operations, and figured it was worth a try. Like many polars users who have used pandas previously, I quickly fell in love with it's API, strong typing, and that it allows nulls in all of it's series no matter the datatype. The funny part is, I'm not a programmer or data engineer by schooling, I'm actually a geography dork who like solving problems with data(2). I've found over the past 20 something years, many problems in the data analytics world can benefit greatly from a geospatial data/processes, and many problems that are encountered in the traditional GIS/geospatial space can greatly benefit from the tooling/ideas that exist from outside the GIS space. That being said, polars has no native support for reading geospatial data formats. At work I have written functions that read from some ESRI formats and produce polars dataframes, but when I'm at home working with spatial data for personal projects I didn't have a seamless way to get data from formats like GPX or geopackages into polars.

  1. I had been using 🐼s daily for 6 ish years at this point
  2. I started programming out of the frustration that the tools I wanted to use didn't exist (or perhaps I was just looking in the wrong places for the tools I wanted πŸ€”)

IO plugins arrive

When expressions plugins for polars were introduced, I built a simple expression plugin(1), so when polars 1.22.0 came out with support for IO plugins, I was immediately interested in seeing how the guts of the IO plugins worked. The IO plugins section of the user guide made it very easy to understand what was needed. I was aware of pyogrio and how it was able to produce a pyarrow RecordBatchReader, and looking at the IO plugin guide it was obvious to me that they were a perfect fit for one another. I learn things best by doing, so I decided I wanted to see if I could take pyogrio and feed the data into the polars IO plugin. It took me a while to settle on how to structure the spatial data in the dataframes to make it easy to work with, performant, not require more RAM than necessary, and be able to work with custom coordinate systems, not just ones with a well known SRID(2). What I settled on was using a polars struct column containing two fields, one to store a geometry as WKB, and another that contains the coordinate refrence system's WKT stored in the field as a categorical which makes the RAM for the CRS essentially negligible. I proceeded to wire up the scan_spatial function in spatial polars and was very pleased at the speed at which I was able to read from all sorts of geospatial formats into a polars dataframe.

  1. This never made it to the python package index, someday I may spruce it up and create a package from it.
  2. I've encountered custom coordinate systems regularly in the past 2x years working with spatial data.

Spatial data deserves Spatial stuff

Now that I had created a way to scan a spatial source into a polars lazyframe, I needed to have a way to use the spatial data as it was structured in the frame. After all, reading the data is only half the battle(1), if I couldn't process and display the data, it had limited use. For operations, I knew I could rely on shapely's numpy ufuncs to come to the rescue to process spatial data quickly(2). For visualizing the data, I turned to lonboard because of it's ability to render large volumes of data dramatically faster than any other map viewer I've ever worked with.

  1. ...or something like that...
  2. The vectorized functions in shapely have fundamentally changed the way I approach processing spatial data πŸš€

So why make a new package and not contribute to something else?

Geopolars

Geopolars is blocked, although after I started working on the code for this project, the polars team has stated "We want to do this. We hope to get to this soonβ„’" which is totally awesome, and when that happens, I'm hopeful that I'll be able to contribute to that project, because I think it has a lot more potential than what I've got going here.

Polars ST

Polars ST is a working alternative to what I've created here, and it looks pretty niceπŸ‘! However the use of EWKB doesn't work for me and my desire to support coordinate reference systems which don't have an SRID, and I didn't feel that coming into a new project and saying something along the lines of "this is nice! can we restructure everything so it fits an edge case that I may run into someday?".

I wanted to learn something new

Before this project I had never put anything on the python package index, and all this fancy github CI/CD stuff was totally new to me, and I really wanted to learn more about using mkdocs/mkdocs-material for creating documentation like this. I figured what better way to learn it all than by doing it.

Why now?

The funny part is that I started writing this blog article almost 5 months ago, but it simply hasnt been high on my todo list. Then, the apache sedona people released sedonadb, a single node database engine that supports geospatial data. At the same time they also released spatialbench, a tool for assesing spatial query performance of various engines. I was absolutely PUMPED to see what they had come up with in spatialbench. Don't get me wrong, the query engine, sedonadb is pretty neat, it is SUPER fast πŸš€ (1) but I still prefer the expressions api provided by polars/spatial polars(2)(3). Spatialbench includes benchmarks of the same queries executed on the same data with SedonaDB, DuckDB, and GeoPandas. While building spatial polars, I kept second guessing myself about the speed I was seeing comparing the results of the operations to geopandas (4). I kept thinking to myself, "it'd be really great if we had some sort of benchmarking stuff similar to TPC-H or TPC-DS for comparing spatial queries" and then poof, there it was! They were thinking the same thing as I was, AND THEY DID IT! :hands-raised:. Now that we had a set of queries to compare the engines I set forth to see where spatial polars stacked up. I had to make a few changes to spatial polars along the way (specifically around generating convex hulls from multiple geometries resulting from a group_by, and I didn't have any sort of KNN join capabilities in spatial polars before trying to create Q12 (5)), But I've got all 12 queries built using spatial polars, and I'm currently working with the maintainers of spatial bench to add the queries using spatial polars πŸ˜„. So I figured what better way to celebrate all this stuff than finally getting around to finishing up that introductory blog article! πŸŽ‰

  1. A lot faster than what spatial polars offers, or ever will
  2. As a developer/analyst/data engineer, I'm much more productive chaining out polars contexts/epressions than I ever was at composing SQL (1)

    1. I wrote SQL successfuly for years, but since I was introduced to polars I've never looked back.
  3. If you're looking for all out speed on a single node for geospatial queries, you're better off over there than here.

  4. I've used geopandas a fair bit, but I certainly don't consider myself an authority, so I always had in the back of my mind that perhaps why I was seeing some dramatically faster results on some operations using spatial polars was just because I was doing GeoPandas wrong. 🀷
  5. I may have cheated on that KNN join, what I created only considers centroids of features... I'm not sure if the other pacakges consider the entire polygons or not.

The Future

I don't honestly think this is a long term project. I'm very impressed by the geoarrow project, and think that when the polars extensions are released to the world, and geopolars can really get going, I'll try to help out there and make that project a success. Although what I've created here is useful, I believe geopolars has a lot more potential than spatial polars, and I certainly don't have any interest in fragmenting the ecosystem more than I've potentially already done.