PostGIS Support

pycopg provides first-class support for PostGIS spatial operations with GeoPandas integration.

Prerequisites

  1. PostGIS extension installed on your PostgreSQL server

  2. GeoPandas installed: pip install pycopg[geo]

Setup

from pycopg import Database

db = Database.from_env()

# Enable PostGIS extension
db.create_extension("postgis")

# Verify installation
if db.has_extension("postgis"):
    print("PostGIS is ready")

Creating Spatial Tables

From GeoDataFrame

import geopandas as gpd

# Load spatial data
gdf = gpd.read_file("parcels.geojson")

# Create table with spatial index
db.from_geodataframe(
    gdf,
    "parcels",
    primary_key="id",
    spatial_index=True
)

Options

db.from_geodataframe(
    gdf,
    "parcels",
    schema="geo",            # Target schema
    if_exists="replace",     # 'fail', 'replace', 'append'
    primary_key="id",        # Set primary key
    spatial_index=True,      # Create GIST spatial index
    geometry_column="geom",  # Geometry column name
    srid=4326,               # Override SRID
)

Reading Spatial Data

Full Table

gdf = db.to_geodataframe("parcels")
print(gdf.crs)  # EPSG:4326
print(gdf.geometry.head())

With SQL Query

# Spatial query
gdf = db.to_geodataframe(
    sql="""
        SELECT * FROM parcels
        WHERE ST_Within(
            geometry,
            ST_MakeEnvelope(-122.5, 37.7, -122.3, 37.9, 4326)
        )
    """
)

# With parameters
gdf = db.to_geodataframe(
    sql="""
        SELECT * FROM parcels
        WHERE ST_DWithin(
            geometry::geography,
            ST_Point(:lon, :lat)::geography,
            :radius
        )
    """,
    params={"lon": -122.4, "lat": 37.8, "radius": 1000}
)

Spatial Indexes

Create GIST Index

# Create spatial index
db.create_spatial_index("parcels", "geometry")

# With custom name
db.create_spatial_index("parcels", "geometry", name="idx_parcels_geom")

# On specific schema
db.create_spatial_index("parcels", "geometry", schema="geo")

Using Regular Index API

# GIST index for geometry
db.create_index("parcels", "geometry", method="gist")

# GIN index for JSONB properties
db.create_index("parcels", "properties", method="gin")

Listing Geometry Columns

# All geometry columns
columns = db.list_geometry_columns()
# [
#     {
#         'schema': 'public',
#         'table_name': 'parcels',
#         'column_name': 'geometry',
#         'dimensions': 2,
#         'srid': 4326,
#         'geometry_type': 'POLYGON'
#     },
#     ...
# ]

# Filter by schema
columns = db.list_geometry_columns(schema="geo")

Common Spatial Operations

Point in Polygon

result = db.execute("""
    SELECT p.id, p.name
    FROM parcels p
    WHERE ST_Contains(
        p.geometry,
        ST_SetSRID(ST_Point(%s, %s), 4326)
    )
""", [-122.4, 37.8])

Distance Queries

# Find parcels within 1km of a point
result = db.execute("""
    SELECT id, name,
           ST_Distance(
               geometry::geography,
               ST_Point(%s, %s)::geography
           ) AS distance_meters
    FROM parcels
    WHERE ST_DWithin(
        geometry::geography,
        ST_Point(%s, %s)::geography,
        1000
    )
    ORDER BY distance_meters
""", [-122.4, 37.8, -122.4, 37.8])

Intersection

# Find overlapping parcels
result = db.execute("""
    SELECT a.id AS id_a, b.id AS id_b,
           ST_Area(ST_Intersection(a.geometry, b.geometry)) AS overlap_area
    FROM parcels a, parcels b
    WHERE a.id < b.id
      AND ST_Intersects(a.geometry, b.geometry)
""")

Buffer

# Create buffers around points
result = db.execute("""
    SELECT id, name,
           ST_Buffer(geometry::geography, 100)::geometry AS buffer_100m
    FROM locations
""")

Centroid

# Get centroids of polygons
result = db.execute("""
    SELECT id, name,
           ST_X(ST_Centroid(geometry)) AS lon,
           ST_Y(ST_Centroid(geometry)) AS lat
    FROM parcels
""")

Area and Perimeter

# Calculate area and perimeter
result = db.execute("""
    SELECT id, name,
           ST_Area(geometry::geography) AS area_sq_meters,
           ST_Perimeter(geometry::geography) AS perimeter_meters
    FROM parcels
    ORDER BY area_sq_meters DESC
    LIMIT 10
""")

Coordinate Reference Systems

Checking CRS

gdf = db.to_geodataframe("parcels")
print(gdf.crs)  # EPSG:4326

Transforming CRS

# In database
result = db.execute("""
    SELECT id, ST_Transform(geometry, 3857) AS geometry_web_mercator
    FROM parcels
""")

# With GeoPandas
gdf = db.to_geodataframe("parcels")
gdf_3857 = gdf.to_crs(epsg=3857)

Setting SRID

# When creating table
db.from_geodataframe(gdf, "parcels", srid=4326)

# Update existing geometry
db.execute("""
    UPDATE parcels
    SET geometry = ST_SetSRID(geometry, 4326)
    WHERE ST_SRID(geometry) = 0
""")

Example: Spatial Analysis

import geopandas as gpd
from pycopg import Database

db = Database.from_env()

# Ensure PostGIS is available
if not db.has_extension("postgis"):
    db.create_extension("postgis")

# Load and store data
parcels = gpd.read_file("parcels.geojson")
db.from_geodataframe(parcels, "parcels", primary_key="id", spatial_index=True)

buildings = gpd.read_file("buildings.geojson")
db.from_geodataframe(buildings, "buildings", primary_key="id", spatial_index=True)

# Spatial join: buildings within parcels
result = db.to_geodataframe(sql="""
    SELECT b.*, p.parcel_id, p.owner
    FROM buildings b
    JOIN parcels p ON ST_Within(b.geometry, p.geometry)
""")

# Aggregate: total building area per parcel
stats = db.execute("""
    SELECT
        p.id AS parcel_id,
        p.owner,
        COUNT(b.id) AS building_count,
        SUM(ST_Area(b.geometry::geography)) AS total_building_area,
        ST_Area(p.geometry::geography) AS parcel_area
    FROM parcels p
    LEFT JOIN buildings b ON ST_Within(b.geometry, p.geometry)
    GROUP BY p.id, p.owner, p.geometry
    ORDER BY building_count DESC
""")

# Cleanup
db.close()