Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Comprehensive PostGIS spatial table design reference covering geometry types, coordinate systems, spatial indexing, and performance patterns for location-based applications
.claude/skills/timescale-design-postgis-tables/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-09 | ✗→✓ | ▲ Improved | 210% | 0% |
| case-21 | ✗→✓ | ▲ Improved | 152% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 171% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 221% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 135% | 0% |
SQL injection note: When turning these patterns into application code, use parameterized queries for user-provided values (WKT/WKB, coordinates, IDs, radii). Avoid string-concatenating untrusted input into SQL; for dynamic identifiers, use safe identifier quoting/whitelisting.
POINT, LINE, POLYGON, CIRCLE). PostGIS types provide true spatial capabilities.4326 (WGS84) for GPS/global data, appropriate local projections for regional data.GEOMETRY(type, SRID) syntax to ensure data integrity.sql-- Regional data with projected coordinates (UTM Zone 10N for California) CREATE TABLE local_parcels ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, parcel_number TEXT NOT NULL, boundary GEOMETRY(POLYGON, 26910), -- UTM Zone 10N (meters) area_sqm DOUBLE PRECISION GENERATED ALWAYS AS (ST_Area(boundary)) STORED );
sql-- Global data with geodetic calculations CREATE TABLE global_offices ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, city TEXT NOT NULL, location GEOGRAPHY(POINT, 4326) -- WGS84 (lat/lon) ); -- Distance in meters (accurate spherical calculation) SELECT a.name AS office_a, b.name AS office_b, ST_Distance(a.location, b.location) / 1000 AS distance_km FROM global_offices a CROSS JOIN global_offices b WHERE a.id < b.id;
| Aspect | GEOMETRY | GEOGRAPHY | | ----------------- | ------------------------------------- | ------------------------- | | Coordinate system | Any SRID (projected or geodetic) | WGS84 (SRID 4326) only | | Distance units | CRS units (degrees, meters, feet) | Meters (always) | | Distance accuracy | Depends on projection | True spheroidal distance | | Area accuracy | Accurate in projected CRS | Accurate on sphere | | Function support | Full (300+ functions) | Limited (~40 functions) | | Performance | Faster (Cartesian math) | Slower (spherical math) | | Index type | GiST, BRIN, SP-GiST | GiST only | | Best for | Regional/local data, complex analysis | Global data, GPS tracking |
sql-- Single location (stores, sensors, events) location GEOMETRY(POINT, 4326) -- Multiple discrete locations (multi-branch business) locations GEOMETRY(MULTIPOINT, 4326) -- 3D point with elevation location_3d GEOMETRY(POINTZ, 4326) -- Point with measure value (linear referencing) location_m GEOMETRY(POINTM, 4326)
Use POINT for: Store locations, sensor positions, event coordinates, addresses, POIs Use MULTIPOINT for: Multiple related locations stored as single feature
sql-- Single path (road segment, river, route) path GEOMETRY(LINESTRING, 4326) -- Multiple paths (road network, transit lines) network GEOMETRY(MULTILINESTRING, 4326) -- 3D line with elevation profile trail_3d GEOMETRY(LINESTRINGZ, 4326)
Use LINESTRING for: Roads, rivers, pipelines, GPS tracks, routes Use MULTILINESTRING for: Disconnected road segments, river systems
sql-- Single area (parcel, building footprint, zone) boundary GEOMETRY(POLYGON, 4326) -- Multiple areas (archipelago, fragmented habitat) territories GEOMETRY(MULTIPOLYGON, 4326) -- 3D polygon (building with height) footprint_3d GEOMETRY(POLYGONZ, 4326)
Use POLYGON for: Property boundaries, administrative areas, service zones Use MULTIPOLYGON for: Countries with islands, fragmented regions
sql-- Any geometry type (flexible schema) geom GEOMETRY(GEOMETRY, 4326) -- Collection of mixed types features GEOMETRY(GEOMETRYCOLLECTION, 4326)
Use GEOMETRY for: Flexible schemas accepting multiple types Avoid GEOMETRYCOLLECTION: Prefer homogeneous types for better indexing
| SRID | Name | Use Case | Units | | ----------- | ----------------- | ---------------------------- | ------- | | 4326 | WGS84 | GPS, global data, web maps | Degrees | | 3857 | Web Mercator | Web map tiles (display only) | Meters | | 26910-26919 | UTM Zones (US) | Regional analysis | Meters | | 32601-32660 | UTM Zones (North) | Regional analysis | Meters | | 32701-32760 | UTM Zones (South) | Regional analysis | Meters |
sql-- Store in WGS84, calculate in UTM CREATE TABLE survey_points ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, location GEOMETRY(POINT, 4326), -- Storage: WGS84 CONSTRAINT valid_location CHECK (ST_IsValid(location)) ); -- Calculate distance in meters using UTM projection SELECT a.id AS point_a, b.id AS point_b, ST_Distance( ST_Transform(a.location, 26910), -- Transform to UTM ST_Transform(b.location, 26910) ) AS distance_meters FROM survey_points a CROSS JOIN survey_points b WHERE a.id < b.id;
Most versatile spatial index. Use for all geometry/geography columns.
sql-- Geometry (most common) CREATE INDEX idx_your_table_geom_gist ON your_table_name USING GIST (geom); -- Geography (GiST is the supported option) CREATE INDEX idx_your_table_geog_gist ON your_table_name USING GIST (geog); -- Analyze after index creation VACUUM ANALYZE your_table_name;
Supports: All spatial operators (&&, @>, <@, ~=, <->) Best for: General-purpose spatial queries, mixed query patterns
Block Range Index for very large, naturally ordered datasets.
sql-- BRIN for very large, append-only GEOMETRY tables (geography uses GiST) CREATE INDEX idx_your_table_geom_brin ON your_table_name USING BRIN (geom) WITH (pages_per_range = 128);
Supports: Bounding box operators (&&, @>, <@) Best for: Append-only tables, time-series spatial data, very large datasets (>100M rows) Trade-off: Much smaller than GiST, but less precise filtering
Space-partitioned GiST for point data with specific distributions.
sql-- SP-GiST for GEOMETRY(POINT, ...) only CREATE INDEX idx_sensors_location_spgist ON sensors USING SPGIST (location);
Best for: Point-only data, quadtree-friendly distributions Not for: Complex geometries, mixed types
| Scenario | Index Type | Reasoning | | -------------------------------- | ------------- | ------------------------------------------ | | General spatial queries | GiST | Most versatile, supports all operators | | Very large, append-only | BRIN | Tiny footprint, good for time-ordered data | | Point-only, uniform distribution | SP-GiST | Efficient for point lookups | | Geography columns | GiST | Only supported option | | Composite spatial + attribute | GiST + B-tree | Separate indexes or expression index |
sqlCREATE TABLE pois ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, location GEOGRAPHY(POINT, 4326) NOT NULL, address TEXT, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT valid_category CHECK (category IN ( 'restaurant', 'hotel', 'gas_station', 'hospital', 'school' )) ); -- Spatial index CREATE INDEX idx_pois_location ON pois USING GIST (location); -- Category + location for filtered spatial queries CREATE INDEX idx_pois_category ON pois (category); -- Find restaurants within 1km SELECT name, address, ST_Distance( location, ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::GEOGRAPHY ) AS distance_m FROM pois WHERE category = 'restaurant' AND ST_DWithin( location, ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::GEOGRAPHY, 1000 ) ORDER BY distance_m;
sqlCREATE TABLE parcels ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, parcel_id TEXT NOT NULL UNIQUE, owner_name TEXT, boundary GEOMETRY(MULTIPOLYGON, 4326) NOT NULL, centroid GEOMETRY(POINT, 4326) GENERATED ALWAYS AS (ST_Centroid(boundary)) STORED, area_sqm DOUBLE PRECISION GENERATED ALWAYS AS ( ST_Area(boundary::GEOGRAPHY) ) STORED, perimeter_m DOUBLE PRECISION GENERATED ALWAYS AS ( ST_Perimeter(boundary::GEOGRAPHY) ) STORED, CONSTRAINT valid_boundary CHECK (ST_IsValid(boundary)), CONSTRAINT closed_boundary CHECK (ST_IsClosed(ST_ExteriorRing(ST_GeometryN(boundary, 1)))) ); CREATE INDEX idx_parcels_boundary ON parcels USING GIST (boundary); CREATE INDEX idx_parcels_centroid ON parcels USING GIST (centroid); -- Find parcels intersecting a search area SELECT parcel_id, owner_name, area_sqm FROM parcels WHERE ST_Intersects(boundary, ST_MakeEnvelope(-122.5, 37.7, -122.4, 37.8, 4326));
sqlCREATE TABLE gps_tracks ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, device_id TEXT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL, location GEOGRAPHY(POINT, 4326) NOT NULL, speed_kmh DOUBLE PRECISION, heading DOUBLE PRECISION, accuracy_m DOUBLE PRECISION ); -- Composite index for device + time queries CREATE INDEX idx_gps_device_time ON gps_tracks (device_id, recorded_at DESC); -- Spatial index for location queries CREATE INDEX idx_gps_location ON gps_tracks USING GIST (location); -- Note: GEOGRAPHY supports GiST; BRIN is for GEOMETRY (when appropriate). -- Create linestring from track points SELECT device_id, ST_MakeLine(location::GEOMETRY ORDER BY recorded_at) AS track_line, MIN(recorded_at) AS start_time, MAX(recorded_at) AS end_time FROM gps_tracks WHERE device_id = 'device_001' AND recorded_at >= '2024-01-01' GROUP BY device_id;
sqlCREATE TABLE service_zones ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, zone_name TEXT NOT NULL, zone_type TEXT NOT NULL, boundary GEOMETRY(POLYGON, 4326) NOT NULL, population INTEGER, active BOOLEAN NOT NULL DEFAULT true, CONSTRAINT valid_zone_type CHECK (zone_type IN ('delivery', 'service', 'coverage')), CONSTRAINT valid_boundary CHECK (ST_IsValid(boundary)) ); CREATE INDEX idx_zones_boundary ON service_zones USING GIST (boundary); CREATE INDEX idx_zones_active ON service_zones (active) WHERE active = true; -- Check if location is within any active service zone SELECT zone_name, zone_type FROM service_zones WHERE active = true AND ST_Contains(boundary, ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326));
sql-- SLOW: calculates distance for all rows SELECT * FROM pois WHERE ST_Distance(location, ref_point) < 1000; -- FAST: uses spatial index SELECT * FROM pois WHERE ST_DWithin(location, ref_point, 1000);
sql-- Bounding box operator leverages spatial index SELECT * FROM parcels WHERE boundary && ST_MakeEnvelope(-122.5, 37.7, -122.4, 37.8, 4326) AND ST_Intersects(boundary, search_polygon);
sql-- SLOW: function prevents index usage SELECT * FROM parcels WHERE ST_Area(boundary) > 10000; -- FAST: use generated column with regular index ALTER TABLE parcels ADD COLUMN area_sqm DOUBLE PRECISION GENERATED ALWAYS AS (ST_Area(boundary::GEOGRAPHY)) STORED; CREATE INDEX idx_parcels_area ON parcels (area_sqm); SELECT * FROM parcels WHERE area_sqm > 10000;
sql-- Reduce complexity for web display (tolerance in CRS units) SELECT id, name, ST_AsGeoJSON(ST_Simplify(boundary, 0.0001)) AS geojson FROM parcels;
sql-- Reduce coordinate precision for storage efficiency UPDATE locations SET geom = ST_ReducePrecision(geom, 0.000001); -- GeoJSON with limited decimal places SELECT ST_AsGeoJSON(location, 6) AS geojson FROM pois;
sql-- Add validity constraint ALTER TABLE parcels ADD CONSTRAINT valid_geom CHECK (ST_IsValid(boundary)); -- Find and fix invalid geometries SELECT id, ST_IsValidReason(boundary) AS reason FROM parcels WHERE NOT ST_IsValid(boundary); -- Attempt to fix invalid geometries UPDATE parcels SET boundary = ST_MakeValid(boundary) WHERE NOT ST_IsValid(boundary);
sql-- Verify SRID consistency SELECT DISTINCT ST_SRID(geom) FROM spatial_table; -- Enforce SRID with constraint ALTER TABLE locations ADD CONSTRAINT enforce_srid CHECK (ST_SRID(location) = 4326);
sql-- Ensure coordinates are within valid WGS84 bounds ALTER TABLE global_locations ADD CONSTRAINT valid_coords CHECK ( ST_X(location::GEOMETRY) BETWEEN -180 AND 180 AND ST_Y(location::GEOMETRY) BETWEEN -90 AND 90 );
POINT, LINE, POLYGON, CIRCLE) - use PostGIS types instead(longitude, latitude) = (X, Y), not (lat, lon)EXPLAIN ANALYZE to verify spatial index usagequote_ident, format('%I', ...)) or strict allowlists.| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | pass→pass | 12,474 | 10,123 | -19% | 1 | 1 | 0% | 2,399 | 6,512 | +171% | 0 | 0 | — |
case-02 | pass→pass | 11,882 | 10,546 | -11% | 1 | 1 | 0% | 2,028 | 6,520 | +221% | 0 | 0 | — |
case-03 | pass→pass | 15,158 | 11,139 | -27% | 1 | 1 | 0% | 2,896 | 6,811 | +135% | 0 | 0 | — |
case-04 | pass→pass | 15,807 | 10,545 | -33% | 1 | 1 | 0% | 2,479 | 6,653 | +168% | 0 | 0 | — |
case-05 | pass→pass | 11,856 | 8,383 | -29% | 1 | 1 | 0% | 2,060 | 6,134 | +198% | 0 | 0 | — |
case-06 | fail→fail | 12,214 | 6,209 | -49% | 1 | 1 | 0% | 2,083 | 5,808 | +179% | 0 | 0 | — |
case-07 | pass→pass | 6,873 | 5,635 | -18% | 1 | 1 | 0% | 1,189 | 5,690 | +379% | 0 | 0 | — |
case-08 | pass→pass | 15,018 | 7,437 | -50% | 1 | 1 | 0% | 2,597 | 5,945 | +129% | 0 | 0 | — |
case-09 | fail→pass | 9,112 | 3,736 | -59% | 1 | 1 | 0% | 1,724 | 5,346 | +210% | 0 | 0 | — |
case-10 | pass→pass | 8,360 | 5,940 | -29% | 1 | 1 | 0% | 1,458 | 5,644 | +287% | 0 | 0 | — |
case-11 | pass→pass | 10,752 | 7,458 | -31% | 1 | 1 | 0% | 1,936 | 6,093 | +215% | 0 | 0 | — |
case-12 | pass→pass | 5,478 | 3,437 | -37% | 1 | 1 | 0% | 940 | 5,197 | +453% | 0 | 0 | — |
case-13 | pass→pass | 12,013 | 8,470 | -29% | 1 | 1 | 0% | 2,265 | 6,244 | +176% | 0 | 0 | — |
case-14 | pass→pass | 3,309 | 3,776 | +14% | 1 | 1 | 0% | 577 | 5,324 | +823% | 0 | 0 | — |
case-15 | pass→pass | 9,034 | 8,058 | -11% | 1 | 1 | 0% | 1,771 | 6,183 | +249% | 0 | 0 | — |
case-16 | pass→pass | 8,562 | 5,989 | -30% | 1 | 1 | 0% | 1,678 | 5,823 | +247% | 0 | 0 | — |
case-17 | pass→pass | 10,647 | 8,188 | -23% | 1 | 1 | 0% | 1,971 | 6,153 | +212% | 0 | 0 | — |
case-18 | pass→pass | 6,902 | 7,714 | +12% | 1 | 1 | 0% | 1,182 | 6,019 | +409% | 0 | 0 | — |
case-19 | pass→pass | 5,075 | 3,632 | -28% | 1 | 1 | 0% | 928 | 5,283 | +469% | 0 | 0 | — |
case-20 | pass→pass | 12,169 | 11,116 | -9% | 1 | 1 | 0% | 2,293 | 6,401 | +179% | 0 | 0 | — |
case-21 | fail→pass | 15,383 | 11,511 | -25% | 1 | 1 | 0% | 2,669 | 6,723 | +152% | 0 | 0 | — |
case-22 | fail→fail | 9,275 | 9,853 | +6% | 1 | 1 | 0% | 1,781 | 6,247 | +251% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +9 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.