PostGIS Best Practices¶
Objective: Master senior-level PostGIS spatial data patterns for production systems. When you need to handle geospatial data, when you want to optimize spatial queries, when you need enterprise-grade spatial analysis strategies—these best practices become your weapon of choice.
Core Principles¶
- Spatial Indexing: Use appropriate spatial indexes for performance
- Coordinate Systems: Choose correct CRS for your use case
- Data Types: Select optimal geometry vs geography types
- Query Optimization: Optimize spatial queries for performance
- Data Quality: Ensure spatial data integrity and validation
PostGIS Setup and Configuration¶
PostGIS Extension Installation¶
-- Enable PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
CREATE EXTENSION IF NOT EXISTS postgis_sfcgal;
CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;
CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder;
-- Check PostGIS version and capabilities
SELECT PostGIS_Version();
SELECT PostGIS_GEOS_Version();
SELECT PostGIS_Lib_Version();
SELECT PostGIS_Scripts_Build_Date();
-- Check available spatial reference systems
SELECT srid, auth_name, auth_srid, srtext
FROM spatial_ref_sys
WHERE auth_name = 'EPSG'
ORDER BY auth_srid;
Spatial Reference System Configuration¶
-- Create tables with appropriate SRID
CREATE TABLE cities (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
country VARCHAR(50) NOT NULL,
geom GEOMETRY(POINT, 4326), -- WGS84 for global data
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE buildings (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
height_meters INTEGER,
geom GEOMETRY(POLYGON, 3857), -- Web Mercator for web mapping
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE administrative_boundaries (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
level VARCHAR(50) NOT NULL,
geom GEOMETRY(MULTIPOLYGON, 4326), -- WGS84 for global boundaries
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Create geography table for global calculations
CREATE TABLE global_events (
id SERIAL PRIMARY KEY,
event_name VARCHAR(200) NOT NULL,
location GEOGRAPHY(POINT, 4326), -- Geography for global distance calculations
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
Spatial Data Types and Storage¶
Geometry vs Geography¶
-- Use GEOMETRY for local/regional data
CREATE TABLE local_parcels (
id SERIAL PRIMARY KEY,
parcel_id VARCHAR(50) UNIQUE NOT NULL,
owner_name VARCHAR(200),
area_sq_meters NUMERIC(12,2),
geom GEOMETRY(POLYGON, 4326), -- Geometry for local operations
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Use GEOGRAPHY for global data and distance calculations
CREATE TABLE global_sensors (
id SERIAL PRIMARY KEY,
sensor_id VARCHAR(50) UNIQUE NOT NULL,
sensor_type VARCHAR(50) NOT NULL,
location GEOGRAPHY(POINT, 4326), -- Geography for global operations
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Insert sample data
INSERT INTO cities (name, country, geom) VALUES
('New York', 'USA', ST_GeomFromText('POINT(-74.0059 40.7128)', 4326)),
('London', 'UK', ST_GeomFromText('POINT(-0.1276 51.5074)', 4326)),
('Tokyo', 'Japan', ST_GeomFromText('POINT(139.6917 35.6895)', 4326));
INSERT INTO global_sensors (sensor_id, sensor_type, location) VALUES
('SENSOR_001', 'temperature', ST_GeogFromText('POINT(-74.0059 40.7128)')),
('SENSOR_002', 'humidity', ST_GeogFromText('POINT(-0.1276 51.5074)')),
('SENSOR_003', 'pressure', ST_GeogFromText('POINT(139.6917 35.6895)'));
Spatial Data Validation¶
-- Create function to validate spatial data
CREATE OR REPLACE FUNCTION validate_geometry(geom GEOMETRY)
RETURNS BOOLEAN AS $$
BEGIN
-- Check if geometry is valid
IF NOT ST_IsValid(geom) THEN
RETURN FALSE;
END IF;
-- Check if geometry is not empty
IF ST_IsEmpty(geom) THEN
RETURN FALSE;
END IF;
-- Check if geometry has correct dimension
IF ST_Dimension(geom) < 0 THEN
RETURN FALSE;
END IF;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
-- Add validation constraints
ALTER TABLE cities ADD CONSTRAINT cities_geom_valid
CHECK (validate_geometry(geom));
ALTER TABLE buildings ADD CONSTRAINT buildings_geom_valid
CHECK (validate_geometry(geom));
-- Create function to fix invalid geometries
CREATE OR REPLACE FUNCTION fix_invalid_geometry(geom GEOMETRY)
RETURNS GEOMETRY AS $$
BEGIN
IF ST_IsValid(geom) THEN
RETURN geom;
ELSE
RETURN ST_MakeValid(geom);
END IF;
END;
$$ LANGUAGE plpgsql;
Spatial Indexing Strategies¶
GIST Indexes for Spatial Data¶
-- Create spatial indexes
CREATE INDEX idx_cities_geom_gist ON cities USING GIST (geom);
CREATE INDEX idx_buildings_geom_gist ON buildings USING GIST (geom);
CREATE INDEX idx_administrative_boundaries_geom_gist ON administrative_boundaries USING GIST (geom);
CREATE INDEX idx_global_sensors_location_gist ON global_sensors USING GIST (location);
-- Create partial spatial indexes
CREATE INDEX idx_cities_geom_gist_active ON cities USING GIST (geom)
WHERE created_at > CURRENT_DATE - INTERVAL '1 year';
-- Create spatial indexes with specific operators
CREATE INDEX idx_buildings_geom_gist_ops ON buildings USING GIST (geom gist_geometry_ops_2d);
Spatial Index Performance¶
-- Analyze spatial index usage
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE indexname LIKE '%_geom_gist%' OR indexname LIKE '%_location_gist%'
ORDER BY idx_scan DESC;
-- Check spatial index statistics
SELECT
schemaname,
tablename,
attname,
n_distinct,
correlation
FROM pg_stats
WHERE attname LIKE '%geom%' OR attname LIKE '%location%';
Spatial Query Optimization¶
Spatial Query Patterns¶
-- Point-in-polygon queries
SELECT
c.name as city_name,
c.country,
ab.name as boundary_name,
ab.level
FROM cities c
JOIN administrative_boundaries ab ON ST_Contains(ab.geom, c.geom)
WHERE ab.level = 'country';
-- Distance-based queries
SELECT
s1.sensor_id as sensor1,
s2.sensor_id as sensor2,
ST_Distance(s1.location, s2.location) as distance_meters
FROM global_sensors s1
CROSS JOIN global_sensors s2
WHERE s1.sensor_id < s2.sensor_id
AND ST_DWithin(s1.location, s2.location, 1000) -- Within 1km
ORDER BY distance_meters;
-- Spatial joins with performance optimization
SELECT
b.name as building_name,
b.height_meters,
c.name as city_name,
ST_Distance(b.geom, c.geom) as distance_meters
FROM buildings b
JOIN cities c ON ST_DWithin(b.geom, c.geom, 10000) -- Within 10km
WHERE b.height_meters > 50
ORDER BY distance_meters;
Spatial Aggregation¶
-- Spatial aggregation functions
SELECT
ab.name as boundary_name,
ab.level,
COUNT(c.id) as city_count,
AVG(ST_Area(ab.geom)) as avg_area,
ST_Union(c.geom) as city_union_geom
FROM administrative_boundaries ab
LEFT JOIN cities c ON ST_Contains(ab.geom, c.geom)
GROUP BY ab.id, ab.name, ab.level, ab.geom;
-- Spatial clustering
SELECT
ST_ClusterKMeans(geom, 5) as cluster_id,
COUNT(*) as point_count,
ST_Centroid(ST_Collect(geom)) as cluster_center
FROM cities
GROUP BY ST_ClusterKMeans(geom, 5);
Coordinate Reference Systems¶
CRS Transformation¶
-- Transform between coordinate systems
SELECT
name,
geom as original_geom,
ST_Transform(geom, 3857) as web_mercator_geom,
ST_Transform(geom, 32633) as utm_geom
FROM cities
WHERE name = 'New York';
-- Create function for CRS transformation
CREATE OR REPLACE FUNCTION transform_to_web_mercator(geom GEOMETRY)
RETURNS GEOMETRY AS $$
BEGIN
RETURN ST_Transform(geom, 3857);
END;
$$ LANGUAGE plpgsql;
-- Use CRS transformation in queries
SELECT
name,
ST_Area(ST_Transform(geom, 3857)) as area_sq_meters
FROM administrative_boundaries
WHERE level = 'state';
CRS Best Practices¶
-- Create tables with appropriate CRS
CREATE TABLE local_roads (
id SERIAL PRIMARY KEY,
road_name VARCHAR(200) NOT NULL,
road_type VARCHAR(50) NOT NULL,
geom GEOMETRY(LINESTRING, 4326), -- WGS84 for global roads
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE local_parcels (
id SERIAL PRIMARY KEY,
parcel_id VARCHAR(50) UNIQUE NOT NULL,
owner_name VARCHAR(200),
geom GEOMETRY(POLYGON, 4326), -- WGS84 for global parcels
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Create function to get appropriate CRS for region
CREATE OR REPLACE FUNCTION get_regional_crs(geom GEOMETRY)
RETURNS INTEGER AS $$
DECLARE
centroid POINT;
longitude NUMERIC;
latitude NUMERIC;
BEGIN
centroid := ST_Centroid(geom);
longitude := ST_X(centroid);
latitude := ST_Y(centroid);
-- Return appropriate UTM zone
IF latitude >= 0 THEN
RETURN 32600 + (FLOOR((longitude + 180) / 6) + 1);
ELSE
RETURN 32700 + (FLOOR((longitude + 180) / 6) + 1);
END IF;
END;
$$ LANGUAGE plpgsql;
Spatial Analysis Functions¶
Geometric Operations¶
-- Create spatial analysis functions
CREATE OR REPLACE FUNCTION calculate_building_density(
boundary_geom GEOMETRY,
building_geom GEOMETRY
)
RETURNS NUMERIC AS $$
DECLARE
building_count INTEGER;
boundary_area NUMERIC;
BEGIN
-- Count buildings within boundary
SELECT COUNT(*) INTO building_count
FROM buildings
WHERE ST_Within(geom, boundary_geom);
-- Calculate boundary area in square meters
boundary_area := ST_Area(ST_Transform(boundary_geom, 3857));
-- Return density (buildings per square kilometer)
RETURN (building_count / (boundary_area / 1000000));
END;
$$ LANGUAGE plpgsql;
-- Use spatial analysis function
SELECT
ab.name as boundary_name,
calculate_building_density(ab.geom, b.geom) as building_density
FROM administrative_boundaries ab
CROSS JOIN buildings b
WHERE ab.level = 'city';
Spatial Relationships¶
-- Spatial relationship queries
SELECT
c.name as city_name,
b.name as building_name,
ST_Distance(c.geom, b.geom) as distance_meters,
CASE
WHEN ST_Contains(c.geom, b.geom) THEN 'Inside'
WHEN ST_Intersects(c.geom, b.geom) THEN 'Intersects'
WHEN ST_Touches(c.geom, b.geom) THEN 'Touches'
ELSE 'Separate'
END as spatial_relationship
FROM cities c
CROSS JOIN buildings b
WHERE ST_DWithin(c.geom, b.geom, 1000)
ORDER BY distance_meters;
Spatial Data Import/Export¶
Data Import Functions¶
-- Create function to import spatial data from GeoJSON
CREATE OR REPLACE FUNCTION import_geojson_features(
geojson_data JSONB,
target_table TEXT
)
RETURNS INTEGER AS $$
DECLARE
feature JSONB;
feature_count INTEGER := 0;
geom_wkt TEXT;
properties JSONB;
BEGIN
-- Process each feature in the GeoJSON
FOR feature IN SELECT * FROM jsonb_array_elements(geojson_data->'features') LOOP
-- Extract geometry as WKT
geom_wkt := ST_AsText(ST_GeomFromGeoJSON(feature->'geometry'));
-- Extract properties
properties := feature->'properties';
-- Insert into target table
EXECUTE format('INSERT INTO %I (geom, properties) VALUES (ST_GeomFromText(%L, 4326), %L)',
target_table, geom_wkt, properties);
feature_count := feature_count + 1;
END LOOP;
RETURN feature_count;
END;
$$ LANGUAGE plpgsql;
Data Export Functions¶
-- Create function to export spatial data to GeoJSON
CREATE OR REPLACE FUNCTION export_to_geojson(
table_name TEXT,
geom_column TEXT DEFAULT 'geom',
where_clause TEXT DEFAULT '1=1'
)
RETURNS JSONB AS $$
DECLARE
result JSONB;
features JSONB;
BEGIN
-- Build GeoJSON features
EXECUTE format('
SELECT jsonb_build_object(
''type'', ''FeatureCollection'',
''features'', jsonb_agg(
jsonb_build_object(
''type'', ''Feature'',
''geometry'', ST_AsGeoJSON(%I)::jsonb,
''properties'', row_to_json(t) - ''%I''
)
)
)
FROM (
SELECT * FROM %I WHERE %s
) t
', geom_column, geom_column, table_name, where_clause) INTO result;
RETURN result;
END;
$$ LANGUAGE plpgsql;
Spatial Data Quality¶
Data Quality Validation¶
-- Create spatial data quality validation
CREATE OR REPLACE FUNCTION validate_spatial_data_quality(
geom GEOMETRY
)
RETURNS TABLE (
validation_check TEXT,
result BOOLEAN,
message TEXT
) AS $$
BEGIN
-- Check if geometry is valid
RETURN QUERY SELECT 'valid_geometry', ST_IsValid(geom),
CASE WHEN ST_IsValid(geom) THEN 'Geometry is valid' ELSE 'Geometry is invalid' END;
-- Check if geometry is not empty
RETURN QUERY SELECT 'not_empty', NOT ST_IsEmpty(geom),
CASE WHEN NOT ST_IsEmpty(geom) THEN 'Geometry is not empty' ELSE 'Geometry is empty' END;
-- Check if geometry has correct dimension
RETURN QUERY SELECT 'correct_dimension', ST_Dimension(geom) >= 0,
CASE WHEN ST_Dimension(geom) >= 0 THEN 'Geometry has correct dimension' ELSE 'Geometry has incorrect dimension' END;
-- Check if geometry is not too complex
RETURN QUERY SELECT 'not_too_complex', ST_NPoints(geom) < 10000,
CASE WHEN ST_NPoints(geom) < 10000 THEN 'Geometry is not too complex' ELSE 'Geometry is too complex' END;
END;
$$ LANGUAGE plpgsql;
Spatial Data Cleaning¶
-- Create function to clean spatial data
CREATE OR REPLACE FUNCTION clean_spatial_data(
geom GEOMETRY
)
RETURNS GEOMETRY AS $$
BEGIN
-- Remove duplicate points
geom := ST_RemoveRepeatedPoints(geom);
-- Simplify geometry if too complex
IF ST_NPoints(geom) > 1000 THEN
geom := ST_Simplify(geom, 0.0001);
END IF;
-- Fix invalid geometries
IF NOT ST_IsValid(geom) THEN
geom := ST_MakeValid(geom);
END IF;
-- Remove empty geometries
IF ST_IsEmpty(geom) THEN
RETURN NULL;
END IF;
RETURN geom;
END;
$$ LANGUAGE plpgsql;
Spatial Performance Monitoring¶
Spatial Query Performance¶
# monitoring/spatial_monitor.py
import psycopg2
import json
from datetime import datetime
import logging
class SpatialMonitor:
def __init__(self, connection_params):
self.conn_params = connection_params
self.setup_logging()
def setup_logging(self):
"""Setup logging."""
logging.basicConfig(level=logging.INFO)
self.logger = logging.getLogger(__name__)
def get_spatial_index_usage(self):
"""Get spatial index usage statistics."""
conn = psycopg2.connect(**self.conn_params)
try:
with conn.cursor() as cur:
cur.execute("""
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch,
pg_size_pretty(pg_relation_size(indexrelid)) as index_size
FROM pg_stat_user_indexes
WHERE indexname LIKE '%_geom_gist%' OR indexname LIKE '%_location_gist%'
ORDER BY idx_scan DESC
""")
spatial_indexes = cur.fetchall()
return spatial_indexes
except Exception as e:
self.logger.error(f"Error getting spatial index usage: {e}")
return []
finally:
conn.close()
def analyze_spatial_query_performance(self):
"""Analyze spatial query performance."""
conn = psycopg2.connect(**self.conn_params)
try:
with conn.cursor() as cur:
# Test point-in-polygon query performance
start_time = datetime.now()
cur.execute("""
SELECT COUNT(*)
FROM cities c
JOIN administrative_boundaries ab ON ST_Contains(ab.geom, c.geom)
""")
pip_time = (datetime.now() - start_time).total_seconds()
# Test distance query performance
start_time = datetime.now()
cur.execute("""
SELECT COUNT(*)
FROM global_sensors s1
CROSS JOIN global_sensors s2
WHERE ST_DWithin(s1.location, s2.location, 1000)
""")
distance_time = (datetime.now() - start_time).total_seconds()
return {
'point_in_polygon_time': pip_time,
'distance_query_time': distance_time
}
except Exception as e:
self.logger.error(f"Error analyzing spatial query performance: {e}")
return {}
finally:
conn.close()
def get_spatial_data_statistics(self):
"""Get spatial data statistics."""
conn = psycopg2.connect(**self.conn_params)
try:
with conn.cursor() as cur:
cur.execute("""
SELECT
table_name,
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE column_name LIKE '%geom%' OR column_name LIKE '%location%'
ORDER BY table_name, column_name
""")
spatial_columns = cur.fetchall()
return spatial_columns
except Exception as e:
self.logger.error(f"Error getting spatial data statistics: {e}")
return []
finally:
conn.close()
def generate_spatial_report(self):
"""Generate comprehensive spatial data report."""
spatial_indexes = self.get_spatial_index_usage()
query_performance = self.analyze_spatial_query_performance()
spatial_columns = self.get_spatial_data_statistics()
report = {
'report_timestamp': datetime.now().isoformat(),
'spatial_indexes': spatial_indexes,
'query_performance': query_performance,
'spatial_columns': spatial_columns
}
return report
# Usage
if __name__ == "__main__":
monitor = SpatialMonitor({
'host': 'localhost',
'database': 'production',
'user': 'monitor_user',
'password': 'monitor_password'
})
report = monitor.generate_spatial_report()
print(json.dumps(report, indent=2))
TL;DR Runbook¶
Quick Start¶
-- 1. Enable PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;
-- 2. Create spatial tables
CREATE TABLE cities (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
geom GEOMETRY(POINT, 4326)
);
-- 3. Create spatial indexes
CREATE INDEX idx_cities_geom_gist ON cities USING GIST (geom);
-- 4. Insert spatial data
INSERT INTO cities (name, geom) VALUES
('New York', ST_GeomFromText('POINT(-74.0059 40.7128)', 4326));
-- 5. Query spatial data
SELECT name, ST_AsText(geom) as geometry
FROM cities
WHERE ST_DWithin(geom, ST_GeomFromText('POINT(-74.0059 40.7128)', 4326), 1000);
Essential Patterns¶
# Complete PostGIS spatial data setup
def setup_postgis_spatial_data():
# 1. PostGIS extension setup
# 2. Spatial data types and storage
# 3. Spatial indexing strategies
# 4. Spatial query optimization
# 5. Coordinate reference systems
# 6. Spatial analysis functions
# 7. Data import/export
# 8. Spatial data quality and monitoring
print("PostGIS spatial data setup complete!")
This guide provides the complete machinery for PostGIS spatial data excellence. Each pattern includes implementation examples, spatial analysis strategies, and real-world usage patterns for enterprise PostGIS spatial systems.
See also
- PostGIS Geometry Indexing Tutorial — hands-on implementation of the indexing patterns from this guide
- GeoParquet Best Practices — the columnar complement to PostGIS for analytical workloads
- PostgreSQL Performance Tuning — tune the database layer beneath your spatial queries