Когда приложению нужно хранить координаты и искать по ним — «магазины рядом со мной», «доставка в зоне», «ближайшие 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.
geography | geometry | |
|---|---|---|
| Точность на больших расстояниях | да | нет |
| Единица 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.