← назад к разделу

Когда приложению нужно хранить координаты и искать по ним — «магазины рядом со мной», «доставка в зоне», «ближайшие 10 точек» — первый импульс: завести два поля lat и lon типа numeric. Но тогда запрос «все магазины в 5 км от меня» нельзя выполнить быстро: база не умеет индексировать пары чисел как пространственные объекты.

PostGIS — расширение PostgreSQL, которое добавляет полноценные географические типы, пространственные индексы и десятки функций для работы с геоданными. Именно на нём работают OpenStreetMap, Uber и большинство государственных геосервисов.

Когда PostGIS нужен, а когда достаточно чисел

Заводить PostGIS имеет смысл, если вы делаете что-то из этого:

  • поиск объектов в радиусе («кафе в 2 км»);
  • расчёт расстояния между точками;
  • проверка, попадает ли точка в полигон (район, зона доставки);
  • поиск N ближайших объектов;
  • маршруты, геозоны, пересечения границ.

Если координаты просто хранятся и отображаются на карте, но не участвуют в запросах — пары numeric(9,6) вполне достаточно. PostGIS здесь избыточен.

Установка

CREATE EXTENSION postgis;

Расширение доступно в большинстве облачных баз (RDS, Cloud SQL, Supabase) и устанавливается одной командой. После этого появляются новые типы и функции.

Два типа: geography и geometry

PostGIS предлагает два основных типа для хранения геоданных.

geometry — плоская система координат. Расстояния считаются как на листе бумаги. Быстрее, поддерживает больше операций, но даёт точные результаты только на небольших территориях (город, область).

geography — сферическая система, учитывает форму Земли. Медленнее, но точна для любых расстояний на глобусе. ST_Distance возвращает метры, а не градусы.

Для большинства приложений с реальными координатами (широта/долгота) правильный выбор — geography(Point, 4326). Число 4326 — это идентификатор системы координат WGS84, той самой, которую использует GPS.

geographygeometry
Точность на больших расстоянияхданет
Единица ST_Distanceметрыединицы SRID
Скоростьмедленнеебыстрее
SRIDтолько 4326любой

Хранение точек

CREATE TABLE shop (
    id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name      text NOT NULL,
    location  geography(Point, 4326) NOT NULL
);

Вставка точки — через функцию ST_GeogFromText с WKT-строкой или через ST_MakePoint:

-- через WKT
INSERT INTO shop (name, location) VALUES (
    'Магазин на Невском',
    ST_GeogFromText('SRID=4326;POINT(30.3358 59.9343)')
);

-- через ST_MakePoint
INSERT INTO shop (name, location) VALUES (
    'Магазин на Невском',
    ST_MakePoint(30.3358, 59.9343)::geography
);

Частая ошибка: перепутать порядок координат. В WKT и ST_MakePoint сначала долгота, потом широта — то есть POINT(lon lat), не POINT(lat lon). Большинство туториалов путают именно здесь.

Пространственный индекс — без него нет смысла

CREATE INDEX ix_shop_location_gist ON shop USING gist (location);

Без пространственного индекса (GiST) запросы вроде «найди магазины в 5 км» выполняются перебором всей таблицы. На миллионе строк — секунды. С GiST-индексом — миллисекунды.

GiST-индекс обязателен для любой таблицы с географическими данными, по которой делаются запросы.

Поиск в радиусе

Главная функция для поиска в радиусе — ST_DWithin. Она принимает две точки и расстояние в метрах, и использует GiST-индекс:

SELECT id, name,
       ST_Distance(location, ST_GeogFromText('SRID=4326;POINT(30.3158 59.9398)')) AS distance_m
FROM shop
WHERE ST_DWithin(
    location,
    ST_GeogFromText('SRID=4326;POINT(30.3158 59.9398)'),
    5000   -- 5 км в метрах
)
ORDER BY distance_m
LIMIT 50;

Важный момент: ST_DWithin в WHERE — использует индекс. ST_Distance в SELECT — только вычисляет значение для уже отобранных строк. Если написать WHERE ST_Distance(...) < 5000 без ST_DWithin — индекс не задействуется и запрос будет медленным.

Поиск N ближайших точек (kNN)

Когда нужно найти не «всё в радиусе», а «5 ближайших» — используют оператор <-> в ORDER BY:

SELECT id, name
FROM shop
ORDER BY location <-> ST_GeogFromText('SRID=4326;POINT(30.3158 59.9398)')
LIMIT 10;

Этот оператор тоже использует GiST-индекс и работает быстро даже на миллионах строк.

Полигоны: зоны, районы, границы

Полигоны хранятся так же, как точки, только тип — geography(Polygon, 4326):

CREATE TABLE district (
    id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name      text NOT NULL,
    boundary  geography(Polygon, 4326) NOT NULL
);

CREATE INDEX ix_district_boundary_gist ON district USING gist (boundary);

-- Вставка из GeoJSON
INSERT INTO district (name, boundary) VALUES (
    'Центральный',
    ST_GeogFromGeoJSON('{"type":"Polygon","coordinates":[[...]]}')
);

Проверить, в каком районе находится точка:

SELECT name FROM district
WHERE ST_Contains(boundary::geometry, ST_MakePoint(30.3158, 59.9398));

Три основные функции для работы с полигонами:

ФункцияЧто делает
ST_Contains(A, B)A полностью содержит B
ST_Within(A, B)A находится внутри B
ST_Intersects(A, B)A и B имеют хотя бы одну общую точку

Вставка из кода приложения

Самый универсальный способ передать координаты из приложения — WKT-строка вида SRID=4326;POINT(lon lat). Её принимают все драйверы без дополнительных зависимостей.

import org.jooq.DSLContext;
import org.jooq.impl.DSL;
import static com.example.db.Tables.SHOP;

public record GeoPoint(double lat, double lon) {
    public String toWkt() {
        return "SRID=4326;POINT(%f %f)".formatted(lon, lat);
    }
}

void insertShop(DSLContext ctx, String name, GeoPoint pt) {
    ctx.insertInto(SHOP)
       .set(SHOP.NAME, name)
       .set(SHOP.LOCATION,
            DSL.field("ST_GeogFromText({0})", Object.class, pt.toWkt()))
       .execute();
}
import (
    "context"
    "fmt"
    "github.com/jackc/pgx/v5/pgxpool"
)

type GeoPoint struct{ Lat, Lon float64 }

func (p GeoPoint) WKT() string {
    return fmt.Sprintf("SRID=4326;POINT(%f %f)", p.Lon, p.Lat)
}

func insertShop(ctx context.Context, pool *pgxpool.Pool, name string, pt GeoPoint) error {
    _, err := pool.Exec(ctx,
        `INSERT INTO shop (name, location) VALUES ($1, ST_GeogFromText($2))`,
        name, pt.WKT(),
    )
    return err
}
import { Pool } from 'pg';

interface GeoPoint { lat: number; lon: number }

function toWkt(pt: GeoPoint): string {
    return `SRID=4326;POINT(${pt.lon} ${pt.lat})`;
}

async function insertShop(pool: Pool, name: string, pt: GeoPoint): Promise<void> {
    await pool.query(
        `INSERT INTO shop (name, location) VALUES ($1, ST_GeogFromText($2))`,
        [name, toWkt(pt)],
    );
}
import psycopg
from dataclasses import dataclass

@dataclass
class GeoPoint:
    lat: float
    lon: float

    def wkt(self) -> str:
        return f"SRID=4326;POINT({self.lon} {self.lat})"

async def insert_shop(conn: psycopg.AsyncConnection, name: str, pt: GeoPoint) -> None:
    await conn.execute(
        "INSERT INTO shop (name, location) VALUES (%s, ST_GeogFromText(%s))",
        (name, pt.wkt()),
    )

Если нужны геометрические операции на стороне приложения — есть специализированные библиотеки: JTS (org.locationtech.jts) для Java, Shapely для Python, turf.js для Node. Все они работают с теми же форматами (WKT, GeoJSON), что и PostGIS.

Фильтрация по нескольким полям

Допустим, нужно искать только активные магазины в радиусе. Обычный GiST-индекс покрывает только location, а фильтр is_active = true применяется уже после него. Для таких случаев есть расширение btree_gist, которое позволяет создать составной пространственный индекс:

CREATE EXTENSION btree_gist;
CREATE INDEX ix_shop_active_loc ON shop USING gist (is_active, location);

SELECT * FROM shop
WHERE is_active = true
  AND ST_DWithin(location, ST_GeogFromText('SRID=4326;POINT(30.3158 59.9398)'), 5000);

Такой индекс отрабатывает оба условия сразу.

Миграция с lat/lon на geography

Если таблица уже хранит координаты в полях lat и lon, добавить геоданные можно без переписи приложения — через генерируемый столбец:

ALTER TABLE shop ADD COLUMN location geography(Point, 4326)
GENERATED ALWAYS AS (ST_MakePoint(lon, lat)::geography) STORED;

CREATE INDEX ix_shop_location_gist ON shop USING gist (location);

Старый код продолжает читать и писать lat/lon. Новые запросы используют location для пространственных операций. Никакой двойной записи — база вычисляет столбец автоматически.

Частые ошибки

Перепутан порядок координат. POINT(lon lat) — сначала долгота, потом широта. Это противоречит интуиции (мы привыкли говорить «широта, долгота»), поэтому ошибка очень распространена. Результат: точки оказываются в совершенно другом месте.

ST_Distance без ST_DWithin в WHERE. WHERE ST_Distance(...) < 5000 не использует индекс. Всегда используйте ST_DWithin для фильтрации, а ST_Distance только в SELECT для отображения расстояния.

geometry вместо geography для координат GPS. Для небольших зон это работает, но даёт неточные результаты на больших расстояниях — и единица измерения будет не метры. Для координат в формате WGS84 (то, что даёт GPS) используйте geography.

Нет пространственного индекса. Иногда забывают создать GiST-индекс и удивляются медленным запросам. Индекс обязателен сразу при создании таблицы.

Формула Хаверсина в коде. Иногда расстояние считают вручную в приложении, хотя PostGIS делает это точнее и быстрее прямо в запросе.

Коротко

  • PostGIS нужен, когда данные участвуют в пространственных запросах: поиск в радиусе, вхождение в полигон, ближайшие N точек.
  • Для координат GPS используйте тип geography(Point, 4326) — он учитывает форму Земли и возвращает расстояния в метрах.
  • GiST-индекс обязателен: без него пространственные запросы работают в сотни раз медленнее.
  • Для поиска в радиусе — ST_DWithin в WHERE (использует индекс). ST_Distance — только в SELECT.
  • Для N ближайших точек — оператор <-> в ORDER BY.
  • В WKT и ST_MakePoint — сначала долгота, потом широта.
  • Из приложения координаты удобно передавать как WKT-строку SRID=4326;POINT(lon lat).
  • Переход с lat/lon на geography можно сделать через генерируемый столбец без изменения существующего кода.

Что почитать дальше

  • Типы индексов в PostgreSQL — GiST и другие индексы подробно.
  • Materialized views — кешировать результаты тяжёлых геозапросов.
  • Расширения PostgreSQL — как устанавливать postgis и btree_gist.