Skip to content

Lesson 2 — Your first geometry column

In this lesson we add the dispatch app’s first location-aware model: a Depot. Depots are the regional dispatch hubs — the centres that coordinate field work across a region. Field officers don’t typically work out of the depot in person; they’re spread across the region the depot manages. Western Australia is the extreme case: a single depot in Perth coordinates field work across an area larger than most countries. Each depot has a location — the actual building where coordinators sit and dispatchers run their shifts.

Adding the model is mostly conventional Rails. The interesting part is the location column, which won’t be a pair of decimal columns or a JSON blob. It’ll be a real PostGIS geometry column, and ActiveRecord will treat it as a first-class spatial attribute — automatically converting between the database’s binary representation and Ruby objects we can call methods on.

By the end of the lesson the database will have the first depot — Sydney’s central warehouse — and you’ll be able to query it back several ways.

Generating the model

Generate the Depot model. We’ll add the spatial column manually after generating, so the standard generator command is sufficient:

1
bin/rails generate model Depot name:string code:string

This produces a migration file at db/migrate/<timestamp>_create_depots.rb and an empty Depot model at app/models/depot.rb.

Adding the geometry column

Open the generated migration. The default file looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class CreateDepots < ActiveRecord::Migration[8.0]
  def change
    create_table :depots do |t|
      t.string :name
      t.string :code

      t.timestamps
    end
  end
end

Edit it so it reads:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class CreateDepots < ActiveRecord::Migration[8.0]
  def change
    create_table :depots do |t|
      t.string    :name,     null: false
      t.string    :code,     null: false
      t.st_point  :location, srid: 4326, null: false

      t.timestamps
    end

    add_index :depots, :code,     unique: true
    add_index :depots, :location, using: :gist
  end
end

Three substantive changes from the generator’s default:

t.st_point :location, srid: 4326 — declares a PostGIS geometry(Point, 4326) column. The st_point type constrains the column to point geometries (rejecting linestrings or polygons that might end up there by accident). srid: 4326 specifies the coordinate system as WGS84 lat/lng — the same system we discussed in Lesson 1.

null: false on each column. Depots without a name, code, or location wouldn’t be useful to anyone, so we reject them at the schema level.

add_index :depots, :location, using: :gist — adds a GiST index on the geometry column. GiST is PostGIS’s spatial index type. We won’t use the index meaningfully until Module 7 (where it becomes essential for performance), but the discipline of indexing geometry columns at creation time is worth following from the start. The seatbelt analogy applies — you add it without thinking, and it’s there if you need it.

Run the migration:

1
bin/rails db:migrate

PostgreSQL output should indicate that depots was created. Now look at db/schema.rb and find the new entry. The location column appears as a geometry column with the SRID and type constraint recorded:

1
2
t.geometry "location", limit: {:srid=>4326, :type=>"st_point"}, null: false
t.index ["location"], name: "index_depots_on_location", using: :gist

The limit: hash is how the postgis adapter records spatial metadata in schema.rb. Compare with the code index above it — there’s no using: parameter on a regular B-tree index; the postgis adapter remembers we asked for :gist and emits it on round-trip.

Inserting the first depot

Let’s put a depot in the database. We’ll use raw SQL for this first insertion because it makes the spatial value visible — using ActiveRecord directly would hide what’s happening with location.

Open psql:

1
bin/rails dbconsole

Then:

1
2
3
4
5
6
7
8
INSERT INTO depots (name, code, location, created_at, updated_at)
VALUES (
  'Sydney Central',
  'SYD-01',
  ST_SetSRID(ST_MakePoint(151.2093, -33.8688), 4326),
  NOW(),
  NOW()
);

Two PostGIS function calls build the location value. ST_MakePoint(longitude, latitude) constructs a point. ST_SetSRID(geometry, 4326) tags the point with SRID 4326 so the column constraint accepts it. Without ST_SetSRID, the point would be created with SRID 0 (no spatial reference) and the insert would fail because the column requires SRID 4326.

Notice the coordinate order: longitude first (151.2093), then latitude (-33.8688). PostGIS uses [lng, lat] natively, like GeoJSON and MapLibre. We saw this convention in Module 2 Lesson 4.

PostgreSQL should respond INSERT 0 1 indicating one row was inserted.

Querying the depot back

Three ways to look at the row.

See the binary form — the WKB hex string we met in Lesson 1:

1
SELECT name, location FROM depots;

Long, unreadable, the database’s internal representation.

See the WKT form — readable text:

1
SELECT name, ST_AsText(location) FROM depots;
1
2
3
     name      |        st_astext
---------------+--------------------------
 Sydney Central| POINT(151.2093 -33.8688)

See the GeoJSON form — what we’ll eventually serve to the browser:

1
SELECT name, ST_AsGeoJSON(location) FROM depots;
1
2
3
     name      |                    st_asgeojson
---------------+----------------------------------------------------
 Sydney Central| {"type":"Point","coordinates":[151.2093,-33.8688]}

Module 4 shows how a Rails controller produces the GeoJSON form and how a map component consumes it.

Query the depot’s coordinates as scalars

The ST_X and ST_Y accessor functions return a point’s longitude and latitude as plain numbers. In psql:

1
2
SELECT name, ST_X(location) AS lng, ST_Y(location) AS lat
FROM depots;

You should see:

1
2
3
     name      |    lng   |     lat
---------------+----------+-------------
 Sydney Central| 151.2093 | -33.8688

ST_X returns the X coordinate (longitude in lat/lng systems). ST_Y returns Y (latitude).

For meaningful spatial work, you don’t pull out X and Y; you use spatial functions that operate on geometries directly (point-in-polygon, distance, intersects, and so on). But the accessors are useful for inspection — and occasionally for building queries that combine spatial and non-spatial filtering.

Querying via ActiveRecord

The ActiveRecord side is where the activerecord-postgis-adapter earns its keep. Open a Rails console:

1
bin/rails console
1
2
3
4
Depot.first
# => #<Depot id: 1, name: "Sydney Central", code: "SYD-01",
#           location: #<RGeo::...PointImpl ...>,
#           created_at: ..., updated_at: ...>

The location attribute comes back as an RGeo geometry object — a Ruby object representing the point. RGeo is the Ruby spatial library that the adapter uses to wrap geometry values. RGeo objects have methods that mirror PostGIS functions:

1
2
3
4
5
depot = Depot.first
depot.location.x          # => 151.2093  (longitude)
depot.location.y          # => -33.8688  (latitude)
depot.location.as_text    # => "POINT (151.2093 -33.8688)"
depot.location.geometry_type.to_s  # => "Point"

The adapter handles WKB ⇄ RGeo object conversion automatically. You write Ruby; the adapter speaks PostGIS.

Inserting from Ruby is similarly transparent. The simplest way is to assign a WKT string and let the adapter parse it. In the Rails console, add a Brisbane depot:

1
2
3
4
5
Depot.create!(
  name:     "Brisbane North",
  code:     "BNE-01",
  location: "POINT(153.0251 -27.4698)"
)

The adapter parses the WKT, converts it to an RGeo geometry, and serialises it to WKB on the way to the database. No SRID needed in the WKT string because the column constraint forces 4326.

Verify it’s there:

1
2
3
4
Depot.count                  # => 2
Depot.last.name              # => "Brisbane North"
Depot.last.location.x        # => 153.0251
Depot.last.location.y        # => -27.4698

This is the point at which the WKT-vs-Ruby boundary disappears into ActiveRecord and you can mostly stop thinking about it. The location attribute behaves like any other column from the consumer’s perspective — set on creation, read back later — except that the value is a structured spatial object rather than a string or number.

A note on coordinate ordering, again

You’ll notice the insertion forms use longitude first, like PostGIS, GeoJSON, and MapLibre. WKT’s POINT(x y) means POINT(longitude latitude) for lat/lng data.

The Vera gem’s Ruby DSL is the exception that flips to latitude first because that’s the Ruby-natural convention. But once you cross the boundary into spatial data — WKT, GeoJSON, the database — coordinate order is longitude-first, consistently.

This division — Ruby DSL is [lat, lng], spatial data is [lng, lat] — is a deliberate choice. It puts the natural order in the surface developers write the most, and the standards-compliant order at the wire level where consistency matters. Know which side of the boundary you’re on.

Where this leaves us

The depots table now exists with two real rows. Each row has a typed point geometry constrained to SRID 4326, with a GiST index ready to make spatial queries fast when we have enough data to need it.

You’ve seen:

  • The t.st_point migration syntax for a typed geometry column.
  • The using: :gist syntax for a spatial index.
  • WKB, WKT, and GeoJSON as three representations of the same geometry value.
  • ActiveRecord’s automatic conversion to RGeo geometry objects.
  • The WKT-string pattern for setting a geometry attribute from Ruby.

In Lesson 3 we go deeper into SRIDs — what they actually are, what 4326 represents under the hood, and what other SRIDs you might encounter. Lesson 1 gave you enough to follow along; the next lesson fills in the picture.