GitHub link

Getting Started with PostGIS using Go

Table of Contents

PostGIS is a PostgreSQL extension that adds features for storing, indexing, and querying geospatial data. It’s pretty nice that you can get these features in Postgres which is already a powerful database. I couldn’t find much info available on how to integrate this with Go and the pgx package, so I decided to write this guide.

Disclaimer: I am not a GIS expert, so I might miss important points about properly storing and indexing geospatial data. This post will just cover examples to get started.

Setting up PostGIS

For this example, I’ll use Docker to bootstrap PostGIS. You can also follow their documentation if you want to install it another way.

docker run --name postgis-go -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_USER=postgres -e POSTGRES_DB=gopg \
-p 5432:5432 -d  postgis/postgis

Setting up the Go project

Create a new Go project or use an existing one. Install the following dependencies:

go get -u github.com/jackc/pgx/v5 github.com/twpayne/go-geom github.com/twpayne/pgx-geom

The Scenario

To keep it practical, let’s imagine an application where you want to let the users save their favorite places on a map. They can also save travel routes and pull up locations near this route.

To represent locations and routes, you’ll need to select an appropriate geometry type. PostGIS provides several geometry types, we’ll be using POINT and LINESTRING.

POINT represents a single point in space, such as a place on the Earth. We’ll use it for the locations. LINESTRING represents a 1-dimensional line composed of multiple line segments. Each segment has two points, with the end point being the start of the next segment. We’ll use it for the routes.

Depiction of a POINT vs LINESTRING

Creating the tables

package main

import (
	"context"

	"github.com/jackc/pgx/v5"
)

const pgConnStr = "postgres://postgres:postgres@localhost:5432/gopg?sslmode=disable"

func main() {
	ctx := context.Background()

	db, err := pgx.Connect(ctx, pgConnStr)

	if err != nil {
		panic(err)
	}

	defer db.Close(ctx)

	// Scaffold tables
	db.Exec(ctx, `
		CREATE TABLE IF NOT EXISTS favorite_places (
			id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
			name TEXT NOT NULL,
			coordinates geography(POINT, 4326) NOT NULL
		)
		`)

	db.Exec(ctx, `
		CREATE TABLE IF NOT EXISTS routes (
			id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
			path geography(LINESTRING, 4326) NOT NULL
		)
		`)
}

It’s worth noting PostGIS has two geometry types: geography and geometry. The docs recommend using geography to represent geographic coordinates in the form of longitude and latitude. Instead of using straight lines in calculations, it uses arcs which is more appropriate for the Earth’s surface.

We define a geometric column using this syntax:

column_name <abstract-type>(<concrete-type>, <SRID>)

where abstract-type is either geometry or geography, concrete-type is the specific type such as POINT or LINESTRING, and SRID is the Spatial Reference Identifier.

Representing the data in Go

// "github.com/twpayne/go-geom"
type FavoritePlace struct {
	ID          int
	Name        string
	Coordinates geom.Point
}

type Route struct {
	ID   int
	Path geom.LineString
}

You can declare points and linestrings like so:

// geom.Coord is a type alias for []float64

// Specify the use of 2D coordinates, set the SRID to 4326, and pass in the coordinates
pt := geom.NewPoint(geom.XY).SetSRID(4326).MustSetCoords(geom.Coord{45.52515498907135, -73.57520521798992})

// Linestring with 3 segments
line := geom.NewLineString(geom.XY).SetSRID(4326).MustSetCoords([]geom.Coord{
	{45.52515498907135, -73.57520521798992},
	{45.52433716241833, -73.57591484495788},
	{45.523244861127694, -73.57358389203318},
	{45.52071221016285, -73.57593660297117},
})

Adding geometry encoding support to pgx

In order for pgx to be able to encode and decode the types from go-geom, we need to register the types with pgx.

// pgxgeom "github.com/twpayne/pgx-geom"

pgxgeom.Register(ctx, db)

If you don’t do this you’ll get an error like this:

panic: failed to encode args[1]: unable to encode geom.Point{geom0:geom.geom0{layout:1, stride:2, flatCoords:[]float64{45.52516207479769, -73.57524302609725}, srid:4326}} into text format for unknown type (OID 18755): cannot find encode plan

Inserting data

favPlaces := []FavoritePlace{
	{
		Name:        "Ma Poule Mouillée",
		Coordinates: *geom.NewPoint(geom.XY).SetSRID(4326).MustSetCoords(geom.Coord{-73.57524302609725, 45.52516207479769}),
	},
	{
		Name:        "St-Viateur Bagel",
		Coordinates: *geom.NewPoint(geom.XY).SetSRID(4326).MustSetCoords(geom.Coord{-73.60198850368204, 45.52282543077195}),
	},
	{
		Name:        "Kem CoBa",
		Coordinates: *geom.NewPoint(geom.XY).SetSRID(4326).MustSetCoords(geom.Coord{-73.59518469669553, 45.52309288029339}),
	},
}

for _, favPlace := range favPlaces {
	_, err = db.Exec(ctx, "INSERT INTO favorite_places (name, coordinates) VALUES ($1, $2)", favPlace.Name, &favPlace.Coordinates)
	if err != nil {
		panic(err)
	}
}

route := Route{
Path: *geom.NewLineString(geom.XY).SetSRID(4326).MustSetCoords([]geom.Coord{
		{-73.60189653194972, 45.52253964921602},
		{-73.59978123333241, 45.524969604538974},
		{-73.59533615511437, 45.522942689508255},
	}),
}

_, err = db.Exec(ctx, "INSERT INTO routes (path) VALUES ($1)", &route.Path)

if err != nil {
	panic(err)
}

If you look at your database, you should see the data has been inserted. The geometry types will look like this:

SRID=4326;POINT(-73.57524302609725 45.52516207479769)
SRID=4326;LINESTRING(-73.60189653194972 45.52253964921602,-73.59978123333241 45.52496960453897,-73.59533615511437 45.52496960453897)

This format for geometry is known as EWKT (Extended Well-Known-Text). It’s nice because it’s human-readable and includes SRID, but depending on the application you may have reasons to use other formats. PostGIS supports input and output of various formats including:

Querying data

Now let’s implement the query to find favorite places within a certain distance of a given route.

func findClosest(ctx context.Context, db *pgx.Conn, routeId int, distanceMeters float64) ([]FavoritePlace, error) {
	rows, err := db.Query(ctx, `
		SELECT * FROM favorite_places
		WHERE ST_DISTANCE((
			SELECT path FROM routes WHERE routes.id = $1
		), coordinates) < $2
		`, routeId, distanceMeters)

	if err != nil {
		return nil, err
	}

	return pgx.CollectRows(rows, pgx.RowToStructByName[FavoritePlace])
}

The important part here is the ST_DISTANCE function. This function calculates the distance between the two geometries passed as arguments and let’s us filter the results based on that distance. With that, we can add the following code:

// Find favorite places within 100 meters of route with ID 1
favPlaces, err = findClosest(ctx, db, 1, 100.0)

if err != nil {
	panic(err)
}

for _, favPlace := range favPlaces {
	fmt.Printf("%s at %f, %f\n", favPlace.Name, favPlace.Coordinates.X(), favPlace.Coordinates.Y())
}

// Output:
// St-Viateur Bagel at -73.601989, 45.522825
// Kem CoBa at -73.595185, 45.523093

You should see that only St-Viateur and Kem CoBa are returned, as Ma Poule Mouillée is farther than 100 meters from the route. You can also see that it decoded the coordinates column as a geom.Point type.

Wrapping up

That covers the basics of integrating your Go code with PostGIS. There’s a lot more you can do with PostGIS, but hopefully this is enough to get you started.