How PostGIS Stores Geometry: SRID, EWKB and the Typed Column

On this page

Problem statement

Two tables hold the same 21,253 Land Registry parcels for Rutland, and they look identical in QGIS. Ask each which parcels lie within 200 m of a point in Oakham:

query = """SELECT count(*) FROM {table}
           WHERE ST_DWithin(geom, ST_SetSRID(ST_MakePoint(486000, 309000), 27700), 200)"""
for table in ["parcels_a", "parcels_b"]:
    declared = sql(f"SELECT type, srid FROM geometry_columns WHERE f_table_name = '{table}'")[0]
    indexes = sql(f"SELECT count(*) FROM pg_indexes WHERE tablename = '{table}'")[0][0]
    rows, ms = timed(query.format(table=table))
    print(f"{table}: declared {declared[0]}({declared[1]}), {indexes} index(es) -> {rows} parcels in {ms:.2f} ms")
parcels_a: declared POLYGON(27700), 1 index(es) -> 255 parcels in 0.36 ms
parcels_b: declared GEOMETRY(0), 0 index(es) -> 255 parcels in 23.22 ms

The same answer, about seventy times slower. The first table was written by GeoPandas' to_postgis(), which declared the column geometry(Polygon, 27700) and indexed it; the second was created by hand with a bare geometry column and no index. The second column is untyped. It accepts a MultiPolygon in EPSG:27700, a Point in EPSG:4326 and a 3D LineString in the same column, all at once β€” and every query against it has to cope with whatever is actually in there.

Understanding what PostGIS keeps in a geometry column, and what a column declaration adds on top, explains most of the surprises: why an SRID mismatch stops a query dead, why a MultiPolygon column quietly accepts a Polygon, why a spatial index sometimes does nothing, and why to_postgis on an untyped column is a decision rather than a default.

Quick answer

A PostGIS geometry value is EWKB β€” well-known binary with an SRID attached. A geometry column optionally constrains three things about the values it accepts:

CREATE TABLE parcels (
    geom geometry(MultiPolygon, 27700) NOT NULL
    --       β”‚        β”‚           β”‚
    --       β”‚        β”‚           └── SRID: which coordinate system
    --       β”‚        └────────────── type: which geometry kinds are allowed
    --       └─────────────────────── the base type; ZM go on the type name
);
Level What it is What it costs you to skip
geometry any shape, any CRS, any dimension every query defends against the mixture
geometry(MultiPolygon) one shape kind mixed types break ST_Union, ST_Area semantics
geometry(MultiPolygon, 27700) one kind, one CRS a mixed-SRID query fails outright, mid-job
+ NOT NULL every row has a shape outer joins produce unqueryable rows
+ GIST index fast bounding-box lookup sequential scans over every row
# what Python sends, and what the column checks
print(parcels.crs)                          # becomes the SRID in EWKB
print(parcels.geom_type.unique())           # checked against the declared type
print(parcels.geometry.has_z.any())         # checked against the declared dimension
EPSG:27700
<ArrowStringArray>
['Polygon']
Length: 1, dtype: str
False

Three properties, checked on every insert. Get them right once at CREATE TABLE and the database refuses everything that would have become a data-quality problem later.

What a geometry value actually contains

The parts of an EWKB value with the SRID flag highlighted. what a geometry value carriesbyte ordertype + flagsSRIDcoordinates0103000020346C0000325113 674881 …littlePolygon +SRID27700the actual shapethis is what WKB lacks and EWKB addsST_AsText drops the SRID Β· ST_AsEWKT keeps it spatialworkflow.io
The SRID rides with the geometry. That is the difference between WKB and EWKB.

EWKB values, SRID lookups, type modifiers

Levels of constraint on a PostGIS geometry column. each level rejects a class of bad data at write timegeometryaccepts anythinggeometry(MultiPolygon)one shape kindgeometry(MultiPolygon, 27700)one CRS+ NOT NULLevery row has a shape+ GIST indexand it is fastA mixed-SRID query fails outright β€” PostGIS refuses to guess spatialworkflow.io
Each level rejects a class of bad data at write time instead of at query time.

The value: EWKB

Standard WKB β€” the OGC binary format β€” describes a shape and nothing else. It has no idea which coordinate system its numbers are in. PostGIS extends it:

SELECT ST_AsEWKT(geom) FROM parcels LIMIT 1;
-- SRID=27700;MULTIPOLYGON(((325113 674881, 325160 674881, ...)))

SELECT ST_AsText(geom) FROM parcels LIMIT 1;
-- MULTIPOLYGON(((325113 674881, ...)))       ← the SRID is gone

ST_AsEWKT keeps the SRID; ST_AsText drops it. That distinction matters when you round-trip geometry through Python:

print(sql("SELECT left(ST_AsEWKT(geom), 40), left(ST_AsText(geom), 30) FROM parcels_a LIMIT 1")[0])
for label, select in [("geom as stored", "geom"), ("ST_AsBinary(geom)", "ST_AsBinary(geom)"),
                      ("a constructor", "ST_MakeEnvelope(486000, 309000, 486100, 309100)")]:
    frame = gpd.read_postgis(f"SELECT {select} AS geom FROM parcels_a LIMIT 5", engine, geom_col="geom")
    print(f"{label:18} -> crs {frame.crs}")
('SRID=27700;POLYGON((495533.11 299678.86,', 'POLYGON((495533.11 299678.86,4')
geom as stored     -> crs EPSG:27700
ST_AsBinary(geom)  -> crs None
a constructor      -> crs None

read_postgis reads the SRID from the EWKB and sets the GeoDataFrame's crs from it. A query that constructs geometry with a plain constructor produces SRID 0, and the frame comes back with no CRS β€” which is the SRID 0 error waiting to happen on the next write.

The SRID: a lookup key, not a definition

print(sql("SELECT srid, auth_name, auth_srid, left(srtext, 50) FROM spatial_ref_sys WHERE srid = 27700")[0])
(27700, 'EPSG', 27700, 'PROJCS["OSGB 1936 / British National Grid",GEOGCS[')

spatial_ref_sys is an ordinary table that PostGIS populates on install. The SRID stored with each geometry is a foreign key into it in spirit, though not enforced as one. Two consequences follow:

  • The number is meaningless without the table. A database missing rows in spatial_ref_sys cannot transform, which is the server-side counterpart of a missing proj.db.
  • SRID 0 is legal. It means "unknown", not "invalid". PostGIS stores it happily and then refuses to transform it, because there is nothing to transform from.
for statement in ["SELECT ST_Transform(ST_SetSRID(geom, 0), 4326) FROM parcels_a LIMIT 1",
                  "SELECT ST_Transform(ST_SetSRID(ST_MakePoint(1, 2), 990001), 4326)",
                  "SELECT ST_SRID(ST_MakePoint(1, 2)), ST_SRID(ST_MakePoint(1, 2)) IS NULL"]:
    print(sql(statement))
ERROR: ST_Transform: Input geometry has unknown (0) SRID
ERROR: Cannot find SRID (990001) in spatial_ref_sys
[(0, False)]

The type modifier: a constraint, checked per row

ALTER TABLE parcels ALTER COLUMN geom TYPE geometry(MultiPolygon, 27700);

This is not metadata β€” it is enforced. An insert of the wrong SRID or an incompatible type raises, which is exactly the behaviour that keeps a table trustworthy.

The type check has one asymmetry worth knowing, because it is not what people expect. A MultiPolygon column accepts a single-part Polygon and promotes it; a Polygon column rejects a MultiPolygon:

INSERT INTO t_multi  VALUES (ST_SetSRID(ST_MakeEnvelope(0,0,1,1), 27700));          -- ok, stored as MULTIPOLYGON
INSERT INTO t_single VALUES (ST_Multi(ST_SetSRID(ST_MakeEnvelope(0,0,1,1), 27700)));
-- ERROR: Geometry type (MultiPolygon) does not match column type (Polygon)

Run against PostGIS 3.4:

sql("DROP TABLE IF EXISTS t_multi, t_single")
sql("CREATE TABLE t_multi (geom geometry(MultiPolygon, 27700))")
sql("CREATE TABLE t_single (geom geometry(Polygon, 27700))")
square = "ST_SetSRID(ST_MakeEnvelope(0, 0, 1, 1), 27700)"
print(sql(f"INSERT INTO t_multi VALUES ({square})"), sql("SELECT GeometryType(geom) FROM t_multi"))
print(sql(f"INSERT INTO t_single VALUES (ST_Multi({square}))"))
print(sql("INSERT INTO t_single VALUES (ST_SetSRID(ST_MakeEnvelope(0, 0, 1, 1), 4326))"))
ok [('MULTIPOLYGON',)]
ERROR: Geometry type (MultiPolygon) does not match column type (Polygon)
ERROR: Geometry SRID (4326) does not match column SRID (27700)

So declaring MultiPolygon is the forgiving choice β€” it takes both, and normalises. The type name also carries dimensionality:

Declaration Accepts
geometry(Polygon, 27700) 2D single-part polygons only
geometry(MultiPolygon, 27700) multi-part polygons β€” and single ones, silently promoted
geometry(PolygonZ, 27700) 3D polygons
geometry(Geometry, 27700) any shape, one CRS
geometry anything at all

geometry(Geometry, 27700) is the useful middle ground people forget: it accepts mixed shape types while still guaranteeing one coordinate system β€” which is the constraint that actually prevents silent join failures.

Milliseconds for the same ST_DWithin query on 21,253 Rutland parcels, typed and indexed against untyped with no index. parcels within 200 m of a point, 21,253 rowstyped + GiST index0.35 msuntyped, no index23.29 ms012.5 ms25 msBoth return the same 255 parcels spatialworkflow.io
Measured on this page, averaged over 20 runs: about seventy times slower for the same answer.

The index: bounding boxes in an R-tree

CREATE INDEX parcels_geom_idx ON parcels USING GIST (geom);

A GIST index does not store geometry. It stores each row's bounding box, in a tree that supports "which boxes overlap this box" efficiently. That is why the index can accelerate ST_Intersects and ST_DWithin but not ST_Distance(...) < 500 β€” the first two can be expressed as a box question, the third cannot.

SELECT ST_Extent(geom) FROM parcels;
-- BOX(320111 670222, 330544 680915)         ← the same idea, one level up

Two things silently disable it:

-- a function around the indexed column: the index is on geom, not on ST_Buffer(geom)
WHERE ST_Intersects(ST_Buffer(geom, 10), :shape)

-- an SRID mismatch: forces a per-row transform, and the transformed value is not indexed
WHERE ST_Intersects(geom, ST_Transform(:shape_4326, 27700))   -- fine, :shape transformed once
WHERE ST_Intersects(ST_Transform(geom, 4326), :shape_4326)    -- not fine, geom transformed per row

Asking the planner for each form, on the indexed parcels:

point = "ST_SetSRID(ST_MakePoint(486000, 309000), 27700)"
predicates = {
    "ST_DWithin(geom, point, 200)": f"ST_DWithin(geom, {point}, 200)",
    "ST_Distance(geom, point) < 200": f"ST_Distance(geom, {point}) < 200",
    "ST_Intersects(ST_Buffer(geom, 10), point)": f"ST_Intersects(ST_Buffer(geom, 10), {point})",
    "transform the constant": "ST_Intersects(geom, ST_Transform(ST_SetSRID(ST_MakePoint(-0.73, 52.67), 4326), 27700))",
    "transform the column": "ST_Intersects(ST_Transform(geom, 4326), ST_SetSRID(ST_MakePoint(-0.73, 52.67), 4326))",
}
for label, where in predicates.items():
    plan = " ".join(row[0] for row in sql(f"EXPLAIN SELECT count(*) FROM parcels_a WHERE {where}"))
    print(f"{label:42} {'index scan' if 'Index' in plan else 'sequential scan'}")
ST_DWithin(geom, point, 200)               index scan
ST_Distance(geom, point) < 200             sequential scan
ST_Intersects(ST_Buffer(geom, 10), point)  sequential scan
transform the constant                     index scan
transform the column                       sequential scan

Transform the constant, never the column. See spatial indexes explained.

geography is a different storage decision

geom  geometry(Point, 4326)     -- planar maths on lon/lat degrees
geog  geography(Point, 4326)    -- spheroidal maths, metres

geography stores the same coordinates and computes differently: ST_Distance returns metres on the spheroid rather than degrees on a plane. It accepts only longitude/latitude systems:

print(sql("SELECT ST_SRID(ST_GeogFromText('SRID=4269;POINT(-1 52)')::geometry)"))
print(sql("SELECT ST_GeogFromText('SRID=27700;POINT(486000 309000)')"))
[(4269,)]
ERROR: Only lon/lat coordinate systems are supported in geography.

NAD83 (4269) is accepted; British National Grid is refused. It is right for global data where no single projection works, and slower for everything else. Most national-grid work should use geometry in a projected SRID, where planar maths is both correct and fast.

Inspecting what is really in a column

The data used on this page

The parcels are HM Land Registry's INSPIRE Index Polygons for Rutland β€” 21,253 freehold parcels β€” loaded into a throwaway PostGIS 3.4 in Docker (port 55433) three ways: parcels_a with to_postgis(), parcels_b as a hand-made table with an untyped geometry column and no index, and parcels_loose, an untyped copy in which 253 rows have had their SRID set to 0 and 277 have been reprojected to WGS 84 (picked by their ID, so the choice is repeatable). parcels_mixed holds one parcel in each SRID. The helper sql() runs a statement and returns its rows β€” or the error message, so that the errors on this page are the ones PostGIS actually gives.

import io
import os
import time
import zipfile
from pathlib import Path

import geopandas as gpd
import pyogrio
import requests
from sqlalchemy import create_engine
from sqlalchemy.exc import DBAPIError

os.environ["PGPASSWORD"] = "guide"
if os.system("docker inspect guide-postgis >/dev/null 2>&1") != 0:
    os.system("docker run -d --name guide-postgis -e POSTGRES_USER=user -e POSTGRES_PASSWORD=guide "
              "-e POSTGRES_DB=gis -p 127.0.0.1:55433:5432 postgis/postgis:16-3.4 >/dev/null")
    time.sleep(10)
engine = create_engine("postgresql+psycopg://user@localhost:55433/gis")


def sql(statement):
    try:
        with engine.begin() as conn:
            result = conn.exec_driver_sql(statement)
            return result.all() if result.returns_rows else "ok"
    except DBAPIError as err:
        return "ERROR: " + str(err.orig).splitlines()[0]


def timed(statement, repeat=20):
    sql(statement)
    start = time.perf_counter()
    for _ in range(repeat):
        value = sql(statement)[0][0]
    return value, (time.perf_counter() - start) / repeat * 1000


pyogrio.set_gdal_config_options({"GML_DOWNLOAD_SCHEMA": "NO"})
if not Path("parcels.gpkg").exists():
    reply = requests.Session().get("https://use-land-property-data.service.gov.uk/datasets/inspire/download/"
                                   "Rutland.zip", timeout=600)
    reply.raise_for_status()
    zipfile.ZipFile(io.BytesIO(reply.content)).extract("Land_Registry_Cadastral_Parcels.gml")
    gpd.read_file("Land_Registry_Cadastral_Parcels.gml").to_file("parcels.gpkg")
parcels = gpd.read_file("parcels.gpkg")[["INSPIREID", "geometry"]].rename_geometry("geom")
parcels.to_postgis("parcels_a", engine, if_exists="replace", index=False)
for statement in [
    "DROP TABLE IF EXISTS parcels_b, parcels_loose, parcels_mixed",
    'CREATE TABLE parcels_b ("INSPIREID" int, geom geometry)',
    'INSERT INTO parcels_b SELECT "INSPIREID", geom FROM parcels_a',
    'CREATE TABLE parcels_loose ("INSPIREID" int, geom geometry)',
    'INSERT INTO parcels_loose SELECT "INSPIREID", geom FROM parcels_a',
    'UPDATE parcels_loose SET geom = ST_SetSRID(geom, 0) WHERE mod("INSPIREID", 42) = 0 AND mod("INSPIREID", 84) <> 0',
    'UPDATE parcels_loose SET geom = ST_Transform(geom, 4326) WHERE mod("INSPIREID", 84) = 0',
    "CREATE TABLE parcels_mixed (geom geometry)",
    "INSERT INTO parcels_mixed SELECT geom FROM parcels_a LIMIT 1",
    "INSERT INTO parcels_mixed SELECT ST_Transform(geom, 4326) FROM parcels_a LIMIT 1",
    "ANALYZE parcels_a", "ANALYZE parcels_b",
]:
    sql(statement)
print(sql("SELECT postgis_lib_version()")[0][0], "|", len(parcels), "parcels |",
      sql("SELECT ST_SRID(geom), count(*) FROM parcels_loose GROUP BY 1 ORDER BY 1"))
3.4.3 | 21253 parcels | [(0, 253), (4326, 277), (27700, 20723)]
Which declarations accept which geometries, and where the asymmetry is. MultiPolygon is the forgiving declarationa single-part polygona multi-part polygongeometry(Polygon, …)βœ“acceptedβœ•rejectedgeometry(MultiPolygon, …)!promoted, silentlyβœ“acceptedgeometry(PolygonZ, …)βœ•2D rejectedβœ•2D rejectedgeometry(Geometry, …)βœ“acceptedβœ“acceptedThe modifier is enforced on every row, not stored as a note β€” a wrong SRID or type raises spatialworkflow.io
Example 2 tightens a loosely created column, which is the migration this table implies.

Example 1: inspecting what is really in a column

-- the declaration
SELECT f_geometry_column, type, srid, coord_dimension
FROM geometry_columns
WHERE f_table_name = 'parcels';
-- geom | MULTIPOLYGON | 27700 | 2

-- the reality, which can differ on an untyped column
SELECT ST_SRID(geom)      AS srid,
       GeometryType(geom) AS type,
       ST_NDims(geom)     AS dims,
       count(*)
FROM parcels
GROUP BY 1, 2, 3
ORDER BY 4 DESC;

Run on the declared table and on a loosely built copy β€” the parcels with some rows left without an SRID and some reprojected to WGS 84, the kind of table that accumulates when several scripts append to an untyped column:

for table in ["parcels_a", "parcels_loose"]:
    print(table, sql(f"SELECT type, srid, coord_dimension FROM geometry_columns WHERE f_table_name = '{table}'"))
    print("  ", sql(f"""SELECT ST_SRID(geom), GeometryType(geom), ST_NDims(geom), count(*)
                        FROM {table} GROUP BY 1, 2, 3 ORDER BY 4 DESC"""))
parcels_a [('POLYGON', 27700, 2)]
   [(27700, 'POLYGON', 2, 21253)]
parcels_loose [('GEOMETRY', 0, 2)]
   [(27700, 'POLYGON', 2, 20723), (4326, 'POLYGON', 2, 277), (0, 'POLYGON', 2, 253)]

On a properly declared column the second query returns exactly one row. More than one row means the column is untyped and the table has been accumulating a mixture. to_postgis() is not usually the culprit: it declares the type and SRID of the data it is given β€” geometry(Polygon, 27700) for these parcels β€” and indexes the column. It falls back to the generic GEOMETRY type only when the GeoDataFrame itself mixes types, such as Polygon with MultiPolygon. See how to write a GeoDataFrame to PostGIS.

Example 2: tightening a column that was created loosely

BEGIN;

-- 1. see what is actually there
SELECT DISTINCT ST_SRID(geom), GeometryType(geom) FROM parcels;

-- 2. make every row conform
UPDATE parcels
SET geom = ST_Multi(ST_Force2D(ST_SetSRID(geom, 27700)))
WHERE ST_SRID(geom) = 0;            -- only the unlabelled rows

UPDATE parcels
SET geom = ST_Multi(ST_Force2D(ST_Transform(geom, 27700)))
WHERE ST_SRID(geom) NOT IN (0, 27700);   -- genuinely wrong CRS

-- 3. now the constraint can be added
ALTER TABLE parcels
  ALTER COLUMN geom TYPE geometry(MultiPolygon, 27700);

ALTER TABLE parcels ALTER COLUMN geom SET NOT NULL;
CREATE INDEX IF NOT EXISTS parcels_geom_idx ON parcels USING GIST (geom);
ANALYZE parcels;

COMMIT;

The same migration, run on parcels_loose:

with engine.begin() as conn:
    print("before:", conn.exec_driver_sql("SELECT DISTINCT ST_SRID(geom), GeometryType(geom) FROM parcels_loose ORDER BY 1").all())
    conn.exec_driver_sql("UPDATE parcels_loose SET geom = ST_Multi(ST_Force2D(ST_SetSRID(geom, 27700))) WHERE ST_SRID(geom) = 0")
    conn.exec_driver_sql("""UPDATE parcels_loose SET geom = ST_Multi(ST_Force2D(ST_Transform(geom, 27700)))
                            WHERE ST_SRID(geom) NOT IN (0, 27700)""")
    conn.exec_driver_sql("UPDATE parcels_loose SET geom = ST_Multi(geom) WHERE GeometryType(geom) = 'POLYGON'")
    conn.exec_driver_sql("ALTER TABLE parcels_loose ALTER COLUMN geom TYPE geometry(MultiPolygon, 27700)")
    conn.exec_driver_sql("ALTER TABLE parcels_loose ALTER COLUMN geom SET NOT NULL")
    conn.exec_driver_sql("CREATE INDEX IF NOT EXISTS parcels_loose_geom_idx ON parcels_loose USING GIST (geom)")
print("after: ", sql("SELECT type, srid FROM geometry_columns WHERE f_table_name = 'parcels_loose'"),
      sql("SELECT count(*) FROM pg_indexes WHERE tablename = 'parcels_loose'"))
before: [(0, 'POLYGON'), (4326, 'POLYGON'), (27700, 'POLYGON')]
after:  [('MULTIPOLYGON', 27700)] [(1,)]

The extra ST_Multi line handles the rows that were already in 27700 but single-part, which the two WHERE clauses above do not touch β€” without it the ALTER stops on the first of them. Steps 1 and 2 are in the opposite order from the instinct. Adding the constraint first fails on the first bad row and tells you nothing about the rest; conforming first means the ALTER either succeeds or reveals a case you have not handled.

Note ST_SetSRID for the unknown rows and ST_Transform for the wrong ones β€” relabelling versus moving, one layer down from set_crs versus to_crs.

Example 3: the round trip, with the SRID intact

import pandas as pd
import shapely
from shapely import from_wkb

# read: ST_AsEWKB carries the SRID in every value
rows = pd.read_sql('SELECT "INSPIREID", ST_AsEWKB(geom) AS geom FROM parcels_a LIMIT 1000', engine)
shapes = from_wkb(rows["geom"].map(bytes))
print("SRID on the Shapely geometries:", set(shapely.get_srid(shapes)))
print("GeoDataFrame CRS without crs=:", gpd.GeoDataFrame(rows.drop(columns="geom"), geometry=shapes).crs)
gdf = gpd.GeoDataFrame(rows.drop(columns="geom"), geometry=shapes, crs=27700)

# write: GeoAlchemy2 takes the SRID from gdf.crs
gdf.rename_geometry("geom").to_postgis("parcels_roundtrip", engine, if_exists="replace", index=False)
print("round trip:", sql("SELECT type, srid FROM geometry_columns WHERE f_table_name = 'parcels_roundtrip'"))
SRID on the Shapely geometries: {np.int32(27700)}
GeoDataFrame CRS without crs=: None
round trip: [('POLYGON', 27700)]

shapely.from_wkb keeps the SRID from EWKB on each geometry β€” shapely.get_srid() reports 27700 β€” but GeoPandas never reads it: a GeoDataFrame built from those shapes has no CRS. The CRS lives on the GeoDataFrame, not the shape, so setting crs= explicitly is not optional here; without it the next write produces SRID 0.

The value separated from the constraint

A typed geometry column contrasted with an untyped one. what each column acceptsgeometry β€” untypedβœ•MultiPolygon in 27700βœ•Point in 4326βœ•3D LineStringβœ•SRID 0, unknownβœ•every query defends itselfgeometry(MultiPolygon, 27700)βœ“MultiPolygon in 27700βœ“single Polygon, promotedβœ“checked on every insertβœ“one guaranteed CRSβœ“queries can assumeA hand-made or mixed-type table gets the left one; to_postgis declares the right kind spatialworkflow.io
The untyped column accepts everything. Every query afterwards pays for that.

The design worth internalising is that PostGIS separates the value from the constraint, and both are optional.

A geometry value always carries an SRID slot β€” that is what EWKB adds over WKB β€” but the value in that slot can be 0, and PostGIS will store it without complaint. So a table can hold geometry with no meaningful spatial reference, indefinitely, with no error. The database is not being permissive by accident: geometry is a general-purpose type, and there are legitimate uses for unreferenced coordinates.

The type modifier is what turns permission into a guarantee. geometry(MultiPolygon, 27700) is a check constraint in all but syntax, and its value is entirely preventative: it converts a class of future confusion into an immediate, loud, specific error at insert time. That trade β€” a rejected write today against a mystery tomorrow β€” is almost always worth making.

The consequences of skipping it are not evenly distributed. A mixed type column is annoying: ST_Area on a Point returns 0, ST_Union produces a GeometryCollection, and downstream code needs branches. A mixed SRID column stops queries outright:

print(sql("""SELECT count(*) FROM parcels_mixed a, parcels_mixed b WHERE ST_Intersects(a.geom, b.geom)"""))
ERROR: ST_Intersects: Operation on mixed SRID geometries (Polygon, 27700) != (Polygon, 4326)

That message is the one PostGIS 3.4 gave for the table of mixed SRIDs above, with the types it happened to meet first. That is PostGIS being better than the Python side, and worth appreciating. GeoPandas' sjoin across mismatched CRS emits a warning and returns nothing useful, which looks exactly like "no matches"; PostGIS refuses to guess. The cost is that a table with mixed SRIDs has queries that fail for some rows and not others depending on which the planner reaches β€” which is why the SRID belongs in the column declaration rather than in each row.

The index is the third layer and the one with the clearest cost model: without it, every spatial predicate is a sequential scan; with it, the bounding-box filter discards almost everything before the exact test runs β€” 0.3 ms against 24 ms for the parcels above. to_postgis creates one when it creates the table; a table built any other way β€” CREATE TABLE … AS, a bulk COPY, an ogr2ogr load with the index option switched off β€” needs CREATE INDEX by hand, which is why a freshly loaded table sometimes feels slower than the GeoPackage it replaced.

geometry_columns, SRID zero and modifiers

  • geometry_columns is a view, not a table, in modern PostGIS β€” it reads the real type modifiers, so it cannot drift from reality.
  • ST_SRID returns 0, never NULL, for unreferenced geometry. Test = 0, not IS NULL.
  • The type modifier does not validate the shape. A self-intersecting polygon inserts fine into geometry(Polygon, 27700); validity is a separate concern.
  • ST_Multi is idempotent β€” safe to apply to geometry that is already multi-part.
  • Changing a column's type rewrites the table and takes an ACCESS EXCLUSIVE lock. On a large table, plan for it.
  • geography accepts only longitude/latitude SRIDs β€” 4326 by default, but 4269 works too β€” and supports a smaller set of functions than geometry.
  • ST_Force2D drops Z silently. If the Z means something, declare PolygonZ instead.
  • An SRID present in your geometry but absent from spatial_ref_sys stores fine and fails on transform. Custom SRIDs need inserting into that table.

FAQ

What is the difference between WKB and EWKB?

EWKB is PostGIS's extension of WKB that carries an SRID (and Z/M flags) alongside the coordinates. ST_AsBinary produces WKB and loses the SRID; ST_AsEWKB keeps it.

Does declaring the type slow down inserts?

Marginally β€” it is a per-row check on values already in memory. The cost is invisible next to the I/O, and far smaller than the cost of finding a mixed-SRID table six months later.

Does a MultiPolygon column reject a single Polygon?

No β€” it accepts it and promotes it to a one-part MultiPolygon. The rejection only runs the other way: a MultiPolygon into a geometry(Polygon, …) column fails with Geometry type (MultiPolygon) does not match column type (Polygon).

Should I ever use a bare geometry column?

For staging tables that receive unknown input, yes. For a table of record, no β€” geometry(Geometry, 27700) gives you mixed types with a guaranteed CRS, which is almost always what "I need flexibility" actually means.

What happens if I compare geometries with different SRIDs?

PostGIS raises Operation on mixed SRID geometries and the query fails. That is deliberate and helpful β€” it refuses to guess. Declare the SRID on the column so the mismatch cannot get into the table in the first place.

Is geography better than geometry?

Only for global data where no single projection works. It is slower and supports fewer functions. For national or regional data in a projected CRS, geometry is both faster and more accurate.

Why is my spatial query slow right after loading a table?

to_postgis does not create a GIST index, and a freshly loaded table has no planner statistics. Create the index and run ANALYZE.

Can one table have two geometry columns?

Yes β€” for example a parcel outline and its centroid. Each gets its own declaration and its own index, and queries must name the one they mean.