<?xml version="1.0" encoding="UTF-8" ?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" version="2.0"><channel><title>Paul Ramsey | CrunchyData Blog</title>
<atom:link href="https://www.crunchydata.com/blog/author/paul-ramsey/rss.xml" rel="self" type="application/rss+xml" />
<link>https://www.crunchydata.com/blog/author/paul-ramsey</link>
<image><url>https://www.crunchydata.com/build/_assets/paul-ramsey.png-POMCJCK4.webp</url>
<title>Paul Ramsey | CrunchyData Blog</title>
<link>https://www.crunchydata.com/blog/author/paul-ramsey</link>
<width>834</width>
<height>835</height></image>
<description>PostgreSQL experts from Crunchy Data share advice, performance tips, and guides on successfully running PostgreSQL and Kubernetes solutions</description>
<language>en-us</language>
<pubDate>Tue, 09 Dec 2025 08:00:00 EST</pubDate>
<dc:date>2025-12-09T13:00:00.000Z</dc:date>
<dc:language>en-us</dc:language>
<sy:updatePeriod>hourly</sy:updatePeriod>
<sy:updateFrequency>1</sy:updateFrequency>
<item><title><![CDATA[ PostGIS Performance: Simplification ]]></title>
<link>https://www.crunchydata.com/blog/postgis-performance-simplification</link>
<description><![CDATA[ Slim down the size of geometries with ST_Simplify. Also learn about ST_SimplifyVW, ST_RemoveRepeatedPoints, ST_SnapToGrid, ST_ReducePrecision, and ST_CoveranceClean to make your PostGIS as snappy as ever. ]]></description>
<content:encoded><![CDATA[ <p>There’s nothing simple about simplification! It is very common to want to slim down the size of geometries, and there are lots of different approaches to the problem.<p>We will explore different methods starting with <a href=https://postgis.net/docs/ST_Letters.html>ST_Letters</a> for this rendering of the letter “a”.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/87b6bf75-85ef-4198-0bc3-6ff8ee860f00/public><pre><code class=language-sql>SELECT ST_Letters('a');
</code></pre><p>This is a good starting point, but to show the different effects of different algorithms on things like redundant linear points, we need a shape with more vertices along the straights, and fewer along the curves.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/7ef69e54-94ed-4c81-2ac0-49b908572f00/public><pre><code class=language-sql>SELECT ST_RemoveRepeatedPoints(ST_Segmentize(ST_Letters('a'), 1), 1);
</code></pre><p>Here we add in vertices every one meter with <a href=https://postgis.net/docs/ST_Segmentize.html>ST_Segmentize</a> and <a href=https://postgis.net/docs/ST_RemoveRepeatedPoints.html>ST_RemoveRepeatedPoints</a> to thin out the points along the curves. Already we are simplifying!<p>Lets apply the same “remove repeated” algorithm, with a 10 meter tolerance.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/f9915d0e-ee91-4c92-3953-9c6bbd6c3f00/public><pre><code class=language-sql>WITH a AS (
  SELECT ST_RemoveRepeatedPoints(ST_Segmentize(ST_Letters('a'), 1), 1) AS a
)
SELECT ST_RemoveRepeatedPoints(a, 10) FROM a;
</code></pre><p>We do have a lot fewer points, and the constant angle curves are well preserved, but some straight lines are no longer legible as such, and there are redundant vertices in the vertical straight lines.<p>The <a href=https://postgis.net/docs/ST_Simplify.html>ST_Simplify</a> function applies the <a href=https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm>Douglas-Peuker</a> line simplification algorithm to the rings of the polygon. Because it is a line simplifier it does a cruder job preserving some aspects of the polygon area like squareness of the top ligature.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/30262e64-b2e3-4972-ca52-5cd0b6cdc100/public><pre><code class=language-sql>WITH a AS (
  SELECT ST_RemoveRepeatedPoints(ST_Segmentize(ST_Letters('a'), 1), 1) AS a
)
SELECT ST_Simplify(a, 1) FROM a;
</code></pre><p>The <a href=https://postgis.net/docs/ST_SimplifyVW.html>ST_SimplifyVW</a> function applies the Visvalingam–Whyatt algorithm to the rings of the polygon. Visvalingam–Whyatt is better for preserving the shapes of polygons than Douglas-Peuker, but the differences are subtle.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/3a27cbbf-963f-418e-1e73-48fb92344600/public><pre><code class=language-sql>WITH a AS (
  SELECT ST_RemoveRepeatedPoints(ST_Segmentize(ST_Letters('a'), 1), 1) AS a
)
SELECT ST_SimplifyVW(a, 5) FROM a;
</code></pre><p>Coercing a shape onto a fixed precision grid is another form of simplification, sometimes used to force the edges of adjacent objects to line up exactly. The original such function, <a href=https://postgis.net/docs/ST_SnapToGrid.html>ST_SnapToGrid</a>, does exactly what it says on the name. Every vertex is rounded to a fixed grid point.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/c5d2b0aa-8e11-4108-2f81-3b903fc63200/public><pre><code class=language-sql>WITH a AS (
  SELECT ST_RemoveRepeatedPoints(ST_Segmentize(ST_Letters('a'), 1), 1) AS a
)
SELECT ST_SnapToGrid(a, 5) FROM a;
</code></pre><p>However, as you can see at the top left, the grid snapper frequently generates invalidity in polygons, such as the self-intersecting ring in this example.<p>A more modern alternative is precision reduction.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/5b44a882-66f6-4009-d2a8-88115dac5200/public><pre><code class=language-sql>WITH a AS (
  SELECT ST_RemoveRepeatedPoints(ST_Segmentize(ST_Letters('a'), 1), 1) AS a
)
SELECT ST_ReducePrecision(a, 5) FROM a;
</code></pre><p>The <a href=https://postgis.net/docs/ST_ReducePrecision.html>ST_ReducePrecision</a> function not only snaps geometries to a fixed precision grid, it also ensures that outputs are always valid.<p>Because grid snapping tends to introduce a lot of vertices along straight edges, combining it with a line simplifier makes a lot of sense.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/6b7d8270-4d68-4a95-2afe-9a1f1a7ef000/public><pre><code class=language-sql>WITH a AS (
  SELECT ST_RemoveRepeatedPoints(ST_Segmentize(ST_Letters('a'), 1), 1) AS a
)
SELECT ST_Simplify(ST_ReducePrecision(a, 5),1) FROM a;
</code></pre><p>Simplifying single geometries is all well and good, but what about simplifying groups of geometries? Specifically ones that share boundaries?<p>Fortunately, since PostGIS 3.6 there is now a complete set of functions for that problem.<p>Starting with a pair of polygons with a non-matched shared boundary.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/87cc07bf-1bad-4b6d-9870-108204f06d00/public><p>Non-clean boundaries can be cleaned up with the <a href=https://postgis.net/docs/ST_CoverageClean.html>ST_CoverageClean</a> function.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/28bdf680-61db-4403-f7a7-c8f8df052100/public><pre><code class=language-sql>SELECT ST_CoverageClean OVER() AS geom FROM polys;
</code></pre><p>And once the coverage is clean, the shapes including their shared borders can be simplified with <a href=http://st_coveragesimplify/>ST_CoverageSimplify</a>.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/7c683fde-0aa2-4225-9f40-5a4f3ef0d200/public><pre><code class=language-sql>WITH clean AS (
  SELECT ST_CoverageClean OVER() AS geom FROM polys
)
SELECT ST_CoverageSimplify(geom, 10) OVER() FROM clean
</code></pre> ]]></content:encoded>
<category><![CDATA[ PostGIS Performance ]]></category>
<author><![CDATA[ Paul.Ramsey@crunchydata.com (Paul Ramsey) ]]></author>
<dc:creator><![CDATA[ Paul Ramsey ]]></dc:creator>
<guid isPermalink="false">457287f269c3bd9a2b4a707e0d61a3a311672065875f1932040c593ac774b7b5</guid>
<pubDate>Tue, 09 Dec 2025 08:00:00 EST</pubDate>
<dc:date>2025-12-09T13:00:00.000Z</dc:date>
<atom:updated>2025-12-09T13:00:00.000Z</atom:updated></item>
<item><title><![CDATA[ PostGIS Performance: Data Sampling ]]></title>
<link>https://www.crunchydata.com/blog/postgis-performance-data-sampling</link>
<description><![CDATA[ Paul shows off some tricks for sampling data, instead of querying everything. This works for regular Postgres queries too! ]]></description>
<content:encoded><![CDATA[ <p>One of the temptations database users face, when presented with a huge table of interesting data, is to run queries that interrogate every record. Got a billion measurements? What’s the average of that?!<p>One way to find out is to just calculate the average.<pre><code class=language-sql>SELECT avg(value) FROM mytable;
</code></pre><p>For a billion records, that could take a while!<p>Fortunately, the “Law of Large Numbers” is here to bail us out, stating that the average of a sample approaches the average of the population, as the sample size grows. And amazingly, the sample does not even have to be particularly large to be quite close.<p>Here’s a table of 10M values, randomly generated from a normal distribution. We know the average is zero. What will a sample of 10K values tell us it is?<pre><code class=language-sql>CREATE TABLE normal AS
  SELECT random_normal(0,1) AS values
    FROM generate_series(1,10000000);
</code></pre><p>We can take a sample using a sort, or using the <code>random()</code> function, but both of those techniques first scan the whole table, which is exactly what we want to avoid.<p>Instead, we can use the PostgreSQL <code>TABLESAMPLE</code> feature, to get a quick sample of the pages in the table and an estimate of the average.<pre><code class=language-sql>SELECT avg(values)
  FROM normal TABLESAMPLE SYSTEM (1);
</code></pre><p>I get an answer – 0.0031, very close to the population average – and it takes just 43 milliseconds.<p>Can this work with spatial? For the right data, it can. Imagine you had a table that had one point in it for every person in Canada (36 million of them) and you wanted to find out how many people lived in Toronto (or this red circle around Toronto).<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/97df422e-dc48-43c4-4976-a76dac474100/public><pre><code class=language-sql>SELECT count(*)
  FROM census_people
  JOIN yyz
    ON ST_Intersects(yyz.geom, census_people.geom);
</code></pre><p>The answer is 5,010,266, and it takes 7.2 seconds to return. What if we take a 10% sample?<pre><code class=language-sql>SELECT count(*)
  FROM census_people TABLESAMPLE SYSTEM (10)
  JOIN yyz
    ON ST_Intersects(yyz.geom, census_people.geom);
</code></pre><p>The sample is 10%, and the answer comes back as 508,292 (near one tenth of our actual measurement) in 2.2 seconds. What about a 1% sample?<pre><code class=language-sql>SELECT count(*)
  FROM census_people TABLESAMPLE SYSTEM (1)
  JOIN yyz
    ON ST_Intersects(yyz.geom, census_people.geom);
</code></pre><p>The sample is 1%, and the answer comes back as 50,379 (near one hundredth of our actual measurement) in 0.2 seconds. Still a good estimate!<p>Is this black magic? No, the <code>TABLESAMPLE SYSTEM</code> mode gets its speed by reading pages randomly. In our last example, it randomly chose 1% of the pages. Here’s what that looks like in Toronto.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/5aecb50d-6b63-453b-3d96-0c2d55d82400/public><p>See in particular how blotchy the data are in the suburban areas outside the circle. The data in the table are not randomly distributed to the pages, they came from the census data in order, and ended up loaded into the database in order. So for any given database page, the actual rows in the page will tend to be near to one another.<p>This works for this example because the amount of data is high, and the area we are summarizing is a large proportion of the total data – a seventh of the Canadian population lives in that circle.<p>If we were summarizing a smaller area, the results would not have been so good.<p>The <code>TABLESAMPLE SYSTEM</code> is a powerful tool, but <strong>you have to be sure that any given page has a random selection of the data you are sampling for</strong>. Our random normal example worked perfectly, because the data were perfectly random. A sample of time series data would not work well for sample time windows (the data were probably stored in order of arrival) but might work for sampling some other value. ]]></content:encoded>
<category><![CDATA[ PostGIS Performance ]]></category>
<author><![CDATA[ Paul.Ramsey@crunchydata.com (Paul Ramsey) ]]></author>
<dc:creator><![CDATA[ Paul Ramsey ]]></dc:creator>
<guid isPermalink="false">e72f061428ac799d9d20d237d604aff51c0c0fa58b180bbff4ee094e412d0245</guid>
<pubDate>Fri, 21 Nov 2025 08:00:00 EST</pubDate>
<dc:date>2025-11-21T13:00:00.000Z</dc:date>
<atom:updated>2025-11-21T13:00:00.000Z</atom:updated></item>
<item><title><![CDATA[ PostGIS Performance: Intersection Predicates and Overlays ]]></title>
<link>https://www.crunchydata.com/blog/postgis-performance-intersection-predicates-and-overlays</link>
<description><![CDATA[ What is difference between the boolean true / false ST_Intersects and ST_Contains and the overlay options of ST_Intersection and ST_Difference? Also, combining these two ideas can get you really fast queries for geometries fully contained inside areas. ]]></description>
<content:encoded><![CDATA[ <p>In this <a href=https://www.crunchydata.com/blog/topic/postgis-performance>series</a>, we talk about the many different ways you can speed up PostGIS. A common geospatial operation is to clip out a collection of smaller shapes that are contained within a larger shape. Today let's review the most efficient ways to query for things <strong>inside</strong> something else.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/f257c746-d3ab-40b7-d3e6-32ad2211f900/public><p>Frequently the smaller shapes are clipped where they cross the boundary, using the ST_Intersection function.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/e0552ac5-a465-4366-a199-ab62b6a2b800/public><p>The naive SQL is a simple spatial join on ST_Intersects.<pre><code class=language-sql>SELECT ST_Intersection(polygon.geom, p.geom) AS geom
  FROM parcels p
  JOIN polygon
    ON ST_Intersects(polygon.geom, p.geom);
</code></pre><p>When run on the small test area shown in the pictures, the query takes about 14ms. That’s fast, but the problem is small, and larger operations will be slower.<p>There is a simple way to speed up the query that takes advantage of the fact that <strong>boolean spatial predicates are faster than spatial overlay operations</strong>.<p>What?<ul><li>“Boolean spatial predicates” are functions like <a href=https://postgis.net/docs/ST_Intersects.html>ST_Intersects</a> and <a href=https://postgis.net/docs/ST_Contains.html>ST_Contains</a>. They take in two geometries and return “true” or “false” for whether the geometries pass the named test.<li>“Spatial overlay operations” are functions like <a href=https://postgis.net/docs/ST_Intersection.html>ST_Intersection</a> or <a href=https://postgis.net/docs/ST_Difference.html>ST_Difference</a> that take in two geometries, and generate a new geometry based on the named rule.</ul><p>Predicates are faster because their tests often allow for logical short circuits (once you find any two edges that intersect, you know the geometries intersect) and because they can make use of the <a href=https://libgeos.org/doxygen/classgeos_1_1geom_1_1prep_1_1PreparedGeometry.html>prepared geometry optimizations</a> to cache and index edges between function calls.<p>The speed-up for spatial overlay simply observes that, for most overlays there is a large set of features that can be added to the result set unchanged – the features that are fully contained in the clipping shape. We can identify them using ST_Contains.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/392aa63d-d53f-497e-140f-ede402e15a00/public><p>Similarly, there is a smaller set of features that cross the border, and thus do need to be clipped. These are features that ST_Intersects but are not ST_Contains.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/1d76ee16-b712-475f-d15a-437b6c311100/public><p>The higher performance function uses the faster predicates to filter the smaller shapes into two streams, one for intersection, and one for unchanged inclusion.<pre><code class=language-sql>SELECT
  CASE
    WHEN ST_Contains(polygon.geom, p.geom) THEN p.geom
    ELSE ST_Intersection(polygon.geom, p.geom)
    END AS geom
  FROM parcels p
  JOIN polygon
    ON ST_Intersects(polygon.geom, p.geom);
</code></pre><p>Two predicates are used here, the ST_Intersects in the join clause ensures that only parcels that might participate in the overlay are fed into the CASE statement, where the ST_Contains predicate no-ops the parcels that do not cross the boundary.<p>When run against our tiny example, the query executes in just 9ms. Amazing that the difference is large enough to measure on such a small example.<h3 id=using-case-statement-to-combine-predicates-and-overlays><a href=#using-case-statement-to-combine-predicates-and-overlays>Using <code>CASE</code> statement to combine predicates and overlays</a></h3><p>The core idea here is to recognize that boolean spatial predicates like <code>ST_Contains</code> and <code>ST_Intersects</code> are computationally much faster than spatial overlay operations like <code>ST_Intersection</code>. The standard, but slow, approach clips all intersecting features. The optimized method uses a <code>CASE</code> statement and <code>ST_Contains</code> check to create a shortcut: if a smaller geometry is entirely contained within the larger clipping polygon, we return the geometry unchanged (a quick no-op) and completely bypass the slower <code>ST_Intersection</code> calculation.<p>You can apply this optimization pattern to any PostGIS work involving clipping, spatial joins, or overlays where you suspect a significant number of features might be fully contained within a boundary. By filtering and partitioning your geometries into "fully contained" (fast path) and "crossing the border" (slow path) streams, you ensure the expensive overlay operations are only executed when they are strictly necessary to clip the edges.<p><br><br><strong>Need more PostGIS?</strong><br>Join us this year on November 20 for <a href=https://www.snowflake.com/postgis-day-2025/>PostGIS Day 2025</a>, a free, virtual, community event about open source geospatial! <br><br> ]]></content:encoded>
<category><![CDATA[ PostGIS Performance ]]></category>
<author><![CDATA[ Paul.Ramsey@crunchydata.com (Paul Ramsey) ]]></author>
<dc:creator><![CDATA[ Paul Ramsey ]]></dc:creator>
<guid isPermalink="false">1d5f0e4fe1a74ee90a994b7a01cdd6bca41d6acc81bf53be7029783577937984</guid>
<pubDate>Fri, 14 Nov 2025 08:00:00 EST</pubDate>
<dc:date>2025-11-14T13:00:00.000Z</dc:date>
<atom:updated>2025-11-14T13:00:00.000Z</atom:updated></item>
<item><title><![CDATA[ PostGIS Performance: Improve Bounding Boxes with Decompose and Subdivide ]]></title>
<link>https://www.crunchydata.com/blog/postgis-performance-improve-bounding-boxes-with-decompose-and-subdivide</link>
<description><![CDATA[ Large multi-part geometries can have massive poorly-fitting bounding boxes that cover large areas of the ocean. Paul shows you how to make bounding boxes more efficient by decomposing with ST_Dump and subdividing with ST_Subdivide.  ]]></description>
<content:encoded><![CDATA[ <p>In the third installment of the <a href=https://www.crunchydata.com/blog/topic/postgis-performance>PostGIS Performance series</a>, I wanted to talk about performance around bounding boxes.<p>Geometry data is different from most column types you find in a relational database. The objects in a geometry column can be wildly different in the amount of the data domain they cover, and the amount of physical size they take up on disk.<p>The data in the “admin0” Natural Earth data range from the 1.2 hectare Vatican City, to the 1.6 billion hectare Russia, and from the 4 point polygon defining Serranilla Bank to the 68 thousand points of polygons defining Canada.<pre><code class=language-sql>SELECT ST_NPoints(geom) AS npoints, name
FROM admin0
ORDER BY 1 DESC LIMIT 5;

SELECT ST_Area(geom::geography) AS area, name
FROM admin0
ORDER BY 1 DESC LIMIT 5;
</code></pre><p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/b8c16876-23fe-4068-0bd9-695e23511200/public><p>As you can imagine, polygons this different will have different performance characteristics:<ul><li>Physically large objects will take longer to work with. To pull off the disk, to scan, to calculate with.<li>Geographically large objects will cover more other objects, and reduce the effectiveness of your indexes.</ul><p>Your spatial indexes are “r-tree” indexes, where each object is represented by a bounding box.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/972f47d3-218b-461d-90df-aa2ef6803000/public><p>The bounding boxes can overlap, and it is possible for some boxes to cover a lot of the dataset.<p>For example, here is the bounding box of France.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/1ed4545c-5874-4497-c39c-4d853e9e9a00/public><p>What?! How is that France? Well, France is more than just the European parts, it also includes the island of Reunion, in the southern Indian Ocean, and the island of Guadaloupe, in the Caribbean. Taken together they result in this very large bounding box.<p>Such a large box makes a poor addition to the spatial index of all the objects in “admin0”. I could be searching in with a query key in the middle of the Atlantic, and the index would still be telling me “maybe it is in France?”.<p>For this testing, I have made a synthetic dataset of one million random points covering the whole world.<pre><code class=language-sql>CREATE TABLE random_normal AS
  SELECT id,
    ST_Point(
      random_normal(0, 180),
      random_normal(0, 80),
      4326) AS geom
  FROM generate_series(0, 1000000) AS id;


CREATE INDEX random_normal_geom_x ON random_normal USING GIST (geom);
</code></pre><p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/09a9b6bc-7194-4ded-c291-28ee5d6a3000/public><p>The un-altered bounds of “admin0”, the bounds that will be used to run the spatial join, look like this. Lots of overlap, lots of places where they bounds cover areas the polygons do not.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/82d7afed-f68b-407c-86d4-0caedd19a700/public><p>The baseline time to do a spatial join using the un-altered “admin0” data is 9 seconds.<pre><code class=language-sql>SELECT Count(*), admin0.name
  FROM admin0 JOIN random_normal
    ON ST_Intersects(random_normal.geom, admin0.geom)
  GROUP BY admin0.name;
</code></pre><p>What if, instead of joining against the raw “admin0” – which includes weird cases like France and a Canada with hundreds of islands – we first decompose every object into the singular polygons that make it up, using <a href=https://postgis.net/docs/ST_Dump.html>ST_Dump</a>.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/d364439e-e204-465f-ff3d-efcd419cb700/public><p>The decomposed objects cover far less ocean, and much more accurately represent the polygons they are proxying for. And the time – including the cost of decomposing the objects – to do a full join on the 1M points falls to 3.8 seconds.<pre><code class=language-sql>WITH polys AS  (
  SELECT (ST_Dump(geom)).geom AS geom, name
  FROM admin0
)
SELECT Count(*), polys.name
FROM polys JOIN random_normal
ON ST_Intersects(random_normal.geom, polys.geom)
GROUP BY polys.name;
</code></pre><p>There is still a lot of ocean being queried here, and also some of the polygons are not just very spatially large, but include a lot of vertices. What if we make the polygons smaller yet by chopping them up <a href=https://postgis.net/docs/ST_Subdivide.html>ST_Subdivide</a>?<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/d4a887cc-ac69-4709-2416-ea3ebb5b0200/public><p>These bounds are almost perfect, they cover very little of the ocean, and they also have reduced the maximum memory size of any polygon to no more than 256 vertices. And the performance, even including the very expensive subdivision step, gets faster yet.<pre><code class=language-sql>WITH polys AS (
  SELECT ST_Subdivide(geom,128) AS geom, name FROM admin0
)
SELECT Count(*), polys.name
FROM polys JOIN random_normal
ON ST_Intersects(random_normal.geom, polys.geom)
GROUP BY polys.name;
</code></pre><p>The final query takes just 1.8 seconds, twice as fast as the simple boxes, and 4 times faster than a naive spatial join. For smaller collections of points, the naive approach can work as fast as the subdivision, but for this 1M point test set the overhead of doing the subdivision is still far less than the gains from using the more effective bounds.<p>Investing computation into creating better, smaller, and simpler geometries pays off significantly for large datasets by making the spatial index much more effective.<p><br><br><strong>Need more PostGIS?</strong><br>Join us this year on November 20 for <a href=https://www.snowflake.com/postgis-day-2025/>PostGIS Day 2025</a>, a free, virtual, community event about open source geospatial! <br><br> ]]></content:encoded>
<category><![CDATA[ PostGIS Performance ]]></category>
<author><![CDATA[ Paul.Ramsey@crunchydata.com (Paul Ramsey) ]]></author>
<dc:creator><![CDATA[ Paul Ramsey ]]></dc:creator>
<guid isPermalink="false">28b2f1dc071dde118799314a159732e42dd8101f8468029d114f8ebe98c7320b</guid>
<pubDate>Thu, 06 Nov 2025 08:00:00 EST</pubDate>
<dc:date>2025-11-06T13:00:00.000Z</dc:date>
<atom:updated>2025-11-06T13:00:00.000Z</atom:updated></item>
<item><title><![CDATA[ PostGIS Performance: pg_stat_statements and Postgres tuning ]]></title>
<link>https://www.crunchydata.com/blog/postgis-performance-postgres-tuning</link>
<description><![CDATA[ PostGIS performance basics. Second post in a series covering pg_stat_statements, shared buffers, work_mem, and parallel queries. ]]></description>
<content:encoded><![CDATA[ <p>In this <a href=https://www.crunchydata.com/blog/topic/postgis-performance>series</a>, we talk about the many different ways you can speed up PostGIS. Today let’s talk about looking across the queries with pg_stat_statements and some basic tuning.<h2 id=showing-postgres-query-times-with-pg_stat_statements><a href=#showing-postgres-query-times-with-pg_stat_statements>Showing Postgres query times with pg_stat_statements</a></h2><p>A reasonable question to ask, if you are managing a system with variable performance is: “what queries on my system are running slowly?”<p>Fortunately, PostgreSQL includes an extension called “pg_stat_statements” that tracks query performance over time and maintains a list of high cost queries.<pre><code class=language-sql>CREATE EXTENSION pg_stat_statements;
</code></pre><p>Now you will have to leave your database running for a while, so the extension can gather up data about the kind of queries that are run on your database.<p>Once it has been running for a while, you have a whole table – <code>pg_stat_statements</code> – that collects your query statistics. You can query it directly with <code>SELECT *</code> or you can write individual queries to find the slowest queries, the longest running ones, and so on.<p>Here is an example of the longest running 10 queries ranked by duration.<pre><code class=language-sql>SELECT
  total_exec_time,
  mean_exec_time,
  calls,
  rows,
  query
FROM pg_stat_statements
WHERE calls > 0
ORDER BY mean_exec_time DESC
LIMIT 10;
</code></pre><p>While “pg_stat_statements” is good at finding individual queries to tune, and the most frequent cause of slow queries is just inefficient SQL or a need for <a href=https://www.crunchydata.com/blog/postgis-performance-indexing-and-explain>indexing</a> - see the first post in the series.<p>Occasionally performance issues do crop up at the system level. The most frequent culprit is memory pressure. PostgreSQL ships with conservative default settings for memory usage, and some workloads benefit from more memory.<h3 id=shared-buffers><a href=#shared-buffers>Shared buffers</a></h3><p>A database server looks like an infinite, accessible, reliable bucket of data. In fact, the server orchestrates data between the disk – which is permanent and slow – and the random access memory – which is volatile and fast – in order to provide the illusion of such a system.<p><img alt loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/5f3e27bd-de65-4fa6-6dbf-98c5543b5900/public><p>When the balance between slow storage and fast memory is out of whack, system performance falls. When attempts to read data are not present in the fast memory (a “cache hit”), they continue on to the slow disk (a “cache miss”).<p>You can check the balance of your system by looking at the “cache hit ratio”.<pre><code class=language-sql>SELECT
  sum(heap_blks_read) as heap_read,
  sum(heap_blks_hit)  as heap_hit,
  sum(heap_blks_hit) / (sum(heap_blks_hit) +  sum(heap_blks_read)) as ratio
FROM
  pg_statio_user_tables;
</code></pre><p>A result in the 99% is a good sign. Below 90% means that your database could be memory constrained, so increasing the “shared_buffers” parameter may help. As a general rule, “shared buffers” should be about 25% of physical RAM.<h3 id=working-memory><a href=#working-memory>Working memory</a></h3><p>Working memory is controlled by the “work_mem” parameter, and it controls how much memory is available for in-memory sorting, index building, and other short term processes. Unlike the “shared buffers”, which are permanent and fully allocated on startup, the “working memory” is allocated on an as-needed basis.<p>However, the working memory limit is applied for each database connection, so it is possible for the total working memory to radically exceed the “work_mem” value. If 1000 connections each allocate 100MB, your server will probably run out of memory.<p>You can speed up known memory-hungry processes, like building spatial indexes, by temporarily increasing the working memory available to your particular connection, then reduce it when the process is complete.<pre><code class=language-sql>SET work_mem = '2GB';
CREATE INDEX roads_geom_x ON roads USING GIST (geom);
SET work_mem = '100MB';
</code></pre><p>The same principle holds for maintenance tasks, like the “VACUUM” command. You can speed up the maintenance of a large table by increasing the “maintenance_work_mem” temporarily.<pre><code class=language-sql>SET maintenance_work_mem = '2GB';
VACUUM roads;
SET maintenance_work_mem = '128MB';
</code></pre><h3 id=parallelism><a href=#parallelism>Parallelism</a></h3><p>It is common for modern database servers to have multiple CPU cores available, but your PostgreSQL configuration may not be tuned to use them all. Postgres does have <a href=https://www.crunchydata.com/blog/parallel-queries-in-postgres>parallel query support</a>. PostgreSQL is conservative about making use of multiple cores, because executing and coordinating multi-process queries has overheads, but in general large aggregations or scans can frequently make effective use of two to four cores at once.<p>Check what limits are set on your database.<pre><code class=language-sql>SHOW max_worker_processes;

SHOW max_parallel_workers;
</code></pre><p>Setting the maximums to the number of cores on your server is good practice. In particular, don’t be afraid to reduce the number of workers if you have fewer cores – there is no benefit to be had in workers contending for cores.<h2 id=tuning-postgres-basics><a href=#tuning-postgres-basics>Tuning Postgres basics</a></h2><p>To wrap up:<ul><li>Check the slowest queries with pg_stat_statements.<li>Use <a href=https://www.crunchydata.com/blog/postgis-performance-indexing-and-explain>EXPLAIN and Indexing</a> to experiment with improvements<li>Check inefficient memory by looking at:<ul><li>shared buffers<li>working memory (work_mem)<li>parallelism</ul></ul><p>After you do some tuning, don’t forget to <a href=https://docs.crunchybridge.com/guides/refreshing-statistics#when-to-reset-pg_stat_statements>reset pg_stat_statements</a> and check again to see if/how things have improved!<p><br><br><strong>Need more PostGIS?</strong><br>Join us this year on November 20 for <a href=https://www.snowflake.com/postgis-day-2025/>PostGIS Day 2025</a>, a free, virtual, community event about open source geospatial! <br><br> ]]></content:encoded>
<category><![CDATA[ PostGIS Performance ]]></category>
<author><![CDATA[ Paul.Ramsey@crunchydata.com (Paul Ramsey) ]]></author>
<dc:creator><![CDATA[ Paul Ramsey ]]></dc:creator>
<guid isPermalink="false">271b87cca2e30243304f9ebd7d2afbf4877776350b9e81a979f692f59f4c0f26</guid>
<pubDate>Mon, 20 Oct 2025 09:00:00 EDT</pubDate>
<dc:date>2025-10-20T13:00:00.000Z</dc:date>
<atom:updated>2025-10-20T13:00:00.000Z</atom:updated></item></channel></rss>