Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

PostgreSQL in Depth  Module 9 – Search, vectors and maps

Spatial queries with PostGIS

Choose geography or geometry, write longitude first, and run radius and nearest-place queries with PostGIS 3.6 on PostgreSQL 18, with recorded plans.

  • Intermediate
  • 15 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: Geography measures metres on the earth, geometry measures plane units · Longitude comes first, and a swap goes unnoticed · A radius query and a nearest-neighbour query ask different questions · Practise: choose the type, the order and the query. With a pass: 2 in-depth parts and 3 interview answers, about 9 minutes.

What you will learn

  • Choose geography or geometry and the right SRID for location data
  • Find nearby places with a radius test and a GiST index, and order them by distance
  • Validate coordinates, polygons and units before trusting a spatial result

Before you start

Pune and Mumbai are 120.1 km apart, and the same two points are 1.1257 apart: one answer in kilometres on the earth, one in degrees on a flat plane. Which of the two a PostGIS query returns depends on a column type and a number called the SRID, and a pair of coordinates written in the wrong order changes the answer again without any error.

You will measure that pair both ways on PostgreSQL 18.6 with PostGIS 3.6.4, watch the latitude-first mistake move a city 6,999.6 km, and meet the two queries a location feature rests on: the places within a radius, and the nearest few.

Geography measures metres on the earth, geometry measures plane units

A table of places needs a column that says where each place is, and PostGIS offers two types for it. A geography type: A PostGIS type for longitude and latitude values that measures along the earth's surface, so distances and areas come back in metres and square metres. value holds longitude and latitude and measures along the earth’s surface, so its distances come back in metres. A geometry type: A PostGIS type for points, lines and polygons on a flat plane. Its functions use the plane's arithmetic, so results are in the units of the value's SRID. value holds x and y on a flat plane and measures by straight-line arithmetic, so its unit is whatever the coordinates are in. PostGIS, an extension that one statement installs, is open-source software, in general under GPL-2.0-or-later; its LICENSE.TXT names the exceptions. The first run installs it and measures the same two points both ways:

Pune to Mumbai as geography and as geometry, and the swap

types_session.py

"""PostGIS 3.6.4 on PostgreSQL 18.6: versions, geography and geometry for the same two cities, and the longitude-first mistake."""

from pg_extensions import extension_settings
from pg_session import Server

# PostGIS may sit next to PostgreSQL or in a prefix of its own: pg_extensions.py finds such a prefix and returns
# the two settings PostgreSQL 18 needs for it (none when PostGIS is installed with PostgreSQL).
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off", "timezone": "'UTC'"}
SETTINGS.update(extension_settings())

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("types.sql", errors=1)

Output

$ psql -X -a -f types.sql
-- PostGIS is an extension: it is installed in this database by one statement.
CREATE EXTENSION postgis;
CREATE EXTENSION
SELECT postgis_lib_version() AS postgis, postgis_geos_version() AS geos,
       split_part(postgis_proj_version(), ' ', 1) AS proj;
 postgis |        geos        | proj  
---------+--------------------+-------
 3.6.4   | 3.14.1-CAPI-1.20.5 | 9.9.0
(1 row)

-- Two cities as plain numbers: longitude first, then latitude.
CREATE TABLE city (name text PRIMARY KEY, lon float8, lat float8);
CREATE TABLE
INSERT INTO city VALUES ('Pune', 73.8567, 18.5204), ('Mumbai', 72.8777, 19.0760);
INSERT 0 2
-- The same two points as geography (metres on the earth) and as geometry (degrees on a flat plane).
SELECT round(ST_Distance(p.pt::geography, m.pt::geography)::numeric / 1000, 1) AS km_geography,
       round(ST_Distance(p.pt, m.pt)::numeric, 4) AS degrees_geometry
FROM (SELECT ST_Point(lon, lat, 4326) AS pt FROM city WHERE name = 'Pune') p,
     (SELECT ST_Point(lon, lat, 4326) AS pt FROM city WHERE name = 'Mumbai') m;
 km_geography | degrees_geometry 
--------------+------------------
        120.1 |           1.1257
(1 row)

-- The mistake: latitude first. Pune lands at 18.52 E, 73.86 N, in the open sea north of Norway.
SELECT ST_AsText(ST_Point(lat, lon, 4326)) AS swapped_pune,
       round(ST_Distance(ST_Point(lat, lon, 4326)::geography, ST_Point(lon, lat, 4326)::geography)::numeric / 1000, 1) AS km_from_pune
FROM city WHERE name = 'Pune';
      swapped_pune      | km_from_pune 
------------------------+--------------
 POINT(18.5204 73.8567) |       6999.6
(1 row)

-- Both cities swapped: the distance between them still looks believable.
SELECT round(ST_Distance(ST_Point(p.lat, p.lon, 4326)::geography, ST_Point(m.lat, m.lon, 4326)::geography)::numeric / 1000, 1) AS km_swapped_pair
FROM city p, city m WHERE p.name = 'Pune' AND m.name = 'Mumbai';
 km_swapped_pair 
-----------------
           110.7
(1 row)

-- A guard: a loose box around India is 68 to 98 degrees east and 6 to 38 north, so a swapped pair breaks it.
ALTER TABLE city ADD CONSTRAINT city_in_india CHECK (lon BETWEEN 68 AND 98 AND lat BETWEEN 6 AND 38);
ALTER TABLE
INSERT INTO city VALUES ('Swapped Pune', 18.5204, 73.8567);
psql:types.sql:27: ERROR:  new row for relation "city" violates check constraint "city_in_india"
DETAIL:  Failing row contains (Swapped Pune, 18.5204, 73.8567).

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 types_session.py

types.sql

-- PostGIS is an extension: it is installed in this database by one statement.
CREATE EXTENSION postgis;
SELECT postgis_lib_version() AS postgis, postgis_geos_version() AS geos,
       split_part(postgis_proj_version(), ' ', 1) AS proj;

-- Two cities as plain numbers: longitude first, then latitude.
CREATE TABLE city (name text PRIMARY KEY, lon float8, lat float8);
INSERT INTO city VALUES ('Pune', 73.8567, 18.5204), ('Mumbai', 72.8777, 19.0760);

-- The same two points as geography (metres on the earth) and as geometry (degrees on a flat plane).
SELECT round(ST_Distance(p.pt::geography, m.pt::geography)::numeric / 1000, 1) AS km_geography,
       round(ST_Distance(p.pt, m.pt)::numeric, 4) AS degrees_geometry
FROM (SELECT ST_Point(lon, lat, 4326) AS pt FROM city WHERE name = 'Pune') p,
     (SELECT ST_Point(lon, lat, 4326) AS pt FROM city WHERE name = 'Mumbai') m;

-- The mistake: latitude first. Pune lands at 18.52 E, 73.86 N, in the open sea north of Norway.
SELECT ST_AsText(ST_Point(lat, lon, 4326)) AS swapped_pune,
       round(ST_Distance(ST_Point(lat, lon, 4326)::geography, ST_Point(lon, lat, 4326)::geography)::numeric / 1000, 1) AS km_from_pune
FROM city WHERE name = 'Pune';

-- Both cities swapped: the distance between them still looks believable.
SELECT round(ST_Distance(ST_Point(p.lat, p.lon, 4326)::geography, ST_Point(m.lat, m.lon, 4326)::geography)::numeric / 1000, 1) AS km_swapped_pair
FROM city p, city m WHERE p.name = 'Pune' AND m.name = 'Mumbai';

-- A guard: a loose box around India is 68 to 98 degrees east and 6 to 38 north, so a swapped pair breaks it.
ALTER TABLE city ADD CONSTRAINT city_in_india CHECK (lon BETWEEN 68 AND 98 AND lat BETWEEN 6 AND 38);
INSERT INTO city VALUES ('Swapped Pune', 18.5204, 73.8567);

pg_session.py

"""Throwaway PostgreSQL 18.6 servers for the examples of this lesson.

Every session creates a cluster of its own with initdb in a new folder inside the examples folder, starts it, runs
psql scripts and server programs on it, prints what they printed, and stops and deletes it again, also when something
fails. The server is the pinned PostgreSQL 18.6 from the mise installations next to the Python that runs this file.
It listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system reports as
unused, with no Unix socket, and it asks for a password (scram-sha-256) made up for the run and never printed.
Besides PostgreSQL's own programs, the sessions read only this folder (and an extension's own folder where a lesson
needs one), and they connect to nothing but 127.0.0.1.

The output is exactly what the programs printed, with four substitutions so that a record never shows the computer
it ran on and is the same on every run: the run's folder is shown as ".", the folder of PostgreSQL's programs is left
out of their paths, the operating-system user's name is shown as "learner", and the dots pg_ctl prints while it waits
(one about every tenth of a second) as three. A session may name more patterns (mask=) for numbers that change on
every run, such as the rates and times ANALYZE VERBOSE prints or the random key of pg_dump's \\restrict line; the
session's own file says which, and its record shows a fixed word in their place. The sessions never print the port.
A session stops with status 1 when a command fails that should have worked, or reports a different number of errors
than the session expects, so a run that went wrong is never recorded as if it had worked.
"""

import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path

PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"
OS_USER = pwd.getpwuid(os.geteuid()).pw_name


def free_port():
    """A TCP port on 127.0.0.1 that nothing listens on now."""
    with socket.socket() as probe:
        probe.bind(("127.0.0.1", 0))
        return probe.getsockname()[1]


class Server:
    """One throwaway cluster: initdb, settings, start; stopped and deleted when the with block ends."""

    def __init__(self, settings=None, initdb=(), name="data"):
        self.settings = dict(settings or {})
        self.initdb_args = list(initdb)
        self.name = name
        self.running = []

    def __enter__(self):
        if not (BIN / "postgres").exists():
            sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
        self.base = Path(tempfile.mkdtemp(prefix="pg", dir=".")).resolve()
        self.port = free_port()
        self.password = secrets.token_urlsafe(24)
        (self.base / "pwfile").write_text(self.password + "\n")
        self.env = {**os.environ, "PGHOST": "127.0.0.1", "PGPORT": str(self.port), "PGUSER": "postgres",
                    "PGPASSWORD": self.password, "PGDATABASE": "postgres", "PGCONNECT_TIMEOUT": "60"}
        for key in ("PGSERVICE", "PGSERVICEFILE", "PGOPTIONS", "PSQLRC"):
            self.env.pop(key, None)
        for script in Path(".").glob("*.sql"):
            shutil.copy(script, self.base / script.name)
        for data in Path(".").glob("*.csv"):
            shutil.copy(data, self.base / data.name)
        return self

    def __exit__(self, *exc):
        for folder, _port in reversed(self.running):
            self._call(["pg_ctl", "-D", folder, "-m", "fast", "-w", "-t", "60", "stop"])
        self.running = []
        shutil.rmtree(self.base, ignore_errors=True)

    # ---- the cluster ---------------------------------------------------------------------------------------------
    def initdb(self, show=False):
        args = ["initdb", "-D", self.name, "-U", "postgres", "--auth=scram-sha-256", "--pwfile=pwfile",
                *self.initdb_args]
        if not show:
            args.append("--no-instructions")
        self._expect(args, show=show)
        with open(self.base / self.name / "postgresql.conf", "a") as conf:
            conf.write("\n# Added by the session: 127.0.0.1 only, no Unix socket.\n")
            conf.write(f"listen_addresses = '127.0.0.1'\nport = {self.port}\nunix_socket_directories = ''\n")
            for key, value in self.settings.items():
                conf.write(f"{key} = {value}\n")

    def start(self, show=False, folder=None, port=None):
        folder = folder or self.name
        self._expect(["pg_ctl", "-D", folder, "-l", f"{folder}.log", "-w", "-t", "60", "start"], show=show)
        self.running.append((folder, port or self.port))

    def up(self):
        """initdb and start, quietly."""
        self.initdb()
        self.start()
        return self

    def standby(self, folder="standby"):
        """A streaming standby of this server, made with pg_basebackup -R, started on a port of its own."""
        self._expect(["pg_basebackup", "-D", folder, "-R", "-X", "stream", "-c", "fast"])
        port = free_port()
        with open(self.base / folder / "postgresql.conf", "a") as conf:
            conf.write(f"port = {port}\n")
        self.start(folder=folder, port=port)
        for _ in range(300):
            if self.query("SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'") == "1":
                return port
            time.sleep(0.1)
        sys.exit("the standby did not start streaming")

    # ---- running things ------------------------------------------------------------------------------------------
    def _call(self, args, env=None):
        args = [str(a) for a in args]
        done = subprocess.run([str(BIN / args[0]), *args[1:]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                              text=True, cwd=self.base, env=env or self.env)
        return done.returncode, self.clean(done.stdout)

    def _expect(self, args, show=False, status=0, mask=()):
        code, out = self._call(args)
        out = _masked(out, mask)
        if show:
            _show(args, out)
        if code != status:
            if not show:
                print(out)
            sys.exit(f"{args[0]} ended with status {code}, not {status}")
        return out

    def clean(self, text):
        """The run's folder as ".", the user as learner, pg_ctl's waiting dots as three (see the docstring)."""
        text = text.replace(str(BIN) + "/", "").replace(str(self.base) + "/", "").replace(str(self.base), ".")
        text = re.sub(r"(waiting for server to (?:start|shut down))\.+", r"\1...", text)
        return text.replace(f'"{OS_USER}"', '"learner"')

    def wait_for(self, sql, value):
        """Waits (unprinted) until a query returns the value, for example after a reload."""
        for _ in range(300):
            if self.query(sql) == value:
                return
            time.sleep(0.1)
        sys.exit(f"{sql} never returned {value}")

    def show_file(self, path):
        """Prints a text file of the cluster, as `cat` would."""
        print(f"$ cat {path}")
        print((self.base / path).read_text().rstrip("\n"))
        print()

    def query(self, sql, port=None):
        """One value from one query, unprinted (the session uses it to wait or to decide)."""
        env = {**self.env, "PGPORT": str(port or self.port)}
        code, out = self._call(["psql", "-X", "-At", "-c", sql], env=env)
        if code != 0:
            sys.exit(f"the query failed: {out}")
        return out.strip()

    def silent(self, sql):
        """Runs a statement without printing it (used only to give a role the run's password)."""
        self.query(sql)

    def psql(self, script, errors=0, status=None, user=None, db=None, port=None, flags=("-a",), note=None, mask=()):
        """Runs a psql script with -X (no ~/.psqlrc) and the given flags, and prints the command and its output.
        `errors` is how many ERROR lines the script must print; `status` the exit status it must end with."""
        env = {**self.env, "PGPORT": str(port or self.port)}
        if user:
            env["PGUSER"] = user
        if db:
            env["PGDATABASE"] = db
        args = ["psql", "-X", *flags, "-f", script]
        code, out = self._call(args, env=env)
        out = _masked(out, mask)
        shown = ["psql", "-X", *flags, "-f", script]
        if user:
            shown[1:1] = ["-U", user]
        if db:
            shown[1:1] = ["-d", db]
        if note:
            print(f"-- {note}")
        _show(shown, out)
        found = sum(1 for line in out.splitlines() if "ERROR:" in line or ": error:" in line)
        expected_status = 0 if status is None else status
        if found != errors or code != expected_status:
            sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")

    def sessions(self, count):
        """Opens `count` idle psql connections (each one a client backend) until the with block ends."""
        return _Idle(self, count)

    def shell(self, *args, status=0, mask=()):
        """Runs one of PostgreSQL's programs and prints the command and its output."""
        return self._expect(list(args), show=True, status=status, mask=mask)

    def listing(self, folder, prefix=""):
        """Prints the names in a folder of the cluster (sorted, folders with a slash), as `ls -p` would list them."""
        path = self.base / folder
        names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
        print(f"-- in {folder}" + (f", the names that start with {prefix}" if prefix else "") + ":")
        print(textwrap.fill("  ".join(names), width=78, initial_indent="   ", subsequent_indent="   ",
                            break_on_hyphens=False))
        print()
        return names


def _masked(text, mask):
    """Replaces what differs on every run (a rate, a time, a random key) with fixed text (see the docstring)."""
    for pattern, replacement in mask:
        text = re.sub(pattern, replacement, text, flags=re.M)
    return text


def _arg(a):
    """A command-line argument as a reader would type it: double quotes around text with single quotes in it."""
    a = str(a)
    if re.fullmatch(r"[\w@%+=:,./-]+", a):
        return a
    if not re.search(r"[\"$`\\!]", a):
        return f'"{a}"'
    return shlex.quote(a)


def _show(args, out):
    print("$ " + " ".join(_arg(a) for a in args))
    if out.strip():
        print(out.rstrip("\n"))
    print()


class _Idle:
    """Idle psql connections, closed again when the with block ends."""

    def __init__(self, server, count):
        self.server, self.count, self.procs = server, count, []

    def __enter__(self):
        before = int(self.server.query("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"))
        for _ in range(self.count):
            self.procs.append(subprocess.Popen([str(BIN / "psql"), "-X", "-q"], stdin=subprocess.PIPE,
                                               stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                                               env=self.server.env, cwd=self.server.base))
        sql = "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"
        for _ in range(300):
            # the query's own connection is one more client backend
            if int(self.server.query(sql)) == before + self.count:
                return self
            time.sleep(0.1)
        sys.exit("the idle connections did not open")

    def __exit__(self, *exc):
        for p in self.procs:
            p.stdin.close()
            p.wait(timeout=30)

pg_extensions.py

"""Where the sessions of this lesson find an extension that is not installed next to PostgreSQL.

An extension installed together with PostgreSQL (by a package manager, for example) needs nothing from this file:
extension_settings() then returns no settings. An extension built into a prefix of its own, a folder that holds
share/extension/ and lib/, is found by PostgreSQL 18 through two settings, extension_control_path and
dynamic_library_path. This file builds them from the prefixes it finds, in this order:

1. PG_EXTENSION_DIR, a colon-separated list of such prefixes, when it is set;
2. otherwise the entries of a folder named pg-extensions in the session's folder or in a folder above it: each entry
   is a prefix, or a link to one.

The settings go into the throwaway cluster's configuration only. The sessions never print them, because they hold
paths of the computer that ran them.
"""

import os
from pathlib import Path


def extension_prefixes():
    """The prefixes to add, or [] when the extension sits next to PostgreSQL."""
    listed = [p for p in os.environ.get("PG_EXTENSION_DIR", "").split(":") if p]
    if listed:
        return listed
    here = Path.cwd()
    for folder in (here, *here.parents):
        holder = folder / "pg-extensions"
        if holder.is_dir():
            return sorted(str(p.resolve()) for p in holder.iterdir() if (p / "share" / "extension").is_dir())
    return []


def extension_settings():
    """Settings for Server(settings=...): empty when no prefix is needed."""
    prefixes = extension_prefixes()
    if not prefixes:
        return {}
    return {
        "extension_control_path": "'" + ":".join(f"{p}/share" for p in prefixes) + ":$system'",
        "dynamic_library_path": "'" + ":".join(f"{p}/lib" for p in prefixes) + ":$libdir'",
    }

It prints PostGIS 3.6.4 with GEOS 3.14.1 and PROJ 9.9.0, then 120.1 for km_geography and 1.1257 for degrees_geometry. The figure walks through both answers, and through the swap that the run’s later queries make:

Interactive · message by message One pair of cities, two types, two answers Step through the same Pune and Mumbai points asked as geometry and as geography, then with the numbers swapped.
One pair of cities, two types, two answers, step 6 of 6. Messages so far: Your query to geometry: ST_Distance; geometry to Your query: 1.1257 degrees; Your query to geography: ST_Distance; geography to Your query: 120.1 km; Your query to geography: lat, lon swapped.Your querytwo citiesgeometryin degreesgeographyin metresST_Distance1.1257 degreesST_Distance120.1 kmlat, lon swapped6,999.6 km off

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Two points as numbers. Pune is 73.8567 east and 18.5204 north, Mumbai 72.8777 east and 19.0760 north. Both are written longitude first and carry SRID 4326, so each type knows they are degrees.
  2. Ask the plane. As geometry the distance is straight-line arithmetic on the numbers, so the unit of the answer is whatever the coordinates are in.
  3. Degrees come back. The answer is 1.1257, in degrees, which says nothing about kilometres. A length of one degree changes with latitude and direction.
  4. Ask the earth. The same two points cast to geography are measured along the earth's surface, on the spheroid of SRID 4326.
  5. Metres come back. The answer is about 120,100 metres, printed as 120.1 km after dividing by 1,000 and rounding. That is the distance as the crow flies, which the scale bar of a map can confirm.
  6. The swap. Write latitude first and Pune becomes 18.5204 east, 73.8567 north, a point in the open sea north of Norway. Geography does not complain; it measures 6,999.6 km from the real Pune.

The manual lets the size of the working area decide, as the table sums up. Projected systems are the subject of map projections and distortion and measuring distance and area.

Type Choose it when
geography places spread over a large country or the world, and you want metres at once
geometry places sit in one local area, in a projected SRID, and you want speed and more functions

Checkpoint

Check: Geography or geometry

  1. Question 1 of 2 The same Pune and Mumbai points give 120.1 from one query and 1.1257 from another. What explains the difference?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The first was measured as geography, in metres on the earth, and the second as geometry in degrees

    One pair of points, two types: geography answers in metres and the run printed 120.1 km, geometry in 4326 answers in degrees and printed 1.1257.

  2. Question 2 of 2 A delivery app stores addresses for one city only and wants fast local measurements with the widest choice of functions. Which setup fits best?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Geometry in a projected SRID chosen for that area

    The size of the working area decides: country or world, geography; one city or state, geometry in a projected SRID.

Longitude comes first, and a swap goes unnoticed

An SRID: A spatial reference identifier: a number that names the coordinate system of a PostGIS value. 4326 means longitude and latitude in degrees on the WGS 84 datum. is a number that names a coordinate system. SRID 4326 is WGS 84, the longitude and latitude in degrees that GPS receivers use, and a value that carries it knows its numbers are degrees. The function ST_Point(x, y, srid) takes longitude as x and latitude as y, the opposite of how people say a position aloud: Pune is ST_Point(73.8567, 18.5204, 4326), east first.

The run’s next query writes Pune with the numbers swapped. The point lands at 18.5204 east and 73.8567 north, in the open sea north of Norway, and geography measures it 6,999.6 km from the real Pune without complaint. A further query swaps both cities, and their distance, 110.7 km against 120.1, still looks plausible, so one sanity check would pass. The last statements add a check constraint, a loose box around India, and the swapped row is refused.

Latitude first

ST_Point(lat, lon, 4326) stores a valid point in the wrong place and raises no error. Write ST_Point(lon, lat, 4326) and add a range check.

Checkpoint

Check: SRID 4326 and longitude first

  1. One question A pair of cities with latitude and longitude swapped shows 110.7 km between them, where the true figure is 120.1 km. What does that show about checking one distance?

    Choose one answer.

    Show the answer: One question

    Answer: A believable distance does not prove the coordinates are in the right order

    Only a range check on the columns, or a look at the map, finds a swap that moves every point alike.

A radius query and a nearest-neighbour query ask different questions

A radius query keeps every place within a distance of a point and returns a set of any size. A nearest-neighbour query ranks the places by distance, keeps the first few, and returns the same number of rows however crowded the map is. Both would need a distance for every row unless a spatial index: An index, in PostGIS a GiST index, that stores a bounding box for each shape so queries by area or distance can skip most rows. narrows the search, and in PostGIS that is usually a GiST index on the column:

You ask You write
Which places are within 2 km? ST_DWithin(geog, point, 2000) in WHERE
Which five places are closest? ORDER BY geog <-> point LIMIT 5

ST_DWithin tests whether two values lie within a distance, in metres for geography, and it can use a GiST index. The operator <-> in an ORDER BY with a LIMIT returns the closest rows first and can walk the same index in distance order. ST_IsValid checks a polygon’s outline, and ST_Transform converts coordinates into another SRID before you measure on them.

Checkpoint

Check: Radius and nearest-neighbour queries

  1. One question A page must show the 10 pharmacies closest to the visitor, however far away they are. Which query shape fits?

    Choose one answer.

    Show the answer: One question

    Answer: ORDER BY geog <-> point LIMIT 10

    A radius query returns however many places fall inside; a nearest-neighbour query returns exactly k.

Practise: choose the type, the order and the query

Name first what each question is about: the type, the order or the query.

Practice that adapts to you

Practise this lesson

7 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 7 What unit does ST_Distance return for two geometry points in SRID 4326?

    Choose one answer.

    Show the answer: Question 1 of 7

    Answer: Degrees

    The recorded 1.1257 for Pune and Mumbai is in degrees.

  2. Question 2 of 7 You must report distances in metres from longitude and latitude columns spread over all of India, without choosing a projection. Which type do you cast to?

    Choose one answer.

    Show the answer: Question 2 of 7

    Answer: Geography

    Geography accepts degrees and answers in metres, at some cost in speed and available functions.

  3. Question 3 of 7 A table of 50,000 shops in one city is measured thousands of times a second. What is the strongest argument for geometry in a projected SRID?

    Choose one answer.

    Show the answer: Question 3 of 7

    Answer: Planar arithmetic is cheaper than spheroid calculations, and more functions accept geometry

    Choose by the working area: a city fits a projection, so the cheaper arithmetic is available.

  4. Question 4 of 7 Which call stores Pune, 73.8567 east and 18.5204 north, correctly?

    Choose one answer.

    Show the answer: Question 4 of 7

    Answer: ST_Point(73.8567, 18.5204, 4326)

    x is longitude and y is latitude, and the SRID closes the call.

  5. Question 5 of 7 You import 2 million coordinate pairs for Indian cities and fear some are swapped. What catches them in the database for good?

    Choose one answer.

    Show the answer: Question 5 of 7

    Answer: A check constraint on the range of longitude and latitude

    The constraint is cheap, permanent and applies to every future insert.

  6. Question 6 of 7 Which expression asks for the five closest rows?

    Choose one answer.

    Show the answer: Question 6 of 7

    Answer: ORDER BY geog <-> point LIMIT 5

    A nearest-neighbour query is an ORDER BY with a LIMIT.

  7. Question 7 of 7 A map shows every pharmacy within 2 km of the visitor. In a village the list has 3 rows and in a city 400. Which kind of query is that?

    Choose one answer.

    Show the answer: Question 7 of 7

    Answer: A radius query

    The size of the result is the tell: variable for a radius, fixed for nearest neighbour.

Radius queries that use the GiST index, with the plans Pro: opens with a Pro, Premium or Ultimate pass

A radius query is fast only when the planner can ask the GiST index for a short list of candidates. This part runs the same 2,000-metre question in two spellings, counts what each one costs, and shows how the order of the rows on disk changes what the index saves.

In this part:

  • Two spellings of one radius give two plans
  • The index returns candidates and the function decides
  • What the index saves depends on how rows are stored
  • Building the index and reading your own plan

1 diagram · 2 runnable examples (pages.sql, pages_session.py, pg_session.py, radius.sql, radius_session.py, setup.sql) · about 7 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Nearest-first search, and the checks before you trust a result Pro: opens with a Pro, Premium or Ultimate pass

A nearest-neighbour query needs no radius, because the index can hand rows back in distance order. This part runs it, shows two places where it misleads, and checks the polygon and unit mistakes that make correct SQL return wrong numbers.

In this part:

  • Nearest first, straight from the index
  • A selective filter turns the walk into a hunt
  • Two checks before you trust a number: validity and units
  • Four checks to keep after the lesson

3 runnable examples (knn.sql, knn_session.py, nearfilter.sql, nearfilter_session.py, pg_session.py, setup.sql, validate.sql, validate_session.py) · about 7 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Interview questions

Warm-up, fresher to 2–5 years: when does a table of shops use geography, and when geometry?

Use geography when the shops are spread over a large country or the world and you want distances in metres without choosing a projection: ST_Distance and ST_DWithin then work in metres. Use geometry when the data sits in one local area: keep it in a projected SRID such as a UTM zone, where distances are plain planar arithmetic in metres, faster and with a longer list of functions. Geography costs speed and functions. Geometry in 4326 costs meaning, because its distances come back in degrees. In the lesson’s run the same Pune to Mumbai pair was 120.1 km as geography and 1.1257 as geometry in 4326.

Three production scenarios build on this; their pages give what each tests and a first step:

  1. Mid to senior

    A "stores within 3 km" endpoint on 5 million rows takes four seconds. EXPLAIN shows a Seq Scan whose filter is ST_Distance(geog, point) < 3000, although poi has a GiST index on geog. Explain what the planner is doing, rewrite the query, and say how you would prove the fix.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A delivery-zone check, ST_DWithin(geom, shop, 3000) on a geometry(Point, 4326) column, marks customers in another city as inside the zone. A colleague proposes labelling the points with ST_SetSRID(geom, 32643). Say what is wrong with the query and with the proposal, and give two correct fixes.

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    "Nearest open store" runs in 3 ms for all stores but takes 900 ms once you add WHERE open_now AND kind = 'pharmacy', which 0.2 % of stores meet. The query is ORDER BY geog <-> point LIMIT 1 with a GiST index. Explain the cost and choose a fix, including what it costs to keep.

    Model answer, Pro. First step and follow-ups

Flashcards · 3 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What is the difference between PostGIS geography and geometry?

    geography holds longitude and latitude and measures along the earth's surface, so distances come back in metres. geometry holds x and y on a flat plane and measures by straight-line arithmetic, in whatever unit the coordinates are. Pune to Mumbai: 120.1 km as geography, 1.1257 as geometry.

  2. What is an SRID, and why does coordinate order trip people up?

    An SRID names a coordinate system (4326 is WGS 84, degrees). ST_Point(x, y, srid) takes longitude first (east), the opposite of how a position is said aloud; a latitude-first swap stores a valid point in the wrong place with no error, so add a range check.

  3. How do you write a radius query and a nearest-neighbour query in PostGIS?

    Radius: ST_DWithin(geog, point, 2000) in WHERE (metres for geography, can use a GiST index). Nearest: ORDER BY geog <-> point LIMIT 5, which walks a GiST index in distance order. A GiST index narrows the search so not every row needs a distance.

Key takeaways

  • Geography measures in metres along the earth and geometry in the units of its coordinates, so one pair of cities was 120.1 km apart as the first and 1.1257 as the second.
  • SRID 4326 means longitude and latitude in degrees, ST_Point takes longitude first, and a range check catches a swap that every type accepts.
  • A radius query is ST_DWithin, a nearest-neighbour query is ORDER BY geog <-> point LIMIT k, and a GiST index can serve both.
  • Numbers in the wrong unit come back without an error, as the 1.1257 degrees did, so check what a column’s SRID measures in before you trust a distance.

Terms in this lesson

geography type
A PostGIS type for longitude and latitude values that measures along the earth's surface, so distances and areas come back in metres and square metres.
geometry type
A PostGIS type for points, lines and polygons on a flat plane. Its functions use the plane's arithmetic, so results are in the units of the value's SRID.
spatial index
An index, in PostGIS a GiST index, that stores a bounding box for each shape so queries by area or distance can skip most rows.
SRID
A spatial reference identifier: a number that names the coordinate system of a PostGIS value. 4326 means longitude and latitude in degrees on the WGS 84 datum.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.