<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[The Commit Log]]></title><description><![CDATA[The Commit Log]]></description><link>https://uanik.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>The Commit Log</title><link>https://uanik.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 16 Sep 2026 14:13:37 GMT</lastBuildDate><atom:link href="https://uanik.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Haversine vs PostGIS: A Small Experiment with 1 Million Locations]]></title><description><![CDATA[I was working on a location-based search for a service marketplace.The requirement was simple:

Given a user's latitude and longitude, find nearby service providers and order them by distance.

My fir]]></description><link>https://uanik.hashnode.dev/haversine-vs-postgis-a-small-experiment-with-1-million-locations</link><guid isPermaLink="true">https://uanik.hashnode.dev/haversine-vs-postgis-a-small-experiment-with-1-million-locations</guid><category><![CDATA[PostgreSQL]]></category><category><![CDATA[PostGIS]]></category><category><![CDATA[Databases]]></category><category><![CDATA[indexing]]></category><dc:creator><![CDATA[Uanik]]></dc:creator><pubDate>Sun, 23 Aug 2026 16:34:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a51f45ef96be6c7066257e0/e15d82c8-b2e0-4645-8f74-fa0cab37a5ac.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I was working on a location-based search for a service marketplace.The requirement was simple:</p>
<blockquote>
<p>Given a user's latitude and longitude, find nearby service providers and order them by distance.</p>
</blockquote>
<p>My first approach was to calculate the distance using the Haversine formula. Later, I started looking at PostGIS.</p>
<p>I was curious about how much difference it would actually make, so I decided to put 1 million service providers into PostgreSQL and compare the two approaches.</p>
<p>This is not meant to be a proper benchmark of PostGIS. I just wanted to understand what was happening in my own application.</p>
<h2>The Setup</h2>
<p>The <code>service_providers</code> table has latitude, longitude and PostGIS column: location geography(Point, 4326).</p>
<p>I created a GiST index on it:</p>
<pre><code class="language-sql">CREATE INDEX idx_service_providers_location_gist
ON service_providers
USING GIST (location);
</code></pre>
<p>I then generated 1 million test providers.</p>
<p>For the test, I used:</p>
<pre><code class="language-plaintext">Providers:  1,000,000
Latitude:   12.7799
Longitude:  74.8625
Radius:     20 km
</code></pre>
<h2>Approach 1: Haversine</h2>
<p>The first query calculates the distance using the Haversine formula directly in PostgreSQL.</p>
<pre><code class="language-sql">SELECT p.*,
       6371 * acos(
           cos(radians(:lat)) *
           cos(radians(p.latitude)) *
           cos(radians(p.longitude) - radians(:lon)) +
           sin(radians(:lat)) *
           sin(radians(p.latitude))
       ) AS distance
FROM service_providers p
WHERE p.deleted_at IS NULL
  AND p.is_active = true
  AND (
      6371 * acos(
          cos(radians(:lat)) *
          cos(radians(p.latitude)) *
          cos(radians(p.longitude) - radians(:lon)) +
          sin(radians(:lat)) *
          sin(radians(p.latitude))
      )
  ) &lt;= :radius
ORDER BY distance ASC;
</code></pre>
<p>The idea is straightforward.</p>
<p>For every provider, calculate the distance from the user's location, keep the providers within 20 km, and sort them by distance. The important thing I wanted to see was how PostgreSQL would execute this query with 1 million rows.</p>
<h2>Approach 2: PostGIS</h2>
<p>For the second approach, I used the <code>geography</code> type and PostGIS functions.</p>
<p>The query was:</p>
<pre><code class="language-sql">SELECT p.*,
       ST_Distance(
           p.location,
           ST_SetSRID(
               ST_MakePoint(:lon, :lat),
               4326
           )::geography
       ) AS distance
FROM service_providers p
WHERE p.deleted_at IS NULL
  AND p.is_active = true
  AND ST_DWithin(
      p.location,
      ST_SetSRID(
          ST_MakePoint(:lon, :lat),
          4326
      )::geography,
      :radius * 1000
  )
ORDER BY distance ASC;
</code></pre>
<p>The main difference here is <code>ST_DWithin</code>. Instead of calculating the distance for every provider and then checking the radius, the query can use the spatial index to narrow down the candidates.</p>
<h2>Looking at EXPLAIN ANALYZE</h2>
<p>I ran EXPLAIN (ANALYZE, BUFFERS) on the queries.</p>
<h3>Haversine</h3>
<p>Result</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a51f45ef96be6c7066257e0/933d2eab-fdb3-4262-a9c7-f810f1894515.png" alt="" style="display:block;margin:0 auto" />

<p>The important part of the execution plan was: <code>Parallel Seq Scan</code> on service_providers. PostgreSQL was scanning the table and evaluating the distance expression.</p>
<p>The query reported:</p>
<pre><code class="language-plaintext">Rows Removed by Filter: 329504
Execution Time: 342.822 ms
</code></pre>
<h3>PostGIS</h3>
<p>Result</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a51f45ef96be6c7066257e0/d1e1f606-fdfe-4ece-8459-8f98743393ea.png" alt="" style="display:block;margin:0 auto" />

<p>The PostGIS query used the spatial index:</p>
<p><code>Bitmap Index Scan on idx_service_providers_location_gist</code></p>
<p>The index scan produced: <code>actual ... rows=15818</code>. The query then performed the spatial filtering and distance calculation on those candidates.</p>
<pre><code class="language-plaintext">Execution Time: 141.533 ms
</code></pre>
<hr />
<h3>Comparision</h3>
<p>The results from this run were:</p>
<table>
<thead>
<tr>
<th></th>
<th>Haversine</th>
<th>PostGIS</th>
</tr>
</thead>
<tbody><tr>
<td>Rows in table</td>
<td>1,000,000</td>
<td>1,000,000</td>
</tr>
<tr>
<td>Radius</td>
<td>20 km</td>
<td>20 km</td>
</tr>
<tr>
<td>Results</td>
<td>11,489</td>
<td>11,517</td>
</tr>
<tr>
<td>Scan</td>
<td>Parallel Seq Scan</td>
<td>Bitmap Index Scan</td>
</tr>
<tr>
<td>Execution time</td>
<td>342.822 ms</td>
<td>141.533 ms</td>
</tr>
</tbody></table>
<p>I don't want to make much of that number because this was one test on my machine and with my particular data distribution. I found the execution plan more useful than the number itself.</p>
<p>The part that caught my attention was the difference in the scan.</p>
<p>The Haversine query was doing:</p>
<pre><code class="language-plaintext">1,000,000 rows -&gt; Parallel sequential scan -&gt; Calculate distance 
-&gt; Filter -&gt; Sort
</code></pre>
<p>The PostGIS query was doing something closer to:</p>
<pre><code class="language-plaintext">1,000,000 rows -&gt; GiST spatial index -&gt; 15,818 candidate rows -&gt; ST_DWithin -&gt; ST_Distance -&gt; Sort
</code></pre>
<p>The database already has information about where the providers are. It seemed reasonable to let the database use that information before doing the more expensive distance calculation.</p>
<h2>A small detail I noticed</h2>
<p>The two queries returned slightly different numbers of providers <strong>(11,489 vs 11,517)</strong> . The difference comes from the fact that the Haversine calculation and PostGIS <code>geography</code> distance calculation use different distance calculations. Since some points are close to the 20 km boundary, small differences can affect whether a point is included.</p>
]]></content:encoded></item></channel></rss>